@workerdeck/ui 2.7.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +30 -30
  2. package/build/{SessionPanel-B5FfiBxE.mjs → SessionPanel-BClL7zUS.mjs} +694 -413
  3. package/build/SessionPanel-BClL7zUS.mjs.map +1 -0
  4. package/build/{SessionPanel-_lQLhWOw.d.mts → SessionPanel-BVbau2eR.d.mts} +68 -4
  5. package/build/format.mjs +1 -1
  6. package/build/index.d.mts +22 -15
  7. package/build/index.mjs +6 -6
  8. package/build/index.mjs.map +1 -1
  9. package/build/scoped.css +26 -2
  10. package/build/{status-BO9wJloK.mjs → status-kmsBhuFG.mjs} +3 -3
  11. package/build/status-kmsBhuFG.mjs.map +1 -0
  12. package/build/workspace.d.mts +3 -2
  13. package/build/workspace.mjs +7 -8
  14. package/build/workspace.mjs.map +1 -1
  15. package/package.json +18 -18
  16. package/src/components/agent/Composer.tsx +86 -84
  17. package/src/components/agent/ContextDialog.tsx +1 -1
  18. package/src/components/agent/EntryIcon.tsx +1 -1
  19. package/src/components/agent/FileTree.tsx +1 -1
  20. package/src/components/agent/FileViewer.tsx +1 -1
  21. package/src/components/agent/HostFilesDialog.tsx +2 -2
  22. package/src/components/agent/PermissionModeSelect.tsx +2 -2
  23. package/src/components/agent/PermissionPrompt.tsx +2 -2
  24. package/src/components/agent/SessionBrowser.tsx +1 -1
  25. package/src/components/agent/SessionItem.tsx +4 -2
  26. package/src/components/agent/SessionPanel.tsx +306 -226
  27. package/src/components/agent/SessionWorkspace.tsx +3 -0
  28. package/src/components/agent/SkillsDialog.tsx +3 -3
  29. package/src/components/agent/StatusBar.tsx +1 -1
  30. package/src/components/agent/TaskList.tsx +2 -2
  31. package/src/components/agent/TranscriptRows.tsx +1 -1
  32. package/src/components/agent/UsageDialog.tsx +1 -1
  33. package/src/components/agent/UsageMeters.tsx +17 -4
  34. package/src/components/agent/composer-commands.ts +142 -0
  35. package/src/components/agent/use-path-links.ts +2 -7
  36. package/src/components/agent/use-subagent-frame.ts +2 -2
  37. package/src/components/agent/use-transcript-jumps.ts +2 -2
  38. package/src/components/prompt-area/clipboard-helpers.ts +2 -2
  39. package/src/components/prompt-area/cursor-helpers.ts +7 -7
  40. package/src/components/prompt-area/dom-helpers.ts +8 -8
  41. package/src/components/prompt-area/html-to-markdown.ts +2 -2
  42. package/src/components/prompt-area/prompt-area-engine.ts +4 -4
  43. package/src/components/prompt-area/prompt-area-list-ops.ts +10 -10
  44. package/src/components/prompt-area/prompt-area.tsx +2 -2
  45. package/src/components/prompt-area/trigger-presets.ts +6 -6
  46. package/src/components/prompt-area/types.ts +6 -6
  47. package/src/components/prompt-area/use-chip-editing.ts +8 -8
  48. package/src/components/prompt-area/use-markdown-mode.ts +3 -3
  49. package/src/components/prompt-area/use-prompt-area-events.ts +3 -3
  50. package/src/components/prompt-area/use-prompt-area-keydown.ts +3 -3
  51. package/src/components/prompt-area/use-prompt-area-state.ts +4 -4
  52. package/src/components/prompt-area/use-prompt-area.ts +5 -5
  53. package/src/components/prompt-area/use-trigger-search.ts +1 -1
  54. package/src/components/terminal/PermissionPrompt.tsx +4 -4
  55. package/src/components/terminal/affordances.tsx +1 -1
  56. package/src/components/terminal/file-link.tsx +20 -0
  57. package/src/components/terminal/height.ts +2 -2
  58. package/src/components/terminal/items.tsx +2 -2
  59. package/src/components/terminal/markdown.tsx +31 -5
  60. package/src/components/terminal/scrubber.tsx +1 -1
  61. package/src/components/terminal/todos.ts +1 -1
  62. package/src/components/ui/Splitter.tsx +1 -1
  63. package/src/index.ts +11 -1
  64. package/src/lib/context-note.ts +3 -3
  65. package/src/lib/file-link.ts +70 -0
  66. package/src/lib/format.ts +2 -2
  67. package/src/styles/scoped.entry.css +2 -2
  68. package/src/styles/terminal.css +94 -79
  69. package/src/styles/theme.css +39 -39
  70. package/build/SessionPanel-B5FfiBxE.mjs.map +0 -1
  71. package/build/status-BO9wJloK.mjs.map +0 -1
