dsh-code 1.0.1 → 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.
Files changed (44) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +21 -13
  3. package/lib/index.mjs +1902 -1092
  4. package/lib/types/app.d.ts +4 -13
  5. package/lib/types/attachments.d.ts +1 -1
  6. package/lib/types/editor-keys.d.ts +105 -0
  7. package/lib/types/git-workflow.d.ts +6 -2
  8. package/lib/types/keyboard.d.ts +31 -0
  9. package/lib/types/mentions.d.ts +2 -0
  10. package/lib/types/model-capabilities.d.ts +82 -0
  11. package/lib/types/provider-settings.d.ts +7 -0
  12. package/lib/types/render/animations.d.ts +27 -11
  13. package/lib/types/render/editor.d.ts +32 -7
  14. package/lib/types/render/lines.d.ts +26 -1
  15. package/lib/types/render/markdown.d.ts +1 -1
  16. package/lib/types/render/projection.d.ts +15 -1
  17. package/lib/types/render/text.d.ts +15 -9
  18. package/lib/types/render/width.d.ts +29 -0
  19. package/lib/types/session-directory.d.ts +27 -0
  20. package/lib/types/settings-file.d.ts +33 -0
  21. package/lib/types/store.d.ts +10 -0
  22. package/lib/types/subagents.d.ts +13 -3
  23. package/package.json +159 -159
  24. package/src/app.ts +920 -764
  25. package/src/attachments.ts +7 -0
  26. package/src/editor-keys.ts +371 -0
  27. package/src/git-workflow.ts +10 -6
  28. package/src/index.ts +1637 -1523
  29. package/src/internals.ts +26 -9
  30. package/src/keyboard.ts +131 -7
  31. package/src/mentions.ts +6 -1
  32. package/src/model-capabilities.ts +318 -0
  33. package/src/provider-settings.ts +16 -0
  34. package/src/render/animations.ts +64 -17
  35. package/src/render/editor.ts +125 -25
  36. package/src/render/lines.ts +403 -342
  37. package/src/render/markdown.ts +4 -7
  38. package/src/render/projection.ts +63 -40
  39. package/src/render/text.ts +152 -150
  40. package/src/render/width.ts +189 -0
  41. package/src/session-directory.ts +56 -0
  42. package/src/settings-file.ts +56 -0
  43. package/src/store.ts +26 -7
  44. package/src/subagents.ts +39 -6
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,
@@ -41,7 +41,9 @@ import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
41
41
  import { type MdSegment, visibleColumns } from './render/markdown.ts'
42
42
  import {
43
43
  busyChaseFrame,
44
+ BUSY_CHASE_TICK_MS,
44
45
  caretVisible,
46
+ DEEP_DIVING_SHIMMER_TICK_MS,
45
47
  DEEPSEEK_WAVE_TICK_MS,
46
48
  deepseekWaveColumnBg,
47
49
  deepseekWaveDuration,
@@ -50,6 +52,8 @@ import {
50
52
  deepseekWaveTier,
51
53
  deepseekWaveWordHue,
52
54
  deepseekWaveWordVisible,
55
+ deepDivingGradientColor,
56
+ deepDivingSparkColor,
53
57
  effortAboveHigh,
54
58
  isOfficialDeepSeekLabel,
55
59
  type DeepseekWaveStyle,
@@ -61,7 +65,7 @@ import type { ModelDirectory, ModelRow } from './models.ts'
61
65
  import type { ProviderConfiguration, ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
62
66
  import type { QuestionSnapshot, QuestionStore } from './questions.ts'
63
67
  import type { SkillsView, SkillRow } from './skills.ts'
64
- import type { MentionCandidate } from './mentions.ts'
68
+ import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
65
69
  import type { SubagentFeedView, SubagentRow } from './subagents.ts'
66
70
  import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
67
71
  import type { PresetRow } from './presets.ts'
@@ -76,19 +80,19 @@ import {
76
80
  type RecallState,
77
81
  } from './history.ts'
78
82
  import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
79
- import type { GitDiffView } from './git-workflow.ts'
80
- import {
81
- authorizationForProvider,
82
- providerAuthorizationStatus,
83
- type ProviderAuthorizationDirectory,
84
- type ProviderAuthorizationRow,
85
- } from './authorization.ts'
86
- import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
87
- import {
88
- looksLikeImagePath,
89
- parsePastedImagePaths,
90
- type ImagePathInspection,
91
- } 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'
92
96
 
93
97
  /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
94
98
  const RESIZE_REFLOW_DELAY_MS = 75
@@ -140,7 +144,16 @@ import {
140
144
  type StatusTone,
141
145
  } from './render/status.ts'
142
146
  import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
143
- import { normalizeKeyboardChunk, PASTE_END_MARKER, PASTE_START_MARKER, stripPasteMarkers } 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'
144
157
  import {
145
158
  clampScroll,
146
159
  followInspectorCursor,
@@ -152,6 +165,7 @@ import {
152
165
  selectionWindow,
153
166
  } from './render/inspector.ts'
154
167
  import {
168
+ clampLiveAllocation,
155
169
  lineSegment,
156
170
  markdownLines,
157
171
  settledEntryLines,
@@ -171,52 +185,57 @@ import {
171
185
  deleteWordBackward,
172
186
  deleteWordForward,
173
187
  editorModel,
188
+ editorRowParts,
174
189
  insertText,
175
190
  type EditResult,
176
191
  killToLineEnd,
177
192
  killToLineStart,
178
- lineBounds,
179
193
  moveCursorBy,
180
194
  moveCursorVertically,
195
+ moveToLineEnd,
196
+ moveToLineStart,
181
197
  moveWordLeft,
182
198
  moveWordRight,
199
+ remapStableRange,
200
+ replaceRangePreservingCursor,
183
201
  sanitizeDraftText,
184
202
  shouldRecallNavigate,
185
203
  splitGraphemes,
186
204
  } from './render/editor.ts'
187
205
 
188
- /** Visual priority for one bounded local notice. */
189
- export type NoticeTone = 'info' | 'warning' | 'error'
190
-
191
- /** One source of truth for TUI-owned slash commands in completion and `/help`. */
192
- const LOCAL_COMMANDS = [
193
- { label: '/help', description: 'show this overlay' },
194
- { label: '/model', description: 'switch the model and manage providers' },
195
- { label: '/effort', description: 'adjust reasoning effort for the current model' },
196
- { label: '/mode', description: 'inspect or select the agent preset (/mode [preset])' },
197
- { label: '/permission', description: 'inspect or select the permission preset (/permission [preset])' },
198
- { label: '/new', description: 'create and switch to a fresh session (/new [preset])' },
199
- { label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
200
- { label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
201
- { label: '/plugin', description: 'inspect the live plugin composition' },
202
- { label: '/jobs', description: 'inspect background jobs' },
203
- { label: '/statusline', description: 'customize the status line items' },
204
- { label: '/theme', description: 'switch the color theme' },
205
- { label: '/history', description: 'search and recall past prompts' },
206
- { label: '/agents', description: 'inspect subagent sessions of this conversation' },
207
- { label: '/todos', description: 'inspect the full todo list' },
208
- { label: '/subagent', description: 'choose the model delegated subagents run on' },
209
- { label: '/delete', description: 'delete a session and its subagent threads' },
210
- { label: '/clear', description: 'clear the screen' },
211
- { label: '/export', description: 'export the transcript to markdown (/export [path])' },
212
- { label: '/title', description: 'rename this session (/title <text>)' },
213
- { label: '/copy', description: 'copy the latest assistant response' },
214
- { label: '/diff', description: 'inspect Git changes (/diff [--staged|ref])' },
215
- { label: '/review', description: 'review Git changes under read-only permissions' },
216
- { label: '/quit', description: 'exit' },
217
- ] as const
218
-
219
- 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)))
220
239
 
221
240
  /** Props the runner hands the app; callbacks stay owned by the runner. */
222
241
  export interface AppProps {
@@ -251,9 +270,9 @@ export interface AppProps {
251
270
  /** Permission preset selected for the current or pending first session. */
252
271
  permission: string
253
272
  /** Submit one line: slash commands to the registry, other text to the agent. */
254
- dispatch(text: string, images?: readonly ImageBlock[]): void
255
- /** Submit steering: consumed at the running turn's next step boundary. */
256
- 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
257
276
  /** Interrupt the running turn (Esc); true when a turn was cancelled. */
258
277
  interrupt(): boolean
259
278
  /** Quit: unmount, flush, and request process exit. */
@@ -261,11 +280,11 @@ export interface AppProps {
261
280
  /** Load the selectable model directory (called when /model opens). */
262
281
  loadModels(): Promise<ModelDirectory>
263
282
  /** Load @mention candidates for the typed query (files + sessions). */
264
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
265
- /** Validate draft image paths without committing attachment objects. */
266
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
267
- /** Validate, normalize and persist images immediately before submission. */
268
- prepareImages(paths: readonly string[]): 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[]>
269
288
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
270
289
  selectModel(row: ModelRow, effortId?: string): string
271
290
  /** The /subagent override label, '' when delegated agents follow the current model. */
@@ -286,21 +305,21 @@ export interface AppProps {
286
305
  unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
287
306
  /** Remove one user-owned provider profile and its page-managed credential. */
288
307
  removeModelProvider?(target: ProviderTargetView): Promise<void>
289
- /** Save endpoint and explicit model capacities through the provider profile. */
290
- saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
291
- /** Provider authorization flows and value-free stored-record facts. */
292
- loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
293
- subscribeProviderAuthorizations?(listener: () => void): () => void
294
- beginProviderAuthorization?(
295
- row: ProviderAuthorizationRow,
296
- method: string,
297
- interaction: AuthorizationInteraction,
298
- signal: AbortSignal,
299
- ): Promise<AuthorizationStatus>
300
- cancelProviderAuthorization?(row: ProviderAuthorizationRow): void
301
- logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
302
- openAuthorizationUrl?(url: string): boolean
303
- 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>
304
323
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
305
324
  cyclePermission(): string
306
325
  /** Select or inspect a permission preset without requiring a pre-existing session. */
@@ -346,6 +365,8 @@ export interface AppProps {
346
365
  recordHistory(text: string): void
347
366
  /** Cancel one queued inbox message by identity (Delete on the empty composer). */
348
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>
349
370
  }
350
371
 
351
372
  /** Pad text with spaces to a visible-column target (menu name column). */
@@ -355,14 +376,15 @@ function padColumns(text: string, width: number): string {
355
376
  }
356
377
 
357
378
  /** Interval-driven frame counter for one self-contained animated leaf. */
358
- function useFrames(intervalMs: number): number {
379
+ function useFrames(intervalMs: number, active = true): number {
359
380
  const [tick, setTick] = useState(0)
360
381
  useEffect(() => {
382
+ if (!active) return
361
383
  const id = setInterval(() => setTick(current => current + 1), intervalMs)
362
384
  return () => {
363
385
  clearInterval(id)
364
386
  }
365
- }, [intervalMs])
387
+ }, [active, intervalMs])
366
388
  return tick
367
389
  }
368
390
 
@@ -381,14 +403,9 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
381
403
  useInput(stableHandler, { isActive: active })
382
404
  }
383
405
 
384
- /**
385
- * The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
386
- * ring trail clockwise around the eight outer positions (8 frames × 125ms =
387
- * the web's 1s cycle). Replaces the plain busy ellipsis as the composer's
388
- * prompt marker and leads the Deep-diving line.
389
- */
406
+ /** The original web StateDot chase used by the busy composer marker. */
390
407
  function BusyChase(): ReactElement {
391
- const tick = useFrames(125)
408
+ const tick = useFrames(BUSY_CHASE_TICK_MS)
392
409
  return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
393
410
  }
394
411
 
@@ -398,33 +415,65 @@ function Caret(): ReactElement {
398
415
  return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
399
416
  }
400
417
 
401
- /** Blinking input cursor: inverse block while the caret phase is on. */
402
- function CursorBlock({ char }: { char: string }): ReactElement {
403
- const tick = useFrames(530)
404
- return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
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 }
405
435
  }
406
436
 
407
437
  /**
408
- * The busy line, web TurnStatus contract: the StateDot chase leads the plain
409
- * `Deep diving...` label, with the elapsed clock appended only once the turn
410
- * has clearly been running (15s) anchored to `turn/start` so a resumed
411
- * mid-turn keeps the real time.
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.
412
442
  */
413
- function DeepDivingLine({ since }: { since: number }): ReactElement {
414
- useFrames(1000)
415
- const elapsed = since === 0 ? 0 : Date.now() - since
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)
416
447
  return createElement(
417
- Box,
418
- { flexDirection: 'row' },
419
- createElement(BusyChase),
420
- createElement(
421
- Text,
422
- { dimColor: true },
423
- elapsed >= 15_000 ? `Deep diving... ${runClock(elapsed)}` : 'Deep diving...',
424
- ),
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
+ }),
425
462
  )
426
463
  }
427
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
+ }
476
+
428
477
  /**
429
478
  * The streaming buffer rendered with a hard size cap: the live region must
430
479
  * ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
@@ -664,11 +713,12 @@ function todoMark(status: TodoItem['status']): string {
664
713
 
665
714
  /**
666
715
  * One-row live subagent summary (the Codex agent status feed, compressed to
667
- * the transcript's budget): running count, total, and the most recently
668
- * active child's current activity. One line, never more the full view is
669
- * 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.
670
720
  */
671
- function AgentsLine({ rows }: { rows: readonly SubagentRow[] }): ReactElement | undefined {
721
+ function AgentsLine({ rows, total }: { rows: readonly SubagentRow[]; total: number }): ReactElement | undefined {
672
722
  if (rows.length === 0) return undefined
673
723
  const running = rows.filter(row => row.state !== 'done').length
674
724
  const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]!
@@ -680,7 +730,7 @@ function AgentsLine({ rows }: { rows: readonly SubagentRow[] }): ReactElement |
680
730
  Text,
681
731
  { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
682
732
  `agents ${running} live`,
683
- createElement(Text, { color: inkColor(getPalette().dim) }, ` · ${rows.length} total · /agents`),
733
+ createElement(Text, { color: inkColor(getPalette().dim) }, ` · ${total} total · /agents`),
684
734
  createElement(Text, { color: inkColor(getPalette().text) }, ` · ${mark} ${newest.label} ${newest.activity}`),
685
735
  ),
686
736
  )
@@ -1488,10 +1538,10 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1488
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)),
1489
1539
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1490
1540
  ...visibleStateRows,
