diff --git a/.changeset/afraid-hairs-talk.md b/.changeset/afraid-hairs-talk.md
new file mode 100644
index 0000000000..21101d3bd8
--- /dev/null
+++ b/.changeset/afraid-hairs-talk.md
@@ -0,0 +1,5 @@
+---
+'@shopify/app': minor
+---
+
+Render footer links in `app dev` as hyperlinks, if supported by the terminal.
diff --git a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx
index 7d6b5f5ab6..62bf6aed3a 100644
--- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx
+++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx
@@ -14,7 +14,14 @@ import {unstyled} from '@shopify/cli-kit/node/output'
import {openURL} from '@shopify/cli-kit/node/system'
import {Writable} from 'stream'
-vi.mock('@shopify/cli-kit/node/system')
+vi.mock('@shopify/cli-kit/node/system', async () => {
+ const actual: any = await vi.importActual('@shopify/cli-kit/node/system')
+ return {
+ ...actual,
+ openURL: vi.fn(),
+ terminalSupportsHyperlinks: mocks.terminalSupportsHyperlinks,
+ }
+})
vi.mock('@shopify/cli-kit/node/context/local')
vi.mock('@shopify/cli-kit/node/tree-kill')
@@ -23,6 +30,7 @@ const mocks = vi.hoisted(() => {
useStdin: vi.fn(() => {
return {isRawModeSupported: true}
}),
+ terminalSupportsHyperlinks: vi.fn(() => false),
}
})
@@ -544,6 +552,59 @@ describe('DevSessionUI', () => {
renderInstance.unmount()
})
+ test('hides URL list when terminal supports hyperlinks', async () => {
+ // Given
+ mocks.terminalSupportsHyperlinks.mockReturnValue(true)
+
+ const renderInstance = render(
+ ,
+ )
+
+ await waitForInputsToBeReady()
+
+ // Then - shortcuts should be present but URL list should be hidden
+ const output = unstyled(renderInstance.lastFrame()!)
+ expect(output).toContain('(p)')
+ expect(output).toContain('(g)')
+ expect(output).not.toContain('Preview URL:')
+ expect(output).not.toContain('GraphiQL URL:')
+
+ renderInstance.unmount()
+ mocks.terminalSupportsHyperlinks.mockReturnValue(false)
+ })
+
+ test('shows URL list when terminal does not support hyperlinks', async () => {
+ // Given
+ mocks.terminalSupportsHyperlinks.mockReturnValue(false)
+
+ const renderInstance = render(
+ ,
+ )
+
+ await waitForInputsToBeReady()
+
+ // Then - both shortcuts and URL list should be present
+ const output = unstyled(renderInstance.lastFrame()!)
+ expect(output).toContain('(p)')
+ expect(output).toContain('(g)')
+ expect(output).toContain('Preview URL: https://shopify.com')
+ expect(output).toContain('GraphiQL URL: https://graphiql.shopify.com')
+
+ renderInstance.unmount()
+ })
+
test('shows non-interactive fallback when raw mode is not supported', async () => {
// Given - mock useStdin to return false for isRawModeSupported
mocks.useStdin.mockReturnValue({isRawModeSupported: false})
diff --git a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx
index 0946bbbca5..882bb30c3b 100644
--- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx
+++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx
@@ -15,7 +15,7 @@ import React, {FunctionComponent, useEffect, useMemo, useState} from 'react'
import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort'
import {Box, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink'
import {handleCtrlC} from '@shopify/cli-kit/node/ui'
-import {openURL} from '@shopify/cli-kit/node/system'
+import {openURL, terminalSupportsHyperlinks} from '@shopify/cli-kit/node/system'
import figures from '@shopify/cli-kit/node/figures'
import {isUnitTest} from '@shopify/cli-kit/node/context/local'
import {treeKill} from '@shopify/cli-kit/node/tree-kill'
@@ -126,7 +126,7 @@ const DevSessionUI: FunctionComponent = ({
shortcuts: [
{
key: 'p',
- condition: () => Boolean(status.previewURL && status.isReady),
+ condition: () => Boolean(status.isReady && status.previewURL),
action: async () => {
await metadata.addPublicMetadata(() => ({
cmd_dev_preview_url_opened: true,
@@ -138,7 +138,7 @@ const DevSessionUI: FunctionComponent = ({
},
{
key: 'g',
- condition: () => Boolean(status.graphiqlURL && status.isReady),
+ condition: () => Boolean(status.isReady && status.graphiqlURL),
action: async () => {
await metadata.addPublicMetadata(() => ({
cmd_dev_graphiql_opened: true,
@@ -168,19 +168,34 @@ const DevSessionUI: FunctionComponent = ({
)}
{canUseShortcuts && (
- {status.isReady ? (
+ {status.isReady && status.previewURL ? (
- {figures.pointerSmall} (p) Open app preview
+ {figures.pointerSmall} (p){' '}
+ {terminalSupportsHyperlinks() ? (
+
+ ) : (
+ 'Open app preview'
+ )}
) : null}
{status.isReady && !status.appEmbedded && status.hasExtensions ? (
- {figures.pointerSmall} (c) Open Dev Console for extension previews
+ {figures.pointerSmall} (c){' '}
+ {terminalSupportsHyperlinks() ? (
+
+ ) : (
+ 'Open Dev Console for extension previews'
+ )}
) : null}
- {status.graphiqlURL && status.isReady ? (
+ {status.isReady && status.graphiqlURL ? (
- {figures.pointerSmall} (g) Open GraphiQL (Admin API)
+ {figures.pointerSmall} (g){' '}
+ {terminalSupportsHyperlinks() ? (
+
+ ) : (
+ 'Open GraphiQL (Admin API)'
+ )}
) : null}
@@ -190,7 +205,7 @@ const DevSessionUI: FunctionComponent = ({
{isShuttingDownMessage}
) : (
<>
- {status.isReady && (
+ {status.isReady && !(canUseShortcuts && terminalSupportsHyperlinks()) && (
<>
{status.previewURL ? (
diff --git a/packages/cli-kit/src/public/node/system.ts b/packages/cli-kit/src/public/node/system.ts
index 537275458e..a532dce70a 100644
--- a/packages/cli-kit/src/public/node/system.ts
+++ b/packages/cli-kit/src/public/node/system.ts
@@ -7,6 +7,7 @@ import {renderWarning} from './ui.js'
import {platformAndArch} from './os.js'
import {shouldDisplayColors, outputDebug} from './output.js'
import {execa, execaCommand, ExecaChildProcess} from 'execa'
+import supportsHyperlinks from 'supports-hyperlinks'
import which from 'which'
import {delimiter} from 'pathe'
@@ -346,6 +347,15 @@ export async function sleep(seconds: number): Promise {
})
}
+/**
+ * Check if the terminal supports OSC 8 hyperlinks.
+ *
+ * @returns True if the terminal supports hyperlinks.
+ */
+export function terminalSupportsHyperlinks(): boolean {
+ return supportsHyperlinks.stdout
+}
+
/**
* Check if the standard input and output streams support prompting.
*