dsh-code 0.9.1 → 1.0.1

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 (67) hide show
  1. package/README.en.md +278 -249
  2. package/README.md +131 -102
  3. package/bin/deepseek.mjs +100 -6
  4. package/cordis.patch.yml +36 -1
  5. package/lib/index.mjs +3055 -819
  6. package/lib/startup.mjs +21 -11
  7. package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
  8. package/lib/types/app.d.ts +84 -16
  9. package/lib/types/attachments.d.ts +20 -0
  10. package/lib/types/authorization-panel.d.ts +22 -0
  11. package/lib/types/authorization.d.ts +36 -0
  12. package/lib/types/editor.d.ts +6 -0
  13. package/lib/types/fork.d.ts +8 -0
  14. package/lib/types/git-workflow.d.ts +23 -0
  15. package/lib/types/index.d.ts +6 -0
  16. package/lib/types/kernel-panels.d.ts +39 -0
  17. package/lib/types/keyboard.d.ts +41 -0
  18. package/lib/types/mentions.d.ts +30 -38
  19. package/lib/types/models.d.ts +3 -1
  20. package/lib/types/permissions.d.ts +4 -14
  21. package/lib/types/presets.d.ts +5 -20
  22. package/lib/types/provider-settings.d.ts +16 -0
  23. package/lib/types/render/animations.d.ts +10 -39
  24. package/lib/types/render/editor.d.ts +137 -0
  25. package/lib/types/render/export.d.ts +1 -1
  26. package/lib/types/render/lines.d.ts +6 -2
  27. package/lib/types/render/markdown.d.ts +3 -1
  28. package/lib/types/render/projection.d.ts +29 -3
  29. package/lib/types/render/status.d.ts +6 -13
  30. package/lib/types/session-directory.d.ts +1 -3
  31. package/lib/types/startup.d.ts +14 -11
  32. package/lib/types/store.d.ts +11 -9
  33. package/lib/types/subagents.d.ts +3 -3
  34. package/lib/types/theme.d.ts +14 -1
  35. package/lib/types/version.d.ts +15 -2
  36. package/package.json +159 -141
  37. package/src/app.ts +1490 -663
  38. package/src/attachments.ts +128 -0
  39. package/src/authorization-panel.ts +285 -0
  40. package/src/authorization.ts +147 -0
  41. package/src/editor.ts +51 -0
  42. package/src/fork.ts +31 -0
  43. package/src/git-workflow.ts +87 -0
  44. package/src/index.ts +1523 -1374
  45. package/src/internals.ts +14 -1
  46. package/src/kernel-panels.ts +914 -798
  47. package/src/keyboard.ts +126 -0
  48. package/src/mentions.ts +78 -117
  49. package/src/models.ts +20 -14
  50. package/src/permissions.ts +5 -13
  51. package/src/presets.ts +6 -22
  52. package/src/provider-settings.ts +95 -1
  53. package/src/render/animations.ts +420 -450
  54. package/src/render/editor.ts +398 -0
  55. package/src/render/export.ts +79 -79
  56. package/src/render/lines.ts +342 -236
  57. package/src/render/markdown.ts +99 -26
  58. package/src/render/projection.ts +106 -19
  59. package/src/render/status.ts +713 -650
  60. package/src/render/text.ts +150 -150
  61. package/src/render/tool-detail.ts +3 -1
  62. package/src/session-directory.ts +4 -4
  63. package/src/startup.ts +136 -119
  64. package/src/store.ts +23 -11
  65. package/src/subagents.ts +13 -5
  66. package/src/theme.ts +214 -206
  67. package/src/version.ts +58 -1
package/src/app.ts CHANGED
@@ -14,18 +14,18 @@
14
14
  * @module @deepseek-ai/dsh-code/app
15
15
  */
16
16
 