1491
- ...visible.map((row) => {
1492
- const index = rows.indexOf(row)
1493
- const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
1494
- 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}`)
1495
1545
  return createElement(
1496
1546
  Text,
1497
1547
  {
@@ -1521,17 +1571,17 @@ function providerStateLabel(row: ProviderTargetView): string {
1521
1571
  }
1522
1572
 
1523
1573
  /** The provider-management stage reached from /model with `a`. */
1524
- function ProviderPanel({ directory, error, authorizations, authorizationError, onCredential, onConfigure, onUnset, onRemove, onLogin, onLogout, onRetry, onBack }: {
1525
- directory: ProviderSettingsDirectory | undefined
1526
- error: string | undefined
1527
- authorizations: ProviderAuthorizationDirectory | undefined
1528
- authorizationError: string | undefined
1529
- onCredential(target: ProviderTargetView): void
1530
- onConfigure(target: ProviderTargetView): void
1531
- onUnset(target: ProviderTargetView): void
1532
- onRemove(target: ProviderTargetView): void
1533
- onLogin(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
1534
- 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
1535
1585
  onRetry(): void
1536
1586
  onBack(): void
1537
1587
  }): ReactElement {
@@ -1598,27 +1648,27 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1598
1648
  }
1599
1649
  return
1600
1650
  }
1601
- if (input === 'x') {
1651
+ if (input === 'x') {
1602
1652
  if (!target.removable) {
1603
1653
  setActionError('this provider profile is not removable')
1604
1654
  } else {
1605
1655
  onRemove(target)
1606
1656
  }
1607
- return
1608
- }
1609
- const authorization = authorizationForProvider(authorizations, target.provider)
1610
- if (input === 'l' || input === 'L') {
1611
- if (authorization === undefined) setActionError('this provider offers no interactive login flow')
1612
- else if (authorization.inFlight) setActionError('a login attempt is already running for this provider')
1613
- else onLogin(target, authorization)
1614
- return
1615
- }
1616
- if (input === 'o' || input === 'O') {
1617
- if (authorization === undefined || !authorization.record.configured) setActionError('this provider has no login record to remove')
1618
- else if (!authorization.record.writable) setActionError('this login record is read-only')
1619
- else onLogout(target, authorization)
1620
- return
1621
- }
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
+ }
1622
1672
  if (key.return) {
1623
1673
  if (target.settingsNs.length === 0) {
1624
1674
  setActionError('this provider is not managed by Harness settings')
@@ -1646,19 +1696,19 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1646
1696
  ...(actionError === undefined
1647
1697
  ? []
1648
1698
  : [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
1649
- ...(directory?.failures ?? []).map((failure, index) => createElement(
1699
+ ...(directory?.failures ?? []).map((failure, index) => createElement(
1650
1700
  Text,
1651
1701
  { key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1652
- truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1653
- )),
1654
- ...(authorizationError === undefined
1655
- ? []
1656
- : [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` login status unavailable: ${singleLineText(authorizationError)}`, viewport.contentColumns))]),
1657
- ...(authorizations?.failures ?? []).map((failure, index) => createElement(
1658
- Text,
1659
- { key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1660
- truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1661
- )),
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
+ )),
1662
1712
  ...(rows.length === 0
1663
1713
  ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
1664
1714
  : []),
@@ -1674,13 +1724,13 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1674
1724
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1675
1725
  ...visibleStateRows,
1676
1726
  ...visible.map((row) => {
1677
- const index = rows.indexOf(row)
1678
- const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
1679
- const authorization = authorizationForProvider(authorizations, row.provider)
1680
- const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
1681
- const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
1682
- const authLabel = showAuthorization ? ` · ${providerAuthorizationStatus(authorization)}` : ''
1683
- 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' : ''}`
1684
1734
  return createElement(
1685
1735
  Text,
1686
1736
  { key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' },
@@ -1688,7 +1738,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
1688
1738
  )
1689
1739
  }),
1690
1740
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1691
- 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)),
1692
1742
  )
1693
1743
  }
1694
1744
 
@@ -1969,7 +2019,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1969
2019
  createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
1970
2020
  createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · up/down history · tab complete'),