@@ -18,12 +18,12 @@
18
18
 
19
19
  import type { TriggerConfig, TriggerPosition } from './types.ts'
20
20
 
21
- // Shared option type — everything in TriggerConfig except the keys each
21
+ // Shared option type - everything in TriggerConfig except the keys each
22
22
  // factory sets by default.
23
23
 
24
24
  type TriggerPresetOptions = Omit<Partial<TriggerConfig>, 'char' | 'position' | 'mode'>
25
25
 
26
- // @mention — dropdown at any position
26
+ // @mention - dropdown at any position
27
27
 
28
28
  export type MentionTriggerOptions = TriggerPresetOptions & {
29
29
  /** Override the trigger character. Defaults to `'@'`. */
@@ -48,14 +48,14 @@ export function mentionTrigger(opts: MentionTriggerOptions = {}): TriggerConfig
48
48
  }
49
49
  }
50
50
 
51
- // /command — dropdown anywhere (opt into line-start-only with `position`)
51
+ // /command - dropdown anywhere (opt into line-start-only with `position`)
52
52
 
53
53
  export type CommandTriggerOptions = TriggerPresetOptions & {
54
54
  /** Override the trigger character. Defaults to `'/'`. */
55
55
  char?: string
56
56
  /**
57
57
  * Where the command trigger is valid. Defaults to `'any'`, so commands fire
58
- * anywhere a `/` follows whitespace — not just at the start of a line.
58
+ * anywhere a `/` follows whitespace - not just at the start of a line.
59
59
  * Set to `'start'` to restrict the dropdown to the very start of the input
60
60
  * or immediately after a newline (the classic slash-command behavior).
61
61
  */
@@ -83,7 +83,7 @@ export function commandTrigger(opts: CommandTriggerOptions = {}): TriggerConfig
83
83
  }
84
84
  }
85
85
 
86
- // #hashtag — dropdown at any position, auto-resolve on space
86
+ // #hashtag - dropdown at any position, auto-resolve on space
87
87
 
88
88
  export type HashtagTriggerOptions = TriggerPresetOptions & {
89
89
  /** Override the trigger character. Defaults to `'#'`. */
@@ -132,7 +132,7 @@ export function callbackTrigger(opts: CallbackTriggerOptions): TriggerConfig {
132
132
  }
133
133
  }
134
134
 
135
- // Launch trigger — fires onActivate and swallows the character
135
+ // Launch trigger - fires onActivate and swallows the character
136
136
 
137
137
  export type LaunchTriggerOptions = Omit<Partial<TriggerConfig>, 'mode'> & {
138
138
  /** The trigger character. Required. */
@@ -46,7 +46,7 @@ export type TriggerPosition = 'start' | 'any'
46
46
  * - 'dropdown': Shows a popover with suggestions from `onSearch`
47
47
  * - 'callback': Inserts the char, then fires `onActivate` with the typed query
48
48
  * - 'launch': Fires `onActivate` on keydown and SUPPRESSES the char (it never
49
- * enters the editor) — for opening an external surface (dialog, palette) where
49
+ * enters the editor) - for opening an external surface (dialog, palette) where
50
50
  * no in-editor text should appear. Honors `position` like the other modes.
51
51
  */
52
52
  export type TriggerMode = 'dropdown' | 'callback' | 'launch'