17
- import {
17
+ import { basename } from 'node:path'
18
+ import {
18
19
  createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
19
20
  } from 'react'
20
- import { Box, Static, Text, useInput, useStdout, type Key } from 'ink'
21
- import { assertNever } from '@deepseek-ai/dsh-llm'
22
- import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
- import type { TodoItem } from '@deepseek-ai/dsh-session'
24
- import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
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'
25
27
  import {
26
- brand,
27
28
  dim,
28
- error as paintError,
29
29
  getPalette,
30
30
  getTheme,
31
31
  inkColor,
@@ -35,16 +35,14 @@ import {
35
35
  } from './theme.ts'
36
36
  import { ThemePanel } from './theme-panel.ts'
37
37
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
38
- import { DSH_CODE_VERSION } from './version.ts'
38
+ import { DSH_CODE_VERSION, dshKernelVersion } from './version.ts'
39
39
  import type { TranscriptStore } from './store.ts'
40
40
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
41
- import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
42
- import type { ToolDetail } from './render/tool-detail.ts'
41
+ import { type MdSegment, visibleColumns } from './render/markdown.ts'
43
42
  import {
44
43
  busyChaseFrame,
45
44
  caretVisible,
46
45
  DEEPSEEK_WAVE_TICK_MS,
47
- deepseekWaveBorderColor,
48
46
  deepseekWaveColumnBg,
49
47
  deepseekWaveDuration,
50
48
  deepseekWaveSpark,
@@ -54,25 +52,23 @@ import {
54
52
  deepseekWaveWordVisible,
55
53
  effortAboveHigh,
56
54
  isOfficialDeepSeekLabel,
57
- pulseFrame,
58
- WAVE_BASE_DARK,
59
- WAVE_BASE_LIGHT,
60
55
  type DeepseekWaveStyle,
61
56
  type DeepseekWaveTier,
62
57
  } from './render/animations.ts'
63
58
  import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
64
59
  import type { CommandsView } from './commands.ts'
65
60
  import type { ModelDirectory, ModelRow } from './models.ts'
66
- import type { ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
61
+ import type { ProviderConfiguration, ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
67
62
  import type { QuestionSnapshot, QuestionStore } from './questions.ts'
68
63
  import type { SkillsView, SkillRow } from './skills.ts'
69
64
  import type { MentionCandidate } from './mentions.ts'
70
65
  import type { SubagentFeedView, SubagentRow } from './subagents.ts'
71
- import { AgentsPanel, EffortPanel, ModePanel, HistoryPanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, SubagentPanel } from './kernel-panels.ts'
66
+ import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
72
67
  import type { PresetRow } from './presets.ts'
73
68
  import type { PermissionRow } from './permissions.ts'
74
69
  import type { PluginRow } from './plugin-inventory.ts'
75
70
  import {
71
+ beginRecall,
76
72
  recallEntries,
77
73
  recallNewer,
78
74
  recallOlder,
@@ -80,14 +76,57 @@ import {
80
76
  type RecallState,
81
77
  } from './history.ts'
82
78
  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
92
 
84
93
  /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
85
94
  const RESIZE_REFLOW_DELAY_MS = 75
86
95
 
96
+ /**
97
+ * Cap on rendered settled history, in physical rows (header and hint
98
+ * included). 3,000 rows sits inside Codex's 1k–10k reflow budget range:
99
+ * replays stay under ~200ms while roughly a hundred messages stay visible
100
+ * before the oldest drop out. `DSH_SETTLED_ROWS` overrides it; 0 disables
101
+ * the cap entirely (the historical unbounded behavior).
102
+ */
103
+ const SETTLED_ROW_CAP = readSettledRowCap()
104
+ /** Hysteresis: the cap may overflow by 25% before one trimming replay fires. */
105
+ /** Header rows plus the trim hint, reserved out of the row cap. */
106
+ const SETTLED_ROW_RESERVE = 12
107
+
108
+ /** Read the configurable settled-history cap once per process. */
109
+ function readSettledRowCap(): number {
110
+ const raw = process.env.DSH_SETTLED_ROWS
111
+ if (raw === undefined) return 3_000
112
+ const parsed = Number.parseInt(raw, 10)
113
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3_000
114
+ }
115
+
116
+ /**
117
+ * Safety net for a bracketed paste whose end marker never arrives (terminal
118
+ * defect or crash mid-paste): past this window the open-paste flag resets so
119
+ * Enter submits again instead of inserting newlines forever.
120
+ */
121
+ const PASTE_BRACKET_TIMEOUT_MS = 1_000
122
+
87
123
  /** Reset region/style, clear the visible screen and scrollback, then home. */
88
124
  const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
125
+ /** Ask terminals supporting DEC synchronized updates to hold the frame. */
126
+ const SYNCHRONIZED_UPDATE_BEGIN = '\x1b[?2026h'
127
+ /** Release the held frame after Ink has replayed the source-backed Static rows. */
128
+ const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l'
89
129
  import {
90
- formatTokens,
91
130
  layoutStatusBar,
92
131
  parseStatuslineItems,
93
132
  STATUS_CYCLE_HINT,
@@ -101,6 +140,7 @@ import {
101
140
  type StatusTone,
102
141
  } from './render/status.ts'
103
142
  import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
143
+ import { normalizeKeyboardChunk, PASTE_END_MARKER, PASTE_START_MARKER, stripPasteMarkers } from './keyboard.ts'
104
144
  import {
105
145
  clampScroll,
106
146
  followInspectorCursor,
@@ -114,16 +154,69 @@ import {
114
154
  import {
115
155
  lineSegment,
116
156
  markdownLines,
117
- reasoningLines,
157
+ settledEntryLines,
118
158
  styledLines,
119
159
  textLines,
120
160
  transcriptEntryLines,
121
161
  type LineStyle,
122
162
  type StyledLine,
123
163
  } from './render/lines.ts'
164
+ import {
165
+ caretSite,
166
+ clampCursor,
167
+ composerMaxRows,
168
+ deleteBackward,
169
+ deleteForward,
170
+ deleteLastGrapheme,
171
+ deleteWordBackward,
172
+ deleteWordForward,
173
+ editorModel,
174
+ insertText,
175
+ type EditResult,
176
+ killToLineEnd,
177
+ killToLineStart,
178
+ lineBounds,
179
+ moveCursorBy,
180
+ moveCursorVertically,
181
+ moveWordLeft,
182
+ moveWordRight,
183
+ sanitizeDraftText,
184
+ shouldRecallNavigate,
185
+ splitGraphemes,
186
+ } from './render/editor.ts'
124
187
 
125
- /** Visual priority for one bounded local notice. */
126
- export type NoticeTone = 'info' | 'warning' | 'error'
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)))
127
220
 
128
221
  /** Props the runner hands the app; callbacks stay owned by the runner. */
129
222
  export interface AppProps {
@@ -158,9 +251,9 @@ export interface AppProps {
158
251
  /** Permission preset selected for the current or pending first session. */
159
252
  permission: string
160
253
  /** Submit one line: slash commands to the registry, other text to the agent. */
161
- dispatch(text: string): void
162
- /** Submit steering: consumed at the running turn's next step boundary. */
163
- steer(text: string): void
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
164
257
  /** Interrupt the running turn (Esc); true when a turn was cancelled. */
165
258
  interrupt(): boolean
166
259
  /** Quit: unmount, flush, and request process exit. */
@@ -168,7 +261,11 @@ export interface AppProps {
168
261
  /** Load the selectable model directory (called when /model opens). */
169
262
  loadModels(): Promise<ModelDirectory>
170
263
  /** Load @mention candidates for the typed query (files + sessions). */
171
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
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[]>
172
269
  /** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
173
270
  selectModel(row: ModelRow, effortId?: string): string
174
271
  /** The /subagent override label, '' when delegated agents follow the current model. */
@@ -189,6 +286,21 @@ export interface AppProps {
189
286
  unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
190
287
  /** Remove one user-owned provider profile and its page-managed credential. */
191
288
  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>
192
304
  /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
193
305
  cyclePermission(): string
194
306
  /** Select or inspect a permission preset without requiring a pre-existing session. */
@@ -197,12 +309,20 @@ export interface AppProps {
197
309
  exportTranscript(argument: string): Promise<void>
198
310
  /** Rename the session (/title <text>); returns the outcome line for the notice. */
199
311
  renameTitle(argument: string): string
312
+ /** Copy the latest complete assistant response; resolves to notice text. */
313
+ copyLastResponse(): Promise<string>
314
+ /** Load a complete read-only Git diff for the file-oriented viewport. */
315
+ loadGitDiff(argument: string): Promise<GitDiffView>
316
+ /** Start a model review after applying the read-only permission preset. */
317
+ reviewChanges(argument: string): void
200
318
  /** Preset/session/plugin kernel operations. */
201
319
  loadPresets(): Promise<readonly PresetRow[]>
202
320
  switchMode(id: string): Promise<string>
203
321
  /** Load the switchable permission presets for the /permission panel. */
204
322
  loadPermissions(): Promise<readonly PermissionRow[]>
205
323
  createSession(mode?: string): void
324
+ /** Fork the active session at a completed-turn boundary. */
325
+ forkSession(argument: string): void
206
326
  loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
207
327
  loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
208
328
  /** Load this session's subagent conversations (children by lineage). */
@@ -210,6 +330,8 @@ export interface AppProps {
210
330
  switchSession(row: SessionRow): void
211
331
  cancelSessionSwitch(): boolean
212
332
  loadPlugins(): readonly PluginRow[]
333
+ /** Caller-visible background jobs (the host jobs registry, read-only). */
334
+ loadJobs(): readonly JobRow[]
213
335
  /** Registers the app's notice channel with the runner (called once on mount). */
214
336
  onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
215
337
  /** Ordered enabled status items (/statusline config); the runner owns persistence. */
@@ -259,12 +381,6 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
259
381
  useInput(stableHandler, { isActive: active })
260
382
  }
261
383
 
262
- /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
263
- function Pulse(): ReactElement {
264
- const tick = useFrames(125)
265
- return createElement(Text, { color: inkColor(getPalette().brandBright) }, pulseFrame(tick))
266
- }
267
-
268
384
  /**
269
385
  * The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
270
386
  * ring trail clockwise around the eight outer positions (8 frames × 125ms =
@@ -288,14 +404,6 @@ function CursorBlock({ char }: { char: string }): ReactElement {
288
404
  return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
289
405
  }
290
406
 
291
- /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
292
- function runClock(ms: number): string {
293
- const total = Math.max(0, Math.floor(ms / 1000))
294
- const minutes = Math.floor(total / 60)
295
- const seconds = total % 60
296
- return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
297
- }
298
-
299
407
  /**
300
408
  * The busy line, web TurnStatus contract: the StateDot chase leads the plain
301
409
  * `Deep diving...` label, with the elapsed clock appended only once the turn
@@ -335,11 +443,11 @@ function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = pref
335
443
  }): ReactElement {
336
444
  const columns = useStdout().stdout?.columns ?? 80
337
445
  const safeRows = Math.max(1, maxRows)
338
- // App padding consumes two columns; the final extra column keeps a caret
339
- // from wrapping onto an unbudgeted row. Both prefixes participate because
340
- // every physical row now repeats its hanging indent.
446
+ // The final extra column keeps a caret from wrapping onto an unbudgeted
447
+ // row. Both prefixes participate because every physical row repeats its
448
+ // hanging indent.
341
449
  const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
342
- const contentColumns = Math.max(10, columns - 3 - prefixColumns)
450
+ const contentColumns = Math.max(10, columns - 1 - prefixColumns)
343
451
  const initial = displayTail(text, contentColumns, safeRows)
344
452
  // Reserve one row for the omission marker only when a marker is needed.
345
453
  const tail = initial.truncated && safeRows > 1
@@ -434,241 +542,84 @@ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
434
542
  )
435
543
  }
436
544
 
437
- /** Codex-style panel rhythm that still participates in the row budget. */
438
- function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
439
- return visible ? createElement(Text, null, ' ') : undefined
440
- }
441
-
442
- /** One settled markdown document rendered as styled lines at the terminal width. */
443
- function MarkdownBody({ text, indent = 0 }: { text: string; indent?: number }): ReactElement {
444
- const columns = useStdout().stdout?.columns ?? 80
445
- // Cached by (text, width): settled replies re-layout only when either moves.
446
- // The indent participates in the wrap budget so padded replies never
447
- // double-wrap inside the padded box.
448
- const lines = useMemo(
449
- () => renderMarkdown(displayText(text), Math.max(20, columns - 2 - indent)),
450
- [text, columns, indent],
451
- )
545
+ /** File-oriented, color-coded unified diff viewport. */
546
+ function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): ReactElement {
547
+ const stdout = useStdout().stdout
548
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
549
+ const [fileIndex, setFileIndex] = useState(0)
550
+ const [scroll, setScroll] = useState(0)
551
+ const file = view.files[fileIndex]
552
+ const lines = useMemo(() => {
553
+ if (file === undefined) return textLines(' (no changes)', viewport.contentColumns, 'dim')
554
+ return file.lines.flatMap(line => styledLines([
555
+ lineSegment(line, line.startsWith('+') && !line.startsWith('+++')
556
+ ? 'success'
557
+ : line.startsWith('-') && !line.startsWith('---')
558
+ ? 'error'
559
+ : line.startsWith('@@') || line.startsWith('diff --git') || line.startsWith('index ')
560
+ ? 'brand'
561
+ : 'dim'),
562
+ ], viewport.contentColumns))
563
+ }, [file, viewport.contentColumns])
564
+ const visibleScroll = clampScroll(scroll, lines.length, viewport.bodyRows)
565
+ useInput((input, key) => {
566
+ if (key.escape || input === 'q') onClose()
567
+ else if (key.leftArrow && view.files.length > 0) {
568
+ setFileIndex(current => (current + view.files.length - 1) % view.files.length)
569
+ setScroll(0)
570
+ } else if (key.rightArrow && view.files.length > 0) {
571
+ setFileIndex(current => (current + 1) % view.files.length)
572
+ setScroll(0)
573
+ }
574
+ else if (input === 'g') setScroll(0)
575
+ else if (input === 'G') setScroll(Math.max(0, lines.length - viewport.bodyRows))
576
+ else if (key.upArrow) setScroll(current => moveScroll(current, -1, lines.length, viewport.bodyRows))
577
+ else if (key.downArrow) setScroll(current => moveScroll(current, 1, lines.length, viewport.bodyRows))
578
+ else if (key.pageUp) setScroll(current => moveScroll(current, -viewport.bodyRows, lines.length, viewport.bodyRows))
579
+ else if (key.pageDown) setScroll(current => moveScroll(current, viewport.bodyRows, lines.length, viewport.bodyRows))
580
+ })
581
+ if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${view.title} · ${view.files.length} files · esc/q close`, viewport.contentColumns))
452
582
  return createElement(
453
583
  Box,
454
- { flexDirection: 'column', paddingLeft: indent },
455
- ...lines.map((line, index) => createElement(
456
- Text,
457
- { key: index },
458
- line.segments.length === 0
459
- ? ' '
460
- : line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
461
- )),
584
+ { flexDirection: 'column', borderStyle: 'round', borderColor: inkColor(getPalette().dim), paddingX: 1 },
585
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`${view.title} · ${view.files.length === 0 ? 'no files' : `${fileIndex + 1}/${view.files.length} ${file?.path ?? ''}`} · rows ${lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(lines.length, visibleScroll + viewport.bodyRows)}/${lines.length}`, viewport.contentColumns)),
586
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
587
+ createElement(StyledRows, { lines: lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
588
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
589
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑/↓ scroll · g/G ends · esc/q close', viewport.contentColumns)),
462
590
  )
463
591
  }
464
592
 
465
- /** Expanded reasoning with the same two-column content edge as the reply. */
466
- function ReasoningBody({ text }: { text: string }): ReactElement {
467
- const columns = useStdout().stdout?.columns ?? 80
468
- const lines = useMemo(() => reasoningLines(text, Math.max(10, columns - 2)), [text, columns])
469
- return createElement(StyledRows, { lines })
593
+ /** Codex-style panel rhythm that still participates in the row budget. */
594
+ function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
595
+ return visible ? createElement(Text, null, ' ') : undefined
470
596
  }
471
597
 
472
- /**
473
- * One expanded tool-card body for the verbose transcript (Ctrl+O): the
474
- * presentation contract's structured cards — inline diffs, read windows,
475
- * web sources — rendered as plain terminal rows, degradation-safe against
476
- * replayed metadata.
477
- */
478
- function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
479
- switch (detail.kind) {
480
- case 'diff':
481
- return createElement(
482
- Box,
483
- { flexDirection: 'column' },
484
- ...detail.diffs.map((diff, index) => createElement(
485
- Box,
486
- { key: index, flexDirection: 'column' },
487
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
488
- ...diff.lines.map((line, at) => createElement(
489
- Text,
490
- {
491
- key: at,
492
- color: line.mark === '+' ? inkColor(getPalette().success) : line.mark === '-' ? inkColor(getPalette().error) : inkColor(getPalette().dim),
493
- wrap: 'truncate-end',
494
- },
495
- ` ${line.mark}${displayText(line.text)}`,
496
- )),
497
- )),
498
- )
499
- case 'read':
500
- return createElement(
501
- Box,
502
- { flexDirection: 'column' },
503
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`),
504
- ...detail.lines.map((line, at) => createElement(
505
- Text,
506
- { key: at, dimColor: true, wrap: 'truncate-end' },
507
- ` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
508
- )),
509
- )
510
- case 'web-search':
511
- return createElement(
512
- Box,
513
- { flexDirection: 'column' },
514
- ...detail.sources.map((source, at) => createElement(
515
- Text,
516
- { key: at, wrap: 'truncate-end' },
517
- brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
518
- createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
519
- )),
520
- createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
521
- )
522
- case 'web-fetch':
523
- return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
524
- case 'raw':
525
- return createElement(
526
- Box,
527
- { flexDirection: 'column' },
528
- ...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
529
- createElement(Text, { dimColor: true }, detail.truncated ? ' … (output truncated)' : ' (end of output)'),
530
- )
531
- default:
532
- return assertNever(detail, 'tool detail kind')
533
- }
534
- }
535
- /** One settled transcript row. */
536
- function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
537
- switch (entry.kind) {
538
- case 'user':
539
- // Collapsed injected context reads as a dim ↳ row; only direct human
540
- // prompts get the brand ❯ (they are different surfaces, not the same).
541
- return entry.notice
542
- ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
543
- : createElement(Text, null, brand('❯ '), displayText(entry.text))
544
- case 'assistant':
545
- // Claude-Code-style thinking: a dim ✻ marker collapsed, the reasoning
546
- // text dim-italic expanded (Ctrl+R toggles globally). The collapsed
547
- // row is static — an animated counter inside the text would jitter the
548
- // line width every frame. The reply body carries the same two-column
549
- // gutter as the composer, so reply text aligns with the input cursor
550
- // (Codex LIVE_PREFIX alignment).
551
- return createElement(
552
- Box,
553
- { flexDirection: 'column' },
554
- entry.reasoning === ''
555
- ? undefined
556
- : showReasoning
557
- ? createElement(ReasoningBody, { text: entry.reasoning })
558
- : createElement(Text, { color: inkColor(getPalette().dim) }, `✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
559
- createElement(MarkdownBody, { text: entry.text, indent: 2 }),
560
- )
561
- case 'tool': {
562
- // Claude-Code-style tool card: the invocation row plus a nested ⎿
563
- // result line, so the summary reads under its call instead of inline.
564
- const mark = entry.state === 'running'
565
- ? createElement(Pulse)
566
- : entry.state === 'error'
567
- ? createElement(Text, { color: inkColor(getPalette().error) }, '⨯')
568
- : createElement(Text, { color: inkColor(getPalette().success) }, '⏺')
569
- return createElement(
570
- Box,
571
- { flexDirection: 'column' },
572
- createElement(
573
- Text,
574
- { wrap: verbose ? 'truncate-end' : undefined },
575
- mark,
576
- ' ',
577
- brand(displayText(entry.name)),
578
- entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
579
- ),
580
- entry.summary === ''
581
- ? undefined
582
- : createElement(
583
- Text,
584
- { color: entry.state === 'error' ? inkColor(getPalette().error) : inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined },
585
- ` ⎿ ${displayText(entry.summary)}`,
586
- ),
587
- verbose && entry.detail !== undefined
588
- ? createElement(ToolDetailBody, { detail: entry.detail })
589
- : undefined,
590
- )
591
- }
592
- case 'command': {
593
- const mark = entry.state === 'running'
594
- ? createElement(Pulse)
595
- : entry.state === 'error'
596
- ? createElement(Text, { color: inkColor(getPalette().error) }, '⨯')
597
- : createElement(Text, { color: inkColor(getPalette().success) }, '⏺')
598
- return createElement(
599
- Box,
600
- { flexDirection: 'column' },
601
- createElement(
602
- Text,
603
- { wrap: verbose ? 'truncate-end' : undefined },
604
- mark,
605
- ' ',
606
- brand(displayText(`/${entry.name}`)),
607
- entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
608
- ),
609
- entry.summary === ''
610
- ? undefined
611
- : createElement(Text, { color: inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
612
- )
613
- }
614
- case 'turn-marker':
615
- // Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
616
- return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
617
- case 'compaction':
618
- // Completed compaction lifecycle: what it reclaimed, or why it failed.
619
- return createElement(
620
- Text,
621
- { dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
622
- entry.ok
623
- ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
624
- : ` ⧉ compaction failed: ${displayText(entry.error)}`,
625
- )
626
- case 'retry':
627
- // Provider-routed retry: amber while the backoff waits, dim once the
628
- // next attempt is underway.
629
- return createElement(
630
- Text,
631
- { color: entry.state === 'running' ? inkColor(getPalette().warn) : inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined },
632
- ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
633
- )
634
- case 'files': {
635
- // Turn-tail deliverables: the turn's mutated files (web turnTail chips).
636
- const shown = entry.paths.slice(0, 3).map(path => displayText(path)).join(' · ')
637
- const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : ''
638
- return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⎄ ${shown}${more}`)
639
- }
640
- case 'pending':
641
- // Codex PendingSteer: queued prompts render as ordinary user rows; the
642
- // durable user/message retires them seamlessly.
643
- return createElement(
644
- Text,
645
- { wrap: verbose ? 'truncate-end' : undefined },
646
- brand('❯ '),
647
- displayText(entry.text),
648
- )
649
- case 'error':
650
- return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
651
- default:
652
- return assertNever(entry, 'transcript entry kind')
653
- }
654
- }
655
598
 
656
599
  /**
657
- * The whale header with a compact three-line copy lockup. The title, bilingual
658
- * slogan, and key hint stay centered inside the existing eight content rows,
659
- * preserving the Static header's ten physical rows. Short or narrow terminals
660
- * keep a one-line form.
600
+ * The whale header with a compact copy lockup. The dsh kernel version (when
601
+ * the host manifest resolves), the title, the bilingual slogan, and the key
602
+ * hint stay centered inside the existing eight content rows, preserving the
603
+ * Static header's ten physical rows; without a resolvable host the lockup
604
+ * keeps its historical three lines. Short or narrow terminals keep a one-line
605
+ * form without the kernel line.
661
606
  */
662
607
  function Header({ resumed }: { resumed: boolean }): ReactElement {
663
608
  const stdout = useStdout().stdout
664
609
  const rows = stdout?.rows ?? 40
665
610
  const columns = stdout?.columns ?? 80
611
+ const kernelLine = (() => {
612
+ const version = dshKernelVersion()
613
+ return version === undefined ? undefined : `dsh-v${version}`
614
+ })()
666
615
  const title = `DeepSeek Harness · v${DSH_CODE_VERSION}`
667
616
  const slogan = 'Into the Unknown 探索未至之境'
668
617
  const hint = resumed ? 'resumed · /help · Esc interrupt' : '/help · Esc interrupt · Ctrl+C quit'
669
- const copyColumns = Math.max(visibleColumns(title), visibleColumns(slogan), visibleColumns(hint))
618
+ const copyWidths = [visibleColumns(title), visibleColumns(slogan), visibleColumns(hint)]
619
+ if (kernelLine !== undefined) copyWidths.push(visibleColumns(kernelLine))
620
+ const copyColumns = Math.max(...copyWidths)
670
621
  const compact = `${title} · ${hint}`
671
- if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 8) {
622
+ if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 10) {
672
623
  return createElement(
673
624
  Box,
674
625
  { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1 },
@@ -679,7 +630,10 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
679
630
  Box,
680
631
  // alignSelf shrinks the border to the whale-plus-copy content instead of
681
632
  // stretching across the terminal and stranding empty space on the right.
682
- { flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1, alignSelf: 'flex-start' },
633
+ // paddingX: 2 keeps a comfortable margin between the border and both the
634
+ // whale on the left and the copy on the right (each side gains one
635
+ // column over the previous paddingX: 1) without changing row height.
636
+ { flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 2, alignSelf: 'flex-start' },
683
637
  createElement(
684
638
  Box,
685
639
  { flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
@@ -688,6 +642,9 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
688
642
  createElement(
689
643
  Box,
690
644
  { flexDirection: 'column', width: copyColumns, justifyContent: 'center' },
645
+ ...(kernelLine === undefined
646
+ ? []
647
+ : [createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, kernelLine)]),
691
648
  createElement(Text, { color: inkColor(getPalette().brandBright), bold: true, wrap: 'truncate-end' }, title),
692
649
  createElement(
693
650
  Text,
@@ -745,10 +702,74 @@ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | un
745
702
  `todos ${completed}/${todos.length}`,
746
703
  createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
747
704
  current === undefined ? '' : createElement(Text, { color: inkColor(getPalette().brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
705
+ createElement(Text, { color: inkColor(getPalette().dim) }, ' · /todos'),
748
706
  ),
749
707
  )
750
708
  }
751
709
 
710
+ /**
711
+ * The /todos subpage: the full todo list in one bounded, scrollable panel.
712
+ * The live tree's TodoPanel stays a one-row summary; this exclusive view
713
+ * shows EVERY item with its three-state mark inside the shared panel
714
+ * viewport (same contract as /help and Ctrl+O: border/title/body/footer all
715
+ * ride one height budget, the composer and status stay put below).
716
+ */
717
+ function TodoListPanel({ todos, onClose }: { todos: readonly TodoItem[]; onClose: () => void }): ReactElement {
718
+ const stdout = useStdout().stdout
719
+ const columns = stdout?.columns ?? 80
720
+ const viewport = panelViewport(columns, stdout?.rows ?? 30)
721
+ const [scroll, setScroll] = useState(0)
722
+ const bodyColumns = Math.max(4, viewport.contentColumns - 4)
723
+ const completed = todos.filter(todo => todo.status === 'completed').length
724
+ const inProgress = todos.filter(todo => todo.status === 'in_progress').length
725
+ const pending = todos.length - completed - inProgress
726
+ const rows = todos.length === 0
727
+ ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no todos yet')]
728
+ : todos.map(todo => createElement(
729
+ Text,
730
+ { key: todo.content, dimColor: true, wrap: 'truncate-end' },
731
+ ` ${todoMark(todo.status)} ${truncateColumns(displayText(todo.content), bodyColumns)}`,
732
+ ))
733
+ const visibleScroll = clampScroll(scroll, rows.length, viewport.bodyRows)
734
+ const scrollBy = (delta: number): void => {
735
+ setScroll(current => moveScroll(current, delta, rows.length, viewport.bodyRows))
736
+ }
737
+
738
+ useEffect(() => {
739
+ if (visibleScroll !== scroll) setScroll(visibleScroll)
740
+ }, [visibleScroll, scroll])
741
+
742
+ useInput((input, key) => {
743
+ if (key.escape || input === 'q') {
744
+ onClose()
745
+ return
746
+ }
747
+ if (key.upArrow) scrollBy(-1)
748
+ else if (key.downArrow) scrollBy(1)
749
+ else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1))
750
+ else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1))
751
+ else if (input === 'g') setScroll(0)
752
+ else if (input === 'G') setScroll(Math.max(0, rows.length - viewport.bodyRows))
753
+ })
754
+
755
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
756
+ if (viewport.compact) {
757
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('todos · esc/q close', viewport.contentColumns))
758
+ }
759
+
760
+ return createElement(
761
+ Box,
762
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
763
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`todos · ${completed}/${todos.length} done · ${inProgress} active · ${pending} pending · rows ${rows.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rows.length, visibleScroll + viewport.bodyRows)}/${rows.length}`, viewport.contentColumns)),
764
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
765
+ ...rows.slice(visibleScroll, visibleScroll + viewport.bodyRows),
766
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
767
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
768
+ )
769
+ }
770
+
771
+ const MemoTodoListPanel = memo(TodoListPanel)
772
+
752
773
  /**
753
774
  * Ink props for one status tone: the Codex status-line accent mapping over
754
775
  * the DeepSeek palette, all blue by design — the status bar speaks only in
@@ -808,7 +829,7 @@ function statusToneProps(tone: StatusTone): {
808
829
  * truncation degrades groups, it never wraps a row.
809
830
  *
810
831
  * The DeepSeek easter egg: when the model label *switches* to an official
811
- * DeepSeek route, the composer's INPUT ROW (not the frame) plays Codex's
832
+ * DeepSeek route, the composer's INPUT ROW (the band's middle) plays Codex's
812
833
  * effort-ignition "Wave" — a blue crest sweeping the content row column by
813
834
  * column, with the `· ✦ ✧` sparkles on the deepseek tier — and the prompt
814
835
  * marker keeps the tier accent afterwards. The border stays a constant
@@ -836,7 +857,30 @@ function StatusLine({ facts, stats, busy, columns, items }: {
836
857
  columns: number
837
858
  items: readonly string[]
838
859
  }): ReactElement {
839
- const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), { busy, items })
860
+ const layout = useMemo(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
861
+ busy,
862
+ items,
863
+ // Match the composer content budget: border + horizontal padding are
864
+ // already excluded, and layoutStatusBar shrinks this ceiling as needed.
865
+ contextWidth: Math.max(5, columns - 6),
866
+ }), [
867
+ facts.model,
868
+ facts.mode,
869
+ facts.cwd,
870
+ facts.branch,
871
+ facts.sessionId,
872
+ facts.title,
873
+ facts.sandbox,
874
+ facts.plan,
875
+ facts.permission,
876
+ facts.goal?.phase,
877
+ facts.goal?.rounds,
878
+ facts.goal?.max,
879
+ stats,
880
+ busy,
881
+ columns,
882
+ items,
883
+ ])
840
884
 
841
885
  const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
842
886
  const leftParts: ReactElement[] = []
@@ -871,8 +915,8 @@ function StatusLine({ facts, stats, busy, columns, items }: {
871
915
  // of wrapping.
872
916
  return createElement(
873
917
  Box,
874
- // Match the prompt text inside the bordered composer: one border column
875
- // plus one padding column. The secondary row adds the model-name indent
918
+ // Match the prompt text inside the composer band: two padding columns.
919
+ // The secondary row adds the model-name indent
876
920
  // (its budget already shrinks by the same amount) so its figures align
877
921
  // under the model name rather than under the busy dot.
878
922
  { paddingLeft: 2 + indent, justifyContent: rightParts.length > 0 ? 'space-between' : undefined },
@@ -1258,11 +1302,14 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
1258
1302
  returnToOptions()
1259
1303
  return
1260
1304
  }
1261
- setCustom(current => current.slice(0, -1))
1305
+ setCustom(current => deleteLastGrapheme(current))
1262
1306
  return
1263
1307
  }
1264
1308
  if (input !== '' && !key.ctrl && !key.meta) {
1265
- setCustom(current => current + input)
1309
+ // Panel drafts see paste markers as literal text (Ink strips only the
1310
+ // leading ESC); strip them so a pasted answer never persists "[200~".
1311
+ const text = stripPasteMarkers(input)
1312
+ if (text !== '') setCustom(current => current + text)
1266
1313
  }
1267
1314
  return
1268
1315
  }
@@ -1441,9 +1488,10 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1441
1488
  createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1442
1489
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1443
1490
  ...visibleStateRows,
1444
- ...visible.map((row) => {
1445
- const index = rows.indexOf(row)
1446
- const label = displayText(`${row.providerName} · ${row.modelName}`)
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}`)
1447
1495
  return createElement(
1448
1496
  Text,
1449
1497
  {
@@ -1473,12 +1521,17 @@ function providerStateLabel(row: ProviderTargetView): string {
1473
1521
  }
1474
1522
 
1475
1523
  /** The provider-management stage reached from /model with `a`. */
1476
- function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRetry, onBack }: {
1477
- directory: ProviderSettingsDirectory | undefined
1478
- error: string | undefined
1479
- onCredential(target: ProviderTargetView): void
1480
- onUnset(target: ProviderTargetView): void
1481
- onRemove(target: ProviderTargetView): void
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
1482
1535
  onRetry(): void
1483
1536
  onBack(): void
1484
1537
  }): ReactElement {
@@ -1529,6 +1582,11 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
1529
1582
  }
1530
1583
  const target = rows[cursor]
1531
1584
  if (target === undefined) return
1585
+ if (key.tab) {
1586
+ if (target.settingsNs.length === 0) setActionError('this provider is not managed by Harness settings')
1587
+ else onConfigure(target)
1588
+ return
1589
+ }
1532
1590
  if (input === 'd') {
1533
1591
  const facts = target.credential
1534
1592
  if (facts?.kind !== 'facts' || !facts.configured) {
@@ -1540,14 +1598,27 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
1540
1598
  }
1541
1599
  return
1542
1600
  }
1543
- if (input === 'x') {
1601
+ if (input === 'x') {
1544
1602
  if (!target.removable) {
1545
1603
  setActionError('this provider profile is not removable')
1546
1604
  } else {
1547
1605
  onRemove(target)
1548
1606
  }
1549
- return
1550
- }
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
+ }
1551
1622
  if (key.return) {
1552
1623
  if (target.settingsNs.length === 0) {
1553
1624
  setActionError('this provider is not managed by Harness settings')
@@ -1575,11 +1646,19 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
1575
1646
  ...(actionError === undefined
1576
1647
  ? []
1577
1648
  : [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
1578
- ...(directory?.failures ?? []).map((failure, index) => createElement(
1649
+ ...(directory?.failures ?? []).map((failure, index) => createElement(
1579
1650
  Text,
1580
1651
  { key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
1581
- truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
1582
- )),
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
+ )),
1583
1662
  ...(rows.length === 0
1584
1663
  ? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
1585
1664
  : []),
@@ -1595,9 +1674,13 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
1595
1674
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1596
1675
  ...visibleStateRows,
1597
1676
  ...visible.map((row) => {
1598
- const index = rows.indexOf(row)
1599
- const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
1600
- const label = `${identity} · ${providerStateLabel(row)}${row.removable ? ' · custom' : ''}`
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' : ''}`
1601
1684
  return createElement(
1602
1685
  Text,
1603
1686
  { key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' },
@@ -1605,7 +1688,111 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
1605
1688
  )
1606
1689
  }),
1607
1690
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1608
- createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter add/update key · d remove key · x remove custom provider · r retry · esc back', viewport.contentColumns)),
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)),
1692
+ )
1693
+ }
1694
+
1695
+ /** Provider configuration editor: only explicit models are written to settings. */
1696
+ function ProviderConfigurationPanel({ target, catalog, save, done, back }: {
1697
+ target: ProviderTargetView
1698
+ catalog: readonly ModelRow[]
1699
+ save(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
1700
+ done(): void
1701
+ back(): void
1702
+ }): ReactElement {
1703
+ const stdout = useStdout().stdout
1704
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1705
+ const [baseURL, setBaseURL] = useState(target.configuration.baseURL ?? '')
1706
+ const [models, setModels] = useState<readonly ProviderConfiguration['models'][number][]>(target.configuration.models)
1707
+ const [cursor, setCursor] = useState(0)
1708
+ const [focus, setFocus] = useState<'url' | 'models' | 'context' | 'output'>('url')
1709
+ const [busy, setBusy] = useState(false)
1710
+ const [error, setError] = useState<string | undefined>(undefined)
1711
+ const choices = useMemo(() => {
1712
+ const known = catalog.filter(row => row.provider === target.provider)
1713
+ const ids = new Set(known.map(row => row.model))
1714
+ return [
1715
+ ...known.map(row => ({ id: row.model, name: row.modelName })),
1716
+ ...models.filter(model => !ids.has(model.id)).map(model => ({ id: model.id, name: model.name ?? model.id })),
1717
+ ]
1718
+ }, [catalog, models, target.provider])
1719
+ const selected = choices[cursor]
1720
+ const selectedModel = selected === undefined ? undefined : models.find(model => model.id === selected.id)
1721
+ const updateSelected = (change: Partial<ProviderConfiguration['models'][number]>): void => {
1722
+ if (selected === undefined) return
1723
+ setModels(current => current.some(model => model.id === selected.id)
1724
+ ? current.map(model => model.id === selected.id ? { ...model, ...change } : model)
1725
+ : [...current, { id: selected.id, name: selected.name, ...change }])
1726
+ }
1727
+ const submit = (): void => {
1728
+ if (busy) return
1729
+ setBusy(true)
1730
+ setError(undefined)
1731
+ void Promise.resolve().then(() => save(target, { ...(baseURL.trim() === '' ? {} : { baseURL }), models })).then(done, (reason: unknown) => {
1732
+ setBusy(false)
1733
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
1734
+ })
1735
+ }
1736
+ useStableInput((input, key) => {
1737
+ if (busy) return
1738
+ if (key.escape || input === 'q') { back(); return }
1739
+ if (key.tab) {
1740
+ setFocus(current => current === 'url' ? 'models' : current === 'models' ? 'context' : current === 'context' ? 'output' : 'url')
1741
+ return
1742
+ }
1743
+ if (key.return) { submit(); return }
1744
+ if (focus === 'url') {
1745
+ if (key.backspace || key.delete) setBaseURL(current => deleteLastGrapheme(current))
1746
+ else if (!key.ctrl && !key.meta && input !== '') setBaseURL(current => current + stripPasteMarkers(input))
1747
+ return
1748
+ }
1749
+ if (key.upArrow && choices.length > 0) { setCursor(current => Math.max(0, current - 1)); return }
1750
+ if (key.downArrow && choices.length > 0) { setCursor(current => Math.min(choices.length - 1, current + 1)); return }
1751
+ if (focus === 'models' && input === ' ') {
1752
+ if (selectedModel === undefined) updateSelected({})
1753
+ else setModels(current => current.filter(model => model.id !== selectedModel.id))
1754
+ return
1755
+ }
1756
+ if ((focus === 'context' || focus === 'output') && selectedModel !== undefined) {
1757
+ const field = focus === 'context' ? 'contextWindow' : 'maxTokens'
1758
+ const current = String(selectedModel[field] ?? '')
1759
+ if (key.backspace || key.delete) {
1760
+ const next = current.slice(0, -1)
1761
+ updateSelected({ [field]: next === '' ? undefined : Number(next) })
1762
+ } else {
1763
+ // A pasted number arrives as one multi-character chunk; accept the
1764
+ // whole digit run instead of the single-character path only.
1765
+ const digits = stripPasteMarkers(input)
1766
+ if (/^[0-9]+$/u.test(digits)) {
1767
+ const next = `${current}${digits}`
1768
+ updateSelected({ [field]: Number(next) })
1769
+ }
1770
+ }
1771
+ }
1772
+ }, true)
1773
+ if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
1774
+ const stateRows = error === undefined ? [] : [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))]
1775
+ const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - 1)
1776
+ const first = selectionWindow(cursor, choices.length, rowBudget)
1777
+ const visible = choices.slice(first, first + rowBudget)
1778
+ return createElement(
1779
+ Box,
1780
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1781
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model - ${target.displayName} configuration`, viewport.contentColumns)),
1782
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1783
+ createElement(Text, { color: focus === 'url' ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${focus === 'url' ? '>' : ' '} endpoint: ${baseURL === '' ? '(adapter default)' : baseURL}`, viewport.contentColumns)),
1784
+ ...stateRows,
1785
+ ...visible.map((choice, index) => {
1786
+ const absolute = first + index
1787
+ const model = models.find(item => item.id === choice.id)
1788
+ const selectedMark = model === undefined ? '[ ]' : '[x]'
1789
+ const context = model?.contextWindow === undefined ? '-' : String(model.contextWindow)
1790
+ const output = model?.maxTokens === undefined ? '-' : String(model.maxTokens)
1791
+ const active = absolute === cursor && focus !== 'url'
1792
+ return createElement(Text, { key: choice.id, color: active ? inkColor(getPalette().brandBright) : model === undefined ? inkColor(getPalette().dim) : inkColor(getPalette().success), wrap: 'truncate-end' }, truncateColumns(`${active ? '>' : ' '} ${selectedMark} ${choice.name} in:${context} out:${output}`, viewport.contentColumns))
1793
+ }),
1794
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1795
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('tab endpoint/models/input/output - space select - arrows model - digits set window - enter save - esc back', viewport.contentColumns)),
1609
1796
  )
1610
1797
  }
1611
1798
 
@@ -1657,7 +1844,7 @@ function ProviderCredentialPanel({ target, save, done, back }: {
1657
1844
  return
1658
1845
  }
1659
1846
  if (key.ctrl || key.meta || input.length === 0) return
1660
- const next = draft + input
1847
+ const next = draft + stripPasteMarkers(input)
1661
1848
  if (next.length > 4096) {
1662
1849
  setError('API key input is too long')
1663
1850
  return
@@ -1780,8 +1967,8 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1780
1967
  )
1781
1968
  const content: ReactElement[] = [
1782
1969
  createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
1783
- createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · alt+enter / ctrl+j newline · up/down history · tab complete'),
1784
- createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' tab also completes bare workspace paths · @ mentions files and sessions'),
1970
+ createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · up/down history · tab complete'),
1971
+ createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' @ mentions workspace files and sessions'),
1785
1972
  createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
1786
1973
  createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
1787
1974
  createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
@@ -1795,25 +1982,12 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
1795
1982
  { key: 'commands-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
1796
1983
  truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
1797
1984
  )]),
1798
- createElement(Box, { key: 'local-help' }, row('/help', 'show this overlay')),
1799
- createElement(Box, { key: 'local-model' }, row('/model', 'switch the model')),
1800
- createElement(Box, { key: 'local-effort' }, row('/effort', 'adjust reasoning effort for the current model')),
1801
- createElement(Box, { key: 'local-mode' }, row('/mode', 'inspect or select the agent preset (/mode [preset])')),
1802
- createElement(Box, { key: 'local-permission' }, row('/permission', 'inspect or select the permission preset (/permission [preset])')),
1803
- createElement(Box, { key: 'local-new' }, row('/new', 'create and switch to a fresh session (/new [preset])')),
1804
- createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
1805
- createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
1806
- createElement(Box, { key: 'local-statusline' }, row('/statusline', 'customize the status line items')),
1807
- createElement(Box, { key: 'local-theme' }, row('/theme', 'switch the color theme')),
1808
- createElement(Box, { key: 'local-history' }, row('/history', 'search and recall past prompts')),
1809
- createElement(Box, { key: 'local-agents' }, row('/agents', 'inspect subagent sessions of this conversation')),
1810
- createElement(Box, { key: 'local-subagent' }, row('/subagent', 'choose the model delegated subagents run on')),
1811
- createElement(Box, { key: 'local-delete' }, row('/delete', 'delete a session and its subagent threads')),
1812
- createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
1813
- createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
1814
- createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
1815
- createElement(Box, { key: 'local-quit' }, row('/quit', 'exit')),
1816
- ...descriptors.map(descriptor => createElement(
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(
1817
1991
  Text,
1818
1992
  { key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
1819
1993
  ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
@@ -1880,21 +2054,74 @@ function verboseLine(text: string, columns: number): string {
1880
2054
  return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
1881
2055
  }
1882
2056
 
1883
- /** One-row editor window keeping the logical cursor visible in long drafts. */
1884
- function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
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 } {
1885
2110
  const width = Math.max(1, columns)
1886
2111
  const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
1887
- const caretSource = value.slice(cursor, cursor + 1)
1888
- const caret = caretSource === '' ? ' ' : normalize(caretSource)
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)
1889
2116
  const remaining = Math.max(0, width - visibleColumns(caret))
1890
- const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))))
2117
+ const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(rest)))
1891
2118
  const beforeBudget = Math.max(0, remaining - afterBudget)
1892
2119
  const before = beforeBudget === 0
1893
2120
  ? ''
1894
- : displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text
2121
+ : displayTail(normalize(value.slice(0, site)), beforeBudget, 1).text
1895
2122
  const after = afterBudget === 0
1896
2123
  ? ''
1897
- : truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
2124
+ : truncateColumns(normalize(rest), afterBudget)
1898
2125
  return { before, caret, after }
1899
2126
  }
1900
2127
 
@@ -2112,7 +2339,7 @@ interface CompletionCandidate {
2112
2339
  /** Human-readable description shown beside the label. */
2113
2340
  description: string
2114
2341
  /** Candidate origin; skills land the same literal text but route through the prompt. */
2115
- origin: 'command' | 'skill' | 'mention' | 'path'
2342
+ origin: 'command' | 'skill' | 'mention'
2116
2343
  }
2117
2344
 
2118
2345
  /**
@@ -2133,32 +2360,12 @@ export function completionCandidates(
2133
2360
  ): readonly CompletionCandidate[] {
2134
2361
  if (!value.startsWith('/')) return []
2135
2362
  const prefix = value.slice(1).split(' ')[0] ?? ''
2136
- const local: CompletionCandidate[] = [
2137
- { label: '/help', description: 'show commands', origin: 'command' },
2138
- { label: '/model', description: 'switch the model', origin: 'command' },
2139
- { label: '/effort', description: 'adjust reasoning effort for the current model', origin: 'command' },
2140
- { label: '/mode', description: 'select the agent preset', origin: 'command' },
2141
- { label: '/permission', description: 'inspect or select the permission preset', origin: 'command' },
2142
- { label: '/new', description: 'start a fresh session', origin: 'command' },
2143
- { label: '/resume', description: 'browse or switch sessions', origin: 'command' },
2144
- { label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
2145
- { label: '/statusline', description: 'customize the status line', origin: 'command' },
2146
- { label: '/theme', description: 'switch the color theme', origin: 'command' },
2147
- { label: '/history', description: 'search and recall past prompts', origin: 'command' },
2148
- { label: '/agents', description: 'inspect subagent sessions of this conversation', origin: 'command' },
2149
- { label: '/subagent', description: 'choose the model delegated subagents run on', origin: 'command' },
2150
- { label: '/delete', description: 'delete a session and its subagent threads', origin: 'command' },
2151
- { label: '/clear', description: 'clear the screen', origin: 'command' },
2152
- { label: '/export', description: 'export the transcript to markdown', origin: 'command' },
2153
- { label: '/title', description: 'rename this session', origin: 'command' },
2154
- { label: '/quit', description: 'exit', origin: 'command' },
2155
- ]
2363
+ const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
2156
2364
  // Local commands shadow registry names (e.g. the TUI-local /permission works
2157
2365
  // before any session exists, while the registry child needs one), so
2158
2366
  // collisions cannot render two rows with the same key.
2159
- const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
2160
- const registry = descriptors
2161
- .filter(descriptor => !localNames.has(descriptor.name))
2367
+ const registry = descriptors
2368
+ .filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name))
2162
2369
  .map((descriptor): CompletionCandidate => ({
2163
2370
  label: `/${descriptor.name}`,
2164
2371
  description: descriptor.description,
@@ -2189,7 +2396,7 @@ export function completionCandidates(
2189
2396
 
2190
2397
  /**
2191
2398
  * The completion menu, rendered inside the composer's subtree directly above
2192
- * the framed box — attached the way Claude-Code anchors its dropdown. Opening
2399
+ * the composer band — attached the way Claude-Code anchors its dropdown. Opening
2193
2400
  * it grows the stack downward: the composer stays the last element on screen
2194
2401
  * and everything above (the flushed static transcript, the status line) never
2195
2402
  * moves. Props-only (no lifted state): the menu is a pure view of the input
@@ -2209,7 +2416,11 @@ function CompletionMenu({ active, mention, index, rows }: {
2209
2416
  const terminalRows = stdout?.rows ?? 30
2210
2417
  if (!active) return undefined
2211
2418
  const contentColumns = Math.max(1, columns - 4)
2212
- const nameWidth = Math.min(18, Math.max(1, contentColumns - 2), Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
2419
+ // File paths are the decision-making data in an @ menu. Give mentions the
2420
+ // full available line and sacrifice their repetitive kind label first.
2421
+ const nameWidth = mention
2422
+ ? Math.max(1, contentColumns - 2)
2423
+ : Math.min(18, Math.max(1, contentColumns - 2), Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
2213
2424
  const descBudget = Math.max(0, contentColumns - nameWidth - 2)
2214
2425
  const showFooter = terminalRows >= 12
2215
2426
  const spacious = terminalRows >= 14
@@ -2244,20 +2455,25 @@ function CompletionMenu({ active, mention, index, rows }: {
2244
2455
  )
2245
2456
  }
2246
2457
 
2247
- /**
2248
- * The prompt box: TUI-local slash commands handled locally, other lines
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
2249
2465
  * dispatched; input editing keeps a cursor with history and completion.
2250
2466
  * While a modal (approval / question / model panel) owns the keys, the
2251
2467
  * box passes every key through untouched.
2252
2468
  */
2253
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openStatusline, openTheme, openHistory, openAgents, openSubagent, openDelete, deleteConfirm, confirmDelete, cancelDelete, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle }: {
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 }: {
2254
2470
  active: boolean
2255
2471
  frozen: boolean
2256
2472
  busy: boolean
2257
2473
  descriptors: readonly CommandDescriptor[]
2258
2474
  skills: readonly SkillRow[]
2259
- dispatch(text: string): void
2260
- steer(text: string): void
2475
+ dispatch(text: string, images?: readonly ImageBlock[]): void
2476
+ steer(text: string, images?: readonly ImageBlock[]): void
2261
2477
  interrupt(): boolean
2262
2478
  quit(): void
2263
2479
  openModel(): void
@@ -2267,6 +2483,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2267
2483
  openPermission(): void
2268
2484
  openResume(): void
2269
2485
  openPlugin(query?: string): void
2486
+ openJobs(): void
2270
2487
  openStatusline(): void
2271
2488
  openTheme(): void
2272
2489
  openHistory(): void
@@ -2274,8 +2491,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2274
2491
  openAgents(): void
2275
2492
  /** Open the /subagent model panel. */
2276
2493
  openSubagent(): void
2494
+ /** Open the /todos subpage (full todo list in one bounded panel). */
2495
+ openTodos(): void
2277
2496
  /** Open the /resume picker in delete mode, optionally pre-armed on one id. */
2278
2497
  openDelete(id?: string): void
2498
+ openDiff(argument: string): void
2499
+ reviewChanges(argument: string): void
2279
2500
  /** The row id awaiting y/n in this box, when a deletion is pending. */
2280
2501
  deleteConfirm?: string
2281
2502
  /** Confirm the pending deletion (y in the box). */
@@ -2283,6 +2504,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2283
2504
  /** Cancel the pending deletion (any other key in the box). */
2284
2505
  cancelDelete(): void
2285
2506
  createSession(mode?: string): void
2507
+ forkSession(argument: string): void
2286
2508
  cancelSessionSwitch(): boolean
2287
2509
  notify(text: string, tone?: NoticeTone): void
2288
2510
  hasNotice: boolean
@@ -2291,10 +2513,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2291
2513
  openVerbose(): void
2292
2514
  clearView(): void
2293
2515
  refresh(): void
2294
- loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
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[]>
2295
2519
  cyclePermission(): string
2296
2520
  exportTranscript(argument: string): Promise<void>
2297
2521
  renameTitle(argument: string): string
2522
+ copyLastResponse(): Promise<string>
2298
2523
  /** Newest-first recall space (persistent + in-session, deduped). */
2299
2524
  recallSpace: readonly string[]
2300
2525
  /** Record one in-session submission (deduped, local only). */
@@ -2317,29 +2542,91 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2317
2542
  waveTier: DeepseekWaveTier | null
2318
2543
  /** The ignition style running, if any: Wave / Aurora / Pulse. */
2319
2544
  waveStyle: DeepseekWaveStyle | null
2545
+ /** Maximum physical editor rows the composer may occupy (see composerMaxRows). */
2546
+ maxRows: number
2547
+ /** Reports the editor's current physical row count so the live budget stays exact. */
2548
+ onEditorRows(rows: number): void
2320
2549
  }): ReactElement {
2321
2550
  const columns = useStdout().stdout?.columns ?? 80
2322
- const [value, setValue] = useState('')
2323
- const [cursor, setCursor] = useState(0)
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)
2562
+ // Codex textarea editing state: a single-entry kill buffer, the vertical
2563
+ // move's preferred display column, the editor's scroll window, and the
2564
+ // bracketed-paste marker state. All of it is editor-local; nothing here
2565
+ // ever reaches the App.
2566
+ const killRef = useRef('')
2567
+ const preferredColumnRef = useRef<number | null>(null)
2568
+ const editorScrollRef = useRef(0)
2569
+ const pasteBracketRef = useRef(false)
2570
+ /** Cancels the pending lost-paste safety timer (undefined when disarmed). */
2571
+ 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)
2324
2574
  // Codex shell-style recall: the navigation cursor, the saved draft restored
2325
2575
  // on Down past the newest entry, and the boundary-gate anchor.
2326
- const recall = useRef<RecallState>({ entries: [], index: null, savedDraft: '', lastRecalled: null })
2576
+ const recall = useRef<RecallState>(beginRecall([], ''))
2327
2577
 
2328
- // A /history panel acceptance lands as a fill: place the text at the end of
2329
- // the composer and resume recall from that entry.
2330
- useEffect(() => {
2331
- if (historyFill === undefined) return
2332
- setValue(historyFill.text)
2333
- setCursor(historyFill.text.length)
2578
+ // A /history panel acceptance lands as a fill: place the sanitized text at
2579
+ // 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([])
2585
+ setValue(safe)
2586
+ setCursor(safe.length)
2587
+ preferredColumnRef.current = null
2334
2588
  setDismissedMenuValue(undefined)
2335
2589
  recall.current = {
2336
2590
  entries: recallSpace,
2337
2591
  index: historyFill.index,
2338
- savedDraft: historyFill.text,
2339
- lastRecalled: historyFill.text,
2592
+ savedDraft: safe,
2593
+ lastRecalled: safe,
2340
2594
  }
2341
2595
  historyConsumed()
2342
- }, [historyFill, recallSpace, 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])
2605
+
2606
+ // Home/End and the Backspace-vs-Delete family never survive Ink's parser
2607
+ // as distinct keys, and kitty CSI-u forms parse as unnamed junk Ink would
2608
+ // insert as draft text.
2609
+ // Patch stdin.read — the single choke point Ink's input loop pulls every
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.
2615
+ useEffect(() => {
2616
+ if (stdin === undefined) return
2617
+ const originalRead = stdin.read.bind(stdin)
2618
+ const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
2619
+ const chunk = originalRead(...args)
2620
+ if (chunk === null) return chunk
2621
+ const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
2622
+ rawAnnotation.current = annotateRawKey(normalized)
2623
+ return normalized
2624
+ } as typeof stdin.read
2625
+ stdin.read = patchedRead
2626
+ return () => {
2627
+ stdin.read = originalRead as typeof stdin.read
2628
+ }
2629
+ }, [stdin])
2343
2630
 