1971
2021
  createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' @ mentions workspace files and sessions'),
1972
- 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'),
1973
2023
  createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
1974
2024
  createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
1975
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'),
@@ -1982,12 +2032,12 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1982
2032
  { key: 'commands-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
1983
2033
  truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
1984
2034
  )]),
1985
- ...LOCAL_COMMANDS.map(command => createElement(
1986
- Box,
1987
- { key: `local-${command.label.slice(1)}` },
1988
- row(command.label, command.description),
1989
- )),
1990
- ...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(
1991
2041
  Text,
1992
2042
  { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
1993
2043
  ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
@@ -2054,83 +2104,13 @@ function verboseLine(text: string, columns: number): string {
2054
2104
  return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
2055
2105
  }
2056
2106
 
2057
- /**
2058
- * Keys Ink 5's parser cannot express at the useInput boundary: Home/End
2059
- * arrive with `input === ''` and no flag, and Backspace vs Delete both
2060
- * collapse onto `key.delete`. The composer patches `stdin.read` — the one
2061
- * choke point every Ink input chunk already passes through — and annotates
2062
- * the exact sequences the editor must own; Ink's own view of the same chunk
2063
- * is a no-op for every one of them.
2064
- */
2065
- type RawKeyAnnotation =
2066
- | 'home'
2067
- | 'end'
2068
- | 'delete-backward'
2069
- | 'delete-word-backward'
2070
- | 'delete-forward'
2071
- | 'delete-word-forward'
2072
- | undefined
2073
-
2074
- /** Identify one whole-chunk key sequence Ink drops or blurs. */
2075
- function annotateRawKey(chunk: string): RawKeyAnnotation {
2076
- switch (chunk) {
2077
- case '':
2078
- return 'delete-backward'
2079
- case '':
2080
- case '':
2081
- return 'delete-word-backward'
2082
- case '[3~':
2083
- case '[3;2~':
2084
- return 'delete-forward'
2085
- case '[3;3~':
2086
- case '[3;5~':
2087
- return 'delete-word-forward'
2088
- case '':
2089
- case '[1~':
2090
- case '[7~':
2091
- case 'OH':
2092
- return 'home'
2093
- case '':
2094
- case '[4~':
2095
- case '[8~':
2096
- case 'OF':
2097
- return 'end'
2098
- default:
2099
- return undefined
2100
- }
2101
- }
2102
-
2103
- /**
2104
- * One-row editor window keeping the logical cursor visible in long drafts.
2105
- * The caret and its surroundings slice at grapheme boundaries: splitting a
2106
- * star-plane surrogate pair would render an isolated half under the block
2107
- * caret with a width the terminal never draws.
2108
- */
2109
- export function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
2110
- const width = Math.max(1, columns)
2111
- const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
2112
- const site = clampCursor(value, cursor)
2113
- const caretSpan = splitGraphemes(value).find(span => span.start === site)
2114
- const caret = caretSpan === undefined ? ' ' : normalize(caretSpan.text)
2115
- const rest = value.slice(caretSpan === undefined ? site : caretSpan.end)
2116
- const remaining = Math.max(0, width - visibleColumns(caret))
2117
- const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(rest)))
2118
- const beforeBudget = Math.max(0, remaining - afterBudget)
2119
- const before = beforeBudget === 0
2120
- ? ''
2121
- : displayTail(normalize(value.slice(0, site)), beforeBudget, 1).text
2122
- const after = afterBudget === 0
2123
- ? ''
2124
- : truncateColumns(normalize(rest), afterBudget)
2125
- return { before, caret, after }
2126
- }
2127
-
2128
2107
  /** The empty-composer placeholder text (shared by the static and wave paths). */
2129
2108
  const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
2130
2109
 
2131
2110
  /** One physical cell of the wave-painted composer row: a char plus styles. */
2132
2111
  interface ComposerCell {
2133
2112
  char: string
2113
+ width?: number
2134
2114
  color?: string
2135
2115
  backgroundColor?: string
2136
2116
  bold?: boolean
@@ -2360,12 +2340,12 @@ export function completionCandidates(
2360
2340
  ): readonly CompletionCandidate[] {
2361
2341
  if (!value.startsWith('/')) return []
2362
2342
  const prefix = value.slice(1).split(' ')[0] ?? ''
2363
- const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
2343
+ const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
2364
2344
  // Local commands shadow registry names (e.g. the TUI-local /permission works
2365
2345
  // before any session exists, while the registry child needs one), so
2366
2346
  // collisions cannot render two rows with the same key.
2367
- const registry = descriptors
2368
- .filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name))
2347
+ const registry = descriptors
2348
+ .filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name))
2369
2349
  .map((descriptor): CompletionCandidate => ({
2370
2350
  label: `/${descriptor.name}`,
2371
2351
  description: descriptor.description,
@@ -2455,25 +2435,25 @@ function CompletionMenu({ active, mention, index, rows }: {
2455
2435
  )
2456
2436
  }
2457
2437
 
2458
- interface DraftImage extends ImagePathInspection {
2459
- /** Visible draft token; deleting it also detaches the hidden path. */
2460
- readonly marker: string
2461
- }
2462
-
2463
- /**
2464
- * 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
2465
2445
  * dispatched; input editing keeps a cursor with history and completion.
2466
2446
  * While a modal (approval / question / model panel) owns the keys, the
2467
2447
  * box passes every key through untouched.
2468
2448
  */
2469
- 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 }: {
2470
2450
  active: boolean
2471
2451
  frozen: boolean
2472
2452
  busy: boolean
2473
2453
  descriptors: readonly CommandDescriptor[]
2474
2454
  skills: readonly SkillRow[]
2475
- dispatch(text: string, images?: readonly ImageBlock[]): void
2476
- steer(text: string, images?: readonly ImageBlock[]): void
2455
+ dispatch(text: string, images?: readonly ImageBlock[]): void
2456
+ steer(text: string, images?: readonly ImageBlock[]): void
2477
2457
  interrupt(): boolean
2478
2458
  quit(): void
2479
2459
  openModel(): void
@@ -2507,15 +2487,17 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2507
2487
  forkSession(argument: string): void
2508
2488
  cancelSessionSwitch(): boolean
2509
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>
2510
2492
  hasNotice: boolean
2511
2493
  dismissNotice(): void
2512
2494
  toggleReasoning(): void
2513
2495
  openVerbose(): void
2514
2496
  clearView(): void
2515
2497
  refresh(): void
2516
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
2517
- inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
2518
- prepareImages(paths: readonly string[]): 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[]>
2519
2501
  cyclePermission(): string
2520
2502
  exportTranscript(argument: string): Promise<void>
2521
2503
  renameTitle(argument: string): string
@@ -2548,17 +2530,26 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2548
2530
  onEditorRows(rows: number): void
2549
2531
  }): ReactElement {
2550
2532
  const columns = useStdout().stdout?.columns ?? 80
2551
- const stdin = useStdin().stdin
2552
- const [value, setValue] = useState('')
2553
- const [cursor, setCursor] = useState(0)
2554
- const valueRef = useRef(value)
2555
- const cursorRef = useRef(cursor)
2556
- valueRef.current = value
2557
- cursorRef.current = cursor
2558
- const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
2559
- const draftImagesRef = useRef(draftImages)
2560
- draftImagesRef.current = draftImages
2561
- const [preparingImages, setPreparingImages] = useState(false)
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
+ }, [])
2562
2553
  // Codex textarea editing state: a single-entry kill buffer, the vertical
2563
2554
  // move's preferred display column, the editor's scroll window, and the
2564
2555
  // bracketed-paste marker state. All of it is editor-local; nothing here
@@ -2569,21 +2560,30 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2569
2560
  const pasteBracketRef = useRef(false)
2570
2561
  /** Cancels the pending lost-paste safety timer (undefined when disarmed). */
2571
2562
  const pasteBracketCancelRef = useRef<(() => void) | undefined>(undefined)
2572
- /** Annotation of the stdin chunk Ink is about to deliver to useInput. */
2573
- const rawAnnotation = useRef<RawKeyAnnotation>(undefined)
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)
2574
2567
  // Codex shell-style recall: the navigation cursor, the saved draft restored
2575
2568
  // on Down past the newest entry, and the boundary-gate anchor.
2576
2569
  const recall = useRef<RecallState>(beginRecall([], ''))
2577
2570
 
2571
+ useEffect(() => {
2572
+ preferredColumnRef.current = null
2573
+ }, [editorColumns])
2574
+
2578
2575
  // A /history panel acceptance lands as a fill: place the sanitized text at
2579
2576
  // the end of the composer and resume recall from that entry.
2580
- useEffect(() => {
2581
- if (historyFill === undefined) return
2582
- const safe = sanitizeDraftText(historyFill.text)
2583
- draftImagesRef.current = []
2584
- setDraftImages([])
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
2585
2584
  setValue(safe)
2586
2585
  setCursor(safe.length)
2586
+ resetCursorBlink()
2587
2587
  preferredColumnRef.current = null
2588
2588
  setDismissedMenuValue(undefined)
2589
2589
  recall.current = {
@@ -2593,25 +2593,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2593
2593
  lastRecalled: safe,
2594
2594
  }
2595
2595
  historyConsumed()
2596
- }, [historyFill, recallSpace, historyConsumed])
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])
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])
2605
2605
 
2606
2606
  // Home/End and the Backspace-vs-Delete family never survive Ink's parser