@@ -102,7 +102,7 @@ export type TriggerConfig = {
102
102
  * For 'dropdown' mode: opt a suggestion out of becoming a chip.
103
103
  *
104
104
  * Return a string and the trigger's range is replaced with that **plain,
105
- * editable text** — the trigger character included — with the caret left at
105
+ * editable text** - the trigger character included - with the caret left at
106
106
  * its end. Return undefined and the suggestion resolves to a chip as usual,
107
107
  * so one dropdown can mix both kinds.
108
108
  *
@@ -239,7 +239,7 @@ export type PromptAreaProps = {
239
239
  /**
240
240
  * When markdown is on, the editor rewrites typed list markers (`- ` / `* `)
241
241
  * to a `•` bullet glyph in the model. Set to `false` to keep the original
242
- * marker in the value/`onChange` text — needed when a host renders the output
242
+ * marker in the value/`onChange` text - needed when a host renders the output
243
243
  * as real markdown, where `•` is not a valid list marker. Default `true`.
244
244
  */
245
245
  normalizeBullets?: boolean
@@ -271,8 +271,8 @@ export type PromptAreaProps = {
271
271
  * the caret kept where the edit happened. Chips count as their
272
272
  * `trigger + displayText` length.
273
273
  *
274
- * The cap applies to typing only. Paste is not capped — divert it via
275
- * `onRawPaste` if needed — and the imperative `setText` / `appendText` also
274
+ * The cap applies to typing only. Paste is not capped - divert it via
275
+ * `onRawPaste` if needed - and the imperative `setText` / `appendText` also
276
276
  * bypass it, so a programmatic write can exceed the cap until the next
277
277
  * keystroke truncates.
278
278
  */
@@ -316,7 +316,7 @@ export type PromptAreaProps = {
316
316
  onBlur?: (e: React.FocusEvent<HTMLDivElement>) => void
317
317
  /**
318
318
  * Called at the start of a paste, before PromptArea reads the clipboard. Call
319
- * `preventDefault()` to take over the paste completely — e.g. to divert large
319
+ * `preventDefault()` to take over the paste completely - e.g. to divert large
320
320
  * text or non-image files to an upload pipeline. The built-in segment/image
321
321
  * paste handling is skipped when the event's default is prevented.
322
322
  */
@@ -80,7 +80,7 @@ export function useChipEditing({
80
80
 
81
81
  // The chip node currently edited via `reopenOnChipClick`, kept in lockstep with
82
82
  // `editingChip`/`activeTrigger`. Answers "is THIS exact element the open one" by
83
- // reference identity — trigger+value cannot distinguish two chips sharing a value.
83
+ // reference identity - trigger+value cannot distinguish two chips sharing a value.
84
84
  const openChipNode = useRef<HTMLElement | null>(null)
85
85
 
86
86
  // Set by `handleMouseDown` when the mousedown landed on `openChipNode.current`;
@@ -88,7 +88,7 @@ export function useChipEditing({
88
88
  // "toggle closed". A real mousedown on the editor root rather than a
89
89
  // `dismissTrigger` flag: bubbling reaches the root before `document`, where
90
90
  // TriggerPopover's outside-click dismiss listens and would clear `openChipNode`
91
- // first — and it stays scoped to this node, so an unrelated dismiss cannot poison
91
+ // first - and it stays scoped to this node, so an unrelated dismiss cannot poison
92
92
  // a later click on the same chip.
93
93
  const suppressReopenChip = useRef<HTMLElement | null>(null)
94
94
 
@@ -106,7 +106,7 @@ export function useChipEditing({
106
106
 
107
107
  let node: Node | null = target
108
108
  while (node && node !== editor) {
109
- // Check for URL link click — only navigate on Cmd/Ctrl+Click;
109
+ // Check for URL link click - only navigate on Cmd/Ctrl+Click;
110
110
  // plain click just positions the cursor for editing.
111
111
  if (isLinkElement(node)) {
112
112
  if (e.metaKey || e.ctrlKey) {
@@ -137,12 +137,12 @@ export function useChipEditing({
137
137
  if (chip) {
138
138
  // Native chip-click dropdown: reopen this trigger's suggestions
139
139
  // anchored to the chip so the selection can replace it in place.
140
- // Gated on `!disabled` — a disabled composer must not accept edits
140
+ // Gated on `!disabled` - a disabled composer must not accept edits
141
141
  // through any path, including this one.
142
142
  const config = triggers.find((t) => t.char === chip.trigger)
143
143
  // A click on THIS exact chip element while its own dropdown was
144
144
  // open just closed it (see `suppressReopenChip` and
145
- // `handleMouseDown`) — treat that as a toggle-close, not a reopen.
145
+ // `handleMouseDown`) - treat that as a toggle-close, not a reopen.
146
146
  const wasOpenForThisChip = suppressReopenChip.current === node
147
147
  suppressReopenChip.current = null
148
148
  if (!disabled && !wasOpenForThisChip && config?.reopenOnChipClick && config.mode === 'dropdown' && config.onSearch) {
@@ -212,7 +212,7 @@ export function useChipEditing({
212
212
  }
213
213
  const editor = editorRef.current
214
214
  if (editor && !disabled) {
215
- // Re-verify the click-time index still holds the same chip — the
215
+ // Re-verify the click-time index still holds the same chip - the
216
216
  // model may have shifted (external value update, undo/redo) while
217
217
  // the dropdown was open. If it moved, recover ONLY when exactly one
218
218
  // chip in the document now matches trigger+value: with duplicates,
@@ -258,7 +258,7 @@ export function useChipEditing({
258
258
  renderSegmentsToDOM(newSegments)
259
259
 
260
260
  // Same value + display text + data: treat as a no-op confirmation
261
- // rather than a destructive delete+add — onChipDelete is
261
+ // rather than a destructive delete+add - onChipDelete is
262
262
  // documented as firing on backspace/forward-delete, not on
263
263
  // re-confirming the already-selected suggestion.
264
264
  const unchanged =
@@ -271,7 +271,7 @@ export function useChipEditing({
271
271
  }
272
272
 
273
273
  // +1 when a space was inserted, matching resolveChip's own
274
- // "+1 accounts for the trailing space after the chip" placement —
274
+ // "+1 accounts for the trailing space after the chip" placement -
275
275
  // landing exactly at the chip's end would put the caret at the
276
276
  // same bare element boundary the inserted space exists to avoid.
277
277
  const caretOffset = segmentsToPlainText(newSegments.slice(0, segIdx + 1)).length + (insertedSpace ? 1 : 0)
@@ -37,7 +37,7 @@ import { useCallback, useMemo, useState } from 'react'
37
37
  */
38
38
  export type PromptAreaMode = 'markdown' | 'plain'
39
39
 
40
- /** Returns the other mode. Pure — handy for building custom toggles. */
40
+ /** Returns the other mode. Pure - handy for building custom toggles. */
41
41
  export function oppositeMode(mode: PromptAreaMode): PromptAreaMode {
42
42
  return mode === 'markdown' ? 'plain' : 'markdown'
43
43
  }
@@ -47,7 +47,7 @@ export type UseMarkdownModeOptions = {
47
47
  initialMode?: PromptAreaMode
48
48
  /**
49
49
  * Controlled mode. When provided, the hook mirrors this value and never owns
50
- * its own state — drive changes through `onModeChange`.
50
+ * its own state - drive changes through `onModeChange`.
51
51
  */
52
52
  mode?: PromptAreaMode
53
53
  /** Called with the next mode whenever `toggle`/`setMode` change it. */
@@ -57,7 +57,7 @@ export type UseMarkdownModeOptions = {
57
57
  export type MarkdownModeState = {
58
58
  /** The active mode. */
59
59
  mode: PromptAreaMode
60
- /** `true` in markdown mode — spread onto `<PromptArea markdown={markdown} />`. */
60
+ /** `true` in markdown mode - spread onto `<PromptArea markdown={markdown} />`. */
61
61
  markdown: boolean
62
62
  /** `true` in plain-text mode (the inverse of `markdown`). */
63
63
  isPlainText: boolean
@@ -165,9 +165,9 @@ export function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHand
165
165
  }
166
166
 
167
167
  // When markdown mode is on, prefer the richest clipboard flavor:
168
- // 1. text/markdown — some apps (e.g. Slack) hand out markdown directly,
168
+ // 1. text/markdown - some apps (e.g. Slack) hand out markdown directly,
169
169
  // preserving nested lists that their text/plain flattens.
170
- // 2. text/html — convert web/Notion/Docs/GitHub HTML to markdown.
170
+ // 2. text/html - convert web/Notion/Docs/GitHub HTML to markdown.
171
171
  // Otherwise (markdown off, or neither present) fall back to plain text.
172
172
  let text = ''
173
173
  if (markdownEnabled) {
@@ -180,7 +180,7 @@ export function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHand
180
180
  } else {
181
181
  const html = e.clipboardData.getData('text/html')
182
182
  // A converter failure (e.g. stack overflow on pathologically deep
183
- // nesting) must not drop the paste — leave text empty so the
183
+ // nesting) must not drop the paste - leave text empty so the
184
184
  // text/plain fallback below still runs.
185
185
  if (html) {
186
186
  try {
@@ -373,7 +373,7 @@ export function usePromptAreaKeydown({
373
373
  }
374
374
 
375
375
  // 1.75 Launch triggers: a trigger with mode 'launch' fires onActivate on
376
- // keydown and suppresses the char so it never enters the editor — for
376
+ // keydown and suppresses the char so it never enters the editor - for
377
377
  // opening an external surface (dialog, palette). The DOM read is gated on
378
378
  // the typed key actually matching a launch char, so it stays off the hot
379
379
  // path. insertChip still inserts a chip at the cursor if the consumer
@@ -406,7 +406,7 @@ export function usePromptAreaKeydown({
406
406
  // 2. Trigger dropdown navigation. Gated on the dropdown actually being
407
407
  // ON SCREEN, which matches TriggerPopover's own render condition
408
408
  // (non-empty suggestions, OR loading/error/emptyMessage) rather than
409
- // just `suggestions.length > 0` — otherwise a popover left open in a
409
+ // just `suggestions.length > 0` - otherwise a popover left open in a
410
410
  // loading/empty state (e.g. right after a chip-click reopen, before its
411
411
  // empty-query search resolves) lets Enter fall through to onSubmit and
412
412
  // Escape fall through to onEscape while still visibly on screen.
@@ -499,7 +499,7 @@ export function usePromptAreaKeydown({
499
499
  }
500
500
 
501
501
  // 3. Enter without Shift (skipping IME): under `submitOnEnter` it submits
502
- // *unconditionally* — the send key must not turn into "another bullet" because
502
+ // *unconditionally* - the send key must not turn into "another bullet" because
503
503
  // of what the line above starts with; list continuation lives on Shift+Enter
504
504
  // (branch 2.8). Without `submitOnEnter`, Enter is the newline key and continues.
505
505
  if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
@@ -35,11 +35,11 @@ export type UsePromptAreaStateOptions = {
35
35
  }
36
36
 
37
37
  export type PromptAreaBind = {
38
- /** Ref to attach to PromptArea — gives access to imperative methods. */
38
+ /** Ref to attach to PromptArea - gives access to imperative methods. */
39
39
  ref: React.RefObject<PromptAreaHandle | null>
40
- /** Current segment array — pass as `value` prop. */
40
+ /** Current segment array - pass as `value` prop. */
41
41
  value: Segment[]
42
- /** Setter — pass as `onChange` prop. */
42
+ /** Setter - pass as `onChange` prop. */
43
43
  onChange: (segments: Segment[]) => void
44
44
  }
45
45
 
@@ -83,7 +83,7 @@ export function usePromptAreaState(options: UsePromptAreaStateOptions = {}): Pro
83
83
 
84
84
  const chips = useMemo(() => value.filter((seg): seg is ChipSegment => seg.type === 'chip'), [value])
85
85
 
86
- // Bind object — safe to spread onto <PromptArea>
86
+ // Bind object - safe to spread onto <PromptArea>
87
87
  const bind = useMemo<PromptAreaBind>(() => ({ ref, value, onChange: setValue }), [value])
88
88
 
89
89
  const clear = useCallback(() => {
@@ -88,7 +88,7 @@ type UsePromptAreaReturn = {
88
88
  const UNDO_DEBOUNCE_MS = 300
89
89
 
90
90
  // Shared default so an omitted `triggers` prop keeps a stable identity across
91
- // renders — a fresh [] would re-create renderSegmentsToDOM (and everything
91
+ // renders - a fresh [] would re-create renderSegmentsToDOM (and everything
92
92
  // keyed on it) on every parent render.
93
93
  const NO_TRIGGERS: TriggerConfig[] = []
94
94
 
@@ -401,7 +401,7 @@ export function usePromptArea({
401
401
  // pending passive effects before rendering that keystroke's onChange. The
402
402
  // stale flush would compare an older render's `value` against the
403
403
  // lastRenderedValue that handleInput just advanced, read its own echo lag as
404
- // an external change, and rewrite the editor to pre-keystroke content —
404
+ // an external change, and rewrite the editor to pre-keystroke content -
405
405
  // leaving the caret behind the character once the echo re-rendered it.
406
406
  // Inside the commit, `value` can never be older than lastRenderedValue.
407
407
  useLayoutEffect(() => {
@@ -423,7 +423,7 @@ export function usePromptArea({
423
423
  }
424
424
 
425
425
  // A rebuild churns the caret and the decorations, so skip it when the
426
- // editor already shows exactly this content — e.g. a consumer echoed our
426
+ // editor already shows exactly this content - e.g. a consumer echoed our
427
427
  // onChange back re-chunked, or the DOM is split by decorations. Adopt the
428
428
  // value so the equality guard above short-circuits from here on.
429
429
  if (segmentsContentEqual(readSegmentsFromDOM(), value)) {
@@ -520,7 +520,7 @@ export function usePromptArea({
520
520
  // Native structural edits (e.g. a Backspace that deleted or merged a list
521
521
  // row) bypass applyEditResult, so rebuild ordered-list numbering here too.
522
522
  // handleInput fires on every keystroke, so gate on a genuine ordered-list
523
- // run — this renumbers a real list (1,2,4 → 1,2,3) but leaves incidental
523
+ // run - this renumbers a real list (1,2,4 → 1,2,3) but leaves incidental
524
524
  // numeric prose ("1985. Born / 2020. Died") untouched.
525
525
  let nextSegments = segments
526
526
  let renumberedCursor: number | null = null
@@ -643,7 +643,7 @@ export function usePromptArea({
643
643
 
644
644
  const selectSuggestion = selectSuggestionInternal
645
645
 
646
- // Handle key events — the numbered router and its chip-deletion helpers live in
646
+ // Handle key events - the numbered router and its chip-deletion helpers live in
647
647
  // use-prompt-area-keydown.ts.
648
648
 
649
649
  const handleKeyDown = usePromptAreaKeydown({
@@ -25,7 +25,7 @@ export function useTriggerSearch(): UseTriggerSearchReturn {
25
25
  const [suggestionsLoading, setSuggestionsLoading] = useState(false)
26
26
  const [suggestionsError, setSuggestionsError] = useState<string | null>(null)
27
27
 
28
- // Version counter – belt-and-suspenders alongside AbortController
28
+ // Version counter - belt-and-suspenders alongside AbortController
29
29
  const searchVersion = useRef(0)
30
30
  const abortController = useRef<AbortController | null>(null)
31
31
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -42,8 +42,8 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
42
42
  onCancel={() => setDenying(false)}
43
43
  placeholder={
44
44
  plan
45
- ? 'What should change? (optional) — the agent keeps planning and reads this'
46
- : 'Reason (optional) — the agent reads this and can try something else'
45
+ ? 'What should change? (optional) - the agent keeps planning and reads this'
46
+ : 'Reason (optional) - the agent reads this and can try something else'
47
47
  }
48
48
  />
49
49
  ) : undefined
@@ -51,7 +51,7 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
51
51
  const options = plan
52
52
  ? [
53
53
  { key: 'allow', label: 'Approve plan' },
54
- { key: 'deny', label: 'Keep planning — tell it what to change', detail: reasonInput },
54
+ { key: 'deny', label: 'Keep planning - tell it what to change', detail: reasonInput },
55
55
  { key: 'stop', label: 'No, and stop the turn', danger: true },
56
56
  ]
57
57
  : [
@@ -116,7 +116,7 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
116
116
  <Hint>
117
117
  {denying
118
118
  ? 'Enter to send · Shift+Enter for a new line · Esc to go back'
119
- : 'Enter to select · ↑/↓ to navigate · 1–3 to choose · Esc to cancel'}
119
+ : 'Enter to select · ↑/↓ to navigate · 1-3 to choose · Esc to cancel'}
120
120
  </Hint>
121
121
  </div>
122
122
  )
@@ -44,7 +44,7 @@ export function WithActions({ actions, children, className }: { actions: ReactNo
44
44
 
45
45
  // Bookmarks are a host concern (which items, where they persist); the transcript only needs
46
46
  // membership and a toggle. A missing provider renders no action at all, so embeddings that
47
- // never wire bookmarks pay nothing — the same contract AffordanceContext has.
47
+ // never wire bookmarks pay nothing - the same contract AffordanceContext has.
48
48
  export type BookmarkHandle = {
49
49
  has: (itemId: string) => boolean
50
50
  toggle: (itemId: string) => void
@@ -0,0 +1,20 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+
3
+ export type FileLinkOpener = (path: string, line?: number) => void
4
+
5
+ export type FileLinkHandle = {
6
+ cwd?: string
7
+ open: FileLinkOpener
8
+ }
9
+
10
+ const FileLinkContext = createContext<FileLinkHandle | undefined>(undefined)
11
+
12
+ // A missing provider leaves every link an ordinary anchor, so embeddings that have nowhere to
13
+ // open a file pay nothing - the same contract BookmarkContext has.
14
+ export function FileLinkProvider({ value, children }: { value: FileLinkHandle | undefined; children: ReactNode }) {
15
+ return <FileLinkContext.Provider value={value}>{children}</FileLinkContext.Provider>
16
+ }
17
+
18
+ export function useFileLinks(): FileLinkHandle | undefined {
19
+ return useContext(FileLinkContext)
20
+ }
@@ -102,7 +102,7 @@ type Token = { kind: 'word' | 'space'; w: number; exact: boolean }
102
102
  const TAB_SIZE = 2
103
103
 
104
104
  // Break after these unless a digit follows; `?` is here because Chrome breaks long URLs after it (verified against real break rects).
105
- const BREAK_AFTER = new Set(['-', '–', '—', '?'])
105
+ const BREAK_AFTER = new Set(['-', '-', '-', '?'])
106
106
 
107
107
  const PLAIN_ASCII = /^[\x20-\x7e]*$/
108
108
 
@@ -181,7 +181,7 @@ function tokenize(line: string): Token[] {
181
181
  const { w, exact } = clusterCells(segment)
182
182
  const cp = segment.codePointAt(0) ?? 0
183
183
  if (isWide(cp) || PICTOGRAPHIC.test(segment)) {
184
- // Each wide cluster is its own token — a break may fall between any two.
184
+ // Each wide cluster is its own token - a break may fall between any two.
185
185
  flushWord()
186
186
  tokens.push({ kind: 'word', w, exact })
187
187
  col += w
@@ -173,8 +173,8 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
173
173
  }}
174
174
  >
175
175
  {truncated
176
- ? `… +${missing.toLocaleString()} chars — fetch the rest`
177
- : `… +${hidden} line${hidden === 1 ? '' : 's'} — show all ${text.length.toLocaleString()} chars`}
176
+ ? `… +${missing.toLocaleString()} chars - fetch the rest`
177
+ : `… +${hidden} line${hidden === 1 ? '' : 's'} - show all ${text.length.toLocaleString()} chars`}
178
178
  </button>
179
179
  ) : (
180
180
  <>
@@ -1,7 +1,9 @@
1
1
  import { memo, type ReactNode } from 'react'
2
2
  import { Streamdown, type Components } from 'streamdown'
3
3
  import { cn } from '../../lib/utils.ts'
4
+ import { parseFileLink } from '../../lib/file-link.ts'
4
5
  import { CopyAction, WithActions } from './affordances.tsx'
6
+ import { useFileLinks } from './file-link.tsx'
5
7
  import { Band } from './row.tsx'
6
8
 
7
9
  function codeText(node: ReactNode): string {
@@ -45,6 +47,34 @@ function heading(tone: 'bright' | 'fg'): Components['h1'] {
45
47
  }
46
48
  }
47
49
 
50
+ // An agent writes a file as a markdown link far more often than it writes a URL, and the link text is
51
+ // its name, not its path: an anchor to a scheme-less href resolves against the host page and leads nowhere.
52
+ function Link({ href, children }: { href?: string; children: ReactNode }) {
53
+ const links = useFileLinks()
54
+ const file = links ? parseFileLink(href, links.cwd) : undefined
55
+ if (file) {
56
+ return (
57
+ <button
58
+ type="button"
59
+ className="term-link"
60
+ data-tone="blue"
61
+ title={file.line === undefined ? file.path : `${file.path}:${file.line}`}
62
+ onClick={(event) => {
63
+ event.stopPropagation()
64
+ links?.open(file.path, file.line)
65
+ }}
66
+ >
67
+ {children}
68
+ </button>
69
+ )
70
+ }
71
+ return (
72
+ <a className="term-link" data-tone="blue" href={href} target="_blank" rel="noreferrer">
73
+ {children}
74
+ </a>
75
+ )
76
+ }
77
+
48
78
  const TERMINAL_COMPONENTS: Components = {
49
79
  p: ({ children }) => <div className="term-block">{children}</div>,
50
80
 
@@ -85,11 +115,7 @@ const TERMINAL_COMPONENTS: Components = {
85
115
  </strong>
86
116
  ),
87
117
  em: ({ children }) => <em className="term-em">{children}</em>,
88
- a: ({ children, href }) => (
89
- <a className="term-link" data-tone="blue" href={href} target="_blank" rel="noreferrer">
90
- {children}
91
- </a>
92
- ),
118
+ a: ({ children, href }) => <Link href={href}>{children}</Link>,
93
119
 
94
120
  table: ({ children }) => (
95
121
  <div className="term-block term-table-wrap">
@@ -351,7 +351,7 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
351
351
  setPeek(null)
352
352
  }, [props.items])
353
353
 
354
- // Manual listener because it must `preventDefault` — React's root wheel listeners are passive.
354
+ // Manual listener because it must `preventDefault` - React's root wheel listeners are passive.
355
355
  useEffect(() => {
356
356
  if (!interactive) {
357
357
  return
@@ -29,7 +29,7 @@ export function todoPreview(name: string, input: unknown): TodoPreview | undefin
29
29
  }
30
30
  }
31
31
 
32
- // Height and render both draw this exact string — `toolRowHeight` counts what `TerminalTodos` paints.
32
+ // Height and render both draw this exact string - `toolRowHeight` counts what `TerminalTodos` paints.
33
33
  export function todoLine(todo: TodoEntry): string {
34
34
  return `${GLYPH[todo.status]} ${todo.text}`
35
35
  }
@@ -59,7 +59,7 @@ export function Splitter({
59
59
  }
60
60
  }
61
61
 
62
- // Reached after `endDrag` — the second click of a double-click has already started a drag, and dblclick fires after pointerup.
62
+ // Reached after `endDrag` - the second click of a double-click has already started a drag, and dblclick fires after pointerup.
63
63
  const onDoubleClick = () => {
64
64
  if (defaultValue !== undefined) {
65
65
  onValueChange(clamp(defaultValue))
package/src/index.ts CHANGED
@@ -61,6 +61,8 @@ export { TerminalDiff, previewPatch } from './components/terminal/diff.tsx'
61
61
  export { TerminalMarkdown, type TerminalMarkdownProps } from './components/terminal/markdown.tsx'
62
62
  export { Band, Blank, Ink, Row, type RowProps, type Tone } from './components/terminal/row.tsx'
63
63
  export { CopyAction, WithActions, useAffordances, type TerminalAffordances } from './components/terminal/affordances.tsx'
64
+ export { FileLinkProvider, useFileLinks, type FileLinkHandle, type FileLinkOpener } from './components/terminal/file-link.tsx'
65
+ export { parseFileLink, type FileLink } from './lib/file-link.ts'
64
66
  export { Conversation, ConversationContent, ConversationScrollButton, type ConversationProps } from './components/agent/Conversation.tsx'
65
67
  export { Message, MessageContent, type MessageProps } from './components/agent/Message.tsx'
66
68
  export {
@@ -86,7 +88,15 @@ export {
86
88
  type QuestionPromptProps,
87
89
  type QuestionBehaviorMeta,
88
90
  } from './components/agent/QuestionPrompt.tsx'
89
- export { Composer, skillPrompt, type ComposerFileMatch, type ComposerHandle, type ComposerProps } from './components/agent/Composer.tsx'
91
+ export { Composer, type ComposerFileMatch, type ComposerHandle, type ComposerProps } from './components/agent/Composer.tsx'
92
+ export {
93
+ mergeComposerRows,
94
+ rankComposerRows,
95
+ matchClientCommand,
96
+ skillPrompt,
97
+ type ClientCommand,
98
+ type ComposerRow,
99
+ } from './components/agent/composer-commands.ts'
90
100
  export { ModelSelect, type ModelSelectProps } from './components/agent/ModelSelect.tsx'
91
101
  export {
92
102
  PERMISSION_MODES,
@@ -9,11 +9,11 @@ export interface ContextNote {
9
9
 
10
10
  const CODEX_CONTEXT_NOTE: ContextNote = {
11
11
  summary:
12
- 'Codex budgets 272K input by default and reports 95% of it, so a 1M-class model reads ~258k. That is codex’s price tier, not the model’s ceiling — and it is the number auto-compaction fires on, so the meter is honest.',
13
- hint: 'Raising it is yours to set: model_context_window in ~/.codex/config.toml — and watch for auto-compaction failing afterwards.',
12
+ 'Codex budgets 272K input by default and reports 95% of it, so a 1M-class model reads ~258k. That is codex’s price tier, not the model’s ceiling - and it is the number auto-compaction fires on, so the meter is honest.',
13
+ hint: 'Raising it is yours to set: model_context_window in ~/.codex/config.toml - and watch for auto-compaction failing afterwards.',
14
14
  detail: [
15
15
  'To raise it, set model_context_window in ~/.codex/config.toml, up to 872000 (reported as 828400). Codex clamps to its own max_context_window, itself below OpenAI’s stated 922,000 max input.',
16
- 'Above 272K input, a request bills at 2x input / 1.5x output for the whole request. WorkerDeck never writes this setting — it is a cost decision on your account.',
16
+ 'Above 272K input, a request bills at 2x input / 1.5x output for the whole request. WorkerDeck never writes this setting - it is a cost decision on your account.',
17
17
  ],
18
18
  caveat:
19
19
  'Raise it and watch for auto-compaction failing: openai/codex #16068 reports it stops firing after the first overflow. Unverified here on 0.149.0.',
@@ -0,0 +1,70 @@
1
+ export type FileLink = {
2
+ path: string
3
+ line?: number
4
+ }
5
+
6
+ const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
7
+ const TRAILING_LINE = /^(.*?):(\d+)(?::\d+)?$/
8
+ const FRAGMENT_LINE = /^L(\d+)/
9
+
10
+ // What an agent writes as a markdown link target when it means a file on the host: `./SPEC.md`,
11
+ // `docs/AUTH.md#anchor`, `/Users/me/project/src/main.ts:42`, `file:///tmp/out.txt`. Anything with a
12
+ // real scheme, and anything relative that arrives before the session's cwd is known, stays a web link.
13
+ export function parseFileLink(href: string | undefined, cwd: string | undefined): FileLink | undefined {
14
+ const raw = href?.trim()
15
+ if (!raw || raw.startsWith('#') || raw.startsWith('//')) {
16
+ return undefined
17
+ }
18
+
19
+ let target = raw
20
+ if (SCHEME.test(target)) {
21
+ if (!target.startsWith('file:')) {
22
+ return undefined
23
+ }
24
+ try {
25
+ target = decodeURIComponent(new URL(target).pathname)
26
+ } catch {
27
+ return undefined
28
+ }
29
+ }
30
+
31
+ let line: number | undefined
32
+ const hash = target.indexOf('#')
33
+ if (hash !== -1) {
34
+ line = readLine(FRAGMENT_LINE.exec(target.slice(hash + 1))?.[1])
35
+ target = target.slice(0, hash)
36
+ }
37
+
38
+ const suffix = TRAILING_LINE.exec(target)
39
+ if (suffix) {
40
+ target = suffix[1]!
41
+ line ??= readLine(suffix[2])
42
+ }
43
+
44
+ if (!target) {
45
+ return undefined
46
+ }
47
+
48
+ const path = target.startsWith('/') ? normalize(target) : cwd ? normalize(`${cwd}/${target}`) : undefined
49
+ return path ? { path, ...(line === undefined ? {} : { line }) } : undefined
50
+ }
51
+
52
+ function readLine(value: string | undefined): number | undefined {
53
+ const parsed = value === undefined ? Number.NaN : Number(value)
54
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
55
+ }
56
+
57
+ function normalize(path: string): string {
58
+ const out: string[] = []
59
+ for (const part of path.split('/')) {
60
+ if (part === '' || part === '.') {
61
+ continue
62
+ }
63
+ if (part === '..') {
64
+ out.pop()
65
+ continue
66
+ }
67
+ out.push(part)
68
+ }
69
+ return `/${out.join('/')}`
70
+ }
package/src/lib/format.ts CHANGED
@@ -28,7 +28,7 @@ export function compactionText(item: {
28
28
 
29
29
  export function formatCost(usd: number | undefined): string {
30
30
  if (usd === undefined || Number.isNaN(usd)) {
31
- return '—'
31
+ return '-'
32
32
  }
33
33
  if (usd === 0) {
34
34
  return '$0.00'
@@ -92,7 +92,7 @@ export function formatCountdown(untilEpochMs: number, now = Date.now()): string
92
92
 
93
93
  export function formatRelativeTime(epochMs: number | undefined, now = Date.now()): string {
94
94
  if (!epochMs) {
95
- return '—'
95
+ return '-'
96
96
  }
97
97
  const diff = Math.max(0, now - epochMs)
98
98
  const s = Math.floor(diff / 1000)