2344
2631
  // Keep the navigation's recall space fresh while browsing state survives
2345
2632
  // (new local submissions extend the space; the index stays valid unless
@@ -2362,37 +2649,67 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2362
2649
  const mentionToken = tokenMatch === null
2363
2650
  ? undefined
2364
2651
  : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
2365
- const mentionActive = mentionToken !== undefined
2366
- const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
2367
-
2368
- // Bare path token: the last whitespace-delimited run on the cursor's line
2369
- // when it already looks like a path (Claude-Code bare Tab completion). A
2370
- // LEADING '/' is the command namespace, never a path — without this guard
2371
- // typing the bare '/' hijacked the menu into the workspace file scan and
2372
- // the slash-command candidates never appeared.
2373
- const bareTokenMatch = /([^\s]+)$/u.exec(lastLine)
2374
- const bareToken = bareTokenMatch === null ? '' : bareTokenMatch[1] ?? ''
2375
- const pathActive = !mentionActive
2376
- && !bareToken.startsWith('/')
2377
- && (bareToken.includes('/') || bareToken === '.' || bareToken === '..')
2378
- const pathTokenStart = beforeCursor.length - bareToken.length
2379
- const [pathRows, setPathRows] = useState<readonly MentionCandidate[]>([])
2380
-
2381
- useEffect(() => {
2382
- if (!active || !pathActive) {
2383
- setPathRows([])
2384
- return
2385
- }
2386
- const controller = new AbortController()
2387
- setPathRows([])
2388
- loadMentions(bareToken, controller.signal).then(
2389
- rows => setPathRows(rows.filter(row => row.kind !== 'session')),
2390
- () => {},
2391
- )
2392
- return () => {
2393
- controller.abort()
2394
- }
2395
- }, [active, pathActive, bareToken])
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
+ }
2396
2713
 