2607
2607
  // as distinct keys, and kitty CSI-u forms parse as unnamed junk Ink would
2608
2608
  // insert as draft text.
2609
2609
  // Patch stdin.read — the single choke point Ink's input loop pulls every
2610
2610
  // chunk through — to first rewrite decodable CSI-u sequences to their
2611
- // legacy bytes, then annotate the resulting chunk before Ink emits the
2612
- // matching 'input' event, so the useInput handler below reads the
2613
- // annotation for exactly the chunk it is processing. Ink receives and
2614
- // parses the normalized string; every other byte passes through untouched.
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.
2615
2614
  useEffect(() => {
2616
2615
  if (stdin === undefined) return
2617
2616
  const originalRead = stdin.read.bind(stdin)
@@ -2619,14 +2618,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2619
2618
  const chunk = originalRead(...args)
2620
2619
  if (chunk === null) return chunk
2621
2620
  const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
2622
- rawAnnotation.current = annotateRawKey(normalized)
2623
- return normalized
2621
+ const input = focusReporting
2622
+ ? stripTerminalFocusEvents(normalized, focused => {
2623
+ terminalFocusedRef.current = focused
2624
+ })
2625
+ : normalized
2626
+ rawEditorTokens.current = tokenizeRawEditorChunk(input)
2627
+ return input
2624
2628
  } as typeof stdin.read
2625
2629
  stdin.read = patchedRead
2626
2630
  return () => {
2627
2631
  stdin.read = originalRead as typeof stdin.read
2628
2632
  }
2629
- }, [stdin])
2633
+ }, [focusReporting, stdin])
2630
2634
 
2631
2635
  // Keep the navigation's recall space fresh while browsing state survives
2632
2636
  // (new local submissions extend the space; the index stays valid unless
@@ -2649,80 +2653,99 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2649
2653
  const mentionToken = tokenMatch === null
2650
2654
  ? undefined
2651
2655
  : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
2652
- const mentionActive = mentionToken !== undefined
2653
- const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
2654
-
2655
- const sameImagePath = (left: string, right: string): boolean => (
2656
- process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
2657
- )
2658
-
2659
- const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
2660
- const safeName = singleLineText(sanitizeDraftText(name))
2661
- const base = source === 'mention' ? `@${safeName}` : `[image: ${safeName}]`
2662
- let marker = base
2663
- let suffix = 2
2664
- while (valueRef.current.includes(marker) || draftImagesRef.current.some(image => image.marker === marker) || reserved.includes(marker)) {
2665
- marker = source === 'mention' ? `@${safeName} (${suffix})` : `[image: ${safeName} ${suffix}]`
2666
- suffix += 1
2667
- }
2668
- return marker
2669
- }
2670
-
2671
- const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
2672
- if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
2673
- notify(`${inspection.name} is already attached`, 'warning')
2674
- return false
2675
- }
2676
- const next = [...draftImagesRef.current, { ...inspection, marker }]
2677
- draftImagesRef.current = next
2678
- setDraftImages(next)
2679
- return true
2680
- }
2681
-
2682
- const insertDroppedImages = (paths: readonly string[]): void => {
2683
- notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
2684
- void inspectImages(paths).then((inspected) => {
2685
- const additions: DraftImage[] = []
2686
- const markers: string[] = []
2687
- for (const inspection of inspected) {
2688
- if ([...draftImagesRef.current, ...additions].some(image => sameImagePath(image.path, inspection.path))) continue
2689
- const marker = uniqueImageMarker(inspection.name, 'drop', markers)
2690
- additions.push({ ...inspection, marker })
2691
- markers.push(marker)
2692
- }
2693
- if (additions.length === 0) {
2694
- notify('those images are already attached', 'warning')
2695
- return
2696
- }
2697
- const at = cursorRef.current
2698
- const current = valueRef.current
2699
- const insertion = `${at > 0 && !/\s$/u.test(current.slice(0, at)) ? ' ' : ''}${markers.join(' ')}${current.slice(at) === '' ? '' : ' '}`
2700
- const next = current.slice(0, at) + insertion + current.slice(at)
2701
- valueRef.current = next
2702
- cursorRef.current = at + insertion.length
2703
- setValue(next)
2704
- setCursor(cursorRef.current)
2705
- const nextImages = [...draftImagesRef.current, ...additions]
2706
- draftImagesRef.current = nextImages
2707
- setDraftImages(nextImages)
2708
- notify(`${additions.length} image${additions.length === 1 ? '' : 's'} ready for the next message`)
2709
- }, (reason: unknown) => {
2710
- notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
2711
- })
2712
- }
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
+ }
2713
2729
 
2714
2730
  useEffect(() => {
2731
+ const requestId = mentionRequestRef.current + 1
2732
+ mentionRequestRef.current = requestId
2715
2733
  if (!active || !mentionActive) {
2716
2734
  setMentionRows([])
2717
2735
  return
2718
2736
  }
2719
2737
  const controller = new AbortController()
2720
- setMentionRows([])
2721
- loadMentions(mentionToken.query, controller.signal).then(
2722
- rows => setMentionRows(rows),
2723
- () => {},
2724
- )
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)
2725
2747
  return () => {
2748
+ clearTimeout(timer)
2726
2749
  controller.abort()
2727
2750
  }
2728
2751
  }, [active, mentionActive, mentionToken?.query])
@@ -2730,9 +2753,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2730
2753
  // Codex routes keys to the topmost surface first. Completion therefore
2731
2754
  // remains available while a turn runs, and Esc dismisses it before the
2732
2755
  // same key is allowed to interrupt the turn.
2733
- const menuActive = (slashActive || mentionActive) && dismissedMenuValue !== value
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
2734
2760
  const menuRows: readonly CompletionCandidate[] = mentionActive
2735
- ? mentionRows.map(row => ({
2761
+ ? visibleMentionRows.map(row => ({
2736
2762
  label: row.label.startsWith('@')
2737
2763
  ? row.label
2738
2764
  : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
@@ -2744,56 +2770,75 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2744
2770
  /** Accept the highlighted completion-menu candidate into the draft. */
2745
2771
  const acceptMenuCandidate = (): void => {
2746
2772
  if (mentionActive && mentionToken !== undefined) {
2747
- const row = mentionRows[completionIndex % mentionRows.length]
2748
- if (row !== undefined) {
2749
- if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
2750
- const tokenText = value.slice(mentionToken.start, cursor)
2751
- const start = mentionToken.start
2752
- notify(`checking image ${basename(row.path)}…`)
2753
- void inspectImages([row.path]).then((inspected) => {
2754
- const inspection = inspected[0]
2755
- if (inspection === undefined) return
2756
- const current = valueRef.current
2757
- if (current.slice(start, start + tokenText.length) !== tokenText) return
2758
- if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
2759
- const next = current.slice(0, start) + current.slice(start + tokenText.length)
2760
- valueRef.current = next
2761
- cursorRef.current = start
2762
- setValue(next)
2763
- setCursor(start)
2764
- setDismissedMenuValue(next)
2765
- notify(`${inspection.name} is already attached`, 'warning')
2766
- return
2767
- }
2768
- const marker = uniqueImageMarker(inspection.name, 'mention')
2769
- const next = current.slice(0, start) + marker + current.slice(start + tokenText.length)
2770
- valueRef.current = next
2771
- cursorRef.current = start + marker.length
2772
- setValue(next)
2773
- setCursor(cursorRef.current)
2774
- setDismissedMenuValue(next)
2775
- registerDraftImage(inspection, marker)
2776
- notify(`${inspection.name} ready for the next message`)
2777
- }, (reason: unknown) => {
2778
- notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
2779
- })
2780
- setCompletionIndex(0)
2781
- setDismissedMenuValue(undefined)
2782
- return
2783
- }
2784
- // Session rows carry the canonical @[label](dsh-session:…) token;
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;
2785
2819
  // file rows insert `@path` (directories keep their trailing slash).
2786
2820
  const insertion = row.label.startsWith('@')
2787
2821
  ? row.label
2788
2822
  : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
2789
- setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
2790
- setCursor(mentionToken.start + insertion.length)
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()
2791
2830
  }
2792
2831
  } else {
2832
+ if (candidates.length === 0) return
2793
2833
  const candidate = candidates[completionIndex % candidates.length]
2794
2834
  if (candidate !== undefined) {
2795
- setValue(`${candidate.label} `)
2796
- setCursor(candidate.label.length + 1)
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()
2797
2842
  }
2798
2843
  }
2799
2844
  setCompletionIndex(0)
@@ -2803,8 +2848,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2803
2848
  /** Apply one editor edit: draft, cursor, kill buffer, menu reset. */
2804
2849
  const applyEdit = (edit: EditResult): void => {
2805
2850
  if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2851
+ valueRef.current = edit.value
2852
+ cursorRef.current = edit.cursor
2806
2853
  setValue(edit.value)
2807
2854
  setCursor(edit.cursor)
2855
+ resetCursorBlink()
2808
2856
  preferredColumnRef.current = null
2809
2857
  setCompletionIndex(0)
2810
2858
  setDismissedMenuValue(undefined)
@@ -2812,15 +2860,106 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2812
2860
 
2813
2861
  /** Move the cursor without editing; horizontal moves clear the column preference. */
2814
2862
  const moveCursorTo = (next: number): void => {
2815
- if (next === cursor) return
2863
+ resetCursorBlink()
2864
+ if (next === cursorRef.current) return
2865
+ cursorRef.current = next
2816
2866
  setCursor(next)
2817
2867
  preferredColumnRef.current = null
2818
2868
  }
2819
2869
 
2820
- useInput((input, key) => {
2821
- // Modal ownership: approval/question/model dialogs consume all keys.
2822
- if (!active) return
2823
- if (preparingImages) return
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
+ }
2824
2963
  // Deletion confirm owns the box: y proceeds, anything else cancels.
2825
2964
  // Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
2826
2965
  // as the box's own prompt, not an invisible panel keypress.
@@ -2843,7 +2982,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2843
2982
  return
2844
2983
  }
2845
2984
  // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
2846
- if (key.ctrl && input === 'r') {
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
2847
2989
  toggleReasoning()
2848
2990
  return
2849
2991
  }
@@ -2860,11 +3002,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2860
3002
  if (key.ctrl && input === 'c') {
2861
3003
  if (busy) {
2862
3004
  interrupt()
2863
- } else if (value !== '') {
2864
- setValue('')
2865
- setCursor(0)
2866
- draftImagesRef.current = []
2867
- setDraftImages([])
3005
+ } else if (liveValue !== '') {
3006
+ valueRef.current = ''
3007
+ cursorRef.current = 0
3008
+ setValue('')
3009
+ setCursor(0)
3010
+ resetCursorBlink()
3011
+ draftImagesRef.current = []
3012
+ setDraftImages([])
2868
3013
  setCompletionIndex(0)
2869
3014
  setDismissedMenuValue(undefined)
2870
3015
  } else {
@@ -2875,8 +3020,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2875
3020
  if (key.ctrl && input === 'd') {
2876
3021
  // Codex: Ctrl+D deletes forward while a draft exists; the app-level
2877
3022
  // exit only fires from an empty composer.
2878
- if (value !== '') {
2879
- applyEdit(deleteForward(value, cursor))
3023
+ if (liveValue !== '') {
3024
+ applyEdit(deleteForward(liveValue, liveCursor))
2880
3025
  return
2881
3026
  }
2882
3027
  if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
@@ -2885,7 +3030,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2885
3030
  }
2886
3031
  if (key.escape) {
2887
3032
  if (menuActive) {
2888
- setDismissedMenuValue(value)
3033
+ setDismissedMenuValue(liveValue)
2889
3034
  return
2890
3035
  }
2891
3036
  if (hasNotice) {
@@ -2897,14 +3042,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2897
3042
  }
2898
3043
  // Delete on the empty composer cancels the newest queued message (the
2899
3044
  // web queue-mirror contract: the durable splice drops the pending row).
2900
- if (key.delete && value === '' && queued.length > 0) {
3045
+ if (key.delete && liveValue === '' && queued.length > 0) {
2901
3046
  cancelQueued(queued[queued.length - 1]!.messageId)
2902
3047
  return
2903
3048
  }
2904
3049
  if (key.return) {
2905
3050
  // A newline inside an open bracketed paste inserts; it never submits.
2906
3051
  if (pasteBracketRef.current) {
2907
- applyEdit(insertText(value, cursor, '\n'))
3052
+ applyEdit(insertText(liveValue, liveCursor, '\n'))
2908
3053
  return
2909
3054
  }
2910
3055
  // Enter on an open completion menu accepts the highlighted candidate
@@ -2913,43 +3058,54 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2913
3058
  // exactly, in which case Enter submits it (typing a full "/effort" and
2914
3059
  // pressing return must run the command, not re-accept its own text).
2915
3060
  if (menuActive) {
2916
- const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === value)
3061
+ const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
2917
3062
  if (!exactSlash) {
2918
3063
  acceptMenuCandidate()
2919
3064
  return
2920
3065
  }
2921
- }
2922
- const text = value.trim()
2923
- if (draftImagesRef.current.length > 0) {
2924
- setPreparingImages(true)
2925
- notify(`processing ${draftImagesRef.current.length} image${draftImagesRef.current.length === 1 ? '' : 's'}…`)
2926
- const snapshot = draftImagesRef.current
2927
- void prepareImages(snapshot.map(image => image.path)).then((images) => {
2928
- setPreparingImages(false)
2929
- valueRef.current = ''
2930
- cursorRef.current = 0
2931
- setValue('')
2932
- setCursor(0)
2933
- draftImagesRef.current = []
2934
- setDraftImages([])
2935
- setCompletionIndex(0)
2936
- setDismissedMenuValue(undefined)
2937
- dismissNotice()
2938
- if (text !== '') {
2939
- recordLocal(text)
2940
- recordHistory(text)
2941
- }
2942
- recall.current = beginRecall(recallSpace, '')
2943
- if (busy) steer(text, images)
2944
- else dispatch(text, images)
2945
- }, (reason: unknown) => {
2946
- setPreparingImages(false)
2947
- notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
2948
- })
2949
- return
2950
- }
2951
- setValue('')
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('')
2952
3107
  setCursor(0)
3108
+ resetCursorBlink()
2953
3109
  setCompletionIndex(0)
2954
3110
  setDismissedMenuValue(undefined)
2955
3111
  if (text === '') return
@@ -3076,6 +3232,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3076
3232
  openTodos()
3077
3233
  return
3078
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
+ }
3079
3242
  if (text === '/subagent') {
3080
3243
  openSubagent()
3081
3244
  return
@@ -3097,6 +3260,15 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3097
3260
  // Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
3098
3261
  // stripping the leading escape. Neither is a multiline shortcut.
3099
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
+ }
3100
3272
  if (menuActive && key.upArrow) {
3101
3273
  setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
3102
3274
  return
@@ -3105,135 +3277,86 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3105
3277
  setCompletionIndex(index => (index + 1) % menuRows.length)
3106
3278
  return
3107
3279
  }
3108
- // Raw-annotated keys (Home/End, the delete family): Ink's own flags for
3109
- // the same chunk are blank or blurred, so the read-patch annotation is
3110
- // authoritative whenever it is set.
3111
- const rawKey = rawAnnotation.current
3112
- if (rawKey !== undefined) {
3113
- if (rawKey === 'home') moveCursorTo(lineBounds(value, cursor).start)
3114
- else if (rawKey === 'end') moveCursorTo(lineBounds(value, cursor).end)
3115
- else if (rawKey === 'delete-backward') applyEdit(deleteBackward(value, cursor))
3116
- else if (rawKey === 'delete-word-backward') applyEdit(deleteWordBackward(value, cursor))
3117
- else if (rawKey === 'delete-forward') applyEdit(deleteForward(value, cursor))
3118
- else applyEdit(deleteWordForward(value, cursor))
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)
3119
3286
  return
3120
3287
  }
3121
3288
  if (key.upArrow || key.downArrow) {
3122
- // Codex boundary gate: shell recall runs from an empty draft, or from
3123
- // a boundary of a draft that still matches the last recalled entry;
3124
- // every interior Up/Down moves the caret across the multiline draft.
3125
- if (recall.current.entries.length > 0 && shouldRecallNavigate(value, cursor, recall.current.lastRecalled)) {
3126
- const step = key.upArrow ? recallOlder(recall.current, value) : recallNewer(recall.current)
3127
- recall.current = step.state
3128
- if (step.entry !== undefined) {
3129
- const safe = sanitizeDraftText(step.entry)
3130
- setValue(safe)
3131
- setCursor(safe.length)
3132
- preferredColumnRef.current = null
3133
- setDismissedMenuValue(undefined)
3134
- }
3135
- return
3136
- }
3137
- const model = editorModel(value, Math.max(1, columns - 6))
3138
- const preferred = preferredColumnRef.current ?? caretSite(model, cursor).column
3139
- const next = moveCursorVertically(model, cursor, preferred, key.upArrow ? -1 : 1)
3140
- if (next !== cursor) {
3141
- setCursor(next)
3142
- preferredColumnRef.current = preferred
3143
- }
3289
+ navigateVertical(key.upArrow ? -1 : 1)
3144
3290
  return
3145
3291
  }
3146
3292
  // Ctrl+P / Ctrl+N share the Up/Down contract (Codex binds them to
3147
3293
  // move_up/move_down, so the history gate applies first).
3148
3294
  if (key.ctrl && (input === 'p' || input === 'n')) {
3149
- const up = input === 'p'
3150
- if (recall.current.entries.length > 0 && shouldRecallNavigate(value, cursor, recall.current.lastRecalled)) {
3151
- const step = up ? recallOlder(recall.current, value) : recallNewer(recall.current)
3152
- recall.current = step.state
3153
- if (step.entry !== undefined) {
3154
- const safe = sanitizeDraftText(step.entry)
3155
- setValue(safe)
3156
- setCursor(safe.length)
3157
- preferredColumnRef.current = null
3158
- setDismissedMenuValue(undefined)
3159
- }
3160
- return
3161
- }
3162
- const model = editorModel(value, Math.max(1, columns - 6))
3163
- const preferred = preferredColumnRef.current ?? caretSite(model, cursor).column
3164
- const next = moveCursorVertically(model, cursor, preferred, up ? -1 : 1)
3165
- if (next !== cursor) {
3166
- setCursor(next)
3167
- preferredColumnRef.current = preferred
3168
- }
3169
- return
3170
- }
3171
- if (key.tab && menuActive) {
3172
- acceptMenuCandidate()
3295
+ navigateVertical(input === 'p' ? -1 : 1)
3173
3296
  return
3174
3297
  }
3175
3298
  // Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
3176
3299
  // plain arrows and Ctrl+B/F move by grapheme.
3177
3300
  if (key.leftArrow) {
3178
- moveCursorTo(key.meta || key.ctrl ? moveWordLeft(value, cursor) : moveCursorBy(value, cursor, -1))
3301
+ moveCursorTo(key.meta || key.ctrl ? moveWordLeft(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, -1))
3179
3302
  return
3180
3303
  }
3181
3304
  if (key.rightArrow) {
3182
- moveCursorTo(key.meta || key.ctrl ? moveWordRight(value, cursor) : moveCursorBy(value, cursor, 1))
3305
+ moveCursorTo(key.meta || key.ctrl ? moveWordRight(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, 1))
3183
3306
  return
3184
3307
  }
3185
3308
  if (key.meta && input === 'b') {
3186
- moveCursorTo(moveWordLeft(value, cursor))
3309
+ moveCursorTo(moveWordLeft(liveValue, liveCursor))
3187
3310
  return
3188
3311
  }
3189
3312
  if (key.meta && input === 'f') {
3190
- moveCursorTo(moveWordRight(value, cursor))
3313
+ moveCursorTo(moveWordRight(liveValue, liveCursor))
3191
3314
  return
3192
3315
  }
3193
3316
  if (key.ctrl && input === 'b') {
3194
- moveCursorTo(moveCursorBy(value, cursor, -1))
3317
+ moveCursorTo(moveCursorBy(liveValue, liveCursor, -1))
3195
3318
  return
3196
3319
  }
3197
3320
  if (key.ctrl && input === 'f') {
3198
- moveCursorTo(moveCursorBy(value, cursor, 1))
3321
+ moveCursorTo(moveCursorBy(liveValue, liveCursor, 1))
3199
3322
  return
3200
3323
  }
3201
3324
  // Ctrl+W and Alt+Backspace delete the previous word piece into the kill
3202
3325
  // buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
3203
3326
  if (key.ctrl && input === 'w') {
3204
- applyEdit(deleteWordBackward(value, cursor))
3327
+ applyEdit(deleteWordBackward(liveValue, liveCursor))
3205
3328
  return
3206
3329
  }
3207
3330
  if (key.meta && input === 'd') {
3208
- applyEdit(deleteWordForward(value, cursor))
3331
+ applyEdit(deleteWordForward(liveValue, liveCursor))
3209
3332
  return
3210
3333
  }
3211
3334
  // Un-annotated backspace/delete (Ink maps both  and  here):
3212
3335
  // delete the grapheme before the cursor.
3213
3336
  if (key.backspace || key.delete) {
3214
- applyEdit(deleteBackward(value, cursor))
3337
+ applyEdit(deleteBackward(liveValue, liveCursor))
3215
3338
  return
3216
3339
  }
3217
3340
  // Readline parity over the LOGICAL line: A/E to its ends, U/K kill to
3218
3341
  // them (filling the single kill buffer), Y yanks it back.
3219
3342
  if (key.ctrl && input === 'a') {
3220
- moveCursorTo(lineBounds(value, cursor).start)
3343
+ moveCursorTo(moveToLineStart(liveValue, liveCursor, true))
3221
3344
  return
3222
3345
  }
3223
3346
  if (key.ctrl && input === 'e') {
3224
- moveCursorTo(lineBounds(value, cursor).end)
3347
+ moveCursorTo(moveToLineEnd(liveValue, liveCursor, true))
3225
3348
  return
3226
3349
  }
3227
3350
  if (key.ctrl && input === 'u') {
3228
- applyEdit(killToLineStart(value, cursor))
3351
+ applyEdit(killToLineStart(liveValue, liveCursor))
3229
3352
  return
3230
3353
  }
3231
3354
  if (key.ctrl && input === 'k') {
3232
- applyEdit(killToLineEnd(value, cursor))
3355
+ applyEdit(killToLineEnd(liveValue, liveCursor))
3233
3356
  return
3234
3357
  }
3235
3358
  if (key.ctrl && input === 'y') {
3236
- if (killRef.current !== '') applyEdit(insertText(value, cursor, killRef.current))
3359
+ if (killRef.current !== '') applyEdit(insertText(liveValue, liveCursor, killRef.current))
3237
3360
  return
3238
3361
  }
3239
3362
  // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
@@ -3268,16 +3391,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3268
3391
  pasteBracketRef.current = false
3269
3392
  pasteBracketCancelRef.current?.()
3270
3393
  text = text.replaceAll(PASTE_END_MARKER, '')
3271
- }
3272
- if (text === '') return
3273
- const droppedPaths = text.length > 1 ? parsePastedImagePaths(text) : []
3274
- if (droppedPaths.length > 0) {
3275
- insertDroppedImages(droppedPaths)
3276
- return
3277
- }
3278
- applyEdit(insertText(value, cursor, text))
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))
3279
3402
  }
3280
- })
3403
+ }, active)
3281
3404
 
3282
3405
  // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
3283
3406
  // the interval re-renders only the composer band at 30fps, never the whole
@@ -3299,7 +3422,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3299
3422
  setWaveTick(0)
3300
3423
  }
