dsh-code 1.0.2 → 1.0.3

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.
package/src/app.ts CHANGED
@@ -14,16 +14,16 @@
14
14
  * @module @deepseek-ai/dsh-code/app
15
15
  */
16
16
 
17
- import { basename } from 'node:path'
18
- import {
17
+ import { basename } from 'node:path'
18
+ import {
19
19
  createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
20
20
  } from 'react'
21
21
  import { Box, Static, Text, useInput, useStdin, useStdout, type Key } from 'ink'
22
- import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
- import type { ImageBlock } from '@deepseek-ai/dsh-llm'
24
- import type { TodoItem } from '@deepseek-ai/dsh-session'
25
- import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
26
- import type { AuthorizationInteraction, AuthorizationStatus } from '@deepseek-ai/dsh-authorization'
22
+ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
+ import type { ImageBlock } from '@deepseek-ai/dsh-llm'
24
+ import type { TodoItem } from '@deepseek-ai/dsh-session'
25
+ import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
26
+ import type { AuthorizationInteraction, AuthorizationStatus } from '@deepseek-ai/dsh-authorization'
27
27
  import {
28
28
  dim,
29
29
  getPalette,
@@ -40,20 +40,20 @@ import type { TranscriptStore } from './store.ts'
40
40
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
41
41
  import { type MdSegment, visibleColumns } from './render/markdown.ts'
42
42
  import {
43
- busyChaseFrame,
44
- BUSY_CHASE_TICK_MS,
45
- caretVisible,
46
- DEEP_DIVING_SHIMMER_TICK_MS,
47
- DEEPSEEK_WAVE_TICK_MS,
48
- deepseekWaveColumnBg,
49
- deepseekWaveDuration,
50
- deepseekWaveSpark,
51
- deepseekWaveStyleRandom,
52
- deepseekWaveTier,
53
- deepseekWaveWordHue,
54
- deepseekWaveWordVisible,
55
- deepDivingGradientColor,
56
- deepDivingSparkColor,
43
+ busyChaseFrame,
44
+ BUSY_CHASE_TICK_MS,
45
+ caretVisible,
46
+ DEEP_DIVING_SHIMMER_TICK_MS,
47
+ DEEPSEEK_WAVE_TICK_MS,
48
+ deepseekWaveColumnBg,
49
+ deepseekWaveDuration,
50
+ deepseekWaveSpark,
51
+ deepseekWaveStyleRandom,
52
+ deepseekWaveTier,
53
+ deepseekWaveWordHue,
54
+ deepseekWaveWordVisible,
55
+ deepDivingGradientColor,
56
+ deepDivingSparkColor,
57
57
  effortAboveHigh,
58
58
  isOfficialDeepSeekLabel,
59
59
  type DeepseekWaveStyle,
@@ -65,7 +65,7 @@ import type { ModelDirectory, ModelRow } from './models.ts'
65
65
  import type { ProviderConfiguration, ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
66
66
  import type { QuestionSnapshot, QuestionStore } from './questions.ts'
67
67
  import type { SkillsView, SkillRow } from './skills.ts'
68
- import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
68
+ import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
69
69
  import type { SubagentFeedView, SubagentRow } from './subagents.ts'
70
70
  import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
71
71
  import type { PresetRow } from './presets.ts'
@@ -80,19 +80,19 @@ import {
80
80
  type RecallState,
81
81
  } from './history.ts'
82
82
  import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
83
- import type { GitDiffView } from './git-workflow.ts'
84
- import {
85
- authorizationForProvider,
86
- providerAuthorizationStatus,
87
- type ProviderAuthorizationDirectory,
88
- type ProviderAuthorizationRow,
89
- } from './authorization.ts'
90
- import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
91
- import {
92
- looksLikeImagePath,
93
- parsePastedImagePaths,
94
- type ImagePathInspection,
95
- } from './attachments.ts'
83
+ import type { GitDiffView } from './git-workflow.ts'
84
+ import {
85
+ authorizationForProvider,
86
+ providerAuthorizationStatus,
87
+ type ProviderAuthorizationDirectory,
88
+ type ProviderAuthorizationRow,
89
+ } from './authorization.ts'
90
+ import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
91
+ import {
92
+ looksLikeImagePath,
93
+ parsePastedImagePaths,
94
+ type ImagePathInspection,
95
+ } from './attachments.ts'
96
96
 
97
97
  /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
98
98
  const RESIZE_REFLOW_DELAY_MS = 75
@@ -144,16 +144,16 @@ import {
144
144
  type StatusTone,
145
145
  } from './render/status.ts'
146
146
  import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
147
- import {
148
- isVsCodeTerminalEnv,
149
- normalizeKeyboardChunk,
150
- PASTE_END_MARKER,
151
- PASTE_START_MARKER,
152
- stripPasteMarkers,
153
- stripTerminalFocusEvents,
154
- tokenizeRawEditorChunk,
155
- type RawEditorToken,
156
- } from './keyboard.ts'
147
+ import {
148
+ isVsCodeTerminalEnv,
149
+ normalizeKeyboardChunk,
150
+ PASTE_END_MARKER,
151
+ PASTE_START_MARKER,
152
+ stripPasteMarkers,
153
+ stripTerminalFocusEvents,
154
+ tokenizeRawEditorChunk,
155
+ type RawEditorToken,
156
+ } from './keyboard.ts'
157
157
  import {
158
158
  clampScroll,
159
159
  followInspectorCursor,
@@ -165,6 +165,7 @@ import {
165
165
  selectionWindow,
166
166
  } from './render/inspector.ts'
167
167
  import {
168
+ clampLiveAllocation,
168
169
  lineSegment,
169
170
  markdownLines,
170
171
  settledEntryLines,
@@ -181,59 +182,60 @@ import {
181
182
  deleteBackward,
182
183
  deleteForward,
183
184
  deleteLastGrapheme,
184
- deleteWordBackward,
185
- deleteWordForward,
186
- editorModel,
187
- editorRowParts,
185
+ deleteWordBackward,
186
+ deleteWordForward,
187
+ editorModel,
188
+ editorRowParts,
188
189
  insertText,
189
190
  type EditResult,
190
191
  killToLineEnd,
191
192
  killToLineStart,
192
- moveCursorBy,
193
- moveCursorVertically,
194
- moveToLineEnd,
195
- moveToLineStart,
196
- moveWordLeft,
197
- moveWordRight,
198
- remapStableRange,
199
- replaceRangePreservingCursor,
193
+ moveCursorBy,
194
+ moveCursorVertically,
195
+ moveToLineEnd,
196
+ moveToLineStart,
197
+ moveWordLeft,
198
+ moveWordRight,
199
+ remapStableRange,
200
+ replaceRangePreservingCursor,
200
201
  sanitizeDraftText,
201
202
  shouldRecallNavigate,
202
203
  splitGraphemes,
203
204
  } from './render/editor.ts'
204
205
 
205
- /** Visual priority for one bounded local notice. */
206
- export type NoticeTone = 'info' | 'warning' | 'error'
207
-
208
- /** One source of truth for TUI-owned slash commands in completion and `/help`. */
209
- const LOCAL_COMMANDS = [
210
- { label: '/help', description: 'show this overlay' },
211
- { label: '/model', description: 'switch the model and manage providers' },
212
- { label: '/effort', description: 'adjust reasoning effort for the current model' },
213
- { label: '/mode', description: 'inspect or select the agent preset (/mode [preset])' },
214
- { label: '/permission', description: 'inspect or select the permission preset (/permission [preset])' },
215
- { label: '/new', description: 'create and switch to a fresh session (/new [preset])' },
216
- { label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
217
- { label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
218
- { label: '/plugin', description: 'inspect the live plugin composition' },
219
- { label: '/jobs', description: 'inspect background jobs' },
220
- { label: '/statusline', description: 'customize the status line items' },
221
- { label: '/theme', description: 'switch the color theme' },
222
- { label: '/history', description: 'search and recall past prompts' },
223
- { label: '/agents', description: 'inspect subagent sessions of this conversation' },
224
- { label: '/todos', description: 'inspect the full todo list' },
225
- { label: '/subagent', description: 'choose the model delegated subagents run on' },
226
- { label: '/delete', description: 'delete a session and its subagent threads' },
227
- { label: '/clear', description: 'clear the screen' },
228
- { label: '/export', description: 'export the transcript to markdown (/export [path])' },
229
- { label: '/title', description: 'rename this session (/title <text>)' },
230
- { label: '/copy', description: 'copy the latest assistant response' },
231
- { label: '/diff', description: 'inspect Git changes (/diff [--staged|ref])' },
232
- { label: '/review', description: 'review Git changes under read-only permissions' },
233
- { label: '/quit', description: 'exit' },
234
- ] as const
235
-
236
- const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map(command => command.label.slice(1)))
206
+ /** Visual priority for one bounded local notice. */
207
+ export type NoticeTone = 'info' | 'warning' | 'error'
208
+
209
+ /** One source of truth for TUI-owned slash commands in completion and `/help`. */
210
+ const LOCAL_COMMANDS = [
211
+ { label: '/help', description: 'show this overlay' },
212
+ { label: '/model', description: 'switch the model and manage providers' },
213
+ { label: '/effort', description: 'adjust reasoning effort for the current model' },
214
+ { label: '/mode', description: 'inspect or select the agent preset (/mode [preset])' },
215
+ { label: '/permission', description: 'inspect or select the permission preset (/permission [preset])' },
216
+ { label: '/new', description: 'create and switch to a fresh session (/new [preset])' },
217
+ { label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
218
+ { label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
219
+ { label: '/plugin', description: 'inspect the live plugin composition' },
220
+ { label: '/jobs', description: 'inspect background jobs' },
221
+ { label: '/statusline', description: 'customize the status line items' },
222
+ { label: '/theme', description: 'switch the color theme' },
223
+ { label: '/history', description: 'search and recall past prompts' },
224
+ { label: '/agents', description: 'inspect subagent sessions of this conversation' },
225
+ { label: '/todos', description: 'inspect the full todo list' },
226
+ { label: '/subagent', description: 'choose the model delegated subagents run on' },
227
+ { label: '/vscode-keys', description: 'pass ctrl+r through the vs code terminal' },
228
+ { label: '/delete', description: 'delete a session and its subagent threads' },
229
+ { label: '/clear', description: 'clear the screen' },
230
+ { label: '/export', description: 'export the transcript to markdown (/export [path])' },
231
+ { label: '/title', description: 'rename this session (/title <text>)' },
232
+ { label: '/copy', description: 'copy the latest assistant response' },
233
+ { label: '/diff', description: 'inspect Git changes (/diff [--staged|ref])' },
234
+ { label: '/review', description: 'review Git changes under read-only permissions' },
235
+ { label: '/quit', description: 'exit' },
236
+ ] as const
237
+
238
+ const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map(command => command.label.slice(1)))
237
239
 
238
240
  /** Props the runner hands the app; callbacks stay owned by the runner. */
239
241
  export interface AppProps {
@@ -268,9 +270,9 @@ export interface AppProps {
268
270
  /** Permission preset selected for the current or pending first session. */
269
271
  permission: string
270
272
  /** Submit one line: slash commands to the registry, other text to the agent. */
271
- dispatch(text: string, images?: readonly ImageBlock[]): void
272
- /** Submit steering: consumed at the running turn's next step boundary. */
273
- steer(text: string, images?: readonly ImageBlock[]): void
273
+ dispatch(text: string, images?: readonly ImageBlock[]): void
274
+ /** Submit steering: consumed at the running turn's next step boundary. */
275
+ steer(text: string, images?: readonly ImageBlock[]): void
274
276
  /** Interrupt the running turn (Esc); true when a turn was cancelled. */
275
277
  interrupt(): boolean
276
278
  /** Quit: unmount, flush, and request process exit. */
@@ -278,11 +280,11 @@ export interface AppProps {
278
280
  /** Load the selectable model directory (called when /model opens). */
279
281
  loadModels(): Promise<ModelDirectory>
280
282
  /** Load @mention candidates for the typed query (files + sessions). */
281
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
282
- /** Validate draft image paths without committing attachment objects. */
283
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
284
- /** Validate, normalize and persist images immediately before submission. */
285
- prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
283
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
284
+ /** Validate draft image paths without committing attachment objects. */
285
+ inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
286
+ /** Validate, normalize and persist images immediately before submission. */
287
+ prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
286
288
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
287
289
  selectModel(row: ModelRow, effortId?: string): string
288
290
  /** The /subagent override label, '' when delegated agents follow the current model. */
@@ -303,21 +305,21 @@ export interface AppProps {
303
305
  unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
304
306
  /** Remove one user-owned provider profile and its page-managed credential. */
305
307
  removeModelProvider?(target: ProviderTargetView): Promise<void>
306
- /** Save endpoint and explicit model capacities through the provider profile. */
307
- saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
308
- /** Provider authorization flows and value-free stored-record facts. */
309
- loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
310
- subscribeProviderAuthorizations?(listener: () => void): () => void
311
- beginProviderAuthorization?(
312
- row: ProviderAuthorizationRow,
313
- method: string,
314
- interaction: AuthorizationInteraction,
315
- signal: AbortSignal,
316
- ): Promise<AuthorizationStatus>
317
- cancelProviderAuthorization?(row: ProviderAuthorizationRow): void
318
- logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
319
- openAuthorizationUrl?(url: string): boolean
320
- copyTextValue?(text: string): Promise<void>
308
+ /** Save endpoint and explicit model capacities through the provider profile. */
309
+ saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
310
+ /** Provider authorization flows and value-free stored-record facts. */
311
+ loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
312
+ subscribeProviderAuthorizations?(listener: () => void): () => void
313
+ beginProviderAuthorization?(
314
+ row: ProviderAuthorizationRow,
315
+ method: string,
316
+ interaction: AuthorizationInteraction,
317
+ signal: AbortSignal,
318
+ ): Promise<AuthorizationStatus>
319
+ cancelProviderAuthorization?(row: ProviderAuthorizationRow): void
320
+ logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
321
+ openAuthorizationUrl?(url: string): boolean
322
+ copyTextValue?(text: string): Promise<void>
321
323
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
322
324
  cyclePermission(): string
323
325
  /** Select or inspect a permission preset without requiring a pre-existing session. */
@@ -363,6 +365,8 @@ export interface AppProps {
363
365
  recordHistory(text: string): void
364
366
  /** Cancel one queued inbox message by identity (Delete on the empty composer). */
365
367
  cancelQueued(messageId: string): void
368
+ /** Apply the Ctrl+R terminal passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
369
+ applyEditorKeys(): Promise<string>
366
370
  }
367
371
 
368
372
  /** Pad text with spaces to a visible-column target (menu name column). */
@@ -372,15 +376,15 @@ function padColumns(text: string, width: number): string {
372
376
  }
373
377
 
374
378
  /** Interval-driven frame counter for one self-contained animated leaf. */
375
- function useFrames(intervalMs: number, active = true): number {
376
- const [tick, setTick] = useState(0)
377
- useEffect(() => {
378
- if (!active) return
379
- const id = setInterval(() => setTick(current => current + 1), intervalMs)
379
+ function useFrames(intervalMs: number, active = true): number {
380
+ const [tick, setTick] = useState(0)
381
+ useEffect(() => {
382
+ if (!active) return
383
+ const id = setInterval(() => setTick(current => current + 1), intervalMs)
380
384
  return () => {
381
385
  clearInterval(id)
382
386
  }
383
- }, [active, intervalMs])
387
+ }, [active, intervalMs])
384
388
  return tick
385
389
  }
386
390
 
@@ -399,11 +403,11 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
399
403
  useInput(stableHandler, { isActive: active })
400
404
  }
401
405
 
402
- /** The original web StateDot chase used by the busy composer marker. */
403
- function BusyChase(): ReactElement {
404
- const tick = useFrames(BUSY_CHASE_TICK_MS)
405
- return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
406
- }
406
+ /** The original web StateDot chase used by the busy composer marker. */
407
+ function BusyChase(): ReactElement {
408
+ const tick = useFrames(BUSY_CHASE_TICK_MS)
409
+ return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
410
+ }
407
411
 
408
412
  /** Blinking block caret appended to streaming text. */
409
413
  function Caret(): ReactElement {
@@ -411,54 +415,64 @@ function Caret(): ReactElement {
411
415
  return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
412
416
  }
413
417
 
414
- /** One resettable input-caret phase shared by the entire composer. */
415
- function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
416
- const [epoch, setEpoch] = useState(0)
417
- const [visible, setVisible] = useState(true)
418
- useEffect(() => {
419
- setVisible(true)
420
- if (!active) return
421
- const id = setInterval(() => setVisible(current => !current), 530)
422
- return () => {
423
- clearInterval(id)
424
- }
425
- }, [active, epoch])
426
- const reset = useCallback((): void => {
427
- setVisible(true)
428
- setEpoch(current => current + 1)
429
- }, [])
430
- return { visible, reset }
431
- }
418
+ /** One resettable input-caret phase shared by the entire composer. */
419
+ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
420
+ const [epoch, setEpoch] = useState(0)
421
+ const [visible, setVisible] = useState(true)
422
+ useEffect(() => {
423
+ setVisible(true)
424
+ if (!active) return
425
+ const id = setInterval(() => setVisible(current => !current), 530)
426
+ return () => {
427
+ clearInterval(id)
428
+ }
429
+ }, [active, epoch])
430
+ const reset = useCallback((): void => {
431
+ setVisible(true)
432
+ setEpoch(current => current + 1)
433
+ }, [])
434
+ return { visible, reset }
435
+ }
432
436
 
433
437
  /**
434
- * The busy line, web TurnStatus contract: a continuously moving blue gradient
435
- * paints the complete `Deep diving...` label, with the elapsed clock appended
436
- * only once the turn has clearly been running (15s) anchored to `turn/start`
437
- * so a resumed mid-turn keeps the real time.
438
- */
439
- function DeepDivingLine({ since }: { since: number }): ReactElement {
440
- const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
441
- const elapsed = since === 0 ? 0 : Date.now() - since
442
- const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
443
- const palette = getPalette()
444
- const graphemes = splitGraphemes(text)
445
- return createElement(
446
- Text,
447
- { wrap: 'truncate-end' },
448
- ...graphemes.map((grapheme, index) => {
449
- const sparkle = grapheme.text === '✻'
450
- return createElement(
451
- Text,
452
- {
453
- key: `${grapheme.start}-${grapheme.end}`,
454
- color: inkColor(sparkle ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright) : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
455
- bold: sparkle || undefined,
456
- },
457
- grapheme.text,
458
- )
459
- }),
460
- )
461
- }
438
+ * One bounded line painted with the deep-diving shimmer: a continuously
439
+ * moving blue gradient across graphemes, the `✻` glyph in the breathing
440
+ * spark color. Shared by the busy line and the collapsed thinking marker;
441
+ * always exactly one row (truncate-end) so the live budget stays exact.
442
+ */
443
+ function ShimmerLine({ text }: { text: string }): ReactElement {
444
+ const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
445
+ const palette = getPalette()
446
+ const graphemes = splitGraphemes(text)
447
+ return createElement(
448
+ Text,
449
+ { wrap: 'truncate-end' },
450
+ ...graphemes.map((grapheme, index) => {
451
+ const sparkle = grapheme.text === ''
452
+ return createElement(
453
+ Text,
454
+ {
455
+ key: `${grapheme.start}-${grapheme.end}`,
456
+ color: inkColor(sparkle ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright) : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
457
+ bold: sparkle || undefined,
458
+ },
459
+ grapheme.text,
460
+ )
461
+ }),
462
+ )
463
+ }
464
+
465
+ /**
466
+ * The busy line, web TurnStatus contract: a continuously moving blue gradient
467
+ * paints the complete `Deep diving...` label, with the elapsed clock appended
468
+ * only once the turn has clearly been running (15s) — anchored to `turn/start`
469
+ * so a resumed mid-turn keeps the real time.
470
+ */
471
+ function DeepDivingLine({ since }: { since: number }): ReactElement {
472
+ const elapsed = since === 0 ? 0 : Date.now() - since
473
+ const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
474
+ return createElement(ShimmerLine, { text })
475
+ }
462
476
 
463
477
  /**
464
478
  * The streaming buffer rendered with a hard size cap: the live region must
@@ -699,11 +713,12 @@ function todoMark(status: TodoItem['status']): string {
699
713
 
700
714
  /**
701
715
  * One-row live subagent summary (the Codex agent status feed, compressed to
702
- * the transcript's budget): running count, total, and the most recently
703
- * active child's current activity. One line, never more the full view is
704
- * the /agents panel.
716
+ * the transcript's budget): running count, the observed total (the row cap
717
+ * is a display budget, not the fan-out size), and the most recently active
718
+ * child's current activity. One line, never more — the full view is the
719
+ * /agents panel.
705
720
  */
706
- function AgentsLine({ rows }: { rows: readonly SubagentRow[] }): ReactElement | undefined {
721
+ function AgentsLine({ rows, total }: { rows: readonly SubagentRow[]; total: number }): ReactElement | undefined {
707
722
  if (rows.length === 0) return undefined
708
723
  const running = rows.filter(row => row.state !== 'done').length
709
724
  const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]!
@@ -715,7 +730,7 @@ function AgentsLine({ rows }: { rows: readonly SubagentRow[] }): ReactElement |
715
730
  Text,
716
731
  { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
717
732
  `agents ${running} live`,
718
- createElement(Text, { color: inkColor(getPalette().dim) }, ` · ${rows.length} total · /agents`),
733
+ createElement(Text, { color: inkColor(getPalette().dim) }, ` · ${total} total · /agents`),
719
734
  createElement(Text, { color: inkColor(getPalette().text) }, ` · ${mark} ${newest.label} ${newest.activity}`),
720
735
  ),
721
736
  )
@@ -1523,10 +1538,10 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1523
1538
  createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1524
1539
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1525
1540
  ...visibleStateRows,
1526
- ...visible.map((row) => {
1527
- const index = rows.indexOf(row)
1528
- const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
1529
- const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
1541
+ ...visible.map((row) => {
1542
+ const index = rows.indexOf(row)
1543
+ const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
1544
+ const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
1530
1545
  return createElement(
1531
1546
  Text,
1532
1547
  {
@@ -1556,17 +1571,17 @@ function providerStateLabel(row: ProviderTargetView): string {
1556
1571
  }
1557
1572
 
1558
1573
  /** The provider-management stage reached from /model with `a`. */
1559
- function ProviderPanel({ directory, error, authorizations, authorizationError, onCredential, onConfigure, onUnset, onRemove, onLogin, onLogout, onRetry, onBack }: {
1560
- directory: ProviderSettingsDirectory | undefined
1561
- error: string | undefined
1562
- authorizations: ProviderAuthorizationDirectory | undefined
1563
- authorizationError: string | undefined
1564
- onCredential(target: ProviderTargetView): void
1565
- onConfigure(target: ProviderTargetView): void
1566
- onUnset(target: ProviderTargetView): void
1567
- onRemove(target: ProviderTargetView): void
1568
- onLogin(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1569
- onLogout(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1574
+ function ProviderPanel({ directory, error, authorizations, authorizationError, onCredential, onConfigure, onUnset, onRemove, onLogin, onLogout, onRetry, onBack }: {
1575
+ directory: ProviderSettingsDirectory | undefined
1576
+ error: string | undefined
1577
+ authorizations: ProviderAuthorizationDirectory | undefined
1578
+ authorizationError: string | undefined
1579
+ onCredential(target: ProviderTargetView): void
1580
+ onConfigure(target: ProviderTargetView): void
1581
+ onUnset(target: ProviderTargetView): void
1582
+ onRemove(target: ProviderTargetView): void
1583
+ onLogin(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1584
+ onLogout(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1570
1585
  onRetry(): void
1571
1586
  onBack(): void
1572
1587
  }): ReactElement {
@@ -1633,27 +1648,27 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1633
1648
  }
1634
1649
  return
1635
1650
  }
1636
- if (input === 'x') {
1651
+ if (input === 'x') {
1637
1652
  if (!target.removable) {
1638
1653
  setActionError('this provider profile is not removable')
1639
1654
  } else {
1640
1655
  onRemove(target)
1641
1656
  }
1642
- return
1643
- }
1644
- const authorization = authorizationForProvider(authorizations, target.provider)
1645
- if (input === 'l' || input === 'L') {
1646
- if (authorization === undefined) setActionError('this provider offers no interactive login flow')
1647
- else if (authorization.inFlight) setActionError('a login attempt is already running for this provider')
1648
- else onLogin(target, authorization)
1649
- return
1650
- }
1651
- if (input === 'o' || input === 'O') {
1652
- if (authorization === undefined || !authorization.record.configured) setActionError('this provider has no login record to remove')
1653
- else if (!authorization.record.writable) setActionError('this login record is read-only')
1654
- else onLogout(target, authorization)
1655
- return
1656
- }
1657
+ return
1658
+ }
1659
+ const authorization = authorizationForProvider(authorizations, target.provider)
1660
+ if (input === 'l' || input === 'L') {
1661
+ if (authorization === undefined) setActionError('this provider offers no interactive login flow')
1662
+ else if (authorization.inFlight) setActionError('a login attempt is already running for this provider')
1663
+ else onLogin(target, authorization)
1664
+ return
1665
+ }
1666
+ if (input === 'o' || input === 'O') {
1667
+ if (authorization === undefined || !authorization.record.configured) setActionError('this provider has no login record to remove')
1668
+ else if (!authorization.record.writable) setActionError('this login record is read-only')
1669
+ else onLogout(target, authorization)
1670
+ return
1671
+ }
1657
1672
  if (key.return) {
1658
1673
  if (target.settingsNs.length === 0) {
1659
1674
  setActionError('this provider is not managed by Harness settings')
@@ -1681,19 +1696,19 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1681
1696
  ...(actionError === undefined
1682
1697
  ? []
1683
1698
  : [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
1684
- ...(directory?.failures ?? []).map((failure, index) => createElement(
1699
+ ...(directory?.failures ?? []).map((failure, index) => createElement(
1685
1700
  Text,
1686
1701
  { key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1687
- truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1688
- )),
1689
- ...(authorizationError === undefined
1690
- ? []
1691
- : [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` login status unavailable: ${singleLineText(authorizationError)}`, viewport.contentColumns))]),
1692
- ...(authorizations?.failures ?? []).map((failure, index) => createElement(
1693
- Text,
1694
- { key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1695
- truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1696
- )),
1702
+ truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1703
+ )),
1704
+ ...(authorizationError === undefined
1705
+ ? []
1706
+ : [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` login status unavailable: ${singleLineText(authorizationError)}`, viewport.contentColumns))]),
1707
+ ...(authorizations?.failures ?? []).map((failure, index) => createElement(
1708
+ Text,
1709
+ { key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1710
+ truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1711
+ )),
1697
1712
  ...(rows.length === 0
1698
1713
  ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
1699
1714
  : []),
@@ -1709,13 +1724,13 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1709
1724
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1710
1725
  ...visibleStateRows,
1711
1726
  ...visible.map((row) => {
1712
- const index = rows.indexOf(row)
1713
- const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
1714
- const authorization = authorizationForProvider(authorizations, row.provider)
1715
- const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
1716
- const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
1717
- const authLabel = showAuthorization ? ` · ${providerAuthorizationStatus(authorization)}` : ''
1718
- const label = `${identity} · ${providerStateLabel(row)}${authLabel}${row.removable ? ' · custom' : ''}`
1727
+ const index = rows.indexOf(row)
1728
+ const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
1729
+ const authorization = authorizationForProvider(authorizations, row.provider)
1730
+ const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
1731
+ const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
1732
+ const authLabel = showAuthorization ? ` · ${providerAuthorizationStatus(authorization)}` : ''
1733
+ const label = `${identity} · ${providerStateLabel(row)}${authLabel}${row.removable ? ' · custom' : ''}`
1719
1734
  return createElement(
1720
1735
  Text,
1721
1736
  { key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' },
@@ -1723,7 +1738,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1723
1738
  )
1724
1739
  }),
1725
1740
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1726
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter key · l login · o logout · tab configure · d remove key · x remove provider · r retry · esc back', viewport.contentColumns)),
1741
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter key · l login · o logout · tab configure · d remove key · x remove provider · r retry · esc back', viewport.contentColumns)),
1727
1742
  )
1728
1743
  }
1729
1744
 
@@ -2004,7 +2019,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2004
2019
  createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
2005
2020
  createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · up/down history · tab complete'),
2006
2021
  createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' @ mentions workspace files and sessions'),
2007
- createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
2022
+ createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl/alt+r thinking · shift+tab permission preset'),
2008
2023
  createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
2009
2024
  createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
2010
2025
  createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ' ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends'),
@@ -2017,12 +2032,12 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
2017
2032
  { key: 'commands-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
2018
2033
  truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
2019
2034
  )]),
2020
- ...LOCAL_COMMANDS.map(command => createElement(
2021
- Box,
2022
- { key: `local-${command.label.slice(1)}` },
2023
- row(command.label, command.description),
2024
- )),
2025
- ...descriptors.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name)).map(descriptor => createElement(
2035
+ ...LOCAL_COMMANDS.map(command => createElement(
2036
+ Box,
2037
+ { key: `local-${command.label.slice(1)}` },
2038
+ row(command.label, command.description),
2039
+ )),
2040
+ ...descriptors.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name)).map(descriptor => createElement(
2026
2041
  Text,
2027
2042
  { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
2028
2043
  ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
@@ -2089,13 +2104,13 @@ function verboseLine(text: string, columns: number): string {
2089
2104
  return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
2090
2105
  }
2091
2106
 
2092
- /** The empty-composer placeholder text (shared by the static and wave paths). */
2093
- const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
2107
+ /** The empty-composer placeholder text (shared by the static and wave paths). */
2108
+ const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
2094
2109
 
2095
2110
  /** One physical cell of the wave-painted composer row: a char plus styles. */
2096
- interface ComposerCell {
2097
- char: string
2098
- width?: number
2111
+ interface ComposerCell {
2112
+ char: string
2113
+ width?: number
2099
2114
  color?: string
2100
2115
  backgroundColor?: string
2101
2116
  bold?: boolean
@@ -2325,12 +2340,12 @@ export function completionCandidates(
2325
2340
  ): readonly CompletionCandidate[] {
2326
2341
  if (!value.startsWith('/')) return []
2327
2342
  const prefix = value.slice(1).split(' ')[0] ?? ''
2328
- const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
2343
+ const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
2329
2344
  // Local commands shadow registry names (e.g. the TUI-local /permission works
2330
2345
  // before any session exists, while the registry child needs one), so
2331
2346
  // collisions cannot render two rows with the same key.
2332
- const registry = descriptors
2333
- .filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name))
2347
+ const registry = descriptors
2348
+ .filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name))
2334
2349
  .map((descriptor): CompletionCandidate => ({
2335
2350
  label: `/${descriptor.name}`,
2336
2351
  description: descriptor.description,
@@ -2420,25 +2435,25 @@ function CompletionMenu({ active, mention, index, rows }: {
2420
2435
  )
2421
2436
  }
2422
2437
 
2423
- interface DraftImage extends ImagePathInspection {
2424
- /** Visible draft token; deleting it also detaches the hidden path. */
2425
- readonly marker: string
2426
- }
2427
-
2428
- /**
2429
- * The prompt box: TUI-local slash commands handled locally, other lines
2438
+ interface DraftImage extends ImagePathInspection {
2439
+ /** Visible draft token; deleting it also detaches the hidden path. */
2440
+ readonly marker: string
2441
+ }
2442
+
2443
+ /**
2444
+ * The prompt box: TUI-local slash commands handled locally, other lines
2430
2445
  * dispatched; input editing keeps a cursor with history and completion.
2431
2446
  * While a modal (approval / question / model panel) owns the keys, the
2432
2447
  * box passes every key through untouched.
2433
2448
  */
2434
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle, maxRows, onEditorRows }: {
2449
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle, maxRows, onEditorRows }: {
2435
2450
  active: boolean
2436
2451
  frozen: boolean
2437
2452
  busy: boolean
2438
2453
  descriptors: readonly CommandDescriptor[]
2439
2454
  skills: readonly SkillRow[]
2440
- dispatch(text: string, images?: readonly ImageBlock[]): void
2441
- steer(text: string, images?: readonly ImageBlock[]): void
2455
+ dispatch(text: string, images?: readonly ImageBlock[]): void
2456
+ steer(text: string, images?: readonly ImageBlock[]): void
2442
2457
  interrupt(): boolean
2443
2458
  quit(): void
2444
2459
  openModel(): void
@@ -2472,15 +2487,17 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2472
2487
  forkSession(argument: string): void
2473
2488
  cancelSessionSwitch(): boolean
2474
2489
  notify(text: string, tone?: NoticeTone): void
2490
+ /** Apply the Ctrl+R passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
2491
+ applyEditorKeys(): Promise<string>
2475
2492
  hasNotice: boolean
2476
2493
  dismissNotice(): void
2477
2494
  toggleReasoning(): void
2478
2495
  openVerbose(): void
2479
2496
  clearView(): void
2480
2497
  refresh(): void
2481
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
2482
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
2483
- prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
2498
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
2499
+ inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
2500
+ prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
2484
2501
  cyclePermission(): string
2485
2502
  exportTranscript(argument: string): Promise<void>
2486
2503
  renameTitle(argument: string): string
@@ -2511,28 +2528,28 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2511
2528
  maxRows: number
2512
2529
  /** Reports the editor's current physical row count so the live budget stays exact. */
2513
2530
  onEditorRows(rows: number): void
2514
- }): ReactElement {
2515
- const columns = useStdout().stdout?.columns ?? 80
2516
- const editorColumns = Math.max(1, columns - 6)
2517
- const stdin = useStdin().stdin
2518
- const focusReporting = isVsCodeTerminalEnv()
2519
- const [value, setValue] = useState('')
2520
- const [cursor, setCursor] = useState(0)
2521
- const valueRef = useRef(value)
2522
- const cursorRef = useRef(cursor)
2523
- valueRef.current = value
2524
- cursorRef.current = cursor
2525
- const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
2526
- const draftImagesRef = useRef(draftImages)
2527
- draftImagesRef.current = draftImages
2528
- const [preparingImages, setPreparingImages] = useState(false)
2529
- const prepareAbortRef = useRef<AbortController | undefined>(undefined)
2530
- const prepareEpochRef = useRef(0)
2531
- const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
2532
- useEffect(() => () => {
2533
- prepareEpochRef.current += 1
2534
- prepareAbortRef.current?.abort()
2535
- }, [])
2531
+ }): ReactElement {
2532
+ const columns = useStdout().stdout?.columns ?? 80
2533
+ const editorColumns = Math.max(1, columns - 6)
2534
+ const stdin = useStdin().stdin
2535
+ const focusReporting = isVsCodeTerminalEnv()
2536
+ const [value, setValue] = useState('')
2537
+ const [cursor, setCursor] = useState(0)
2538
+ const valueRef = useRef(value)
2539
+ const cursorRef = useRef(cursor)
2540
+ valueRef.current = value
2541
+ cursorRef.current = cursor
2542
+ const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
2543
+ const draftImagesRef = useRef(draftImages)
2544
+ draftImagesRef.current = draftImages
2545
+ const [preparingImages, setPreparingImages] = useState(false)
2546
+ const prepareAbortRef = useRef<AbortController | undefined>(undefined)
2547
+ const prepareEpochRef = useRef(0)
2548
+ const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
2549
+ useEffect(() => () => {
2550
+ prepareEpochRef.current += 1
2551
+ prepareAbortRef.current?.abort()
2552
+ }, [])
2536
2553
  // Codex textarea editing state: a single-entry kill buffer, the vertical
2537
2554
  // move's preferred display column, the editor's scroll window, and the
2538
2555
  // bracketed-paste marker state. All of it is editor-local; nothing here
@@ -2543,30 +2560,30 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2543
2560
  const pasteBracketRef = useRef(false)
2544
2561
  /** Cancels the pending lost-paste safety timer (undefined when disarmed). */
2545
2562
  const pasteBracketCancelRef = useRef<(() => void) | undefined>(undefined)
2546
- /** Ordered editor tokens from the stdin chunk Ink is about to deliver. */
2547
- const rawEditorTokens = useRef<readonly RawEditorToken[] | undefined>(undefined)
2548
- /** VS Code focus state from xterm focus-report events; starts focused. */
2549
- const terminalFocusedRef = useRef(true)
2563
+ /** Ordered editor tokens from the stdin chunk Ink is about to deliver. */
2564
+ const rawEditorTokens = useRef<readonly RawEditorToken[] | undefined>(undefined)
2565
+ /** VS Code focus state from xterm focus-report events; starts focused. */
2566
+ const terminalFocusedRef = useRef(true)
2550
2567
  // Codex shell-style recall: the navigation cursor, the saved draft restored
2551
2568
  // on Down past the newest entry, and the boundary-gate anchor.
2552
- const recall = useRef<RecallState>(beginRecall([], ''))
2553
-
2554
- useEffect(() => {
2555
- preferredColumnRef.current = null
2556
- }, [editorColumns])
2569
+ const recall = useRef<RecallState>(beginRecall([], ''))
2570
+
2571
+ useEffect(() => {
2572
+ preferredColumnRef.current = null
2573
+ }, [editorColumns])
2557
2574
 
2558
2575
  // A /history panel acceptance lands as a fill: place the sanitized text at
2559
2576
  // the end of the composer and resume recall from that entry.
2560
- useEffect(() => {
2561
- if (historyFill === undefined) return
2562
- const safe = sanitizeDraftText(historyFill.text)
2563
- draftImagesRef.current = []
2564
- setDraftImages([])
2565
- valueRef.current = safe
2566
- cursorRef.current = safe.length
2567
- setValue(safe)
2568
- setCursor(safe.length)
2569
- resetCursorBlink()
2577
+ useEffect(() => {
2578
+ if (historyFill === undefined) return
2579
+ const safe = sanitizeDraftText(historyFill.text)
2580
+ draftImagesRef.current = []
2581
+ setDraftImages([])
2582
+ valueRef.current = safe
2583
+ cursorRef.current = safe.length
2584
+ setValue(safe)
2585
+ setCursor(safe.length)
2586
+ resetCursorBlink()
2570
2587
  preferredColumnRef.current = null
2571
2588
  setDismissedMenuValue(undefined)
2572
2589
  recall.current = {
@@ -2576,44 +2593,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2576
2593
  lastRecalled: safe,
2577
2594
  }
2578
2595
  historyConsumed()
2579
- }, [historyFill, recallSpace, historyConsumed, resetCursorBlink])
2580
-
2581
- useEffect(() => {
2582
- setDraftImages((current) => {
2583
- const next = current.filter(image => value.includes(image.marker))
2584
- draftImagesRef.current = next
2585
- return next.length === current.length ? current : next
2586
- })
2587
- }, [value])
2596
+ }, [historyFill, recallSpace, historyConsumed, resetCursorBlink])
2597
+
2598
+ useEffect(() => {
2599
+ setDraftImages((current) => {
2600
+ const next = current.filter(image => value.includes(image.marker))
2601
+ draftImagesRef.current = next
2602
+ return next.length === current.length ? current : next
2603
+ })
2604
+ }, [value])
2588
2605
 
2589
2606
  // Home/End and the Backspace-vs-Delete family never survive Ink's parser
2590
2607
  // as distinct keys, and kitty CSI-u forms parse as unnamed junk Ink would
2591
2608
  // insert as draft text.
2592
2609
  // Patch stdin.read — the single choke point Ink's input loop pulls every
2593
- // chunk through — to first rewrite decodable CSI-u sequences to their
2594
- // legacy bytes, then tokenize editor-only sequences before Ink emits the
2595
- // matching input event. Batched Home/End/Delete/Backspace actions remain
2596
- // ordered even though Ink invokes useInput only once for the whole chunk.
2610
+ // chunk through — to first rewrite decodable CSI-u sequences to their
2611
+ // legacy bytes, then tokenize editor-only sequences before Ink emits the
2612
+ // matching input event. Batched Home/End/Delete/Backspace actions remain
2613
+ // ordered even though Ink invokes useInput only once for the whole chunk.
2597
2614
  useEffect(() => {
2598
2615
  if (stdin === undefined) return
2599
2616
  const originalRead = stdin.read.bind(stdin)
2600
2617
  const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
2601
- const chunk = originalRead(...args)
2602
- if (chunk === null) return chunk
2603
- const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
2604
- const input = focusReporting
2605
- ? stripTerminalFocusEvents(normalized, focused => {
2606
- terminalFocusedRef.current = focused
2607
- })
2608
- : normalized
2609
- rawEditorTokens.current = tokenizeRawEditorChunk(input)
2610
- return input
2618
+ const chunk = originalRead(...args)
2619
+ if (chunk === null) return chunk
2620
+ const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
2621
+ const input = focusReporting
2622
+ ? stripTerminalFocusEvents(normalized, focused => {
2623
+ terminalFocusedRef.current = focused
2624
+ })
2625
+ : normalized
2626
+ rawEditorTokens.current = tokenizeRawEditorChunk(input)
2627
+ return input
2611
2628
  } as typeof stdin.read
2612
2629
  stdin.read = patchedRead
2613
2630
  return () => {
2614
2631
  stdin.read = originalRead as typeof stdin.read
2615
2632
  }
2616
- }, [focusReporting, stdin])
2633
+ }, [focusReporting, stdin])
2617
2634
 
2618
2635
  // Keep the navigation's recall space fresh while browsing state survives
2619
2636
  // (new local submissions extend the space; the index stays valid unless
@@ -2636,112 +2653,112 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2636
2653
  const mentionToken = tokenMatch === null
2637
2654
  ? undefined
2638
2655
  : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
2639
- const mentionActive = mentionToken !== undefined
2640
- const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
2641
- const mentionRequestRef = useRef(0)
2642
-
2643
- const sameImagePath = (left: string, right: string): boolean => (
2644
- process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
2645
- )
2646
-
2647
- const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
2648
- const safeName = singleLineText(sanitizeDraftText(name))
2649
- const base = source === 'mention' ? `@${safeName}` : `[image: ${safeName}]`
2650
- let marker = base
2651
- let suffix = 2
2652
- while (valueRef.current.includes(marker) || draftImagesRef.current.some(image => image.marker === marker) || reserved.includes(marker)) {
2653
- marker = source === 'mention' ? `@${safeName} (${suffix})` : `[image: ${safeName} ${suffix}]`
2654
- suffix += 1
2655
- }
2656
- return marker
2657
- }
2658
-
2659
- const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
2660
- if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
2661
- notify(`${inspection.name} is already attached`, 'warning')
2662
- return false
2663
- }
2664
- const next = [...draftImagesRef.current, { ...inspection, marker }]
2665
- draftImagesRef.current = next
2666
- setDraftImages(next)
2667
- return true
2668
- }
2669
-
2670
- const insertDroppedImages = (paths: readonly string[]): void => {
2671
- const originalValue = valueRef.current
2672
- const originalCursor = cursorRef.current
2673
- notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
2674
- void inspectImages(paths).then((inspected) => {
2675
- const additions: DraftImage[] = []
2676
- const markers: string[] = []
2677
- for (const inspection of inspected) {
2678
- if ([...draftImagesRef.current, ...additions].some(image => sameImagePath(image.path, inspection.path))) continue
2679
- const marker = uniqueImageMarker(inspection.name, 'drop', markers)
2680
- additions.push({ ...inspection, marker })
2681
- markers.push(marker)
2682
- }
2683
- if (additions.length === 0) {
2684
- notify('those images are already attached', 'warning')
2685
- return
2686
- }
2687
- const current = valueRef.current
2688
- const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
2689
- if (anchor === undefined) {
2690
- notify('draft changed at the image drop point; drop the images again', 'warning')
2691
- return
2692
- }
2693
- const at = anchor.start
2694
- const insertion = `${at > 0 && !/\s$/u.test(current.slice(0, at)) ? ' ' : ''}${markers.join(' ')}${current.slice(at) === '' ? '' : ' '}`
2695
- const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, insertion)
2696
- const nextCursor = current === originalValue && cursorRef.current === originalCursor
2697
- ? at + insertion.length
2698
- : edit.cursor
2699
- valueRef.current = edit.value
2700
- cursorRef.current = nextCursor
2701
- setValue(edit.value)
2702
- setCursor(nextCursor)
2703
- resetCursorBlink()
2704
- const nextImages = [...draftImagesRef.current, ...additions]
2705
- draftImagesRef.current = nextImages
2706
- setDraftImages(nextImages)
2707
- notify(`${additions.length} image${additions.length === 1 ? '' : 's'} ready for the next message`)
2708
- }, (reason: unknown) => {
2709
- notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
2710
- })
2711
- }
2712
-
2713
- useEffect(() => {
2714
- const requestId = mentionRequestRef.current + 1
2715
- mentionRequestRef.current = requestId
2716
- if (!active || !mentionActive) {
2717
- setMentionRows([])
2718
- return
2719
- }
2720
- const controller = new AbortController()
2721
- const query = mentionToken.query
2722
- const timer = setTimeout(() => {
2723
- void loadMentions(query, controller.signal).then(
2724
- rows => {
2725
- if (!controller.signal.aborted && mentionRequestRef.current === requestId) setMentionRows(rows)
2726
- },
2727
- () => {},
2728
- )
2729
- }, 50)
2730
- return () => {
2731
- clearTimeout(timer)
2732
- controller.abort()
2733
- }
2734
- }, [active, mentionActive, mentionToken?.query])
2656
+ const mentionActive = mentionToken !== undefined
2657
+ const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
2658
+ const mentionRequestRef = useRef(0)
2659
+
2660
+ const sameImagePath = (left: string, right: string): boolean => (
2661
+ process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
2662
+ )
2663
+
2664
+ const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
2665
+ const safeName = singleLineText(sanitizeDraftText(name))
2666
+ const base = source === 'mention' ? `@${safeName}` : `[image: ${safeName}]`
2667
+ let marker = base
2668
+ let suffix = 2
2669
+ while (valueRef.current.includes(marker) || draftImagesRef.current.some(image => image.marker === marker) || reserved.includes(marker)) {
2670
+ marker = source === 'mention' ? `@${safeName} (${suffix})` : `[image: ${safeName} ${suffix}]`
2671
+ suffix += 1
2672
+ }
2673
+ return marker
2674
+ }
2675
+
2676
+ const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
2677
+ if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
2678
+ notify(`${inspection.name} is already attached`, 'warning')
2679
+ return false
2680
+ }
2681
+ const next = [...draftImagesRef.current, { ...inspection, marker }]
2682
+ draftImagesRef.current = next
2683
+ setDraftImages(next)
2684
+ return true
2685
+ }
2686
+
2687
+ const insertDroppedImages = (paths: readonly string[]): void => {
2688
+ const originalValue = valueRef.current
2689
+ const originalCursor = cursorRef.current
2690
+ notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
2691
+ void inspectImages(paths).then((inspected) => {
2692
+ const additions: DraftImage[] = []
2693
+ const markers: string[] = []
2694
+ for (const inspection of inspected) {
2695
+ if ([...draftImagesRef.current, ...additions].some(image => sameImagePath(image.path, inspection.path))) continue
2696
+ const marker = uniqueImageMarker(inspection.name, 'drop', markers)
2697
+ additions.push({ ...inspection, marker })
2698
+ markers.push(marker)
2699
+ }
2700
+ if (additions.length === 0) {
2701
+ notify('those images are already attached', 'warning')
2702
+ return
2703
+ }
2704
+ const current = valueRef.current
2705
+ const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
2706
+ if (anchor === undefined) {
2707
+ notify('draft changed at the image drop point; drop the images again', 'warning')
2708
+ return
2709
+ }
2710
+ const at = anchor.start
2711
+ const insertion = `${at > 0 && !/\s$/u.test(current.slice(0, at)) ? ' ' : ''}${markers.join(' ')}${current.slice(at) === '' ? '' : ' '}`
2712
+ const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, insertion)
2713
+ const nextCursor = current === originalValue && cursorRef.current === originalCursor
2714
+ ? at + insertion.length
2715
+ : edit.cursor
2716
+ valueRef.current = edit.value
2717
+ cursorRef.current = nextCursor
2718
+ setValue(edit.value)
2719
+ setCursor(nextCursor)
2720
+ resetCursorBlink()
2721
+ const nextImages = [...draftImagesRef.current, ...additions]
2722
+ draftImagesRef.current = nextImages
2723
+ setDraftImages(nextImages)
2724
+ notify(`${additions.length} image${additions.length === 1 ? '' : 's'} ready for the next message`)
2725
+ }, (reason: unknown) => {
2726
+ notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
2727
+ })
2728
+ }
2729
+
2730
+ useEffect(() => {
2731
+ const requestId = mentionRequestRef.current + 1
2732
+ mentionRequestRef.current = requestId
2733
+ if (!active || !mentionActive) {
2734
+ setMentionRows([])
2735
+ return
2736
+ }
2737
+ const controller = new AbortController()
2738
+ const query = mentionToken.query
2739
+ const timer = setTimeout(() => {
2740
+ void loadMentions(query, controller.signal).then(
2741
+ rows => {
2742
+ if (!controller.signal.aborted && mentionRequestRef.current === requestId) setMentionRows(rows)
2743
+ },
2744
+ () => {},
2745
+ )
2746
+ }, 50)
2747
+ return () => {
2748
+ clearTimeout(timer)
2749
+ controller.abort()
2750
+ }
2751
+ }, [active, mentionActive, mentionToken?.query])
2735
2752
 
2736
2753
  // Codex routes keys to the topmost surface first. Completion therefore
2737
2754
  // remains available while a turn runs, and Esc dismisses it before the
2738
- // same key is allowed to interrupt the turn.
2739
- const menuActive = !preparingImages && (slashActive || mentionActive) && dismissedMenuValue !== value
2740
- const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
2741
- ? mentionRows.filter(row => row.kind !== 'session')
2742
- : mentionRows
2743
- const menuRows: readonly CompletionCandidate[] = mentionActive
2744
- ? visibleMentionRows.map(row => ({
2755
+ // same key is allowed to interrupt the turn.
2756
+ const menuActive = !preparingImages && (slashActive || mentionActive) && dismissedMenuValue !== value
2757
+ const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
2758
+ ? mentionRows.filter(row => row.kind !== 'session')
2759
+ : mentionRows
2760
+ const menuRows: readonly CompletionCandidate[] = mentionActive
2761
+ ? visibleMentionRows.map(row => ({
2745
2762
  label: row.label.startsWith('@')
2746
2763
  ? row.label
2747
2764
  : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
@@ -2750,199 +2767,199 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2750
2767
  }))
2751
2768
  : candidates
2752
2769
 
2753
- /** Accept the highlighted completion-menu candidate into the draft. */
2754
- const acceptMenuCandidate = (): void => {
2755
- if (mentionActive && mentionToken !== undefined) {
2756
- if (visibleMentionRows.length === 0) return
2757
- const row = visibleMentionRows[completionIndex % visibleMentionRows.length]
2758
- if (row !== undefined) {
2759
- if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
2760
- const tokenText = value.slice(mentionToken.start, cursor)
2761
- const start = mentionToken.start
2762
- const originalValue = value
2763
- notify(`checking image ${basename(row.path)}…`)
2764
- void inspectImages([row.path]).then((inspected) => {
2765
- const inspection = inspected[0]
2766
- if (inspection === undefined) return
2767
- const current = valueRef.current
2768
- const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
2769
- if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
2770
- notify('draft changed around the image mention; select it again', 'warning')
2771
- return
2772
- }
2773
- if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
2774
- const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, '')
2775
- valueRef.current = edit.value
2776
- cursorRef.current = edit.cursor
2777
- setValue(edit.value)
2778
- setCursor(edit.cursor)
2779
- resetCursorBlink()
2780
- setDismissedMenuValue(edit.value)
2781
- notify(`${inspection.name} is already attached`, 'warning')
2782
- return
2783
- }
2784
- const marker = uniqueImageMarker(inspection.name, 'mention')
2785
- const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, marker)
2786
- valueRef.current = edit.value
2787
- cursorRef.current = edit.cursor
2788
- setValue(edit.value)
2789
- setCursor(edit.cursor)
2790
- resetCursorBlink()
2791
- setDismissedMenuValue(edit.value)
2792
- registerDraftImage(inspection, marker)
2793
- notify(`${inspection.name} ready for the next message`)
2794
- }, (reason: unknown) => {
2795
- notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
2796
- })
2797
- setCompletionIndex(0)
2798
- setDismissedMenuValue(undefined)
2799
- return
2800
- }
2801
- // Session rows carry the canonical @[label](dsh-session:…) token;
2770
+ /** Accept the highlighted completion-menu candidate into the draft. */
2771
+ const acceptMenuCandidate = (): void => {
2772
+ if (mentionActive && mentionToken !== undefined) {
2773
+ if (visibleMentionRows.length === 0) return
2774
+ const row = visibleMentionRows[completionIndex % visibleMentionRows.length]
2775
+ if (row !== undefined) {
2776
+ if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
2777
+ const tokenText = value.slice(mentionToken.start, cursor)
2778
+ const start = mentionToken.start
2779
+ const originalValue = value
2780
+ notify(`checking image ${basename(row.path)}…`)
2781
+ void inspectImages([row.path]).then((inspected) => {
2782
+ const inspection = inspected[0]
2783
+ if (inspection === undefined) return
2784
+ const current = valueRef.current
2785
+ const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
2786
+ if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
2787
+ notify('draft changed around the image mention; select it again', 'warning')
2788
+ return
2789
+ }
2790
+ if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
2791
+ const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, '')
2792
+ valueRef.current = edit.value
2793
+ cursorRef.current = edit.cursor
2794
+ setValue(edit.value)
2795
+ setCursor(edit.cursor)
2796
+ resetCursorBlink()
2797
+ setDismissedMenuValue(edit.value)
2798
+ notify(`${inspection.name} is already attached`, 'warning')
2799
+ return
2800
+ }
2801
+ const marker = uniqueImageMarker(inspection.name, 'mention')
2802
+ const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, marker)
2803
+ valueRef.current = edit.value
2804
+ cursorRef.current = edit.cursor
2805
+ setValue(edit.value)
2806
+ setCursor(edit.cursor)
2807
+ resetCursorBlink()
2808
+ setDismissedMenuValue(edit.value)
2809
+ registerDraftImage(inspection, marker)
2810
+ notify(`${inspection.name} ready for the next message`)
2811
+ }, (reason: unknown) => {
2812
+ notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
2813
+ })
2814
+ setCompletionIndex(0)
2815
+ setDismissedMenuValue(undefined)
2816
+ return
2817
+ }
2818
+ // Session rows carry the canonical @[label](dsh-session:…) token;
2802
2819
  // file rows insert `@path` (directories keep their trailing slash).
2803
- const insertion = row.label.startsWith('@')
2804
- ? row.label
2805
- : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2806
- const nextValue = value.slice(0, mentionToken.start) + insertion + value.slice(cursor)
2807
- const nextCursor = mentionToken.start + insertion.length
2808
- valueRef.current = nextValue
2809
- cursorRef.current = nextCursor
2810
- setValue(nextValue)
2811
- setCursor(nextCursor)
2812
- resetCursorBlink()
2813
- }
2814
- } else {
2815
- if (candidates.length === 0) return
2816
- const candidate = candidates[completionIndex % candidates.length]
2817
- if (candidate !== undefined) {
2818
- const nextValue = `${candidate.label} `
2819
- const nextCursor = candidate.label.length + 1
2820
- valueRef.current = nextValue
2821
- cursorRef.current = nextCursor
2822
- setValue(nextValue)
2823
- setCursor(nextCursor)
2824
- resetCursorBlink()
2825
- }
2820
+ const insertion = row.label.startsWith('@')
2821
+ ? row.label
2822
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2823
+ const nextValue = value.slice(0, mentionToken.start) + insertion + value.slice(cursor)
2824
+ const nextCursor = mentionToken.start + insertion.length
2825
+ valueRef.current = nextValue
2826
+ cursorRef.current = nextCursor
2827
+ setValue(nextValue)
2828
+ setCursor(nextCursor)
2829
+ resetCursorBlink()
2830
+ }
2831
+ } else {
2832
+ if (candidates.length === 0) return
2833
+ const candidate = candidates[completionIndex % candidates.length]
2834
+ if (candidate !== undefined) {
2835
+ const nextValue = `${candidate.label} `
2836
+ const nextCursor = candidate.label.length + 1
2837
+ valueRef.current = nextValue
2838
+ cursorRef.current = nextCursor
2839
+ setValue(nextValue)
2840
+ setCursor(nextCursor)
2841
+ resetCursorBlink()
2842
+ }
2826
2843
  }
2827
2844
  setCompletionIndex(0)
2828
2845
  setDismissedMenuValue(undefined)
2829
2846
  }
2830
2847
 
2831
2848
  /** Apply one editor edit: draft, cursor, kill buffer, menu reset. */
2832
- const applyEdit = (edit: EditResult): void => {
2833
- if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2834
- valueRef.current = edit.value
2835
- cursorRef.current = edit.cursor
2836
- setValue(edit.value)
2837
- setCursor(edit.cursor)
2838
- resetCursorBlink()
2849
+ const applyEdit = (edit: EditResult): void => {
2850
+ if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2851
+ valueRef.current = edit.value
2852
+ cursorRef.current = edit.cursor
2853
+ setValue(edit.value)
2854
+ setCursor(edit.cursor)
2855
+ resetCursorBlink()
2839
2856
  preferredColumnRef.current = null
2840
2857
  setCompletionIndex(0)
2841
2858
  setDismissedMenuValue(undefined)
2842
2859
  }
2843
2860
 
2844
2861
  /** Move the cursor without editing; horizontal moves clear the column preference. */
2845
- const moveCursorTo = (next: number): void => {
2846
- resetCursorBlink()
2847
- if (next === cursorRef.current) return
2848
- cursorRef.current = next
2849
- setCursor(next)
2850
- preferredColumnRef.current = null
2851
- }
2852
-
2853
- /** Apply an ordered raw-key batch against one current draft snapshot. */
2854
- const applyRawEditorTokens = (tokens: readonly RawEditorToken[]): void => {
2855
- let nextValue = valueRef.current
2856
- let nextCursor = cursorRef.current
2857
- for (const token of tokens) {
2858
- if (token.kind === 'text') {
2859
- const edit = insertText(nextValue, nextCursor, token.text)
2860
- nextValue = edit.value
2861
- nextCursor = edit.cursor
2862
- continue
2863
- }
2864
- if (token.kind === 'home') {
2865
- nextCursor = moveToLineStart(nextValue, nextCursor, false)
2866
- continue
2867
- }
2868
- if (token.kind === 'end') {
2869
- nextCursor = moveToLineEnd(nextValue, nextCursor, false)
2870
- continue
2871
- }
2872
- const edit = token.kind === 'delete-backward'
2873
- ? deleteBackward(nextValue, nextCursor)
2874
- : token.kind === 'delete-word-backward'
2875
- ? deleteWordBackward(nextValue, nextCursor)
2876
- : token.kind === 'delete-forward'
2877
- ? deleteForward(nextValue, nextCursor)
2878
- : deleteWordForward(nextValue, nextCursor)
2879
- if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2880
- nextValue = edit.value
2881
- nextCursor = edit.cursor
2882
- }
2883
- valueRef.current = nextValue
2884
- cursorRef.current = nextCursor
2885
- setValue(nextValue)
2886
- setCursor(nextCursor)
2887
- resetCursorBlink()
2888
- preferredColumnRef.current = null
2889
- setCompletionIndex(0)
2890
- setDismissedMenuValue(undefined)
2891
- }
2892
-
2893
- const cancelImageSubmission = (): void => {
2894
- prepareEpochRef.current += 1
2895
- prepareAbortRef.current?.abort()
2896
- prepareAbortRef.current = undefined
2897
- setPreparingImages(false)
2898
- dismissNotice()
2899
- notify('image submission cancelled', 'warning')
2900
- }
2901
-
2902
- /** Move through visual rows first, then cross history at the true edge. */
2903
- const navigateVertical = (direction: -1 | 1): void => {
2904
- const currentValue = valueRef.current
2905
- const currentCursor = cursorRef.current
2906
- const model = editorModel(currentValue, editorColumns)
2907
- const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
2908
- const next = moveCursorVertically(model, currentCursor, preferred, direction)
2909
- if (next !== currentCursor) {
2910
- cursorRef.current = next
2911
- setCursor(next)
2912
- resetCursorBlink()
2913
- preferredColumnRef.current = preferred
2914
- return
2915
- }
2916
- if (recall.current.entries.length > 0
2917
- && shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
2918
- const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
2919
- recall.current = step.state
2920
- if (step.entry !== undefined) {
2921
- const safe = sanitizeDraftText(step.entry)
2922
- valueRef.current = safe
2923
- cursorRef.current = safe.length
2924
- setValue(safe)
2925
- setCursor(safe.length)
2926
- preferredColumnRef.current = null
2927
- setDismissedMenuValue(undefined)
2928
- }
2929
- }
2930
- resetCursorBlink()
2931
- }
2932
-
2933
- useStableInput((input, key) => {
2934
- // Modal ownership: approval/question/model dialogs consume all keys.
2935
- if (!active) return
2936
- // React may not have committed the previous Tab completion render before
2937
- // the next terminal byte arrives. Read the synchronous editor refs so a
2938
- // completion followed immediately by text edits never uses stale closure
2939
- // state.
2940
- const liveValue = valueRef.current
2941
- const liveCursor = cursorRef.current
2942
- if (preparingImages) {
2943
- if (key.escape || (key.ctrl && input === 'c')) cancelImageSubmission()
2944
- return
2945
- }
2862
+ const moveCursorTo = (next: number): void => {
2863
+ resetCursorBlink()
2864
+ if (next === cursorRef.current) return
2865
+ cursorRef.current = next
2866
+ setCursor(next)
2867
+ preferredColumnRef.current = null
2868
+ }
2869
+
2870
+ /** Apply an ordered raw-key batch against one current draft snapshot. */
2871
+ const applyRawEditorTokens = (tokens: readonly RawEditorToken[]): void => {
2872
+ let nextValue = valueRef.current
2873
+ let nextCursor = cursorRef.current
2874
+ for (const token of tokens) {
2875
+ if (token.kind === 'text') {
2876
+ const edit = insertText(nextValue, nextCursor, token.text)
2877
+ nextValue = edit.value
2878
+ nextCursor = edit.cursor
2879
+ continue
2880
+ }
2881
+ if (token.kind === 'home') {
2882
+ nextCursor = moveToLineStart(nextValue, nextCursor, false)
2883
+ continue
2884
+ }
2885
+ if (token.kind === 'end') {
2886
+ nextCursor = moveToLineEnd(nextValue, nextCursor, false)
2887
+ continue
2888
+ }
2889
+ const edit = token.kind === 'delete-backward'
2890
+ ? deleteBackward(nextValue, nextCursor)
2891
+ : token.kind === 'delete-word-backward'
2892
+ ? deleteWordBackward(nextValue, nextCursor)
2893
+ : token.kind === 'delete-forward'
2894
+ ? deleteForward(nextValue, nextCursor)
2895
+ : deleteWordForward(nextValue, nextCursor)
2896
+ if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2897
+ nextValue = edit.value
2898
+ nextCursor = edit.cursor
2899
+ }
2900
+ valueRef.current = nextValue
2901
+ cursorRef.current = nextCursor
2902
+ setValue(nextValue)
2903
+ setCursor(nextCursor)
2904
+ resetCursorBlink()
2905
+ preferredColumnRef.current = null
2906
+ setCompletionIndex(0)
2907
+ setDismissedMenuValue(undefined)
2908
+ }
2909
+
2910
+ const cancelImageSubmission = (): void => {
2911
+ prepareEpochRef.current += 1
2912
+ prepareAbortRef.current?.abort()
2913
+ prepareAbortRef.current = undefined
2914
+ setPreparingImages(false)
2915
+ dismissNotice()
2916
+ notify('image submission cancelled', 'warning')
2917
+ }
2918
+
2919
+ /** Move through visual rows first, then cross history at the true edge. */
2920
+ const navigateVertical = (direction: -1 | 1): void => {
2921
+ const currentValue = valueRef.current
2922
+ const currentCursor = cursorRef.current
2923
+ const model = editorModel(currentValue, editorColumns)
2924
+ const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
2925
+ const next = moveCursorVertically(model, currentCursor, preferred, direction)
2926
+ if (next !== currentCursor) {
2927
+ cursorRef.current = next
2928
+ setCursor(next)
2929
+ resetCursorBlink()
2930
+ preferredColumnRef.current = preferred
2931
+ return
2932
+ }
2933
+ if (recall.current.entries.length > 0
2934
+ && shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
2935
+ const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
2936
+ recall.current = step.state
2937
+ if (step.entry !== undefined) {
2938
+ const safe = sanitizeDraftText(step.entry)
2939
+ valueRef.current = safe
2940
+ cursorRef.current = safe.length
2941
+ setValue(safe)
2942
+ setCursor(safe.length)
2943
+ preferredColumnRef.current = null
2944
+ setDismissedMenuValue(undefined)
2945
+ }
2946
+ }
2947
+ resetCursorBlink()
2948
+ }
2949
+
2950
+ useStableInput((input, key) => {
2951
+ // Modal ownership: approval/question/model dialogs consume all keys.
2952
+ if (!active) return
2953
+ // React may not have committed the previous Tab completion render before
2954
+ // the next terminal byte arrives. Read the synchronous editor refs so a
2955
+ // completion followed immediately by text edits never uses stale closure
2956
+ // state.
2957
+ const liveValue = valueRef.current
2958
+ const liveCursor = cursorRef.current
2959
+ if (preparingImages) {
2960
+ if (key.escape || (key.ctrl && input === 'c')) cancelImageSubmission()
2961
+ return
2962
+ }
2946
2963
  // Deletion confirm owns the box: y proceeds, anything else cancels.
2947
2964
  // Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
2948
2965
  // as the box's own prompt, not an invisible panel keypress.
@@ -2964,11 +2981,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2964
2981
  }
2965
2982
  return
2966
2983
  }
2967
- // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
2968
- if (key.ctrl && input === 'r') {
2969
- if (focusReporting && !terminalFocusedRef.current) return
2970
- toggleReasoning()
2971
- return
2984
+ // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
2985
+ // Alt+R is the zero-config alias: VS Code never intercepts Alt chords,
2986
+ // so the toggle stays reachable before /vscode-keys has been applied.
2987
+ if ((key.ctrl || key.meta) && input === 'r') {
2988
+ if (focusReporting && !terminalFocusedRef.current) return
2989
+ toggleReasoning()
2990
+ return
2972
2991
  }
2973
2992
  // Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
2974
2993
  // adapted to append-only static rows): one history entry at a time with
@@ -2981,16 +3000,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2981
3000
  // cancelled, a non-empty draft is cleared, and only an idle empty input
2982
3001
  // exits. Ctrl+D always means exit but refuses mid-turn.
2983
3002
  if (key.ctrl && input === 'c') {
2984
- if (busy) {
2985
- interrupt()
2986
- } else if (liveValue !== '') {
2987
- valueRef.current = ''
2988
- cursorRef.current = 0
2989
- setValue('')
2990
- setCursor(0)
2991
- resetCursorBlink()
2992
- draftImagesRef.current = []
2993
- setDraftImages([])
3003
+ if (busy) {
3004
+ interrupt()
3005
+ } else if (liveValue !== '') {
3006
+ valueRef.current = ''
3007
+ cursorRef.current = 0
3008
+ setValue('')
3009
+ setCursor(0)
3010
+ resetCursorBlink()
3011
+ draftImagesRef.current = []
3012
+ setDraftImages([])
2994
3013
  setCompletionIndex(0)
2995
3014
  setDismissedMenuValue(undefined)
2996
3015
  } else {
@@ -3001,8 +3020,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3001
3020
  if (key.ctrl && input === 'd') {
3002
3021
  // Codex: Ctrl+D deletes forward while a draft exists; the app-level
3003
3022
  // exit only fires from an empty composer.
3004
- if (liveValue !== '') {
3005
- applyEdit(deleteForward(liveValue, liveCursor))
3023
+ if (liveValue !== '') {
3024
+ applyEdit(deleteForward(liveValue, liveCursor))
3006
3025
  return
3007
3026
  }
3008
3027
  if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
@@ -3011,7 +3030,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3011
3030
  }
3012
3031
  if (key.escape) {
3013
3032
  if (menuActive) {
3014
- setDismissedMenuValue(liveValue)
3033
+ setDismissedMenuValue(liveValue)
3015
3034
  return
3016
3035
  }
3017
3036
  if (hasNotice) {
@@ -3023,14 +3042,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3023
3042
  }
3024
3043
  // Delete on the empty composer cancels the newest queued message (the
3025
3044
  // web queue-mirror contract: the durable splice drops the pending row).
3026
- if (key.delete && liveValue === '' && queued.length > 0) {
3045
+ if (key.delete && liveValue === '' && queued.length > 0) {
3027
3046
  cancelQueued(queued[queued.length - 1]!.messageId)
3028
3047
  return
3029
3048
  }
3030
3049
  if (key.return) {
3031
3050
  // A newline inside an open bracketed paste inserts; it never submits.
3032
3051
  if (pasteBracketRef.current) {
3033
- applyEdit(insertText(liveValue, liveCursor, '\n'))
3052
+ applyEdit(insertText(liveValue, liveCursor, '\n'))
3034
3053
  return
3035
3054
  }
3036
3055
  // Enter on an open completion menu accepts the highlighted candidate
@@ -3039,55 +3058,55 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3039
3058
  // exactly, in which case Enter submits it (typing a full "/effort" and
3040
3059
  // pressing return must run the command, not re-accept its own text).
3041
3060
  if (menuActive) {
3042
- const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
3061
+ const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
3043
3062
  if (!exactSlash) {
3044
3063
  acceptMenuCandidate()
3045
3064
  return
3046
3065
  }
3047
- }
3048
- const text = liveValue.trim()
3049
- if (draftImagesRef.current.length > 0) {
3050
- const controller = new AbortController()
3051
- const epoch = prepareEpochRef.current + 1
3052
- prepareEpochRef.current = epoch
3053
- prepareAbortRef.current = controller
3054
- setPreparingImages(true)
3055
- notify(`processing ${draftImagesRef.current.length} image${draftImagesRef.current.length === 1 ? '' : 's'}…`)
3056
- const snapshot = draftImagesRef.current
3057
- void prepareImages(snapshot.map(image => image.path), controller.signal).then((images) => {
3058
- if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3059
- prepareAbortRef.current = undefined
3060
- setPreparingImages(false)
3061
- valueRef.current = ''
3062
- cursorRef.current = 0
3063
- setValue('')
3064
- setCursor(0)
3065
- draftImagesRef.current = []
3066
- setDraftImages([])
3067
- setCompletionIndex(0)
3068
- setDismissedMenuValue(undefined)
3069
- dismissNotice()
3070
- if (text !== '') {
3071
- recordLocal(text)
3072
- recordHistory(text)
3073
- }
3074
- recall.current = beginRecall(recallSpace, '')
3075
- if (busy) steer(text, images)
3076
- else dispatch(text, images)
3077
- }, (reason: unknown) => {
3078
- if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3079
- prepareAbortRef.current = undefined
3080
- setPreparingImages(false)
3081
- notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3082
- })
3083
- return
3084
- }
3085
- valueRef.current = ''
3086
- cursorRef.current = 0
3087
- setValue('')
3088
- setCursor(0)
3089
- resetCursorBlink()
3090
- setCompletionIndex(0)
3066
+ }
3067
+ const text = liveValue.trim()
3068
+ if (draftImagesRef.current.length > 0) {
3069
+ const controller = new AbortController()
3070
+ const epoch = prepareEpochRef.current + 1
3071
+ prepareEpochRef.current = epoch
3072
+ prepareAbortRef.current = controller
3073
+ setPreparingImages(true)
3074
+ notify(`processing ${draftImagesRef.current.length} image${draftImagesRef.current.length === 1 ? '' : 's'}…`)
3075
+ const snapshot = draftImagesRef.current
3076
+ void prepareImages(snapshot.map(image => image.path), controller.signal).then((images) => {
3077
+ if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3078
+ prepareAbortRef.current = undefined
3079
+ setPreparingImages(false)
3080
+ valueRef.current = ''
3081
+ cursorRef.current = 0
3082
+ setValue('')
3083
+ setCursor(0)
3084
+ draftImagesRef.current = []
3085
+ setDraftImages([])
3086
+ setCompletionIndex(0)
3087
+ setDismissedMenuValue(undefined)
3088
+ dismissNotice()
3089
+ if (text !== '') {
3090
+ recordLocal(text)
3091
+ recordHistory(text)
3092
+ }
3093
+ recall.current = beginRecall(recallSpace, '')
3094
+ if (busy) steer(text, images)
3095
+ else dispatch(text, images)
3096
+ }, (reason: unknown) => {
3097
+ if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
3098
+ prepareAbortRef.current = undefined
3099
+ setPreparingImages(false)
3100
+ notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
3101
+ })
3102
+ return
3103
+ }
3104
+ valueRef.current = ''
3105
+ cursorRef.current = 0
3106
+ setValue('')
3107
+ setCursor(0)
3108
+ resetCursorBlink()
3109
+ setCompletionIndex(0)
3091
3110
  setDismissedMenuValue(undefined)
3092
3111
  if (text === '') return
3093
3112
  dismissNotice()
@@ -3213,6 +3232,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3213
3232
  openTodos()
3214
3233
  return
3215
3234
  }
3235
+ if (text === '/vscode-keys' || text.startsWith('/vscode-keys ')) {
3236
+ void applyEditorKeys().then(
3237
+ summary => notify(summary),
3238
+ error => notify(`vscode-keys failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
3239
+ )
3240
+ return
3241
+ }
3216
3242
  if (text === '/subagent') {
3217
3243
  openSubagent()
3218
3244
  return
@@ -3231,19 +3257,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3231
3257
  dispatch(text)
3232
3258
  return
3233
3259
  }
3234
- // Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
3235
- // stripping the leading escape. Neither is a multiline shortcut.
3236
- if (input === '\n' || input === '\r') return
3237
- // A fast Tab followed by text can arrive as one readable chunk in an
3238
- // integrated terminal. Accept the candidate first, then apply the
3239
- // remaining characters against the synchronously updated editor refs.
3240
- if (menuActive && (key.tab || input.startsWith('\t'))) {
3241
- const remainder = key.tab ? '' : input.slice(1)
3242
- acceptMenuCandidate()
3243
- if (remainder !== '') applyEdit(insertText(valueRef.current, cursorRef.current, remainder))
3244
- return
3245
- }
3246
- if (menuActive && key.upArrow) {
3260
+ // Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
3261
+ // stripping the leading escape. Neither is a multiline shortcut.
3262
+ if (input === '\n' || input === '\r') return
3263
+ // A fast Tab followed by text can arrive as one readable chunk in an
3264
+ // integrated terminal. Accept the candidate first, then apply the
3265
+ // remaining characters against the synchronously updated editor refs.
3266
+ if (menuActive && (key.tab || input.startsWith('\t'))) {
3267
+ const remainder = key.tab ? '' : input.slice(1)
3268
+ acceptMenuCandidate()
3269
+ if (remainder !== '') applyEdit(insertText(valueRef.current, cursorRef.current, remainder))
3270
+ return
3271
+ }
3272
+ if (menuActive && key.upArrow) {
3247
3273
  setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
3248
3274
  return
3249
3275
  }
@@ -3251,86 +3277,86 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3251
3277
  setCompletionIndex(index => (index + 1) % menuRows.length)
3252
3278
  return
3253
3279
  }
3254
- // Batched Home/End/Delete/Backspace sequences bypass Ink's one-key parser
3255
- // and reduce against one current editor snapshot in their original order.
3256
- const rawTokens = rawEditorTokens.current
3257
- rawEditorTokens.current = undefined
3258
- if (rawTokens !== undefined) {
3259
- applyRawEditorTokens(rawTokens)
3260
- return
3261
- }
3262
- if (key.upArrow || key.downArrow) {
3263
- navigateVertical(key.upArrow ? -1 : 1)
3264
- return
3265
- }
3280
+ // Batched Home/End/Delete/Backspace sequences bypass Ink's one-key parser
3281
+ // and reduce against one current editor snapshot in their original order.
3282
+ const rawTokens = rawEditorTokens.current
3283
+ rawEditorTokens.current = undefined
3284
+ if (rawTokens !== undefined) {
3285
+ applyRawEditorTokens(rawTokens)
3286
+ return
3287
+ }
3288
+ if (key.upArrow || key.downArrow) {
3289
+ navigateVertical(key.upArrow ? -1 : 1)
3290
+ return
3291
+ }
3266
3292
  // Ctrl+P / Ctrl+N share the Up/Down contract (Codex binds them to
3267
3293
  // move_up/move_down, so the history gate applies first).
3268
- if (key.ctrl && (input === 'p' || input === 'n')) {
3269
- navigateVertical(input === 'p' ? -1 : 1)
3270
- return
3271
- }
3272
- // Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
3273
- // plain arrows and Ctrl+B/F move by grapheme.
3274
- if (key.leftArrow) {
3275
- moveCursorTo(key.meta || key.ctrl ? moveWordLeft(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, -1))
3276
- return
3277
- }
3278
- if (key.rightArrow) {
3279
- moveCursorTo(key.meta || key.ctrl ? moveWordRight(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, 1))
3280
- return
3281
- }
3282
- if (key.meta && input === 'b') {
3283
- moveCursorTo(moveWordLeft(liveValue, liveCursor))
3284
- return
3285
- }
3286
- if (key.meta && input === 'f') {
3287
- moveCursorTo(moveWordRight(liveValue, liveCursor))
3288
- return
3289
- }
3290
- if (key.ctrl && input === 'b') {
3291
- moveCursorTo(moveCursorBy(liveValue, liveCursor, -1))
3292
- return
3293
- }
3294
- if (key.ctrl && input === 'f') {
3295
- moveCursorTo(moveCursorBy(liveValue, liveCursor, 1))
3296
- return
3297
- }
3294
+ if (key.ctrl && (input === 'p' || input === 'n')) {
3295
+ navigateVertical(input === 'p' ? -1 : 1)
3296
+ return
3297
+ }
3298
+ // Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
3299
+ // plain arrows and Ctrl+B/F move by grapheme.
3300
+ if (key.leftArrow) {
3301
+ moveCursorTo(key.meta || key.ctrl ? moveWordLeft(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, -1))
3302
+ return
3303
+ }
3304
+ if (key.rightArrow) {
3305
+ moveCursorTo(key.meta || key.ctrl ? moveWordRight(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, 1))
3306
+ return
3307
+ }
3308
+ if (key.meta && input === 'b') {
3309
+ moveCursorTo(moveWordLeft(liveValue, liveCursor))
3310
+ return
3311
+ }
3312
+ if (key.meta && input === 'f') {
3313
+ moveCursorTo(moveWordRight(liveValue, liveCursor))
3314
+ return
3315
+ }
3316
+ if (key.ctrl && input === 'b') {
3317
+ moveCursorTo(moveCursorBy(liveValue, liveCursor, -1))
3318
+ return
3319
+ }
3320
+ if (key.ctrl && input === 'f') {
3321
+ moveCursorTo(moveCursorBy(liveValue, liveCursor, 1))
3322
+ return
3323
+ }
3298
3324
  // Ctrl+W and Alt+Backspace delete the previous word piece into the kill
3299
- // buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
3300
- if (key.ctrl && input === 'w') {
3301
- applyEdit(deleteWordBackward(liveValue, liveCursor))
3302
- return
3303
- }
3304
- if (key.meta && input === 'd') {
3305
- applyEdit(deleteWordForward(liveValue, liveCursor))
3306
- return
3325
+ // buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
3326
+ if (key.ctrl && input === 'w') {
3327
+ applyEdit(deleteWordBackward(liveValue, liveCursor))
3328
+ return
3329
+ }
3330
+ if (key.meta && input === 'd') {
3331
+ applyEdit(deleteWordForward(liveValue, liveCursor))
3332
+ return
3307
3333
  }
3308
3334
  // Un-annotated backspace/delete (Ink maps both  and  here):
3309
- // delete the grapheme before the cursor.
3310
- if (key.backspace || key.delete) {
3311
- applyEdit(deleteBackward(liveValue, liveCursor))
3312
- return
3335
+ // delete the grapheme before the cursor.
3336
+ if (key.backspace || key.delete) {
3337
+ applyEdit(deleteBackward(liveValue, liveCursor))
3338
+ return
3313
3339
  }
3314
3340
  // Readline parity over the LOGICAL line: A/E to its ends, U/K kill to
3315
- // them (filling the single kill buffer), Y yanks it back.
3316
- if (key.ctrl && input === 'a') {
3317
- moveCursorTo(moveToLineStart(liveValue, liveCursor, true))
3318
- return
3319
- }
3320
- if (key.ctrl && input === 'e') {
3321
- moveCursorTo(moveToLineEnd(liveValue, liveCursor, true))
3322
- return
3323
- }
3324
- if (key.ctrl && input === 'u') {
3325
- applyEdit(killToLineStart(liveValue, liveCursor))
3326
- return
3327
- }
3328
- if (key.ctrl && input === 'k') {
3329
- applyEdit(killToLineEnd(liveValue, liveCursor))
3330
- return
3331
- }
3332
- if (key.ctrl && input === 'y') {
3333
- if (killRef.current !== '') applyEdit(insertText(liveValue, liveCursor, killRef.current))
3341
+ // them (filling the single kill buffer), Y yanks it back.
3342
+ if (key.ctrl && input === 'a') {
3343
+ moveCursorTo(moveToLineStart(liveValue, liveCursor, true))
3344
+ return
3345
+ }
3346
+ if (key.ctrl && input === 'e') {
3347
+ moveCursorTo(moveToLineEnd(liveValue, liveCursor, true))
3348
+ return
3349
+ }
3350
+ if (key.ctrl && input === 'u') {
3351
+ applyEdit(killToLineStart(liveValue, liveCursor))
3352
+ return
3353
+ }
3354
+ if (key.ctrl && input === 'k') {
3355
+ applyEdit(killToLineEnd(liveValue, liveCursor))
3356
+ return
3357
+ }
3358
+ if (key.ctrl && input === 'y') {
3359
+ if (killRef.current !== '') applyEdit(insertText(liveValue, liveCursor, killRef.current))
3334
3360
  return
3335
3361
  }
3336
3362
  // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
@@ -3365,18 +3391,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3365
3391
  pasteBracketRef.current = false
3366
3392
  pasteBracketCancelRef.current?.()
3367
3393
  text = text.replaceAll(PASTE_END_MARKER, '')
3368
- }
3369
- if (text === '') return
3370
- const droppedPaths = text.length > 1 ? parsePastedImagePaths(text) : []
3371
- if (droppedPaths.length > 0) {
3372
- insertDroppedImages(droppedPaths)
3373
- return
3374
- }
3375
- applyEdit(insertText(valueRef.current, cursorRef.current, text))
3376
- }
3377
- }, active)
3378
-
3379
- // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
3394
+ }
3395
+ if (text === '') return
3396
+ const droppedPaths = text.length > 1 ? parsePastedImagePaths(text) : []
3397
+ if (droppedPaths.length > 0) {
3398
+ insertDroppedImages(droppedPaths)
3399
+ return
3400
+ }
3401
+ applyEdit(insertText(valueRef.current, cursorRef.current, text))
3402
+ }
3403
+ }, active)
3404
+
3405
+ // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
3380
3406
  // the interval re-renders only the composer band at 30fps, never the whole
3381
3407
  // tree. App drives the tier/style pair on a model switch; this local effect
3382
3408
  // starts the sweep whenever that pair changes (App picks a NEW random style
@@ -3396,7 +3422,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3396
3422
  setWaveTick(0)
3397
3423
  }
3398
3424
  }, [waveTier, waveStyle])
3399
- const waveActive = !preparingImages && waveTick !== null && waveTier !== null && waveStyle !== null
3425
+ const waveActive = !preparingImages && waveTick !== null && waveTier !== null && waveStyle !== null
3400
3426
  && waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
3401
3427
  useEffect(() => {
3402
3428
  if (!waveActive) return
@@ -3422,7 +3448,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3422
3448
  // column-safe physical rows, with the caret mapped to its exact row and
3423
3449
  // column. Computed before the frozen path so the row report below runs
3424
3450
  // unconditionally.
3425
- const editorViewModel = editorModel(value, editorColumns)
3451
+ const editorViewModel = editorModel(value, editorColumns)
3426
3452
  const clampedCursor = clampCursor(value, cursor)
3427
3453
  const caret = caretSite(editorViewModel, clampedCursor)
3428
3454
  const editorWindowRows = Math.min(editorViewModel.rows.length, Math.max(1, maxRows))
@@ -3448,7 +3474,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3448
3474
  const bandWidth = Math.max(1, columns - 1)
3449
3475
  const bandBg = inkColor(getPalette().composerBand)
3450
3476
  const bandFill = (consumed: number): string => ' '.repeat(Math.max(0, bandWidth - consumed))
3451
- const band = (content: ReactElement): ReactElement => createElement(
3477
+ const band = (content: ReactElement): ReactElement => createElement(
3452
3478
  Box,
3453
3479
  { flexDirection: 'column', width: bandWidth },
3454
3480
  createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
@@ -3486,138 +3512,138 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3486
3512
  rows: menuRows,
3487
3513
  })
3488
3514
 
3489
- // Every state reuses this exact multiline editor window. Only the caret row
3490
- // owns an inverse block; non-caret rows render their text without a hidden
3491
- // spacer or a second blink timer.
3492
- const editorRows: ReactElement[] = []
3493
- for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
3494
- const row = editorViewModel.rows[index]!
3495
- const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
3496
- const placeholder = index === 0 && value === '' && !busy && !preparingImages
3497
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3498
- const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
3499
- editorRows.push(createElement(
3500
- Text,
3501
- { key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
3502
- index === 0
3503
- ? preparingImages
3504
- ? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
3505
- : busy
3506
- ? createElement(BusyChase)
3507
- : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
3508
- : ' ',
3509
- parts.before,
3510
- parts.hasCaret
3511
- ? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
3512
- : null,
3513
- placeholder
3514
- ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
3515
- : parts.after,
3515
+ // Every state reuses this exact multiline editor window. Only the caret row
3516
+ // owns an inverse block; non-caret rows render their text without a hidden
3517
+ // spacer or a second blink timer.
3518
+ const editorRows: ReactElement[] = []
3519
+ for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
3520
+ const row = editorViewModel.rows[index]!
3521
+ const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
3522
+ const placeholder = index === 0 && value === '' && !busy && !preparingImages
3523
+ const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3524
+ const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
3525
+ editorRows.push(createElement(
3526
+ Text,
3527
+ { key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
3528
+ index === 0
3529
+ ? preparingImages
3530
+ ? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
3531
+ : busy
3532
+ ? createElement(BusyChase)
3533
+ : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
3534
+ : ' ',
3535
+ parts.before,
3536
+ parts.hasCaret
3537
+ ? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
3538
+ : null,
3539
+ placeholder
3540
+ ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
3541
+ : parts.after,
3516
3542
  bandFill(consumed),
3517
3543
  ))
3518
- }
3519
- const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
3520
-
3521
- // The wave paints the SAME visible rows and caret site as the static path.
3522
- // Graphemes remain atomic and every background sample advances by terminal
3523
- // display columns, so CJK and emoji cannot move the caret or wrap the band.
3524
- const waveRow = (): ReactElement => {
3525
- const hues = deepseekWaveHues(waveTier!)
3526
- const style = waveStyle!
3527
- const bandRgb = getPalette().composerBand
3528
- const visibleRows = editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows)
3529
- const totalBandRows = visibleRows.length + 2
3530
- const waveBg = (row: number, column: number): string => {
3531
- const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, totalBandRows)
3532
- return rgb === null ? bandBg : inkColor(rgb)
3533
- }
3534
- const blankBandRow = (row: number): ReactElement => {
3535
- const blanks: ComposerCell[] = []
3536
- for (let column = 0; column < bandWidth; column += 1) {
3537
- blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
3538
- }
3539
- return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
3540
- }
3541
- const cellIndexAtColumn = (cells: readonly ComposerCell[], target: number): number | undefined => {
3542
- let column = 0
3543
- for (let index = 0; index < cells.length; index += 1) {
3544
- if (column === target) return index
3545
- column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
3546
- if (column > target) return undefined
3547
- }
3548
- return undefined
3549
- }
3550
- const editorWaveRows = visibleRows.map((row, visibleIndex) => {
3551
- const sourceIndex = editorWindowStart + visibleIndex
3552
- const bandRow = visibleIndex + 1
3553
- const parts = editorRowParts(row, sourceIndex, caret.row, clampedCursor)
3554
- const placeholder = sourceIndex === 0 && value === '' && !busy
3555
- const cells: ComposerCell[] = []
3556
- let usedColumns = 0
3557
- const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
3558
- const width = visibleColumns(char)
3559
- cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
3560
- usedColumns += width
3561
- }
3562
- if (sourceIndex === 0) {
3563
- push(promptGlyph, { color: promptColor, bold: true })
3564
- push(' ', { color: promptColor })
3565
- } else {
3566
- push(' ')
3567
- push(' ')
3568
- }
3569
- for (const span of splitGraphemes(parts.before)) push(span.text)
3570
- if (parts.hasCaret) push(parts.caret, { inverse: cursorVisible })
3571
- const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3572
- for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
3573
- while (usedColumns < bandWidth) push(' ')
3574
-
3575
- const middleBandRow = Math.floor(totalBandRows / 2)
3576
- if (bandRow === middleBandRow && deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
3577
- const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
3578
- const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
3579
- const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
3580
- if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
3581
- for (let at = 0; at < word.length; at += 1) {
3582
- const cell = cells[indices[at]!]!
3583
- cell.char = word[at]!
3584
- cell.width = 1
3585
- cell.color = inkColor(deepseekWaveWordHue(at, hues))
3586
- cell.bold = true
3587
- cell.dim = false
3588
- }
3589
- }
3590
- }
3591
- if (bandRow === middleBandRow && (waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
3592
- const spark = deepseekWaveSpark(waveTick!)
3593
- const lastIndex = cellIndexAtColumn(cells, bandWidth - 1)
3594
- if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
3595
- cells[lastIndex]!.char = spark
3596
- cells[lastIndex]!.color = promptColor
3597
- cells[lastIndex]!.bold = true
3598
- cells[lastIndex]!.dim = false
3599
- }
3600
- }
3601
- return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
3602
- })
3603
- return createElement(
3604
- Box,
3605
- { flexDirection: 'column', width: bandWidth },
3606
- blankBandRow(0),
3607
- ...editorWaveRows,
3608
- blankBandRow(totalBandRows - 1),
3609
- )
3610
- }
3611
-
3612
- return createElement(
3613
- Box,
3614
- { flexDirection: 'column' },
3615
- menu,
3616
- waveTick !== null && waveTier !== null && waveStyle !== null && !busy && !preparingImages
3617
- ? waveRow()
3618
- : band(staticEditor),
3619
- )
3620
- }
3544
+ }
3545
+ const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
3546
+
3547
+ // The wave paints the SAME visible rows and caret site as the static path.
3548
+ // Graphemes remain atomic and every background sample advances by terminal
3549
+ // display columns, so CJK and emoji cannot move the caret or wrap the band.
3550
+ const waveRow = (): ReactElement => {
3551
+ const hues = deepseekWaveHues(waveTier!)
3552
+ const style = waveStyle!
3553
+ const bandRgb = getPalette().composerBand
3554
+ const visibleRows = editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows)
3555
+ const totalBandRows = visibleRows.length + 2
3556
+ const waveBg = (row: number, column: number): string => {
3557
+ const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, totalBandRows)
3558
+ return rgb === null ? bandBg : inkColor(rgb)
3559
+ }
3560
+ const blankBandRow = (row: number): ReactElement => {
3561
+ const blanks: ComposerCell[] = []
3562
+ for (let column = 0; column < bandWidth; column += 1) {
3563
+ blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
3564
+ }
3565
+ return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
3566
+ }
3567
+ const cellIndexAtColumn = (cells: readonly ComposerCell[], target: number): number | undefined => {
3568
+ let column = 0
3569
+ for (let index = 0; index < cells.length; index += 1) {
3570
+ if (column === target) return index
3571
+ column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
3572
+ if (column > target) return undefined
3573
+ }
3574
+ return undefined
3575
+ }
3576
+ const editorWaveRows = visibleRows.map((row, visibleIndex) => {
3577
+ const sourceIndex = editorWindowStart + visibleIndex
3578
+ const bandRow = visibleIndex + 1
3579
+ const parts = editorRowParts(row, sourceIndex, caret.row, clampedCursor)
3580
+ const placeholder = sourceIndex === 0 && value === '' && !busy
3581
+ const cells: ComposerCell[] = []
3582
+ let usedColumns = 0
3583
+ const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
3584
+ const width = visibleColumns(char)
3585
+ cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
3586
+ usedColumns += width
3587
+ }
3588
+ if (sourceIndex === 0) {
3589
+ push(promptGlyph, { color: promptColor, bold: true })
3590
+ push(' ', { color: promptColor })
3591
+ } else {
3592
+ push(' ')
3593
+ push(' ')
3594
+ }
3595
+ for (const span of splitGraphemes(parts.before)) push(span.text)
3596
+ if (parts.hasCaret) push(parts.caret, { inverse: cursorVisible })
3597
+ const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
3598
+ for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
3599
+ while (usedColumns < bandWidth) push(' ')
3600
+
3601
+ const middleBandRow = Math.floor(totalBandRows / 2)
3602
+ if (bandRow === middleBandRow && deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
3603
+ const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
3604
+ const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
3605
+ const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
3606
+ if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
3607
+ for (let at = 0; at < word.length; at += 1) {
3608
+ const cell = cells[indices[at]!]!
3609
+ cell.char = word[at]!
3610
+ cell.width = 1
3611
+ cell.color = inkColor(deepseekWaveWordHue(at, hues))
3612
+ cell.bold = true
3613
+ cell.dim = false
3614
+ }
3615
+ }
3616
+ }
3617
+ if (bandRow === middleBandRow && (waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
3618
+ const spark = deepseekWaveSpark(waveTick!)
3619
+ const lastIndex = cellIndexAtColumn(cells, bandWidth - 1)
3620
+ if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
3621
+ cells[lastIndex]!.char = spark
3622
+ cells[lastIndex]!.color = promptColor
3623
+ cells[lastIndex]!.bold = true
3624
+ cells[lastIndex]!.dim = false
3625
+ }
3626
+ }
3627
+ return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
3628
+ })
3629
+ return createElement(
3630
+ Box,
3631
+ { flexDirection: 'column', width: bandWidth },
3632
+ blankBandRow(0),
3633
+ ...editorWaveRows,
3634
+ blankBandRow(totalBandRows - 1),
3635
+ )
3636
+ }
3637
+
3638
+ return createElement(
3639
+ Box,
3640
+ { flexDirection: 'column' },
3641
+ menu,
3642
+ waveTick !== null && waveTier !== null && waveStyle !== null && !busy && !preparingImages
3643
+ ? waveRow()
3644
+ : band(staticEditor),
3645
+ )
3646
+ }
3621
3647
 
3622
3648
  /** One cached settled row: the row Box plus its roomy-prompt spacers. */
3623
3649
  interface SettledRowRecord {
@@ -3857,13 +3883,13 @@ export function App(props: AppProps): ReactElement {
3857
3883
  const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
3858
3884
  const [modelLabel, setModelLabel] = useState(props.model)
3859
3885
  const [modelOpen, setModelOpen] = useState(false)
3860
- /** Nested /model stages; only one owns terminal input at a time. */
3861
- const [providerOpen, setProviderOpen] = useState(false)
3862
- const [providerAction, setProviderAction] = useState<
3863
- | { kind: 'credential' | 'configure' | 'unset' | 'remove'; target: ProviderTargetView }
3864
- | { kind: 'login' | 'logout'; target: ProviderTargetView; authorization: ProviderAuthorizationRow }
3865
- | undefined
3866
- >(undefined)
3886
+ /** Nested /model stages; only one owns terminal input at a time. */
3887
+ const [providerOpen, setProviderOpen] = useState(false)
3888
+ const [providerAction, setProviderAction] = useState<
3889
+ | { kind: 'credential' | 'configure' | 'unset' | 'remove'; target: ProviderTargetView }
3890
+ | { kind: 'login' | 'logout'; target: ProviderTargetView; authorization: ProviderAuthorizationRow }
3891
+ | undefined
3892
+ >(undefined)
3867
3893
  /** The model row whose effort levels the /model stage lists; undefined shows the model list. */
3868
3894
  const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
3869
3895
  /** Effective reasoning effort, shown in the /model picker and switch notice. */
@@ -3913,10 +3939,10 @@ export function App(props: AppProps): ReactElement {
3913
3939
  }, [modelLabel, effortLabel])
3914
3940
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
3915
3941
  const [modelError, setModelError] = useState<string | undefined>(undefined)
3916
- const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
3917
- const [providerError, setProviderError] = useState<string | undefined>(undefined)
3918
- const [authorizationDirectory, setAuthorizationDirectory] = useState<ProviderAuthorizationDirectory | undefined>(undefined)
3919
- const [authorizationError, setAuthorizationError] = useState<string | undefined>(undefined)
3942
+ const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
3943
+ const [providerError, setProviderError] = useState<string | undefined>(undefined)
3944
+ const [authorizationDirectory, setAuthorizationDirectory] = useState<ProviderAuthorizationDirectory | undefined>(undefined)
3945
+ const [authorizationError, setAuthorizationError] = useState<string | undefined>(undefined)
3920
3946
  const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
3921
3947
  const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
3922
3948
  const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
@@ -3956,21 +3982,21 @@ export function App(props: AppProps): ReactElement {
3956
3982
  return () => {
3957
3983
  cancelled = true
3958
3984
  }
3959
- }, [modelOpen, modelLoadEpoch, props.loadModelProviders])
3960
- useEffect(() => {
3961
- if (!modelOpen || props.loadProviderAuthorizations === undefined) return
3962
- let cancelled = false
3963
- setAuthorizationDirectory(undefined)
3964
- setAuthorizationError(undefined)
3965
- Promise.resolve().then(() => props.loadProviderAuthorizations!()).then((loaded) => {
3966
- if (!cancelled) setAuthorizationDirectory(loaded)
3967
- }, (error: unknown) => {
3968
- if (!cancelled) setAuthorizationError(error instanceof Error ? error.message : String(error))
3969
- })
3970
- return () => {
3971
- cancelled = true
3972
- }
3973
- }, [modelOpen, modelLoadEpoch, props.loadProviderAuthorizations])
3985
+ }, [modelOpen, modelLoadEpoch, props.loadModelProviders])
3986
+ useEffect(() => {
3987
+ if (!modelOpen || props.loadProviderAuthorizations === undefined) return
3988
+ let cancelled = false
3989
+ setAuthorizationDirectory(undefined)
3990
+ setAuthorizationError(undefined)
3991
+ Promise.resolve().then(() => props.loadProviderAuthorizations!()).then((loaded) => {
3992
+ if (!cancelled) setAuthorizationDirectory(loaded)
3993
+ }, (error: unknown) => {
3994
+ if (!cancelled) setAuthorizationError(error instanceof Error ? error.message : String(error))
3995
+ })
3996
+ return () => {
3997
+ cancelled = true
3998
+ }
3999
+ }, [modelOpen, modelLoadEpoch, props.loadProviderAuthorizations])
3974
4000
  useEffect(() => {
3975
4001
  const subscribe = props.subscribeModelProviders
3976
4002
  if (!modelOpen || subscribe === undefined) return
@@ -3979,19 +4005,21 @@ export function App(props: AppProps): ReactElement {
3979
4005
  } catch (error: unknown) {
3980
4006
  setProviderError(error instanceof Error ? error.message : String(error))
3981
4007
  }
3982
- }, [modelOpen, props.subscribeModelProviders])
3983
- useEffect(() => {
3984
- const subscribe = props.subscribeProviderAuthorizations
3985
- if (!modelOpen || subscribe === undefined) return
3986
- try {
3987
- return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
3988
- } catch (error: unknown) {
3989
- setAuthorizationError(error instanceof Error ? error.message : String(error))
3990
- }
3991
- }, [modelOpen, props.subscribeProviderAuthorizations])
4008
+ }, [modelOpen, props.subscribeModelProviders])
4009
+ useEffect(() => {
4010
+ const subscribe = props.subscribeProviderAuthorizations
4011
+ if (!modelOpen || subscribe === undefined) return
4012
+ try {
4013
+ return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
4014
+ } catch (error: unknown) {
4015
+ setAuthorizationError(error instanceof Error ? error.message : String(error))
4016
+ }
4017
+ }, [modelOpen, props.subscribeProviderAuthorizations])
3992
4018
 
3993
4019
  const busy = view.busy
3994
4020
  const [showReasoning, setShowReasoning] = useState(false)
4021
+ // Dedupe for the dynamic-budget tripwire: one warning per distinct shape.
4022
+ const budgetWarnRef = useRef<string | undefined>(undefined)
3995
4023
  const [verboseOpen, setVerboseOpen] = useState(false)
3996
4024
  const [diffView, setDiffView] = useState<GitDiffView | undefined>(undefined)
3997
4025
  const [helpOpen, setHelpOpen] = useState(false)
@@ -4224,6 +4252,22 @@ export function App(props: AppProps): ReactElement {
4224
4252
  ? Math.max(1, Math.floor(streamRows / 3))
4225
4253
  : 1
4226
4254
  const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
4255
+ // Dynamic-height tripwire: the allocation must fit dynamicRows by
4256
+ // construction; a future edit that breaks the derivation clamps here
4257
+ // (answer, then reasoning, then settled live rows) and warns once.
4258
+ const liveAudit = clampLiveAllocation(
4259
+ { live: visibleLiveLines.length, reasoning: reasoningRows, answer: answerRows },
4260
+ dynamicRows,
4261
+ )
4262
+ if (liveAudit.warning !== undefined && budgetWarnRef.current !== liveAudit.warning) {
4263
+ budgetWarnRef.current = liveAudit.warning
4264
+ console.warn(`[dsh-code] ${liveAudit.warning}`)
4265
+ }
4266
+ const auditedLiveLines = liveAudit.allocation.live === visibleLiveLines.length
4267
+ ? visibleLiveLines
4268
+ : visibleLiveLines.slice(-liveAudit.allocation.live)
4269
+ const auditedReasoningRows = liveAudit.allocation.reasoning
4270
+ const auditedAnswerRows = liveAudit.allocation.answer
4227
4271
  const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
4228
4272
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
4229
4273
  const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
@@ -4248,7 +4292,7 @@ export function App(props: AppProps): ReactElement {
4248
4292
  appStdout.write(SYNCHRONIZED_UPDATE_END)
4249
4293
  }, [appStdout, refreshEpoch])
4250
4294
  // An idle Ctrl+R fold toggle joins resize and explicit Ctrl+L as a deliberate
4251
- // source-backed rebuild of native scrollback; busy turns never do.
4295
+ // source-backed rebuild of native scrollback.
4252
4296
 
4253
4297
  // Rendered-history cap: when the settled window overflows the trim
4254
4298
  // hysteresis, one source-backed replay re-windows it (the rebuild branch
@@ -4261,21 +4305,21 @@ export function App(props: AppProps): ReactElement {
4261
4305
  refreshScreen()
4262
4306
  }, [settledNeedsTrim, busy, streamingActive])
4263
4307
 
4264
- const sessionHasImages = useMemo(() => view.entries.some(entry =>
4265
- (entry.kind === 'user' || entry.kind === 'pending') && (entry.images?.length ?? 0) > 0), [view.entries])
4266
-
4267
- /** Apply one /model pick: record the selection, close the panel, report via notice. */
4268
- const applyModel = (row: ModelRow, effortId: string | undefined): void => {
4269
- try {
4270
- const label = props.selectModel(row, effortId)
4271
- setModelLabel(label)
4272
- setEffortLabel(effortId)
4273
- const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
4274
- if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
4275
- notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
4276
- } else {
4277
- notify(`model → next step uses ${selected}`)
4278
- }
4308
+ const sessionHasImages = useMemo(() => view.entries.some(entry =>
4309
+ (entry.kind === 'user' || entry.kind === 'pending') && (entry.images?.length ?? 0) > 0), [view.entries])
4310
+
4311
+ /** Apply one /model pick: record the selection, close the panel, report via notice. */
4312
+ const applyModel = (row: ModelRow, effortId: string | undefined): void => {
4313
+ try {
4314
+ const label = props.selectModel(row, effortId)
4315
+ setModelLabel(label)
4316
+ setEffortLabel(effortId)
4317
+ const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
4318
+ if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
4319
+ notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
4320
+ } else {
4321
+ notify(`model → next step uses ${selected}`)
4322
+ }
4279
4323
  setModelOpen(false)
4280
4324
  setProviderOpen(false)
4281
4325
  setProviderAction(undefined)
@@ -4295,44 +4339,44 @@ export function App(props: AppProps): ReactElement {
4295
4339
  setEffortFor(undefined)
4296
4340
  }
4297
4341
  let modelSurface: ReactElement | undefined
4298
- if (modelOpen && !approvalPending && !questionPending) {
4299
- if (providerAction?.kind === 'login'
4300
- && props.beginProviderAuthorization !== undefined
4301
- && props.cancelProviderAuthorization !== undefined
4302
- && props.openAuthorizationUrl !== undefined
4303
- && props.copyTextValue !== undefined) {
4304
- modelSurface = createElement(ProviderAuthorizationPanel, {
4305
- row: providerAction.authorization,
4306
- begin: props.beginProviderAuthorization,
4307
- cancel: () => props.cancelProviderAuthorization!(providerAction.authorization),
4308
- openUrl: props.openAuthorizationUrl,
4309
- copy: props.copyTextValue,
4310
- done: () => {
4311
- const authorization = providerAction.authorization
4312
- setProviderAction(undefined)
4313
- setProviderOpen(false)
4314
- reloadModelSurfaces()
4315
- notify(`logged in to ${authorization.label}; select a model`)
4316
- },
4317
- back: () => {
4318
- setProviderAction(undefined)
4319
- setProviderOpen(true)
4320
- },
4321
- })
4322
- } else if (providerAction?.kind === 'logout' && props.logoutProviderAuthorization !== undefined) {
4323
- modelSurface = createElement(ProviderAuthorizationLogoutPanel, {
4324
- row: providerAction.authorization,
4325
- confirm: props.logoutProviderAuthorization,
4326
- done: () => {
4327
- const authorization = providerAction.authorization
4328
- setProviderAction(undefined)
4329
- setProviderOpen(true)
4330
- reloadModelSurfaces()
4331
- notify(`logged out from ${authorization.label}`)
4332
- },
4333
- back: () => setProviderAction(undefined),
4334
- })
4335
- } else if (providerAction?.kind === 'configure' && props.saveModelProviderConfiguration !== undefined) {
4342
+ if (modelOpen && !approvalPending && !questionPending) {
4343
+ if (providerAction?.kind === 'login'
4344
+ && props.beginProviderAuthorization !== undefined
4345
+ && props.cancelProviderAuthorization !== undefined
4346
+ && props.openAuthorizationUrl !== undefined
4347
+ && props.copyTextValue !== undefined) {
4348
+ modelSurface = createElement(ProviderAuthorizationPanel, {
4349
+ row: providerAction.authorization,
4350
+ begin: props.beginProviderAuthorization,
4351
+ cancel: () => props.cancelProviderAuthorization!(providerAction.authorization),
4352
+ openUrl: props.openAuthorizationUrl,
4353
+ copy: props.copyTextValue,
4354
+ done: () => {
4355
+ const authorization = providerAction.authorization
4356
+ setProviderAction(undefined)
4357
+ setProviderOpen(false)
4358
+ reloadModelSurfaces()
4359
+ notify(`logged in to ${authorization.label}; select a model`)
4360
+ },
4361
+ back: () => {
4362
+ setProviderAction(undefined)
4363
+ setProviderOpen(true)
4364
+ },
4365
+ })
4366
+ } else if (providerAction?.kind === 'logout' && props.logoutProviderAuthorization !== undefined) {
4367
+ modelSurface = createElement(ProviderAuthorizationLogoutPanel, {
4368
+ row: providerAction.authorization,
4369
+ confirm: props.logoutProviderAuthorization,
4370
+ done: () => {
4371
+ const authorization = providerAction.authorization
4372
+ setProviderAction(undefined)
4373
+ setProviderOpen(true)
4374
+ reloadModelSurfaces()
4375
+ notify(`logged out from ${authorization.label}`)
4376
+ },
4377
+ back: () => setProviderAction(undefined),
4378
+ })
4379
+ } else if (providerAction?.kind === 'configure' && props.saveModelProviderConfiguration !== undefined) {
4336
4380
  modelSurface = createElement(ProviderConfigurationPanel, {
4337
4381
  target: providerAction.target,
4338
4382
  catalog: directory?.rows ?? [],
@@ -4388,11 +4432,11 @@ export function App(props: AppProps): ReactElement {
4388
4432
  back: () => setProviderAction(undefined),
4389
4433
  })
4390
4434
  } else if (providerOpen) {
4391
- modelSurface = createElement(ProviderPanel, {
4392
- directory: providerDirectory,
4393
- error: providerError,
4394
- authorizations: authorizationDirectory,
4395
- authorizationError,
4435
+ modelSurface = createElement(ProviderPanel, {
4436
+ directory: providerDirectory,
4437
+ error: providerError,
4438
+ authorizations: authorizationDirectory,
4439
+ authorizationError,
4396
4440
  onCredential: (target: ProviderTargetView) => {
4397
4441
  if (props.saveModelProviderCredential === undefined) {
4398
4442
  notify('API key storage is unavailable in this profile', 'warning')
@@ -4414,34 +4458,34 @@ export function App(props: AppProps): ReactElement {
4414
4458
  }
4415
4459
  setProviderAction({ kind: 'unset', target })
4416
4460
  },
4417
- onRemove: (target: ProviderTargetView) => {
4461
+ onRemove: (target: ProviderTargetView) => {
4418
4462
  if (props.removeModelProvider === undefined) {
4419
4463
  notify('provider removal is unavailable in this profile', 'warning')
4420
4464
  return
4421
4465
  }
4422
- setProviderAction({ kind: 'remove', target })
4423
- },
4424
- onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
4425
- if (busy) {
4426
- notify('provider login is available only while the agent is idle', 'warning')
4427
- return
4428
- }
4429
- if (props.beginProviderAuthorization === undefined
4430
- || props.cancelProviderAuthorization === undefined
4431
- || props.openAuthorizationUrl === undefined
4432
- || props.copyTextValue === undefined) {
4433
- notify('provider login is unavailable in this profile', 'warning')
4434
- return
4435
- }
4436
- setProviderAction({ kind: 'login', target, authorization })
4437
- },
4438
- onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
4439
- if (props.logoutProviderAuthorization === undefined) {
4440
- notify('provider logout is unavailable in this profile', 'warning')
4441
- return
4442
- }
4443
- setProviderAction({ kind: 'logout', target, authorization })
4444
- },
4466
+ setProviderAction({ kind: 'remove', target })
4467
+ },
4468
+ onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
4469
+ if (busy) {
4470
+ notify('provider login is available only while the agent is idle', 'warning')
4471
+ return
4472
+ }
4473
+ if (props.beginProviderAuthorization === undefined
4474
+ || props.cancelProviderAuthorization === undefined
4475
+ || props.openAuthorizationUrl === undefined
4476
+ || props.copyTextValue === undefined) {
4477
+ notify('provider login is unavailable in this profile', 'warning')
4478
+ return
4479
+ }
4480
+ setProviderAction({ kind: 'login', target, authorization })
4481
+ },
4482
+ onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
4483
+ if (props.logoutProviderAuthorization === undefined) {
4484
+ notify('provider logout is unavailable in this profile', 'warning')
4485
+ return
4486
+ }
4487
+ setProviderAction({ kind: 'logout', target, authorization })
4488
+ },
4445
4489
  onRetry: reloadModelSurfaces,
4446
4490
  onBack: () => setProviderOpen(false),
4447
4491
  })
@@ -4494,22 +4538,39 @@ export function App(props: AppProps): ReactElement {
4494
4538
  // prefix, so streaming text lands exactly where the composer's input
4495
4539
  // text and the settled reply both render (Codex LIVE_PREFIX).
4496
4540
  { flexDirection: 'column' },
4497
- visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
4498
- view.streamingReasoning !== '' && reasoningRows > 0
4499
- ? createElement(StreamTail, {
4500
- text: showReasoning ? view.streamingReasoning : 'Thinking… (Ctrl+R to expand)',
4501
- prefix: '✻ ',
4502
- continuationPrefix: ' ',
4503
- dim: true,
4504
- maxRows: reasoningRows,
4505
- })
4541
+ auditedLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: auditedLiveLines }),
4542
+ view.streamingReasoning !== '' && auditedReasoningRows > 0
4543
+ ? showReasoning
4544
+ ? createElement(StreamTail, {
4545
+ text: view.streamingReasoning,
4546
+ prefix: '',
4547
+ continuationPrefix: ' ',
4548
+ dim: true,
4549
+ maxRows: auditedReasoningRows,
4550
+ })
4551
+ // The collapsed marker shimmers only while reasoning streams
4552
+ // alone: once answer text flows, a periodically re-rendered
4553
+ // animation component would race the store's frame-throttled
4554
+ // notifications and could defer the answer paint by tens to
4555
+ // hundreds of milliseconds (stream-burst contract), so the
4556
+ // marker falls back to the static dim row — same as Deep diving
4557
+ // always yields the live region to streaming content.
4558
+ : view.streaming === ''
4559
+ ? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)' })
4560
+ : createElement(StreamTail, {
4561
+ text: 'Thinking… (Ctrl/Alt+R to expand)',
4562
+ prefix: '✻ ',
4563
+ continuationPrefix: ' ',
4564
+ dim: true,
4565
+ maxRows: auditedReasoningRows,
4566
+ })
4506
4567
  : undefined,
4507
- view.streaming !== '' && answerRows > 0
4568
+ view.streaming !== '' && auditedAnswerRows > 0
4508
4569
  ? createElement(
4509
4570
  StreamTail,
4510
4571
  // The same two-column gutter as settled replies: streamed text
4511
4572
  // lands exactly where the assembled message will render.
4512
- { text: view.streaming, dim: false, maxRows: answerRows, prefix: ' ' },
4573
+ { text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
4513
4574
  busy ? createElement(Caret) : undefined,
4514
4575
  )
4515
4576
  : undefined,
@@ -4517,7 +4578,7 @@ export function App(props: AppProps): ReactElement {
4517
4578
  )
4518
4579
  : undefined,
4519
4580
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
4520
- transcriptVisible ? createElement(AgentsLine, { rows: agentRows }) : undefined,
4581
+ transcriptVisible ? createElement(AgentsLine, { rows: agentRows, total: props.subagents.getTotalSeen() }) : undefined,
4521
4582
  todosOpen && !approvalPending && !questionPending
4522
4583
  ? createElement(MemoTodoListPanel, {
4523
4584
  todos: view.todos,
@@ -4686,16 +4747,17 @@ export function App(props: AppProps): ReactElement {
4686
4747
  descriptors,
4687
4748
  skills,
4688
4749
  dispatch: props.dispatch,
4750
+ applyEditorKeys: props.applyEditorKeys,
4689
4751
  steer: props.steer,
4690
4752
  interrupt: props.interrupt,
4691
4753
  quit: props.quit,
4692
4754
  openModel: () => {
4693
4755
  setDirectory(undefined)
4694
4756
  setModelError(undefined)
4695
- setProviderDirectory(undefined)
4696
- setProviderError(undefined)
4697
- setAuthorizationDirectory(undefined)
4698
- setAuthorizationError(undefined)
4757
+ setProviderDirectory(undefined)
4758
+ setProviderError(undefined)
4759
+ setAuthorizationDirectory(undefined)
4760
+ setAuthorizationError(undefined)
4699
4761
  setProviderOpen(false)
4700
4762
  setProviderAction(undefined)
4701
4763
  setEffortFor(undefined)
@@ -4785,19 +4847,20 @@ export function App(props: AppProps): ReactElement {
4785
4847
  props.store.reset()
4786
4848
  },
4787
4849
  refresh: refreshScreen,
4788
- // Ctrl+R flips the reasoning fold. Idle toggles must be visible: rows
4789
- // already emitted through Static are native scrollback, so the fold
4790
- // state of past entries can only change through the source-backed
4791
- // replay (one clear + rebuild, wrapped in a synchronized frame). A
4792
- // busy/streaming turn stays calm: the live region flips alone and the
4793
- // entries that settle afterward capture the mode.
4850
+ // Ctrl+R flips the reasoning fold. Rows already emitted through
4851
+ // Static are native scrollback, so the fold state of past entries can
4852
+ // only change through the source-backed replay (one clear + rebuild,
4853
+ // wrapped in a synchronized frame). Every toggle replays globally and
4854
+ // immediately — including mid-turn so the whole transcript stays at
4855
+ // one fold state; the resize path already proves replaying during a
4856
+ // stream is safe.
4794
4857
  toggleReasoning: () => {
4795
4858
  setShowReasoning(current => !current)
4796
- if (!busy && !streamingActive) refreshScreen()
4859
+ refreshScreen()
4797
4860
  },
4798
- loadMentions: props.loadMentions,
4799
- inspectImages: props.inspectImages,
4800
- prepareImages: props.prepareImages,
4861
+ loadMentions: props.loadMentions,
4862
+ inspectImages: props.inspectImages,
4863
+ prepareImages: props.prepareImages,
4801
4864
  cyclePermission: props.cyclePermission,
4802
4865
  exportTranscript: props.exportTranscript,
4803
4866
  renameTitle: props.renameTitle,