2397
2714
  useEffect(() => {
2398
2715
  if (!active || !mentionActive) {
@@ -2413,7 +2730,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2413
2730
  // Codex routes keys to the topmost surface first. Completion therefore
2414
2731
  // remains available while a turn runs, and Esc dismisses it before the
2415
2732
  // same key is allowed to interrupt the turn.
2416
- const menuActive = (slashActive || mentionActive || pathActive) && dismissedMenuValue !== value
2733
+ const menuActive = (slashActive || mentionActive) && dismissedMenuValue !== value
2417
2734
  const menuRows: readonly CompletionCandidate[] = mentionActive
2418
2735
  ? mentionRows.map(row => ({
2419
2736
  label: row.label.startsWith('@')
@@ -2422,20 +2739,49 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2422
2739
  description: row.description,
2423
2740
  origin: 'mention',
2424
2741
  }))
2425
- : pathActive
2426
- ? pathRows.map(row => ({
2427
- label: row.label,
2428
- description: row.description,
2429
- origin: 'path',
2430
- }))
2431
- : candidates
2742
+ : candidates
2432
2743
 
2433
2744
  /** Accept the highlighted completion-menu candidate into the draft. */
2434
2745
  const acceptMenuCandidate = (): void => {
2435
2746
  if (mentionActive && mentionToken !== undefined) {
2436
- const row = mentionRows[completionIndex % mentionRows.length]
2437
- if (row !== undefined) {
2438
- // Session rows carry the canonical @[label](dsh-session:…) token;
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;
2439
2785
  // file rows insert `@path` (directories keep their trailing slash).
2440
2786
  const insertion = row.label.startsWith('@')
2441
2787
  ? row.label
@@ -2443,15 +2789,6 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2443
2789
  setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
2444
2790
  setCursor(mentionToken.start + insertion.length)
2445
2791
  }
2446
- } else if (pathActive) {
2447
- const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
2448
- if (row !== undefined) {
2449
- // Bare path completion replaces the typed token with the chosen
2450
- // workspace path (directories keep their trailing slash).
2451
- const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
2452
- setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
2453
- setCursor(pathTokenStart + insertion.length)
2454
- }
2455
2792
  } else {
2456
2793
  const candidate = candidates[completionIndex % candidates.length]
2457
2794
  if (candidate !== undefined) {
@@ -2463,9 +2800,27 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2463
2800
  setDismissedMenuValue(undefined)
2464
2801
  }
2465
2802
 
2466
- useInput((input, key) => {
2467
- // Modal ownership: approval/question/model dialogs consume all keys.
2468
- if (!active) return
2803
+ /** Apply one editor edit: draft, cursor, kill buffer, menu reset. */
2804
+ const applyEdit = (edit: EditResult): void => {
2805
+ if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
2806
+ setValue(edit.value)
2807
+ setCursor(edit.cursor)
2808
+ preferredColumnRef.current = null
2809
+ setCompletionIndex(0)
2810
+ setDismissedMenuValue(undefined)
2811
+ }
2812
+
2813
+ /** Move the cursor without editing; horizontal moves clear the column preference. */
2814
+ const moveCursorTo = (next: number): void => {
2815
+ if (next === cursor) return
2816
+ setCursor(next)
2817
+ preferredColumnRef.current = null
2818
+ }
2819
+
2820
+ useInput((input, key) => {
2821
+ // Modal ownership: approval/question/model dialogs consume all keys.
2822
+ if (!active) return
2823
+ if (preparingImages) return
2469
2824
  // Deletion confirm owns the box: y proceeds, anything else cancels.
2470
2825
  // Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
2471
2826
  // as the box's own prompt, not an invisible panel keypress.
@@ -2505,9 +2860,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2505
2860
  if (key.ctrl && input === 'c') {
2506
2861
  if (busy) {
2507
2862
  interrupt()
2508
- } else if (value !== '') {
2509
- setValue('')
2510
- setCursor(0)
2863
+ } else if (value !== '') {
2864
+ setValue('')
2865
+ setCursor(0)
2866
+ draftImagesRef.current = []
2867
+ setDraftImages([])
2511
2868
  setCompletionIndex(0)
2512
2869
  setDismissedMenuValue(undefined)
2513
2870
  } else {
@@ -2516,6 +2873,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2516
2873
  return
2517
2874
  }
2518
2875
  if (key.ctrl && input === 'd') {
2876
+ // Codex: Ctrl+D deletes forward while a draft exists; the app-level
2877
+ // exit only fires from an empty composer.
2878
+ if (value !== '') {
2879
+ applyEdit(deleteForward(value, cursor))
2880
+ return
2881
+ }
2519
2882
  if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
2520
2883
  else quit()
2521
2884
  return
@@ -2539,13 +2902,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2539
2902
  return
2540
2903
  }
2541
2904
  if (key.return) {
2542
- // Multi-line editing: most terminals send the same byte for
2543
- // shift+enter as enter, so newline insertion rides alt/meta+enter
2544
- // and ctrl+j (the two distinguishable bindings); a bare return submits.
2545
- if (key.meta || (key.ctrl && input === 'j')) {
2546
- setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
2547
- setCursor(cursor + 1)
2548
- setDismissedMenuValue(undefined)
2905
+ // A newline inside an open bracketed paste inserts; it never submits.
2906
+ if (pasteBracketRef.current) {
2907
+ applyEdit(insertText(value, cursor, '\n'))
2549
2908
  return
2550
2909
  }
2551
2910
  // Enter on an open completion menu accepts the highlighted candidate
@@ -2554,14 +2913,42 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2554
2913
  // exactly, in which case Enter submits it (typing a full "/effort" and
2555
2914
  // pressing return must run the command, not re-accept its own text).
2556
2915
  if (menuActive) {
2557
- const exactSlash = !mentionActive && !pathActive && candidates.some(candidate => candidate.label === value)
2916
+ const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === value)
2558
2917
  if (!exactSlash) {
2559
2918
  acceptMenuCandidate()
2560
2919
  return
2561
2920
  }
2562
- }
2563
- const text = value.trim()
2564
- setValue('')
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('')
2565
2952
  setCursor(0)
2566
2953
  setCompletionIndex(0)
2567
2954
  setDismissedMenuValue(undefined)
@@ -2574,7 +2961,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2574
2961
  recordLocal(text)
2575
2962
  recordHistory(text)
2576
2963
  }
2577
- recall.current = { entries: recallSpace, index: null, savedDraft: '', lastRecalled: null }
2964
+ recall.current = beginRecall(recallSpace, '')
2578
2965
  if (text === '/quit') {
2579
2966
  quit()
2580
2967
  return
@@ -2606,6 +2993,21 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2606
2993
  notify(outcome, tone)
2607
2994
  return
2608
2995
  }
2996
+ if (text === '/copy') {
2997
+ void copyLastResponse().then(
2998
+ outcome => notify(outcome),
2999
+ error => notify(`copy failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
3000
+ )
3001
+ return
3002
+ }
3003
+ if (text === '/diff' || text.startsWith('/diff ')) {
3004
+ openDiff(text.slice(5))
3005
+ return
3006
+ }
3007
+ if (text === '/review' || text.startsWith('/review ')) {
3008
+ reviewChanges(text.slice(7))
3009
+ return
3010
+ }
2609
3011
  if (text === '/model' || text.startsWith('/model ')) {
2610
3012
  openModel()
2611
3013
  return
@@ -2642,10 +3044,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2642
3044
  createSession(text.slice(4).trim() || undefined)
2643
3045
  return
2644
3046
  }
3047
+ if (text === '/fork' || text.startsWith('/fork ')) {
3048
+ forkSession(text.slice(5))
3049
+ return
3050
+ }
2645
3051
  if (text === '/plugin' || text.startsWith('/plugin ')) {
2646
3052
  openPlugin(text.slice(7).trim())
2647
3053
  return
2648
3054
  }
3055
+ if (text === '/jobs' || text.startsWith('/jobs ')) {
3056
+ openJobs()
3057
+ return
3058
+ }
2649
3059
  if (text === '/statusline') {
2650
3060
  openStatusline()
2651
3061
  return
@@ -2662,6 +3072,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2662
3072
  openAgents()
2663
3073
  return
2664
3074
  }
3075
+ if (text === '/todos') {
3076
+ openTodos()
3077
+ return
3078
+ }
2665
3079
  if (text === '/subagent') {
2666
3080
  openSubagent()
2667
3081
  return
@@ -2680,6 +3094,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2680
3094
  dispatch(text)
2681
3095
  return
2682
3096
  }
3097
+ // Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
3098
+ // stripping the leading escape. Neither is a multiline shortcut.
3099
+ if (input === '\n' || input === '\r') return
2683
3100
  if (menuActive && key.upArrow) {
2684
3101
  setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
2685
3102
  return
@@ -2688,29 +3105,66 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2688
3105
  setCompletionIndex(index => (index + 1) % menuRows.length)
2689
3106
  return
2690
3107
  }
2691
- if (key.upArrow) {
2692
- // Claude-Code shell recall: Up always walks the global history (the
2693
- // current draft is saved for Down-past-newest restore); the boundary
2694
- // gate from Codex only blocks interior multiline movement, which the
2695
- // user experience here deliberately skips.
2696
- if (recall.current.entries.length === 0) return
2697
- const step = recallOlder(recall.current, value)
2698
- recall.current = step.state
2699
- if (step.entry !== undefined) {
2700
- setValue(step.entry)
2701
- setCursor(step.entry.length)
2702
- setDismissedMenuValue(undefined)
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))
3119
+ return
3120
+ }
3121
+ 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
2703
3143
  }
2704
3144
  return
2705
3145
  }
2706
- if (key.downArrow) {
2707
- if (recall.current.entries.length === 0) return
2708
- const step = recallNewer(recall.current)
2709
- recall.current = step.state
2710
- if (step.entry !== undefined) {
2711
- setValue(step.entry)
2712
- setCursor(step.entry.length)
2713
- setDismissedMenuValue(undefined)
3146
+ // Ctrl+P / Ctrl+N share the Up/Down contract (Codex binds them to
3147
+ // move_up/move_down, so the history gate applies first).
3148
+ 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
2714
3168
  }
2715
3169
  return
2716
3170
  }
@@ -2718,33 +3172,68 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2718
3172
  acceptMenuCandidate()
2719
3173
  return
2720
3174
  }
3175
+ // Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
3176
+ // plain arrows and Ctrl+B/F move by grapheme.
3177
+ if (key.leftArrow) {
3178
+ moveCursorTo(key.meta || key.ctrl ? moveWordLeft(value, cursor) : moveCursorBy(value, cursor, -1))
3179
+ return
3180
+ }
3181
+ if (key.rightArrow) {
3182
+ moveCursorTo(key.meta || key.ctrl ? moveWordRight(value, cursor) : moveCursorBy(value, cursor, 1))
3183
+ return
3184
+ }
3185
+ if (key.meta && input === 'b') {
3186
+ moveCursorTo(moveWordLeft(value, cursor))
3187
+ return
3188
+ }
3189
+ if (key.meta && input === 'f') {
3190
+ moveCursorTo(moveWordRight(value, cursor))
3191
+ return
3192
+ }
3193
+ if (key.ctrl && input === 'b') {
3194
+ moveCursorTo(moveCursorBy(value, cursor, -1))
3195
+ return
3196
+ }
3197
+ if (key.ctrl && input === 'f') {
3198
+ moveCursorTo(moveCursorBy(value, cursor, 1))
3199
+ return
3200
+ }
3201
+ // Ctrl+W and Alt+Backspace delete the previous word piece into the kill
3202
+ // buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
3203
+ if (key.ctrl && input === 'w') {
3204
+ applyEdit(deleteWordBackward(value, cursor))
3205
+ return
3206
+ }
3207
+ if (key.meta && input === 'd') {
3208
+ applyEdit(deleteWordForward(value, cursor))
3209
+ return
3210
+ }
3211
+ // Un-annotated backspace/delete (Ink maps both  and  here):
3212
+ // delete the grapheme before the cursor.
2721
3213
  if (key.backspace || key.delete) {
2722
- if (cursor > 0) {
2723
- setValue(value.slice(0, cursor - 1) + value.slice(cursor))
2724
- setCursor(cursor - 1)
2725
- setCompletionIndex(0)
2726
- setDismissedMenuValue(undefined)
2727
- }
3214
+ applyEdit(deleteBackward(value, cursor))
2728
3215
  return
2729
3216
  }
2730
- if (key.leftArrow) {
2731
- setCursor(Math.max(0, cursor - 1))
3217
+ // Readline parity over the LOGICAL line: A/E to its ends, U/K kill to
3218
+ // them (filling the single kill buffer), Y yanks it back.
3219
+ if (key.ctrl && input === 'a') {
3220
+ moveCursorTo(lineBounds(value, cursor).start)
2732
3221
  return
2733
3222
  }
2734
- if (key.rightArrow) {
2735
- setCursor(Math.min(value.length, cursor + 1))
3223
+ if (key.ctrl && input === 'e') {
3224
+ moveCursorTo(lineBounds(value, cursor).end)
2736
3225
  return
2737
3226
  }
2738
3227
  if (key.ctrl && input === 'u') {
2739
- setValue('')
2740
- setCursor(0)
2741
- setDismissedMenuValue(undefined)
3228
+ applyEdit(killToLineStart(value, cursor))
2742
3229
  return
2743
3230
  }
2744
- // Readline parity: Ctrl+K cuts from the cursor to the end of the line.
2745
3231
  if (key.ctrl && input === 'k') {
2746
- setValue(value.slice(0, cursor))
2747
- setDismissedMenuValue(undefined)
3232
+ applyEdit(killToLineEnd(value, cursor))
3233
+ return
3234
+ }
3235
+ if (key.ctrl && input === 'y') {
3236
+ if (killRef.current !== '') applyEdit(insertText(value, cursor, killRef.current))
2748
3237
  return
2749
3238
  }
2750
3239
  // Ctrl+L refreshes the screen (readline convention): raw ANSI clear
@@ -2754,24 +3243,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2754
3243
  refresh()
2755
3244
  return
2756
3245
  }
2757
- if (key.ctrl && input === 'a') {
2758
- setCursor(0)
2759
- return
2760
- }
2761
- if (key.ctrl && input === 'e') {
2762
- setCursor(value.length)
2763
- return
2764
- }
2765
3246
  if (input !== '' && !key.ctrl && !key.meta) {
2766
- setValue(value.slice(0, cursor) + input + value.slice(cursor))
2767
- setCursor(cursor + input.length)
2768
- setCompletionIndex(0)
2769
- setDismissedMenuValue(undefined)
3247
+ // Bracketed-paste wrappers arrive as unknown escape sequences stripped
3248
+ // of their ESC. Markers may ride their own chunk or the edges of a
3249
+ // content chunk; strip every occurrence and track the open-paste flag
3250
+ // so a chunk that is exactly LF inserts instead of submitting.
3251
+ let text = input
3252
+ if (text.includes(PASTE_START_MARKER)) {
3253
+ pasteBracketRef.current = true
3254
+ // Arm the lost-marker safety net: one timer per open paste, re-armed
3255
+ // if a second start marker rides the same burst.
3256
+ pasteBracketCancelRef.current?.()
3257
+ const timer = setTimeout(() => {
3258
+ pasteBracketRef.current = false
3259
+ pasteBracketCancelRef.current = undefined
3260
+ }, PASTE_BRACKET_TIMEOUT_MS)
3261
+ pasteBracketCancelRef.current = () => {
3262
+ clearTimeout(timer)
3263
+ pasteBracketCancelRef.current = undefined
3264
+ }
3265
+ text = text.replaceAll(PASTE_START_MARKER, '')
3266
+ }
3267
+ if (text.includes(PASTE_END_MARKER)) {
3268
+ pasteBracketRef.current = false
3269
+ pasteBracketCancelRef.current?.()
3270
+ 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))
2770
3279
  }
2771
3280
  })
2772
3281
 
2773
3282
  // The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
2774
- // the interval re-renders only the composer row at 30fps, never the whole
3283
+ // the interval re-renders only the composer band at 30fps, never the whole
2775
3284
  // tree. App drives the tier/style pair on a model switch; this local effect
2776
3285
  // starts the sweep whenever that pair changes (App picks a NEW random style
2777
3286
  // for every replay — including effort changes on the same route — so the
@@ -2812,44 +3321,68 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2812
3321
  const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier)
2813
3322
  const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
2814
3323
  const promptGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
3324
+ // The multiline editor model: the sanitized draft hard-wrapped into
3325
+ // column-safe physical rows, with the caret mapped to its exact row and
3326
+ // column. Computed before the frozen path so the row report below runs
3327
+ // unconditionally.
3328
+ const editorColumns = Math.max(1, columns - 6)
3329
+ const editorViewModel = editorModel(value, editorColumns)
3330
+ const clampedCursor = clampCursor(value, cursor)
3331
+ const caret = caretSite(editorViewModel, clampedCursor)
3332
+ const editorWindowRows = Math.min(editorViewModel.rows.length, Math.max(1, maxRows))
3333
+ const maxEditorScroll = Math.max(0, editorViewModel.rows.length - editorWindowRows)
3334
+ const currentEditorScroll = Math.min(Math.max(0, editorScrollRef.current), maxEditorScroll)
3335
+ // Codex effective_scroll: no scrolling while the rows fit; otherwise the
3336
+ // window follows the caret row with as little movement as possible.
3337
+ const editorWindowStart = caret.row < currentEditorScroll
3338
+ ? caret.row
3339
+ : caret.row >= currentEditorScroll + editorWindowRows
3340
+ ? caret.row - editorWindowRows + 1
3341
+ : currentEditorScroll
3342
+ editorScrollRef.current = editorWindowStart
3343
+ const editorRowCount = frozen ? 1 : editorWindowRows
3344
+ useEffect(() => {
3345
+ onEditorRows(editorRowCount)
3346
+ }, [editorRowCount, onEditorRows])
3347
+ // The composer band: the old border's three-row footprint repainted as a
3348
+ // background-color band (the Codex-style shaded composer strip) — one
3349
+ // blank band row above and below the content rows, full width minus the
3350
+ // final column. Row counts are untouched, so every height budget stays
3351
+ // exact.
3352
+ const bandWidth = Math.max(1, columns - 1)
3353
+ const bandBg = inkColor(getPalette().composerBand)
3354
+ const bandFill = (consumed: number): string => ' '.repeat(Math.max(0, bandWidth - consumed))
3355
+ const band = (content: ReactElement): ReactElement => createElement(
3356
+ Box,
3357
+ { flexDirection: 'column', width: bandWidth },
3358
+ createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
3359
+ content,
3360
+ createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
3361
+ )
2815
3362
  if (frozen) {
2816
- // A pending deletion turns the box into the confirm prompt: the y/n is
2817
- // typed HERE, with a readable warn-styled hint instead of a dim footer.
3363
+ // A pending deletion turns the band into the confirm prompt: the y/n is
3364
+ // typed HERE; without a border the warn color carries the warning.
2818
3365
  if (deleteConfirm !== undefined) {
2819
- return createElement(
2820
- Box,
2821
- { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().warn), paddingX: 1 },
2822
- createElement(
2823
- Text,
2824
- { wrap: 'truncate-end' },
2825
- createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '❯ '),
2826
- createElement(Text, { color: inkColor(getPalette().warn), bold: true }, 'y delete · any other key cancels'),
2827
- ),
2828
- )
3366
+ const warning = 'y delete · any other key cancels'
3367
+ return band(createElement(
3368
+ Text,
3369
+ { backgroundColor: bandBg, wrap: 'truncate-end' },
3370
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '❯ '),
3371
+ createElement(Text, { color: inkColor(getPalette().warn), bold: true }, warning),
3372
+ bandFill(2 + visibleColumns(warning)),
3373
+ ))
2829
3374
  }
2830
- const frozen = value === ''
3375
+ const frozenLine = value === ''
2831
3376
  ? 'type a message'
2832
3377
  : verboseLine(value, Math.max(1, columns - 6))
2833
- return createElement(
2834
- Box,
2835
- { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().dim), paddingX: 1 },
2836
- createElement(
2837
- Text,
2838
- { wrap: 'truncate-end' },
2839
- createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, busy ? '… ' : `${promptGlyph} `),
2840
- frozen,
2841
- ),
2842
- )
3378
+ return band(createElement(
3379
+ Text,
3380
+ { backgroundColor: bandBg, wrap: 'truncate-end' },
3381
+ createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, busy ? '… ' : `${promptGlyph} `),
3382
+ frozenLine,
3383
+ bandFill(2 + visibleColumns(frozenLine)),
3384
+ ))
2843
3385
  }
2844
-
2845
- // The bordered frame: static dim at rest; while the wave runs the border
2846
- // breathes with the sweep (dim blends toward the tier accent and back), so
2847
- // the frame glows up while the crest crosses the row.
2848
- const frame = (row: ReactElement, borderRgb?: RgbTriple): ReactElement => createElement(
2849
- Box,
2850
- { width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(borderRgb ?? getPalette().dim), paddingX: 1 },
2851
- row,
2852
- )
2853
3386
  const menu = createElement(CompletionMenu, {
2854
3387
  active: menuActive,
2855
3388
  mention: mentionActive,
@@ -2857,56 +3390,87 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2857
3390
  rows: menuRows,
2858
3391
  })
2859
3392
 
2860
- // Static row (idle, busy, or after the wave): prompt + editor window. The
2861
- // prompt marker keeps the tier accent while an official DeepSeek model is
2862
- // applied, restoring the static brand on any other route.
2863
- const editor = editorWindow(value, cursor, Math.max(1, columns - 6))
2864
- const staticRow = createElement(
2865
- Text,
2866
- { wrap: 'truncate-end' },
2867
- busy
2868
- ? createElement(BusyChase)
2869
- : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `),
2870
- value === '' ? undefined : editor.before,
2871
- createElement(CursorBlock, { char: editor.caret }),
2872
- value === '' && !busy
2873
- ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
2874
- : editor.after,
2875
- )
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.
3401
+ const editorRows: ReactElement[] = []
3402
+ for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
3403
+ 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)
3415
+ editorRows.push(createElement(
3416
+ Text,
3417
+ { key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
3418
+ index === 0
3419
+ ? busy
3420
+ ? createElement(BusyChase)
3421
+ : createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
3422
+ : ' ',
3423
+ before,
3424
+ createElement(CursorBlock, { key: 'caret', char: caretChar }),
3425
+ placeholder
3426
+ ? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
3427
+ : after,
3428
+ bandFill(consumed),
3429
+ ))
3430
+ }
3431
+ const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
2876
3432
 
2877
- // Wave row: the input row assembled column by column, each cell carrying
2878
- // the sampled wave `backgroundColor` (null outside the crest → transparent),
2879
- // so the crest sweeps the FULL content row prompt, draft, cursor,
2880
- // placeholder, and the trailing blank fill. The deepseek tier drops the
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 bandblank 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
2881
3438
  // `· ✦ ✧` sparkles into the rightmost blank cell from 900ms on.
2882
3439
  const waveRow = (): ReactElement => {
2883
- const contentWidth = Math.max(1, columns - 5)
2884
- const waveEditor = editorWindow(value, cursor, Math.max(1, contentWidth - 2))
2885
3440
  const hues = deepseekWaveHues(waveTier!)
2886
3441
  const style = waveStyle!
2887
- const base = getTheme() === 'light' ? WAVE_BASE_LIGHT : WAVE_BASE_DARK
2888
- const waveBg = (column: number): string | undefined => {
2889
- const rgb = deepseekWaveColumnBg(waveTick!, column, contentWidth, waveTier!, style, hues, base)
2890
- return rgb === null ? undefined : inkColor(rgb)
2891
- }
2892
- const cells: ComposerCell[] = []
2893
- cells.push({ char: promptGlyph, color: promptColor, bold: true, backgroundColor: waveBg(0) })
2894
- cells.push({ char: ' ', color: promptColor, backgroundColor: waveBg(1) })
3442
+ const bandRgb = getPalette().composerBand
3443
+ const waveBg = (row: number, column: number): string => {
3444
+ const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, 3)
3445
+ return rgb === null ? bandBg : inkColor(rgb)
3446
+ }
3447
+ const blankBandRow = (row: number): ReactElement => {
3448
+ 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
+ ]
2895
3459
  for (const char of waveEditor.before) {
2896
- cells.push({ char, backgroundColor: waveBg(cells.length) })
3460
+ cells.push({ char, backgroundColor: waveBg(1, cells.length) })
2897
3461
  }
2898
- cells.push({ char: waveEditor.caret, inverse: true, backgroundColor: waveBg(cells.length) })
3462
+ cells.push({ char: waveEditor.caret, inverse: true, backgroundColor: waveBg(1, cells.length) })
2899
3463
  if (value === '' && !busy) {
2900
3464
  for (let at = 0; at < COMPOSER_PLACEHOLDER.length; at += 1) {
2901
- cells.push({ char: COMPOSER_PLACEHOLDER[at]!, dim: true, backgroundColor: waveBg(cells.length) })
3465
+ cells.push({ char: COMPOSER_PLACEHOLDER[at]!, dim: true, backgroundColor: waveBg(1, cells.length) })
2902
3466
  }
2903
3467
  } else {
2904
3468
  for (const char of waveEditor.after) {
2905
- cells.push({ char, backgroundColor: waveBg(cells.length) })
3469
+ cells.push({ char, backgroundColor: waveBg(1, cells.length) })
2906
3470
  }
2907
3471
  }
2908
- while (cells.length < contentWidth) {
2909
- cells.push({ char: ' ', backgroundColor: waveBg(cells.length) })
3472
+ while (cells.length < bandWidth) {
3473
+ cells.push({ char: ' ', backgroundColor: waveBg(1, cells.length) })
2910
3474
  }
2911
3475
  // The wordmark rides the wave's middle: `deepseek` on the official
2912
3476
  // tiers, `Into the Unknown` on the non-DeepSeek high-effort variant —
@@ -2914,7 +3478,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2914
3478
  // over blank or placeholder cells — real draft text is never covered.
2915
3479
  if (deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
2916
3480
  const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
2917
- const start = Math.max(2, Math.floor((contentWidth - word.length) / 2))
3481
+ const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
2918
3482
  let clear = true
2919
3483
  for (let at = 0; at < word.length; at += 1) {
2920
3484
  const cell = cells[start + at]
@@ -2945,15 +3509,20 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
2945
3509
  }
2946
3510
  }
2947
3511
  }
2948
- const borderRgb = deepseekWaveBorderColor(waveTick!, waveTier!, style, hues, getPalette().dim)
2949
- return frame(createElement(Text, { wrap: 'truncate-end' }, ...waveRowSpans(cells)), borderRgb)
3512
+ return createElement(
3513
+ Box,
3514
+ { flexDirection: 'column', width: bandWidth },
3515
+ blankBandRow(0),
3516
+ createElement(Text, { wrap: 'truncate-end' }, ...waveRowSpans(cells)),
3517
+ blankBandRow(2),
3518
+ )
2950
3519
  }
2951
3520
 
2952
3521
  return createElement(
2953
3522
  Box,
2954
3523
  { flexDirection: 'column' },
2955
3524
  menu,
2956
- waveTick !== null && waveTier !== null && !busy ? waveRow() : frame(staticRow),
3525
+ waveTick !== null && waveTier !== null && !busy ? waveRow() : band(staticEditor),
2957
3526
  )
2958
3527
  }
2959
3528
 
@@ -2965,15 +3534,15 @@ interface SettledRowRecord {
2965
3534
  before: ReactElement | undefined
2966
3535
  /** The roomy-prompt spacer AFTER the row, or undefined. */
2967
3536
  after: ReactElement | undefined
2968
- /** Whether the row's text depends on the reasoning toggle (Ctrl+R). */
2969
- reasonSensitive: boolean
2970
- /** The toggle state the row was built with. */
2971
- showReasoning: boolean
3537
+ /** Physical rows this record contributes (row body plus spacers) — the
3538
+ * unit of the rendered-history cap. */
3539
+ rows: number
2972
3540
  }
2973
3541
 
2974
3542
  /** The incremental settled-history cache (see `computeSettledRows`). */
2975
3543
  interface SettledRowsCache {
2976
- /** The exact settled entries the cache covers (`view.entries[0..entries.length)`). */
3544
+ /** The exact settled entries the cache covers (the WINDOW: the newest
3545
+ * `entries.length` settled entries, oldest dropped entries excluded). */
2977
3546
  entries: TranscriptEntry[]
2978
3547
  /** Records keyed by entry identity; mutated in place so the append path
2979
3548
  * never copies the whole map. */
@@ -2984,10 +3553,21 @@ interface SettledRowsCache {
2984
3553
  resumed: boolean
2985
3554
  /** The toggle state the rows were built with. */
2986
3555
  showReasoning: boolean
2987
- /** The refreshEpoch the rows were built for; a bump forces a full rebuild. */
3556
+ /** The refreshEpoch the rows was built for; a bump forces a full rebuild. */
2988
3557
  epoch: number
2989
- /** The flat row list (header + per-entry before/box/after). */
3558
+ /** The terminal width the rows were wrapped for; a change forces a rebuild. */
3559
+ columns: number
3560
+ /** The flat row list (header + optional hint + per-entry before/box/after). */
2990
3561
  flat: ReactElement[]
3562
+ /** Settled entries dropped from the window's head (rendering only — the
3563
+ * event log keeps everything; Ctrl+O and /export read it directly). */
3564
+ droppedEntries: number
3565
+ /** Physical rows the window's entries contribute (excludes header/hint). */
3566
+ totalRows: number
3567
+ /** The window overflowed the trim hysteresis; one source-backed replay
3568
+ * (epoch bump) will re-window the cache. The append path never mutates
3569
+ * flat's head, so <Static> only ever sees tail appends between remounts. */
3570
+ needsTrim: boolean
2991
3571
  }
2992
3572
 
2993
3573
  /** One step of `computeSettledRows`. */
@@ -2997,23 +3577,36 @@ interface SettledRowsResult {
2997
3577
  built: number
2998
3578
  }
2999
3579
 
3000
- /** Build one settled row (row Box plus its roomy-prompt spacers). */
3001
- function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: boolean): SettledRowRecord {
3002
- const row = createElement(EntryLine, { entry, showReasoning, verbose: false })
3580
+ /** Build one settled row (row Box plus its roomy-prompt spacers and row count). */
3581
+ function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: boolean, columns: number): SettledRowRecord {
3582
+ // The SAME physical-row pipeline as the live tail (settledEntryLines).
3583
+ // Every row carries its own two-column prefix (user ❯, reply body, tool
3584
+ // cards), which is the whole gutter: no extra container padding, so reply
3585
+ // text starts at the same column as the composer's input text and wrapped
3586
+ // continuations keep their hanging indent instead of resetting to column 0.
3003
3587
  const roomyPrompt = entry.kind === 'user' && !entry.notice
3588
+ const lines = settledEntryLines(entry, Math.max(10, columns - 2), showReasoning)
3004
3589
  return {
3005
- box: createElement(Box, { key: index, paddingX: 1 }, row),
3590
+ box: createElement(Box, { key: index }, createElement(StyledRows, { lines })),
3006
3591
  before: roomyPrompt
3007
3592
  ? createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
3008
3593
  : undefined,
3009
3594
  after: roomyPrompt
3010
3595
  ? createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
3011
3596
  : undefined,
3012
- reasonSensitive: entry.kind === 'assistant' && entry.reasoning !== '',
3013
- showReasoning,
3597
+ rows: lines.length + (roomyPrompt ? 2 : 0),
3014
3598
  }
3015
3599
  }
3016
3600
 
3601
+ /** The dim hint row placed under the header once the window has dropped entries. */
3602
+ function settledTrimHint(droppedEntries: number, columns: number): ReactElement {
3603
+ return createElement(
3604
+ Text,
3605
+ { key: 'history-cap-hint', color: inkColor(getPalette().dim), wrap: 'truncate-end' },
3606
+ truncateColumns(`… +${droppedEntries} earlier messages hidden · ctrl+o browse · /export full transcript`, Math.max(10, columns - 2)),
3607
+ )
3608
+ }
3609
+
3017
3610
  /**
3018
3611
  * The settled `<Static>` row set as a PURE incremental state machine (App
3019
3612
  * drives it from the memo; tests drive it directly and read `built`).
@@ -3027,11 +3620,27 @@ function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: b
3027
3620
  * rebuild of rows, Map, or MarkdownBody parses). `records` is mutated in place
3028
3621
  * on the append/toggle paths to stay O(delta).
3029
3622
  *
3623
+ * RENDERED-HISTORY CAP: the window holds at most `rowCap` physical rows of
3624
+ * settled transcript (header and hint reserved on top). The cap exists only
3625
+ * here — the event log, the store projection, /export, Ctrl+O, and /resume
3626
+ * keep the full history. Ink 5's <Static> is a consumption counter
3627
+ * (items.slice(index) keyed on length): deleting head items mid-stream while
3628
+ * appending tail items can permanently swallow new rows, so the append branch
3629
+ * NEVER drops the head — it only accounts rows and flags `needsTrim` once the
3630
+ * window overflows cap + margin. The flag fires one source-backed replay
3631
+ * (epoch bump = the existing clear + <Static> remount), whose rebuild branch
3632
+ * walks the settled entries BACKWARD from the newest, keeps whole entries
3633
+ * until the cap, and counts everything older as `droppedEntries` (those
3634
+ * entries never even reach settledEntryLines). Hysteresis bounds replays to
3635
+ * at most one per 25% growth; resize / Ctrl+L / idle Ctrl+R replays re-window
3636
+ * for free on the same path.
3637
+ *
3030
3638
  * Full rebuilds run only on the rare, deliberate paths: no cache yet, a
3031
- * source-backed replay (`epoch` bump: resize / Ctrl+L / Ctrl+R remounts
3032
- * `<Static>` and must re-flush the CURRENT rows), a `resumed` change, or a shrink
3033
- * (`store.reset`). A reasoning toggle rebuilds only the rows whose text
3034
- * depends on it, preserving the other rows' element identity.
3639
+ * source-backed replay (`epoch` bump: resize / Ctrl+L / an idle Ctrl+R fold
3640
+ * toggle / a cap trim remounts `<Static>` and must re-flush the CURRENT rows
3641
+ * at the CURRENT fold state), a `resumed` change, or a shrink (`store.reset`).
3642
+ * While a turn is busy or streaming, Ctrl+R only flips the live region; rows
3643
+ * already emitted to native scrollback change exclusively through rebuilds.
3035
3644
  */
3036
3645
  export function computeSettledRows(
3037
3646
  previous: SettledRowsCache | undefined,
@@ -3040,50 +3649,58 @@ export function computeSettledRows(
3040
3649
  showReasoning: boolean,
3041
3650
  resumed: boolean,
3042
3651
  epoch: number,
3652
+ columns = 80,
3653
+ rowCap = SETTLED_ROW_CAP,
3043
3654
  ): SettledRowsResult {
3044
3655
  if (previous === undefined || previous.epoch !== epoch || previous.resumed !== resumed
3045
3656
  || settled < previous.entries.length) {
3046
- // Full rebuild from the current settled prefix.
3657
+ // Full rebuild at the CURRENT fold state, newest-first so the cap keeps
3658
+ // whole entries and never even parses dropped ones.
3047
3659
  const records = new Map<TranscriptEntry, SettledRowRecord>()
3048
- const flat: ReactElement[] = [createElement(Header, { key: 'header', resumed })]
3049
- for (let index = 0; index < settled; index++) {
3660
+ const window: ReactElement[] = []
3661
+ let windowRows = 0
3662
+ let droppedEntries = 0
3663
+ let index = settled - 1
3664
+ for (; index >= 0; index--) {
3050
3665
  const entry = entries[index]
3051
- const record = buildSettledRow(entry, index, showReasoning)
3666
+ if (entry === undefined) break
3667
+ const record = buildSettledRow(entry, index, showReasoning, columns)
3668
+ if (rowCap > 0 && windowRows + record.rows > rowCap - SETTLED_ROW_RESERVE) {
3669
+ // This whole entry (and everything older) falls out of the window.
3670
+ droppedEntries = index + 1
3671
+ break
3672
+ }
3052
3673
  records.set(entry, record)
3053
- if (record.before !== undefined) flat.push(record.before)
3054
- flat.push(record.box)
3055
- if (record.after !== undefined) flat.push(record.after)
3674
+ windowRows += record.rows
3675
+ if (record.after !== undefined) window.unshift(record.after)
3676
+ window.unshift(record.box)
3677
+ if (record.before !== undefined) window.unshift(record.before)
3056
3678
  }
3679
+ const header = createElement(Header, { key: 'header', resumed })
3680
+ const flat = droppedEntries > 0
3681
+ ? [header, settledTrimHint(droppedEntries, columns), ...window]
3682
+ : [header, ...window]
3057
3683
  return {
3058
- cache: { entries: entries.slice(0, settled), records, header: flat[0]!, resumed, showReasoning, epoch, flat },
3059
- built: settled,
3684
+ cache: {
3685
+ entries: entries.slice(droppedEntries, settled),
3686
+ records,
3687
+ header,
3688
+ resumed,
3689
+ showReasoning,
3690
+ epoch,
3691
+ columns,
3692
+ flat,
3693
+ droppedEntries,
3694
+ totalRows: windowRows,
3695
+ needsTrim: false,
3696
+ },
3697
+ built: records.size,
3060
3698
  }
3061
3699
  }
3062
3700
  if (previous.showReasoning !== showReasoning) {
3063
- // Reasoning toggle: only rows whose text depends on it rebuild; spacers
3064
- // and the other rows keep their element identity.
3065
- const records = previous.records
3066
- const flat: ReactElement[] = [previous.header]
3067
- let built = 0
3068
- for (let index = 0; index < previous.entries.length; index++) {
3069
- const entry = previous.entries[index]
3070
- const record = records.get(entry)!
3071
- const current = record.reasonSensitive
3072
- ? {
3073
- ...record,
3074
- box: createElement(Box, { key: index, paddingX: 1 }, createElement(EntryLine, { entry, showReasoning, verbose: false })),
3075
- showReasoning,
3076
- }
3077
- : record
3078
- if (current !== record) {
3079
- records.set(entry, current)
3080
- built += 1
3081
- }
3082
- if (current.before !== undefined) flat.push(current.before)
3083
- flat.push(current.box)
3084
- if (current.after !== undefined) flat.push(current.after)
3085
- }
3086
- return { cache: { ...previous, records, showReasoning, flat }, built }
3701
+ // Native scrollback is immutable. Record only the mode future settled
3702
+ // entries will capture; the existing flat row identity stays untouched.
3703
+ return { cache: { ...previous, showReasoning }, built: 0 }
3087
3704
  }
3088
3705
  if (settled === previous.entries.length) {
3089
3706
  // Nothing below the boundary changed (a pending retirement above it, a
@@ -3091,19 +3708,25 @@ export function computeSettledRows(
3091
3708
  // memoized <Static> subtree does not re-render at all.
3092
3709
  return { cache: previous, built: 0 }
3093
3710
  }
3094
- // The boundary grew: build ONLY the newly settled suffix.
3711
+ // The boundary grew: build ONLY the newly settled suffix. The head is never
3712
+ // dropped here (Ink's Static counter would swallow rows on a mixed frame);
3713
+ // overflow only flags the cache for one trimming replay.
3095
3714
  const records = previous.records
3096
3715
  const suffix: TranscriptEntry[] = []
3097
3716
  const added: ReactElement[] = []
3098
- for (let index = previous.entries.length; index < settled; index++) {
3717
+ let deltaRows = 0
3718
+ for (let index = previous.entries.length + previous.droppedEntries; index < settled; index++) {
3099
3719
  const entry = entries[index]
3100
- const record = buildSettledRow(entry, index, showReasoning)
3720
+ const record = buildSettledRow(entry, index, showReasoning, previous.columns)
3101
3721
  records.set(entry, record)
3102
3722
  suffix.push(entry)
3723
+ deltaRows += record.rows
3103
3724
  if (record.before !== undefined) added.push(record.before)
3104
3725
  added.push(record.box)
3105
3726
  if (record.after !== undefined) added.push(record.after)
3106
3727
  }
3728
+ const totalRows = previous.totalRows + deltaRows
3729
+ const needsTrim = rowCap > 0 && totalRows > rowCap + Math.floor(rowCap / 4)
3107
3730
  return {
3108
3731
  cache: {
3109
3732
  entries: previous.entries.concat(suffix),
@@ -3112,9 +3735,13 @@ export function computeSettledRows(
3112
3735
  resumed: previous.resumed,
3113
3736
  showReasoning,
3114
3737
  epoch: previous.epoch,
3738
+ columns: previous.columns,
3115
3739
  flat: previous.flat.concat(added),
3740
+ droppedEntries: previous.droppedEntries,
3741
+ totalRows,
3742
+ needsTrim,
3116
3743
  },
3117
- built: settled - previous.entries.length,
3744
+ built: suffix.length,
3118
3745
  }
3119
3746
  }
3120
3747
 
@@ -3137,12 +3764,13 @@ export function App(props: AppProps): ReactElement {
3137
3764
  const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
3138
3765
  const [modelLabel, setModelLabel] = useState(props.model)
3139
3766
  const [modelOpen, setModelOpen] = useState(false)
3140
- /** Nested /model stages; only one owns terminal input at a time. */
3141
- const [providerOpen, setProviderOpen] = useState(false)
3142
- const [providerAction, setProviderAction] = useState<{
3143
- kind: 'credential' | 'unset' | 'remove'
3144
- target: ProviderTargetView
3145
- } | undefined>(undefined)
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)
3146
3774
  /** The model row whose effort levels the /model stage lists; undefined shows the model list. */
3147
3775
  const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
3148
3776
  /** Effective reasoning effort, shown in the /model picker and switch notice. */
@@ -3156,7 +3784,7 @@ export function App(props: AppProps): ReactElement {
3156
3784
  * follows the applied model label (what the status bar actually shows),
3157
3785
  * never the initial paint, and the tier is derived from the label and
3158
3786
  * cached at the switch. The 33ms tick itself lives inside Input, so the
3159
- * sweep re-renders only the composer row, not the whole tree, at 30fps;
3787
+ * sweep re-renders only the composer band, not the whole tree, at 30fps;
3160
3788
  * App owns the rarely-changing tier/style and Input starts the sweep
3161
3789
  * whenever that pair changes. */
3162
3790
  const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
@@ -3192,8 +3820,10 @@ export function App(props: AppProps): ReactElement {
3192
3820
  }, [modelLabel, effortLabel])
3193
3821
  const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
3194
3822
  const [modelError, setModelError] = useState<string | undefined>(undefined)
3195
- const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
3196
- const [providerError, setProviderError] = 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)
3197
3827
  const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
3198
3828
  const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
3199
3829
  const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
@@ -3233,7 +3863,21 @@ export function App(props: AppProps): ReactElement {
3233
3863
  return () => {
3234
3864
  cancelled = true
3235
3865
  }
3236
- }, [modelOpen, modelLoadEpoch, props.loadModelProviders])
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])
3237
3881
  useEffect(() => {
3238
3882
  const subscribe = props.subscribeModelProviders
3239
3883
  if (!modelOpen || subscribe === undefined) return
@@ -3242,23 +3886,35 @@ export function App(props: AppProps): ReactElement {
3242
3886
  } catch (error: unknown) {
3243
3887
  setProviderError(error instanceof Error ? error.message : String(error))
3244
3888
  }
3245
- }, [modelOpen, props.subscribeModelProviders])
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])
3246
3899
 
3247
3900
  const busy = view.busy
3248
3901
  const [showReasoning, setShowReasoning] = useState(false)
3249
3902
  const [verboseOpen, setVerboseOpen] = useState(false)
3903
+ const [diffView, setDiffView] = useState<GitDiffView | undefined>(undefined)
3250
3904
  const [helpOpen, setHelpOpen] = useState(false)
3251
3905
  const [modeOpen, setModeOpen] = useState(false)
3252
3906
  const [permissionOpen, setPermissionOpen] = useState(false)
3253
3907
  const [resumeOpen, setResumeOpen] = useState(false)
3254
3908
  const [pluginOpen, setPluginOpen] = useState(false)
3255
3909
  const [pluginQuery, setPluginQuery] = useState('')
3910
+ const [jobsOpen, setJobsOpen] = useState(false)
3256
3911
  const [statuslineOpen, setStatuslineOpen] = useState(false)
3257
3912
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
3258
3913
  const [themeOpen, setThemeOpen] = useState(false)
3259
3914
  const [historyOpen, setHistoryOpen] = useState(false)
3260
3915
  const [agentsOpen, setAgentsOpen] = useState(false)
3261
3916
  const [subagentOpen, setSubagentOpen] = useState(false)
3917
+ const [todosOpen, setTodosOpen] = useState(false)
3262
3918
  /** /delete state: delete-mode hint plus an optional pre-armed row id. */
3263
3919
  const [resumeDelete, setResumeDelete] = useState<{ mode: boolean; id?: string }>({ mode: false })
3264
3920
  /** The row id awaiting y/n in the COMPOSER (codex delete confirm): the
@@ -3330,7 +3986,7 @@ export function App(props: AppProps): ReactElement {
3330
3986
  // panel keypress.
3331
3987
  const inputActive = deleteConfirmId !== undefined
3332
3988
  ? !approvalPending && !questionPending
3333
- : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !verboseOpen && !approvalPending && !questionPending
3989
+ : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
3334
3990
 
3335
3991
  // Human questions outrank local inspectors. Close the lower modal instead
3336
3992
  // of leaving an approval/question visible but keyboard-locked behind it.
@@ -3350,8 +4006,10 @@ export function App(props: AppProps): ReactElement {
3350
4006
  setHistoryOpen(false)
3351
4007
  setAgentsOpen(false)
3352
4008
  setSubagentOpen(false)
4009
+ setTodosOpen(false)
3353
4010
  setDeleteConfirmId(undefined)
3354
4011
  setVerboseOpen(false)
4012
+ setDiffView(undefined)
3355
4013
  }, [approvalPending, questionPending])
3356
4014
 
3357
4015
  // Append-only transcript: everything up to the first still-mutable entry
@@ -3366,9 +4024,17 @@ export function App(props: AppProps): ReactElement {
3366
4024
  // settled prefix is permanently final, so a grown boundary builds ONLY the
3367
4025
  // newly settled suffix and reuses every cached element — long histories
3368
4026
  // stop re-creating rows (and re-parsing MarkdownBody) on every durable
3369
- // event. A source-backed replay (`refreshEpoch` bump: resize / Ctrl+L /
3370
- // Ctrl+R remounts `<Static>`) rebuilds the CURRENT row set from index 0,
4027
+ // event. A source-backed replay (`refreshEpoch` bump: resize / Ctrl+L)
4028
+ // rebuilds the CURRENT row set from index 0,
3371
4029
  // so the replay stays complete and never ghosts a pending/running tail.
4030
+ // Hook order is unconditional. Its dimensions drive every live-region
4031
+ // budget before any dynamic rows are constructed.
4032
+ const appStdout = useStdout().stdout
4033
+ const [terminalSize, setTerminalSize] = useState(() => ({
4034
+ columns: appStdout?.columns ?? 80,
4035
+ rows: appStdout?.rows ?? 30,
4036
+ }))
4037
+ const terminalSizeRef = useRef(terminalSize)
3372
4038
  const settledRowsCache = useRef<SettledRowsCache | undefined>(undefined)
3373
4039
  const settledRows = useMemo(() => {
3374
4040
  const result = computeSettledRows(
@@ -3378,19 +4044,15 @@ export function App(props: AppProps): ReactElement {
3378
4044
  showReasoning,
3379
4045
  props.resumed,
3380
4046
  refreshEpoch,
4047
+ terminalSize.columns,
3381
4048
  )
3382
4049
  settledRowsCache.current = result.cache
3383
4050
  return result.cache.flat
3384
- }, [view.entries, settled, showReasoning, props.resumed, refreshEpoch])
4051
+ }, [view.entries, settled, showReasoning, props.resumed, refreshEpoch, terminalSize.columns])
3385
4052
 
3386
- // Hook order is unconditional. Its dimensions drive every live-region
3387
- // budget before any dynamic rows are constructed.
3388
- const appStdout = useStdout().stdout
3389
- const [terminalSize, setTerminalSize] = useState(() => ({
3390
- columns: appStdout?.columns ?? 80,
3391
- rows: appStdout?.rows ?? 30,
3392
- }))
3393
- const terminalSizeRef = useRef(terminalSize)
4053
+ // One pending synchronized frame covers a debounced resize or explicit
4054
+ // source-backed replay. It is closed after the corresponding React commit.
4055
+ const synchronizedReplayPending = useRef(false)
3394
4056
  useEffect(() => {
3395
4057
  if (appStdout === undefined) return
3396
4058
  let replayTimer: ReturnType<typeof setTimeout> | undefined
@@ -3411,7 +4073,8 @@ export function App(props: AppProps): ReactElement {
3411
4073
  setTerminalSize(next)
3412
4074
  if (replayTimer !== undefined) clearTimeout(replayTimer)
3413
4075
  replayTimer = setTimeout(() => {
3414
- appStdout.write(RESIZE_REFLOW_CLEAR)
4076
+ synchronizedReplayPending.current = true
4077
+ appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + RESIZE_REFLOW_CLEAR)
3415
4078
  setRefreshEpoch(epoch => epoch + 1)
3416
4079
  }, RESIZE_REFLOW_DELAY_MS)
3417
4080
  }
@@ -3424,16 +4087,30 @@ export function App(props: AppProps): ReactElement {
3424
4087
  const terminalRows = terminalSize.rows
3425
4088
  const terminalColumns = terminalSize.columns
3426
4089
  const composerGutterRows = layoutGutterRows(terminalRows)
3427
- // Bottom chrome is now composer (3) + status (up to 2 rows); the budget
3428
- // keeps the live/streaming area strictly below the terminal height.
3429
- const dynamicRows = Math.max(1, terminalRows - 13 - composerGutterRows)
4090
+ // The composer's live row count, reported one-way by the editor itself
4091
+ // (frozen modals report 1). This keeps the live/streaming budget exact as
4092
+ // a multiline draft grows, without lifting any editor state into the App.
4093
+ const [composerRows, setComposerRows] = useState(1)
4094
+ const handleEditorRows = useCallback((rows: number): void => {
4095
+ setComposerRows(current => (current === rows ? current : rows))
4096
+ }, [])
4097
+ const composerEditorCap = composerMaxRows(terminalRows)
4098
+ // Bottom chrome is composer (2 borders + composerRows) + menu + status
4099
+ // (up to 2 rows); the budget keeps the live/streaming area strictly below
4100
+ // the terminal height as the editor grows.
4101
+ const dynamicRows = Math.max(1, terminalRows - 13 - composerGutterRows - (composerRows - 1))
3430
4102
  const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
3431
4103
  const deepDivingVisible = busy && !streamingActive
3432
4104
  const allLiveLines = useMemo(
3433
- () => view.entries.slice(settled).flatMap(entry => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))),
3434
- [view.entries, settled, terminalColumns],
4105
+ () => view.entries.slice(settled).flatMap(
4106
+ entry => transcriptEntryLines(entry, Math.max(10, terminalColumns - 2), showReasoning),
4107
+ ),
4108
+ [view.entries, settled, terminalColumns, showReasoning],
3435
4109
  )
3436
- const liveBudget = streamingActive
4110
+ // Reserve the same stream slice from the moment a turn becomes busy. This
4111
+ // keeps the first thinking frame from changing the dynamic-tree geometry
4112
+ // underneath Ink's cursor ledger and avoids a start-of-thinking flash.
4113
+ const liveBudget = busy || streamingActive
3437
4114
  ? Math.max(1, Math.floor(dynamicRows / 3))
3438
4115
  : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
3439
4116
  const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
@@ -3454,24 +4131,58 @@ export function App(props: AppProps): ReactElement {
3454
4131
  ? Math.max(1, Math.floor(streamRows / 3))
3455
4132
  : 1
3456
4133
  const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
3457
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !verboseOpen && !approvalPending && !questionPending
4134
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
3458
4135
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
3459
- const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || inspectorVisible || approvalPending || questionPending
4136
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
3460
4137
  const closeInspector = useCallback((): void => {
3461
4138
  setVerboseOpen(false)
3462
4139
  }, [])
3463
4140
  const refreshScreen = (): void => {
3464
- if (appStdout !== undefined) appStdout.write('\x1b[2J\x1b[3J\x1b[H')
4141
+ // Same source-backed clear the resize path uses: reset the scroll region
4142
+ // (`\x1b[r`) before wiping screen AND scrollback, then home the cursor.
4143
+ // A bare `\x1b[2J\x1b[3J\x1b[H` leaves a previously set scroll region in
4144
+ // place, so Ink's next repaint positions against stale bounds — the
4145
+ // stale-position flicker where the screen keeps redrawing.
4146
+ if (appStdout !== undefined) {
4147
+ synchronizedReplayPending.current = true
4148
+ appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + RESIZE_REFLOW_CLEAR)
4149
+ }
3465
4150
  setRefreshEpoch(epoch => epoch + 1)
3466
4151
  }
4152
+ useEffect(() => {
4153
+ if (!synchronizedReplayPending.current || appStdout === undefined) return
4154
+ synchronizedReplayPending.current = false
4155
+ appStdout.write(SYNCHRONIZED_UPDATE_END)
4156
+ }, [appStdout, refreshEpoch])
4157
+ // 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.
3467
4159
 
3468
- /** Apply one /model pick: record the selection, close the panel, report via notice. */
3469
- const applyModel = (row: ModelRow, effortId: string | undefined): void => {
3470
- try {
3471
- const label = props.selectModel(row, effortId)
3472
- setModelLabel(label)
3473
- setEffortLabel(effortId)
3474
- notify(`model next step uses ${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`)
4160
+ // Rendered-history cap: when the settled window overflows the trim
4161
+ // hysteresis, one source-backed replay re-windows it (the rebuild branch
4162
+ // drops the oldest entries beyond the cap). Deferred while busy or
4163
+ // streaming so the clear never interrupts a visible stream; the flag
4164
+ // survives until the turn calms.
4165
+ const settledNeedsTrim = settledRowsCache.current?.needsTrim === true
4166
+ useEffect(() => {
4167
+ if (!settledNeedsTrim || busy || streamingActive) return
4168
+ refreshScreen()
4169
+ }, [settledNeedsTrim, busy, streamingActive])
4170
+
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
+ }
3475
4186
  setModelOpen(false)
3476
4187
  setProviderOpen(false)
3477
4188
  setProviderAction(undefined)
@@ -3491,8 +4202,58 @@ export function App(props: AppProps): ReactElement {
3491
4202
  setEffortFor(undefined)
3492
4203
  }
3493
4204
  let modelSurface: ReactElement | undefined
3494
- if (modelOpen && !approvalPending && !questionPending) {
3495
- if (providerAction?.kind === 'credential' && props.saveModelProviderCredential !== 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) {
4243
+ modelSurface = createElement(ProviderConfigurationPanel, {
4244
+ target: providerAction.target,
4245
+ catalog: directory?.rows ?? [],
4246
+ save: props.saveModelProviderConfiguration,
4247
+ done: () => {
4248
+ const target = providerAction.target
4249
+ setProviderAction(undefined)
4250
+ setProviderOpen(true)
4251
+ reloadModelSurfaces()
4252
+ notify(`provider configuration saved: ${target.displayName}`)
4253
+ },
4254
+ back: () => setProviderAction(undefined),
4255
+ })
4256
+ } else if (providerAction?.kind === 'credential' && props.saveModelProviderCredential !== undefined) {
3496
4257
  modelSurface = createElement(ProviderCredentialPanel, {
3497
4258
  target: providerAction.target,
3498
4259
  save: props.saveModelProviderCredential,
@@ -3534,9 +4295,11 @@ export function App(props: AppProps): ReactElement {
3534
4295
  back: () => setProviderAction(undefined),
3535
4296
  })
3536
4297
  } else if (providerOpen) {
3537
- modelSurface = createElement(ProviderPanel, {
3538
- directory: providerDirectory,
3539
- error: providerError,
4298
+ modelSurface = createElement(ProviderPanel, {
4299
+ directory: providerDirectory,
4300
+ error: providerError,
4301
+ authorizations: authorizationDirectory,
4302
+ authorizationError,
3540
4303
  onCredential: (target: ProviderTargetView) => {
3541
4304
  if (props.saveModelProviderCredential === undefined) {
3542
4305
  notify('API key storage is unavailable in this profile', 'warning')
@@ -3544,6 +4307,13 @@ export function App(props: AppProps): ReactElement {
3544
4307
  }
3545
4308
  setProviderAction({ kind: 'credential', target })
3546
4309
  },
4310
+ onConfigure: (target: ProviderTargetView) => {
4311
+ if (props.saveModelProviderConfiguration === undefined) {
4312
+ notify('provider configuration is unavailable in this profile', 'warning')
4313
+ return
4314
+ }
4315
+ setProviderAction({ kind: 'configure', target })
4316
+ },
3547
4317
  onUnset: (target: ProviderTargetView) => {
3548
4318
  if (props.unsetModelProviderCredential === undefined) {
3549
4319
  notify('API key removal is unavailable in this profile', 'warning')
@@ -3551,13 +4321,34 @@ export function App(props: AppProps): ReactElement {
3551
4321
  }
3552
4322
  setProviderAction({ kind: 'unset', target })
3553
4323
  },
3554
- onRemove: (target: ProviderTargetView) => {
4324
+ onRemove: (target: ProviderTargetView) => {
3555
4325
  if (props.removeModelProvider === undefined) {
3556
4326
  notify('provider removal is unavailable in this profile', 'warning')
3557
4327
  return
3558
4328
  }
3559
- setProviderAction({ kind: 'remove', target })
3560
- },
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
+ },
3561
4352
  onRetry: reloadModelSurfaces,
3562
4353
  onBack: () => setProviderOpen(false),
3563
4354
  })
@@ -3606,13 +4397,14 @@ export function App(props: AppProps): ReactElement {
3606
4397
  transcriptVisible
3607
4398
  ? createElement(
3608
4399
  Box,
3609
- // The two-column gutter matches the composer's border + padding, so
3610
- // message text aligns with the input cursor (Codex LIVE_PREFIX).
3611
- { flexDirection: 'column', paddingX: 2 },
4400
+ // No container padding: every live row carries its own two-column
4401
+ // prefix, so streaming text lands exactly where the composer's input
4402
+ // text and the settled reply both render (Codex LIVE_PREFIX).
4403
+ { flexDirection: 'column' },
3612
4404
  visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
3613
4405
  view.streamingReasoning !== '' && reasoningRows > 0
3614
4406
  ? createElement(StreamTail, {
3615
- text: showReasoning ? view.streamingReasoning : 'Thinking…',
4407
+ text: showReasoning ? view.streamingReasoning : 'Thinking… (Ctrl+R to expand)',
3616
4408
  prefix: '✻ ',
3617
4409
  continuationPrefix: ' ',
3618
4410
  dim: true,
@@ -3633,6 +4425,14 @@ export function App(props: AppProps): ReactElement {
3633
4425
  : undefined,
3634
4426
  transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
3635
4427
  transcriptVisible ? createElement(AgentsLine, { rows: agentRows }) : undefined,
4428
+ todosOpen && !approvalPending && !questionPending
4429
+ ? createElement(MemoTodoListPanel, {
4430
+ todos: view.todos,
4431
+ onClose: () => {
4432
+ setTodosOpen(false)
4433
+ },
4434
+ })
4435
+ : undefined,
3636
4436
  createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
3637
4437
  createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending, notify }),
3638
4438
  modelSurface,
@@ -3647,6 +4447,12 @@ export function App(props: AppProps): ReactElement {
3647
4447
  },
3648
4448
  })
3649
4449
  : undefined,
4450
+ diffView !== undefined && !approvalPending && !questionPending
4451
+ ? createElement(DiffPanel, {
4452
+ view: diffView,
4453
+ onClose: () => setDiffView(undefined),
4454
+ })
4455
+ : undefined,
3650
4456
  verboseOpen && !approvalPending && !questionPending
3651
4457
  ? createElement(MemoVerbosePanel, {
3652
4458
  entries: view.entries,
@@ -3698,6 +4504,9 @@ export function App(props: AppProps): ReactElement {
3698
4504
  pluginOpen && !approvalPending && !questionPending
3699
4505
  ? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
3700
4506
  : undefined,
4507
+ jobsOpen && !approvalPending && !questionPending
4508
+ ? createElement(JobsPanel, { load: props.loadJobs, close: () => setJobsOpen(false) })
4509
+ : undefined,
3701
4510
  statuslineOpen && !approvalPending && !questionPending
3702
4511
  ? createElement(StatuslinePanel, {
3703
4512
  enabled: statuslineItems,
@@ -3790,8 +4599,10 @@ export function App(props: AppProps): ReactElement {
3790
4599
  openModel: () => {
3791
4600
  setDirectory(undefined)
3792
4601
  setModelError(undefined)
3793
- setProviderDirectory(undefined)
3794
- setProviderError(undefined)
4602
+ setProviderDirectory(undefined)
4603
+ setProviderError(undefined)
4604
+ setAuthorizationDirectory(undefined)
4605
+ setAuthorizationError(undefined)
3795
4606
  setProviderOpen(false)
3796
4607
  setProviderAction(undefined)
3797
4608
  setEffortFor(undefined)
@@ -3844,21 +4655,30 @@ export function App(props: AppProps): ReactElement {
3844
4655
  openPermission: () => setPermissionOpen(true),
3845
4656
  openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
3846
4657
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
4658
+ openJobs: () => setJobsOpen(true),
3847
4659
  openStatusline: () => setStatuslineOpen(true),
3848
4660
  openTheme: () => setThemeOpen(true),
3849
4661
  openHistory: () => setHistoryOpen(true),
3850
4662
  openAgents: () => setAgentsOpen(true),
3851
4663
  openSubagent: () => setSubagentOpen(true),
4664
+ openTodos: () => setTodosOpen(true),
3852
4665
  openDelete: (id?: string) => {
3853
4666
  const armed = id === undefined || id === '' ? undefined : id
3854
4667
  setResumeDelete({ mode: true, ...armed === undefined ? {} : { id: armed } })
3855
4668
  setDeleteConfirmId(armed)
3856
4669
  setResumeOpen(true)
3857
4670
  },
4671
+ openDiff: (argument: string) => {
4672
+ void props.loadGitDiff(argument).then(setDiffView, (error: unknown) => {
4673
+ notify(`diff failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
4674
+ })
4675
+ },
4676
+ reviewChanges: props.reviewChanges,
3858
4677
  deleteConfirm: deleteConfirmId,
3859
4678
  confirmDelete,
3860
4679
  cancelDelete,
3861
4680
  createSession: props.createSession,
4681
+ forkSession: props.forkSession,
3862
4682
  cancelSessionSwitch: props.cancelSessionSwitch,
3863
4683
  notify,
3864
4684
  hasNotice: notice !== undefined,
@@ -3872,18 +4692,23 @@ export function App(props: AppProps): ReactElement {
3872
4692
  props.store.reset()
3873
4693
  },
3874
4694
  refresh: refreshScreen,
3875
- // Ctrl+R must re-render already-settled history too: settled rows
3876
- // flush through <Static> once, so the toggle rides the same
3877
- // source-backed clear+replay the resize path uses one clear, one
3878
- // authoritative re-flush at the new visibility.
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.
3879
4701
  toggleReasoning: () => {
3880
4702
  setShowReasoning(current => !current)
3881
- refreshScreen()
4703
+ if (!busy && !streamingActive) refreshScreen()
3882
4704
  },
3883
- loadMentions: props.loadMentions,
4705
+ loadMentions: props.loadMentions,
4706
+ inspectImages: props.inspectImages,
4707
+ prepareImages: props.prepareImages,
3884
4708
  cyclePermission: props.cyclePermission,
3885
4709
  exportTranscript: props.exportTranscript,
3886
4710
  renameTitle: props.renameTitle,
4711
+ copyLastResponse: props.copyLastResponse,
3887
4712
  recallSpace,
3888
4713
  recordLocal,
3889
4714
  recordHistory: props.recordHistory,
@@ -3893,6 +4718,8 @@ export function App(props: AppProps): ReactElement {
3893
4718
  historyConsumed,
3894
4719
  waveTier,
3895
4720
  waveStyle,
4721
+ maxRows: composerEditorCap,
4722
+ onEditorRows: handleEditorRows,
3896
4723
  }),
3897
4724
  createElement(StatusLine, {
3898
4725
  facts: {