3301
3424
  }, [waveTier, waveStyle])
3302
- const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null
3425
+ const waveActive = !preparingImages && waveTick !== null && waveTier !== null && waveStyle !== null
3303
3426
  && waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
3304
3427
  useEffect(() => {
3305
3428
  if (!waveActive) return
@@ -3325,7 +3448,6 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3325
3448
  // column-safe physical rows, with the caret mapped to its exact row and
3326
3449
  // column. Computed before the frozen path so the row report below runs
3327
3450
  // unconditionally.
3328
- const editorColumns = Math.max(1, columns - 6)
3329
3451
  const editorViewModel = editorModel(value, editorColumns)
3330
3452
  const clampedCursor = clampCursor(value, cursor)
3331
3453
  const caret = caretSite(editorViewModel, clampedCursor)
@@ -3390,131 +3512,126 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3390
3512
  rows: menuRows,
3391
3513
  })
3392
3514
 
3393
- // The multiline editor (idle, busy, or after the wave): every visible
3394
- // physical row renders inside the band, the prompt marker leading the
3395
- // first and a two-space indent aligning continuations under the text
3396
- // column — the same gutter reply rows use. The caret is the inverse block
3397
- // on its exact grapheme, so wide CJK cells and emoji clusters position the
3398
- // block precisely. The prompt marker keeps the tier accent while an
3399
- // official DeepSeek model is applied, restoring the static brand ❯ on any
3400
- // other route.
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.
3401
3518
  const editorRows: ReactElement[] = []
3402
3519
  for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
3403
3520
  const row = editorViewModel.rows[index]!
