Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions ui-tui/packages/clawcodex-ink/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions ui-tui/packages/clawcodex-ink/src/entry-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion ui-tui/packages/clawcodex-ink/src/ink/supports-hyperlinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>

Expand Down
89 changes: 89 additions & 0 deletions ui-tui/src/__tests__/createGatewayEventHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string, string | undefined>, 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<typeof vi.fn>).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}`])
})
})
})
})
14 changes: 13 additions & 1 deletion ui-tui/src/app/createGatewayEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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')

Expand Down
4 changes: 3 additions & 1 deletion ui-tui/src/components/helpHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 11 additions & 0 deletions ui-tui/src/content/hotkeys.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand All @@ -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'],
Expand Down
78 changes: 78 additions & 0 deletions ui-tui/src/lib/linkAffordance.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading