diff --git a/CHANGELOG.md b/CHANGELOG.md index c93e54072..414c49c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ⏰ 1 scheduled` line above the composer (cronStore + CronIndicator), fed by new `cron_status` events that also render fire/stop/restore lines in the transcript. +- **Link-opening gesture is now discoverable per terminal.** Apple Terminal + has no OSC 8 hyperlink support (still true on macOS 26), and the default + inline mode deliberately leaves the mouse to the terminal — so the only + way to open an agent-printed link there is Apple Terminal's own URL + detection: hold ⌘ and double-click the URL. That gesture was undocumented + and undiscoverable, reading as "links are broken". The TUI now prints a + one-time dim tip under the first assistant message that contains a URL + (Apple Terminal only), and `?` quick help / `/help` list the + terminal-appropriate gesture (`Cmd+click link` in OSC 8 terminals, + `Cmd+double-click URL` in Apple Terminal, omitted when unknown). VS Code + (`TERM_PROGRAM=vscode` — also Cursor/Windsurf) is now recognized as an + OSC 8 terminal: xterm.js has handled `Cmd+click` hyperlinks since 2022, + and `supportsHyperlinks` is exported from `@clawcodex/ink` for app-level + use. - **`/memory` now opens memory files in your `$EDITOR` from the TUI** — the full port of openclaude's memory-file picker (`commands/memory/` + `MemoryFileSelector`). Typing `/memory` opens a picker overlay listing the diff --git a/ui-tui/packages/clawcodex-ink/index.d.ts b/ui-tui/packages/clawcodex-ink/index.d.ts index 2a8ccef62..2f5267f23 100644 --- a/ui-tui/packages/clawcodex-ink/index.d.ts +++ b/ui-tui/packages/clawcodex-ink/index.d.ts @@ -34,6 +34,7 @@ export { default as measureElement } from './src/ink/measure-element.ts' export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts' export type { Instance, RenderOptions, Root } from './src/ink/root.ts' export { stringWidth } from './src/ink/stringWidth.ts' +export { supportsHyperlinks } from './src/ink/supports-hyperlinks.ts' export type { MouseTrackingMode } from './src/ink/termio/dec.ts' export { wrapAnsi } from './src/ink/wrapAnsi.ts' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/clawcodex-ink/src/entry-exports.ts b/ui-tui/packages/clawcodex-ink/src/entry-exports.ts index 2251fa6c8..68ff79264 100644 --- a/ui-tui/packages/clawcodex-ink/src/entry-exports.ts +++ b/ui-tui/packages/clawcodex-ink/src/entry-exports.ts @@ -26,6 +26,7 @@ export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' +export { supportsHyperlinks } from './ink/supports-hyperlinks.js' export { isXtermJs } from './ink/terminal.js' export type { MouseTrackingMode } from './ink/termio/dec.js' export { wrapAnsi } from './ink/wrapAnsi.js' diff --git a/ui-tui/packages/clawcodex-ink/src/ink/supports-hyperlinks.ts b/ui-tui/packages/clawcodex-ink/src/ink/supports-hyperlinks.ts index 16aed4a6c..5cd2c1e5e 100644 --- a/ui-tui/packages/clawcodex-ink/src/ink/supports-hyperlinks.ts +++ b/ui-tui/packages/clawcodex-ink/src/ink/supports-hyperlinks.ts @@ -2,7 +2,9 @@ import supportsHyperlinksLib from 'supports-hyperlinks' // Additional terminals that support OSC 8 hyperlinks but aren't detected by supports-hyperlinks. // Checked against both TERM_PROGRAM and LC_TERMINAL (the latter is preserved inside tmux). -export const ADDITIONAL_HYPERLINK_TERMINALS = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2'] +// 'vscode' covers every xterm.js embedder that sets it (VS Code since 1.72, +// Cursor, Windsurf) — xterm.js ships its own OSC 8 link handler on Cmd+click. +export const ADDITIONAL_HYPERLINK_TERMINALS = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2', 'vscode'] type EnvLike = Record diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 596531370..d19ea49c0 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -5,6 +5,7 @@ import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/ov import { turnController } from '../app/turnController.js' import { getTurnState, resetTurnState } from '../app/turnStore.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' +import { LINK_TIP_TEXT, resetLinkTipForTests } from '../lib/linkAffordance.js' import { estimateTokensRough } from '../lib/text.js' import type { Msg } from '../types.js' @@ -1628,4 +1629,92 @@ describe('createGatewayEventHandler', () => { expect(openExternalUrlMock).not.toHaveBeenCalled() }) }) + + describe('Apple Terminal link tip', () => { + // The handler calls linkTipFor with ambient env + supportsHyperlinks(), + // which reads more than TERM_PROGRAM: LC_TERMINAL (iTerm2 exports it + // into every shell, and vitest inherits it), TERM (xterm-kitty) and + // FORCE_HYPERLINK. The helper must pin ALL detection inputs or these + // tests fail on unchanged code when run from an OSC 8 terminal. + const withEnv = (overrides: Record, fn: () => void) => { + const prev = new Map(Object.keys(overrides).map(key => [key, process.env[key]])) + + const apply = (key: string, value: string | undefined) => { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + + for (const [key, value] of Object.entries(overrides)) { + apply(key, value) + } + + try { + fn() + } finally { + for (const [key, value] of prev) { + apply(key, value) + } + } + } + + const APPLE_TERMINAL_ENV = { + FORCE_HYPERLINK: undefined, + LC_TERMINAL: undefined, + TERM: 'xterm-256color', + TERM_PROGRAM: 'Apple_Terminal' + } + + beforeEach(() => { + resetLinkTipForTests() + }) + + it('prints the tip once, after the first assistant message containing a URL', () => { + withEnv(APPLE_TERMINAL_ENV, () => { + const ctx = buildCtx([]) + const onEvent = createGatewayEventHandler(ctx) + + onEvent({ payload: { text: 'plain answer, no links' }, type: 'message.complete' } as any) + expect(ctx.system.sys).not.toHaveBeenCalled() + + onEvent({ + payload: { text: 'see [docs](https://example.com/docs) for details' }, + type: 'message.complete' + } as any) + expect(ctx.system.sys).toHaveBeenCalledWith(LINK_TIP_TEXT) + + // A later linky message must not repeat the tip. + ;(ctx.system.sys as ReturnType).mockClear() + onEvent({ payload: { text: 'also https://example.org' }, type: 'message.complete' } as any) + expect(ctx.system.sys).not.toHaveBeenCalledWith(LINK_TIP_TEXT) + }) + }) + + it('stays quiet outside Apple Terminal', () => { + withEnv({ ...APPLE_TERMINAL_ENV, TERM_PROGRAM: 'vscode' }, () => { + const ctx = buildCtx([]) + const onEvent = createGatewayEventHandler(ctx) + + onEvent({ payload: { text: 'see https://example.com' }, type: 'message.complete' } as any) + expect(ctx.system.sys).not.toHaveBeenCalledWith(LINK_TIP_TEXT) + }) + }) + + it('lands after the assistant message in the transcript, not before', () => { + withEnv(APPLE_TERMINAL_ENV, () => { + const order: string[] = [] + const ctx = buildCtx([]) + ctx.transcript.appendMessage = (msg: Msg) => order.push(`msg:${msg.text}`) + ctx.system.sys = vi.fn((text: string) => order.push(`sys:${text}`)) + + const onEvent = createGatewayEventHandler(ctx) + + onEvent({ payload: { text: 'see https://example.com' }, type: 'message.complete' } as any) + + expect(order).toEqual(['msg:see https://example.com', `sys:${LINK_TIP_TEXT}`]) + }) + }) + }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index a60b15bee..f12371f8a 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -11,6 +11,7 @@ import type { SessionMostRecentResponse } from '../gatewayTypes.js' import { setLastCostSnapshot } from '../lib/costSummary.js' +import { linkTipFor } from '../lib/linkAffordance.js' import { isTodoDone } from '../lib/liveProgress.js' import { openExternalUrl } from '../lib/openExternalUrl.js' import { rpcErrorMessage } from '../lib/rpc.js' @@ -20,8 +21,8 @@ import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.j import { fromSkin } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' -import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import { applyCronSnapshot, resetCronState } from './cronStore.js' +import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import { applyGoalSnapshot, resetGoalState } from './goalStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' @@ -1008,6 +1009,17 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }] msgs.forEach(appendMessage) + // One-time "how to open links here" tip, directly under the first + // assistant message that contains a URL — only in terminals with no + // OSC 8 support (Apple Terminal), where Cmd+click can't work and + // the native gesture is undiscoverable. Plain assistant text only: + // tool-trail rows can carry URLs that aren't rendered as links. + const tip = linkTipFor(msgs.filter(m => m.role === 'assistant' && !m.kind).map(m => m.text)) + + if (tip) { + sys(tip) + } + // Pet beat: celebrate a finished plan, otherwise a clean-finish wave. flashPet(isTodoDone(getTurnState().todos) ? 'jump' : 'wave') diff --git a/ui-tui/src/components/helpHint.tsx b/ui-tui/src/components/helpHint.tsx index 11f6bd4ec..7712dedd4 100644 --- a/ui-tui/src/components/helpHint.tsx +++ b/ui-tui/src/components/helpHint.tsx @@ -12,7 +12,9 @@ const COMMON_COMMANDS: [string, string][] = [ ['/exit', 'exit clawcodex'] ] -const HOTKEY_PREVIEW = HOTKEYS.slice(0, 8) +// 9 rows so the link-opening gesture (inserted near the top when the +// terminal has one) doesn't push Tab-completion out of the preview. +const HOTKEY_PREVIEW = HOTKEYS.slice(0, 9) export function HelpHint({ t }: { t: Theme }) { const labelW = Math.max(...COMMON_COMMANDS.map(([k]) => k.length), ...HOTKEY_PREVIEW.map(([k]) => k.length)) diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 2917e175b..e1baaba27 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -1,8 +1,18 @@ +import { linkOpenHotkey } from '../lib/linkAffordance.js' import { isMac, isRemoteShell } from '../lib/platform.js' const action = isMac ? 'Cmd' : 'Ctrl' const paste = isMac ? 'Cmd' : 'Alt' +// Terminal-appropriate link-opening gesture (Cmd+click in OSC 8 terminals, +// Cmd+double-click the URL in Apple Terminal). Omitted when unknown, so we +// never advertise a gesture the user's terminal can't perform. +const linkHotkeys: [string, string][] = (() => { + const row = linkOpenHotkey() + + return row ? [row] : [] +})() + const copyHotkeys: [string, string][] = isMac ? [ ['Cmd+C', 'copy selection'], @@ -18,6 +28,7 @@ const copyHotkeys: [string, string][] = isMac export const HOTKEYS: [string, string][] = [ ['Esc', 'interrupt the running turn'], ...copyHotkeys, + ...linkHotkeys, [action + '+D', 'exit'], [action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'], [action + '+L', 'redraw / repaint'], diff --git a/ui-tui/src/lib/linkAffordance.test.ts b/ui-tui/src/lib/linkAffordance.test.ts new file mode 100644 index 000000000..428f8d380 --- /dev/null +++ b/ui-tui/src/lib/linkAffordance.test.ts @@ -0,0 +1,78 @@ +import { supportsHyperlinks } from '@clawcodex/ink' +import { beforeEach, describe, expect, it } from 'vitest' + +import { LINK_TIP_TEXT, linkOpenHotkey, linkTipFor, resetLinkTipForTests } from './linkAffordance.js' +import { isMac } from './platform.js' + +const APPLE = { TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv +const XTERM = { TERM_PROGRAM: undefined } as NodeJS.ProcessEnv + +describe('linkOpenHotkey', () => { + it('advertises the platform click gesture when the terminal supports OSC 8', () => { + expect(linkOpenHotkey({ TERM_PROGRAM: 'iTerm.app' } as NodeJS.ProcessEnv, true)).toEqual([ + `${isMac ? 'Cmd' : 'Ctrl'}+click link`, + 'open link in browser' + ]) + }) + + it('advertises Cmd+double-click on the URL in Apple Terminal (no OSC 8)', () => { + expect(linkOpenHotkey(APPLE, false)).toEqual(['Cmd+double-click URL', 'open link in browser']) + }) + + it('stays quiet in unknown terminals without OSC 8 support', () => { + expect(linkOpenHotkey(XTERM, false)).toBeNull() + }) + + it('advertises a plain click in fullscreen mode, where the in-process opener handles every terminal', () => { + expect(linkOpenHotkey(APPLE, false, false)).toEqual(['click link', 'open link in browser']) + expect(linkOpenHotkey(XTERM, false, false)).toEqual(['click link', 'open link in browser']) + }) +}) + +describe('supportsHyperlinks terminal detection', () => { + it('recognizes VS Code (xterm.js has handled OSC 8 Cmd+click since 1.72)', () => { + expect(supportsHyperlinks({ env: { TERM_PROGRAM: 'vscode' }, stdoutSupported: false })).toBe(true) + }) + + it('does not recognize Apple Terminal (no OSC 8 support as of macOS 26)', () => { + expect(supportsHyperlinks({ env: { TERM_PROGRAM: 'Apple_Terminal' }, stdoutSupported: false })).toBe(false) + }) +}) + +describe('linkTipFor', () => { + beforeEach(() => { + resetLinkTipForTests() + }) + + it('fires once for the first assistant text containing a URL in Apple Terminal', () => { + expect(linkTipFor(['see https://example.com/docs'], APPLE, false)).toBe(LINK_TIP_TEXT) + + // Second linky message: already shown this session. + expect(linkTipFor(['more at http://example.org'], APPLE, false)).toBeNull() + }) + + it('does not consume the once-flag on messages without URLs', () => { + expect(linkTipFor(['no links here'], APPLE, false)).toBeNull() + expect(linkTipFor(['now with [docs](https://example.com)'], APPLE, false)).toBe(LINK_TIP_TEXT) + }) + + it('stays quiet when the terminal supports OSC 8 hyperlinks', () => { + expect(linkTipFor(['https://example.com'], APPLE, true)).toBeNull() + }) + + it('stays quiet in fullscreen mode, where a plain click opens links in-process', () => { + expect(linkTipFor(['https://example.com'], APPLE, false, false)).toBeNull() + + // And it must not have consumed the once-flag. + expect(linkTipFor(['https://example.com'], APPLE, false, true)).toBe(LINK_TIP_TEXT) + }) + + it('stays quiet outside Apple Terminal — the gesture is Apple Terminal-specific', () => { + expect(linkTipFor(['https://example.com'], XTERM, false)).toBeNull() + expect(linkTipFor(['https://example.com'], { TERM_PROGRAM: 'WarpTerminal' } as NodeJS.ProcessEnv, false)).toBeNull() + }) + + it('scans every text in the batch', () => { + expect(linkTipFor(['first part', 'second: https://example.com/x'], APPLE, false)).toBe(LINK_TIP_TEXT) + }) +}) diff --git a/ui-tui/src/lib/linkAffordance.ts b/ui-tui/src/lib/linkAffordance.ts new file mode 100644 index 000000000..072bb6b4f --- /dev/null +++ b/ui-tui/src/lib/linkAffordance.ts @@ -0,0 +1,106 @@ +import { supportsHyperlinks } from '@clawcodex/ink' + +import { INLINE_MODE } from '../config/env.js' + +import { isMac } from './platform.js' + +/** + * How links in agent output get opened, per terminal. + * + * Inline mode (the default) never arms mouse tracking — the transcript lives + * in native scrollback, and capturing the mouse would break the terminal's + * own selection and wheel scrolling. So opening a link is delegated entirely + * to the terminal emulator: + * + * - OSC 8-capable terminals (iTerm2, VS Code, kitty, Ghostty, WezTerm, …) + * open our hyperlink metadata on Cmd+click (Ctrl+click elsewhere). + * - Apple Terminal has NO OSC 8 support (still true on macOS 26). Its only + * native affordance is URL detection over the visible text: + * Cmd+double-click the URL (or right-click → Open URL). This is why the + * markdown renderer keeps the target URL visible next to the label. + * - Anything else without OSC 8: we don't know a gesture, so we stay quiet + * rather than advertise one that doesn't work. + * + * (Fullscreen/alt-screen mode is different: mouse tracking is armed there and + * clicks open links in-process via onHyperlinkClick, in every terminal.) + */ + +const APPLE_TERMINAL = 'Apple_Terminal' + +const isAppleTerminal = (env: NodeJS.ProcessEnv) => env.TERM_PROGRAM === APPLE_TERMINAL + +/** + * Hotkey row for the ? quick help / /help panel: [keys, description]. + * Null when the terminal has no link-opening gesture we know of. + */ +export function linkOpenHotkey( + env: NodeJS.ProcessEnv = process.env, + supported: boolean = supportsHyperlinks(), + inline: boolean = INLINE_MODE +): [string, string] | null { + if (supported) { + // OSC 8 terminals open our hyperlink metadata on Cmd/Ctrl+click in both + // modes (xterm.js and iTerm2 handle it themselves even with mouse + // tracking armed — the double-open dance the renderer's click dispatcher + // defers to in @clawcodex/ink's components/App.tsx). + return [`${isMac ? 'Cmd' : 'Ctrl'}+click link`, 'open link in browser'] + } + + if (!inline) { + // Fullscreen arms mouse tracking, so clicks are handled in-process + // (onHyperlinkClick) in every terminal — including Apple Terminal, + // where the captured mouse suppresses the native Cmd+double-click. + return ['click link', 'open link in browser'] + } + + if (isAppleTerminal(env)) { + return ['Cmd+double-click URL', 'open link in browser'] + } + + return null +} + +// Matches any http(s) URL — both bare URLs and the target inside a markdown +// [label](url), since the renderer always keeps the URL text visible. +const URL_RE = /https?:\/\// + +export const LINK_TIP_TEXT = 'tip: to open links in Apple Terminal, hold ⌘ and double-click the URL' + +// Once per session: the tip teaches a single gesture; repeating it under +// every linky message would just be noise. Module state (not React state) +// because the decision is made in the gateway event handler, outside render. +let linkTipShown = false + +export const resetLinkTipForTests = () => { + linkTipShown = false +} + +/** + * The one-time "how to open links here" transcript tip, or null. + * + * Fires only when (a) it hasn't fired this session, (b) the TUI runs in + * inline mode — fullscreen captures the mouse and opens links in-process on + * a plain click, making the tip's gesture both unnecessary and suppressed — + * (c) the terminal lacks OSC 8 support, (d) it's Apple Terminal — the one + * non-OSC 8 terminal whose gesture we can name — and (e) the just-completed + * assistant text actually contains a URL, so the tip lands directly under + * the links it explains. + */ +export function linkTipFor( + texts: readonly string[], + env: NodeJS.ProcessEnv = process.env, + supported: boolean = supportsHyperlinks(), + inline: boolean = INLINE_MODE +): string | null { + if (linkTipShown || supported || !inline || !isAppleTerminal(env)) { + return null + } + + if (!texts.some(t => URL_RE.test(t))) { + return null + } + + linkTipShown = true + + return LINK_TIP_TEXT +}