3404
- const caretAt = index === caret.row ? row.offsets.indexOf(clampedCursor) : -1
3405
- const before = caretAt > 0 ? row.text.slice(0, row.cuts[caretAt]!) : ''
3406
- const caretChar = caretAt >= 0 && caretAt < row.cuts.length - 1 ? row.text.slice(row.cuts[caretAt]!, row.cuts[caretAt + 1]!) : ' '
3407
- const after = caretAt < 0
3408
- ? row.text
3409
- : caretAt < row.cuts.length - 1
3410
- ? row.text.slice(row.cuts[caretAt + 1]!)
3411
- : ''
3412
- const placeholder = index === 0 && value === '' && !busy
3413
- const tail = placeholder ? COMPOSER_PLACEHOLDER : after
3414
- const consumed = 2 + visibleColumns(before) + visibleColumns(caretChar) + visibleColumns(tail)
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)
3415
3525
  editorRows.push(createElement(
3416
3526
  Text,
3417
3527
  { key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
3418
3528
  index === 0
3419
- ? busy
3420
- ? createElement(BusyChase)
3421
- : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
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} `)
3422
3534
  : ' ',
3423
- before,
3424
- createElement(CursorBlock, { key: 'caret', char: caretChar }),
3535
+ parts.before,
3536
+ parts.hasCaret
3537
+ ? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
3538
+ : null,
3425
3539
  placeholder
3426
3540
  ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
3427
- : after,
3541
+ : parts.after,
3428
3542
  bandFill(consumed),
3429
3543
  ))
3430
3544
  }
3431
3545
  const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
3432
3546
 
3433
- // Wave band: all three rows assembled column by column, each cell carrying
3434
- // the sampled wave `backgroundColor` (null outside the crest the band
3435
- // background), so the crest sweeps the FULL band blank rows, prompt,
3436
- // draft, cursor, placeholder, and the trailing fill — with a per-row phase
3437
- // offset that flows the wave down the band. The deepseek tier drops the
3438
- // `· ✦ ✧` sparkles into the rightmost blank cell from 900ms on.
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.
3439
3550
  const waveRow = (): ReactElement => {
3440
3551
  const hues = deepseekWaveHues(waveTier!)
3441
3552
  const style = waveStyle!
3442
3553
  const bandRgb = getPalette().composerBand
3554
+ const visibleRows = editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows)
3555
+ const totalBandRows = visibleRows.length + 2
3443
3556
  const waveBg = (row: number, column: number): string => {
3444
- const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, 3)
3557
+ const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, totalBandRows)
3445
3558
  return rgb === null ? bandBg : inkColor(rgb)
3446
3559
  }
3447
3560
  const blankBandRow = (row: number): ReactElement => {
3448
3561
  const blanks: ComposerCell[] = []
3449
- while (blanks.length < bandWidth) blanks.push({ char: ' ', backgroundColor: waveBg(row, blanks.length) })
3450
- return createElement(Text, { key: row }, ...waveRowSpans(blanks))
3451
- }
3452
- const waveEditor = editorWindow(value, cursor, Math.max(1, columns - 7))
3453
- const cells: ComposerCell[] = [
3454
- { char: ' ', backgroundColor: waveBg(1, 0) },
3455
- { char: ' ', backgroundColor: waveBg(1, 1) },
3456
- { char: promptGlyph, color: promptColor, bold: true, backgroundColor: waveBg(1, 2) },
3457
- { char: ' ', color: promptColor, backgroundColor: waveBg(1, 3) },
3458
- ]
3459
- for (const char of waveEditor.before) {
3460
- cells.push({ char, backgroundColor: waveBg(1, cells.length) })
3461
- }
3462
- cells.push({ char: waveEditor.caret, inverse: true, backgroundColor: waveBg(1, cells.length) })
3463
- if (value === '' && !busy) {
3464
- for (let at = 0; at < COMPOSER_PLACEHOLDER.length; at += 1) {
3465
- cells.push({ char: COMPOSER_PLACEHOLDER[at]!, dim: true, backgroundColor: waveBg(1, cells.length) })
3562
+ for (let column = 0; column < bandWidth; column += 1) {
3563
+ blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
3466
3564
  }
3467
- } else {
3468
- for (const char of waveEditor.after) {
3469
- cells.push({ char, backgroundColor: waveBg(1, cells.length) })
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
3470
3573
  }
3574
+ return undefined
3471
3575
  }
3472
- while (cells.length < bandWidth) {
3473
- cells.push({ char: ' ', backgroundColor: waveBg(1, cells.length) })
3474
- }
3475
- // The wordmark rides the wave's middle: `deepseek` on the official
3476
- // tiers, `Into the Unknown` on the non-DeepSeek high-effort variant
3477
- // in the tier's cycled hues, placed in the row's mid-section and only
3478
- // over blank or placeholder cells — real draft text is never covered.
3479
- if (deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
3480
- const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
3481
- const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
3482
- let clear = true
3483
- for (let at = 0; at < word.length; at += 1) {
3484
- const cell = cells[start + at]
3485
- if (cell === undefined || (cell.char !== ' ' && cell.dim !== true)) { clear = false; break }
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(' ')
3486
3594
  }
3487
- if (clear) {
3488
- for (let at = 0; at < word.length; at += 1) {
3489
- const cell = cells[start + at]!
3490
- cell.char = word[at]!
3491
- cell.color = inkColor(deepseekWaveWordHue(at, hues))
3492
- cell.bold = true
3493
- cell.dim = false
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
+ }
3494
3615
  }
3495
3616
  }
3496
- }
3497
- // The tail sparkles belong to the Wave style's pro tiers only (the
3498
- // deepseek and unknown tiers share the Ultra parameters — Codex paints
3499
- // spark_frame on Wave+Ultra).
3500
- if ((waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
3501
- const spark = deepseekWaveSpark(waveTick!)
3502
- if (spark !== null) {
3503
- const last = cells[cells.length - 1]
3504
- if (last !== undefined && last.char === ' ') {
3505
- last.char = spark
3506
- last.color = promptColor
3507
- last.bold = true
3508
- last.dim = false
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
3509
3625
  }
3510
3626
  }
3511
- }
3627
+ return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
3628
+ })
3512
3629
  return createElement(
3513
3630
  Box,
3514
3631
  { flexDirection: 'column', width: bandWidth },
3515
3632
  blankBandRow(0),
3516
- createElement(Text, { wrap: 'truncate-end' }, ...waveRowSpans(cells)),
3517
- blankBandRow(2),
3633
+ ...editorWaveRows,
3634
+ blankBandRow(totalBandRows - 1),
3518
3635
  )
3519
3636
  }
3520
3637
 
@@ -3522,7 +3639,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3522
3639
  Box,
3523
3640
  { flexDirection: 'column' },
3524
3641
  menu,
3525
- waveTick !== null && waveTier !== null && !busy ? waveRow() : band(staticEditor),
3642
+ waveTick !== null && waveTier !== null && waveStyle !== null && !busy && !preparingImages
3643
+ ? waveRow()
3644
+ : band(staticEditor),
3526
3645
  )
3527
3646
  }
3528
3647
 
@@ -3764,13 +3883,13 @@ export function App(props: AppProps): ReactElement {
3764
3883
  const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
3765
3884
  const [modelLabel, setModelLabel] = useState(props.model)
3766
3885
  const [modelOpen, setModelOpen] = useState(false)
3767
- /** Nested /model stages; only one owns terminal input at a time. */
3768
- const [providerOpen, setProviderOpen] = useState(false)
3769
- const [providerAction, setProviderAction] = useState<
3770
- | { kind: 'credential' | 'configure' | 'unset' | 'remove'; target: ProviderTargetView }
3771
- | { kind: 'login' | 'logout'; target: ProviderTargetView; authorization: ProviderAuthorizationRow }
3772
- | undefined
3773
- >(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)
3774
3893
  /** The model row whose effort levels the /model stage lists; undefined shows the model list. */
3775
3894
  const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
3776
3895
  /** Effective reasoning effort, shown in the /model picker and switch notice. */
@@ -3820,10 +3939,10 @@ export function App(props: AppProps): ReactElement {
3820
3939
  }, [modelLabel, effortLabel])
3821
3940
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
3822
3941
  const [modelError, setModelError] = useState<string | undefined>(undefined)
3823
- const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
3824
- const [providerError, setProviderError] = useState<string | undefined>(undefined)
3825
- const [authorizationDirectory, setAuthorizationDirectory] = useState<ProviderAuthorizationDirectory | undefined>(undefined)
3826
- 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)
3827
3946
  const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
3828
3947
  const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
3829
3948
  const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
@@ -3863,21 +3982,21 @@ export function App(props: AppProps): ReactElement {
3863
3982
  return () => {
3864
3983
  cancelled = true
3865
3984
  }
3866
- }, [modelOpen, modelLoadEpoch, props.loadModelProviders])
3867
- useEffect(() => {
3868
- if (!modelOpen || props.loadProviderAuthorizations === undefined) return
3869
- let cancelled = false
3870
- setAuthorizationDirectory(undefined)
3871
- setAuthorizationError(undefined)
3872
- Promise.resolve().then(() => props.loadProviderAuthorizations!()).then((loaded) => {
3873
- if (!cancelled) setAuthorizationDirectory(loaded)
3874
- }, (error: unknown) => {
3875
- if (!cancelled) setAuthorizationError(error instanceof Error ? error.message : String(error))
3876
- })
3877
- return () => {
3878
- cancelled = true
3879
- }
3880
- }, [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])
3881
4000
  useEffect(() => {
3882
4001
  const subscribe = props.subscribeModelProviders
3883
4002
  if (!modelOpen || subscribe === undefined) return
@@ -3886,19 +4005,21 @@ export function App(props: AppProps): ReactElement {
3886
4005
  } catch (error: unknown) {
3887
4006
  setProviderError(error instanceof Error ? error.message : String(error))
3888
4007
  }
3889
- }, [modelOpen, props.subscribeModelProviders])
3890
- useEffect(() => {
3891
- const subscribe = props.subscribeProviderAuthorizations
3892
- if (!modelOpen || subscribe === undefined) return
3893
- try {
3894
- return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
3895
- } catch (error: unknown) {
3896
- setAuthorizationError(error instanceof Error ? error.message : String(error))
3897
- }
3898
- }, [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])
3899
4018
 
3900
4019
  const busy = view.busy
3901
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)
3902
4023
  const [verboseOpen, setVerboseOpen] = useState(false)
3903
4024
  const [diffView, setDiffView] = useState<GitDiffView | undefined>(undefined)
3904
4025
  const [helpOpen, setHelpOpen] = useState(false)
@@ -4131,6 +4252,22 @@ export function App(props: AppProps): ReactElement {
4131
4252
  ? Math.max(1, Math.floor(streamRows / 3))
4132
4253
  : 1
4133
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
4134
4271
  const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
4135
4272
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
4136
4273
  const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
@@ -4155,7 +4292,7 @@ export function App(props: AppProps): ReactElement {
4155
4292
  appStdout.write(SYNCHRONIZED_UPDATE_END)
4156
4293
  }, [appStdout, refreshEpoch])
4157
4294
  // An idle Ctrl+R fold toggle joins resize and explicit Ctrl+L as a deliberate
4158
- // source-backed rebuild of native scrollback; busy turns never do.
4295
+ // source-backed rebuild of native scrollback.
4159
4296
 
4160
4297
  // Rendered-history cap: when the settled window overflows the trim
4161
4298
  // hysteresis, one source-backed replay re-windows it (the rebuild branch
@@ -4168,21 +4305,21 @@ export function App(props: AppProps): ReactElement {
4168
4305
  refreshScreen()
4169
4306
  }, [settledNeedsTrim, busy, streamingActive])
4170
4307
 
4171
- const sessionHasImages = useMemo(() => view.entries.some(entry =>
4172
- (entry.kind === 'user' || entry.kind === 'pending') && (entry.images?.length ?? 0) > 0), [view.entries])
4173
-
4174
- /** Apply one /model pick: record the selection, close the panel, report via notice. */
4175
- const applyModel = (row: ModelRow, effortId: string | undefined): void => {
4176
- try {
4177
- const label = props.selectModel(row, effortId)
4178
- setModelLabel(label)
4179
- setEffortLabel(effortId)
4180
- const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
4181
- if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
4182
- notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
4183
- } else {
4184
- notify(`model → next step uses ${selected}`)
4185
- }
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
+ }
4186
4323
  setModelOpen(false)
4187
4324
  setProviderOpen(false)
4188
4325
  setProviderAction(undefined)
@@ -4202,44 +4339,44 @@ export function App(props: AppProps): ReactElement {
4202
4339
  setEffortFor(undefined)
4203
4340
  }
4204
4341
  let modelSurface: ReactElement | undefined
4205
- if (modelOpen && !approvalPending && !questionPending) {
4206
- if (providerAction?.kind === 'login'
4207
- && props.beginProviderAuthorization !== undefined
4208
- && props.cancelProviderAuthorization !== undefined
4209
- && props.openAuthorizationUrl !== undefined
4210
- && props.copyTextValue !== undefined) {
4211
- modelSurface = createElement(ProviderAuthorizationPanel, {
4212
- row: providerAction.authorization,
4213
- begin: props.beginProviderAuthorization,
4214
- cancel: () => props.cancelProviderAuthorization!(providerAction.authorization),
4215
- openUrl: props.openAuthorizationUrl,
4216
- copy: props.copyTextValue,
4217
- done: () => {
4218
- const authorization = providerAction.authorization
4219
- setProviderAction(undefined)
4220
- setProviderOpen(false)
4221
- reloadModelSurfaces()
4222
- notify(`logged in to ${authorization.label}; select a model`)
4223
- },
4224
- back: () => {
4225
- setProviderAction(undefined)
4226
- setProviderOpen(true)
4227
- },
4228
- })
4229
- } else if (providerAction?.kind === 'logout' && props.logoutProviderAuthorization !== undefined) {
4230
- modelSurface = createElement(ProviderAuthorizationLogoutPanel, {
4231
- row: providerAction.authorization,
4232
- confirm: props.logoutProviderAuthorization,
4233
- done: () => {
4234
- const authorization = providerAction.authorization
4235
- setProviderAction(undefined)
4236
- setProviderOpen(true)
4237
- reloadModelSurfaces()
4238
- notify(`logged out from ${authorization.label}`)
4239
- },
4240
- back: () => setProviderAction(undefined),
4241
- })
4242
- } 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) {
4243
4380
  modelSurface = createElement(ProviderConfigurationPanel, {
4244
4381
  target: providerAction.target,
4245
4382
  catalog: directory?.rows ?? [],
@@ -4295,11 +4432,11 @@ export function App(props: AppProps): ReactElement {
4295
4432
  back: () => setProviderAction(undefined),
4296
4433
  })
4297
4434
  } else if (providerOpen) {
4298
- modelSurface = createElement(ProviderPanel, {
4299
- directory: providerDirectory,
4300
- error: providerError,
4301
- authorizations: authorizationDirectory,
4302
- authorizationError,
4435
+ modelSurface = createElement(ProviderPanel, {
4436
+ directory: providerDirectory,
4437
+ error: providerError,
4438
+ authorizations: authorizationDirectory,
4439
+ authorizationError,
4303
4440
  onCredential: (target: ProviderTargetView) => {
4304
4441
  if (props.saveModelProviderCredential === undefined) {
4305
4442
  notify('API key storage is unavailable in this profile', 'warning')
@@ -4321,34 +4458,34 @@ export function App(props: AppProps): ReactElement {
4321
4458
  }
4322
4459
  setProviderAction({ kind: 'unset', target })
4323
4460
  },
4324
- onRemove: (target: ProviderTargetView) => {
4461
+ onRemove: (target: ProviderTargetView) => {
4325
4462
  if (props.removeModelProvider === undefined) {
4326
4463
  notify('provider removal is unavailable in this profile', 'warning')
4327
4464
  return
4328
4465
  }
4329
- setProviderAction({ kind: 'remove', target })
4330
- },
4331
- onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
4332
- if (busy) {
4333
- notify('provider login is available only while the agent is idle', 'warning')
4334
- return
4335
- }
4336
- if (props.beginProviderAuthorization === undefined
4337
- || props.cancelProviderAuthorization === undefined
4338
- || props.openAuthorizationUrl === undefined
4339
- || props.copyTextValue === undefined) {
4340
- notify('provider login is unavailable in this profile', 'warning')
4341
- return
4342
- }
4343
- setProviderAction({ kind: 'login', target, authorization })
4344
- },
4345
- onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
4346
- if (props.logoutProviderAuthorization === undefined) {
4347
- notify('provider logout is unavailable in this profile', 'warning')
4348
- return
4349
- }
4350
- setProviderAction({ kind: 'logout', target, authorization })
4351
- },
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
+ },
4352
4489
  onRetry: reloadModelSurfaces,
4353
4490
  onBack: () => setProviderOpen(false),
4354
4491
  })
@@ -4401,22 +4538,39 @@ export function App(props: AppProps): ReactElement {
4401
4538
  // prefix, so streaming text lands exactly where the composer's input
4402
4539
  // text and the settled reply both render (Codex LIVE_PREFIX).
4403
4540
  { flexDirection: 'column' },
4404
- visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
4405
- view.streamingReasoning !== '' && reasoningRows > 0
4406
- ? createElement(StreamTail, {
4407
- text: showReasoning ? view.streamingReasoning : 'Thinking… (Ctrl+R to expand)',
4408
- prefix: '✻ ',
4409
- continuationPrefix: ' ',
4410
- dim: true,
4411
- maxRows: reasoningRows,
4412
- })
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
+ })
4413
4567
  : undefined,
4414
- view.streaming !== '' && answerRows > 0
4568
+ view.streaming !== '' && auditedAnswerRows > 0
4415
4569
  ? createElement(
4416
4570
  StreamTail,
4417
4571
  // The same two-column gutter as settled replies: streamed text
4418
4572
  // lands exactly where the assembled message will render.
4419
- { text: view.streaming, dim: false, maxRows: answerRows, prefix: ' ' },
4573
+ { text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
4420
4574
  busy ? createElement(Caret) : undefined,
4421
4575
  )
4422
4576
  : undefined,
@@ -4424,7 +4578,7 @@ export function App(props: AppProps): ReactElement {
4424
4578
  )
4425
4579
  : undefined,
4426
4580
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
4427
- transcriptVisible ? createElement(AgentsLine, { rows: agentRows }) : undefined,
4581
+ transcriptVisible ? createElement(AgentsLine, { rows: agentRows, total: props.subagents.getTotalSeen() }) : undefined,
4428
4582
  todosOpen && !approvalPending && !questionPending
4429
4583
  ? createElement(MemoTodoListPanel, {
4430
4584
  todos: view.todos,
@@ -4593,16 +4747,17 @@ export function App(props: AppProps): ReactElement {
4593
4747
  descriptors,
4594
4748
  skills,
4595
4749
  dispatch: props.dispatch,
4750
+ applyEditorKeys: props.applyEditorKeys,
4596
4751
  steer: props.steer,
4597
4752
  interrupt: props.interrupt,
4598
4753
  quit: props.quit,
4599
4754
  openModel: () => {
4600
4755
  setDirectory(undefined)
4601
4756
  setModelError(undefined)
4602
- setProviderDirectory(undefined)
4603
- setProviderError(undefined)
4604
- setAuthorizationDirectory(undefined)
4605
- setAuthorizationError(undefined)
4757
+ setProviderDirectory(undefined)
4758
+ setProviderError(undefined)
4759
+ setAuthorizationDirectory(undefined)
4760
+ setAuthorizationError(undefined)
4606
4761
  setProviderOpen(false)
4607
4762
  setProviderAction(undefined)
4608
4763
  setEffortFor(undefined)
@@ -4692,19 +4847,20 @@ export function App(props: AppProps): ReactElement {
4692
4847
  props.store.reset()
4693
4848
  },
4694
4849
  refresh: refreshScreen,
4695
- // Ctrl+R flips the reasoning fold. Idle toggles must be visible: rows
4696
- // already emitted through Static are native scrollback, so the fold
4697
- // state of past entries can only change through the source-backed
4698
- // replay (one clear + rebuild, wrapped in a synchronized frame). A
4699
- // busy/streaming turn stays calm: the live region flips alone and the
4700
- // 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.
4701
4857
  toggleReasoning: () => {
4702
4858
  setShowReasoning(current => !current)
4703
- if (!busy && !streamingActive) refreshScreen()
4859
+ refreshScreen()
4704
4860
  },
4705
- loadMentions: props.loadMentions,
4706
- inspectImages: props.inspectImages,
4707
- prepareImages: props.prepareImages,
4861
+ loadMentions: props.loadMentions,
4862
+ inspectImages: props.inspectImages,
4863
+ prepareImages: props.prepareImages,
4708
4864
  cyclePermission: props.cyclePermission,
4709
4865
  exportTranscript: props.exportTranscript,
4710
4866
  renameTitle: props.renameTitle,