dsh-code 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.en.md +5 -5
  2. package/README.md +5 -5
  3. package/lib/index.mjs +6679 -5232
  4. package/lib/startup.mjs +1 -1
  5. package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
  6. package/lib/types/app.d.ts +32 -45
  7. package/lib/types/attachments.d.ts +16 -7
  8. package/lib/types/completion.d.ts +29 -0
  9. package/lib/types/composer.d.ts +150 -0
  10. package/lib/types/git-workflow.d.ts +6 -0
  11. package/lib/types/index.d.ts +6 -188
  12. package/lib/types/locales/en.d.ts +65 -5
  13. package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
  14. package/lib/types/panels/completion-panel.d.ts +13 -0
  15. package/lib/types/panels/interaction-bars.d.ts +38 -0
  16. package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
  17. package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
  18. package/lib/types/panels/model-panels.d.ts +86 -0
  19. package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
  20. package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
  21. package/lib/types/provider-settings.d.ts +11 -0
  22. package/lib/types/render/inspector.d.ts +8 -0
  23. package/lib/types/render/status.d.ts +4 -1
  24. package/lib/types/render/text.d.ts +4 -0
  25. package/lib/types/runner/harness-gate.d.ts +83 -0
  26. package/lib/types/runner/input-history.d.ts +31 -0
  27. package/lib/types/runner/mode-cycle.d.ts +44 -0
  28. package/lib/types/runner/preferences.d.ts +40 -0
  29. package/lib/types/runner/quit.d.ts +27 -0
  30. package/lib/types/runner/search-rows.d.ts +38 -0
  31. package/lib/types/runner/session-io.d.ts +46 -0
  32. package/lib/types/runner/session-target.d.ts +42 -0
  33. package/lib/types/runner/startup-config.d.ts +33 -0
  34. package/lib/types/runner/submissions.d.ts +87 -0
  35. package/lib/types/session/attach.d.ts +39 -0
  36. package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
  37. package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
  38. package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
  39. package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
  40. package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
  41. package/lib/types/settings-file.d.ts +10 -0
  42. package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
  43. package/lib/types/ui/panel-gap.d.ts +6 -0
  44. package/lib/types/ui/query-editor.d.ts +10 -0
  45. package/lib/types/ui/styled-rows.d.ts +8 -0
  46. package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
  47. package/lib/types/ui/ui-contract.d.ts +12 -0
  48. package/lib/types/ui/use-frames.d.ts +6 -0
  49. package/lib/types/ui/use-stable-input.d.ts +7 -0
  50. package/lib/types/version.d.ts +2 -0
  51. package/package.json +7 -5
  52. package/src/app.ts +707 -4108
  53. package/src/attachments.ts +65 -19
  54. package/src/completion.ts +117 -0
  55. package/src/composer.ts +1956 -0
  56. package/src/git-workflow.ts +18 -0
  57. package/src/index.ts +164 -645
  58. package/src/input-split.ts +24 -4
  59. package/src/internals.ts +1 -1
  60. package/src/locales/en.ts +66 -5
  61. package/src/locales/zh.ts +66 -5
  62. package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
  63. package/src/panels/completion-panel.ts +79 -0
  64. package/src/panels/interaction-bars.ts +567 -0
  65. package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
  66. package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
  67. package/src/panels/model-panels.ts +1021 -0
  68. package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
  69. package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
  70. package/src/provider-settings.ts +38 -0
  71. package/src/rainbow.ts +13 -3
  72. package/src/render/inspector.ts +23 -0
  73. package/src/render/status.ts +80 -29
  74. package/src/render/text.ts +10 -1
  75. package/src/runner/harness-gate.ts +168 -0
  76. package/src/runner/input-history.ts +77 -0
  77. package/src/runner/mode-cycle.ts +49 -0
  78. package/src/runner/preferences.ts +67 -0
  79. package/src/runner/quit.ts +53 -0
  80. package/src/runner/search-rows.ts +55 -0
  81. package/src/runner/session-io.ts +206 -0
  82. package/src/runner/session-target.ts +81 -0
  83. package/src/runner/startup-config.ts +54 -0
  84. package/src/runner/submissions.ts +157 -0
  85. package/src/session/attach.ts +87 -0
  86. package/src/{fork.ts → session/fork.ts} +11 -7
  87. package/src/{history.ts → session/history.ts} +14 -0
  88. package/src/{session-directory.ts → session/session-directory.ts} +83 -4
  89. package/src/{session-switch.ts → session/session-switch.ts} +14 -0
  90. package/src/{store.ts → session/store.ts} +20 -2
  91. package/src/{subagents.ts → session/subagents.ts} +12 -1
  92. package/src/settings-file.ts +19 -1
  93. package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
  94. package/src/ui/panel-gap.ts +9 -0
  95. package/src/ui/query-editor.ts +16 -0
  96. package/src/ui/styled-rows.ts +124 -0
  97. package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
  98. package/src/ui/ui-contract.ts +10 -0
  99. package/src/ui/use-frames.ts +23 -0
  100. package/src/ui/use-stable-input.ts +17 -0
  101. package/src/version.ts +5 -0
  102. /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
@@ -0,0 +1,567 @@
1
+ /** Approval and structured-question keyboard-owned terminal surfaces. */
2
+
3
+ import { createElement, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
4
+ import { Box, Text, useInput, useStdout } from 'ink'
5
+ import type { AskUserQuestionAnswerItem, AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions'
6
+ import type { ApprovalSnapshot } from '../approval.ts'
7
+ import type { QuestionSnapshot, QuestionStore } from '../questions.ts'
8
+ import { PanelGap } from '../ui/panel-gap.ts'
9
+ import { deleteLastGrapheme } from '../render/editor.ts'
10
+ import { clampScroll, moveScroll, panelViewport, revealRow } from '../render/inspector.ts'
11
+ import { lineSegment, markdownLines, styledLines, textLines, type LineStyle, type StyledLine } from '../render/lines.ts'
12
+ import { truncateColumns } from '../render/text.ts'
13
+ import { StyledRows } from '../ui/styled-rows.ts'
14
+ import { dim, getPalette, inkColor } from '../theme.ts'
15
+ import { t } from '../i18n.ts'
16
+ import type { NoticeTone } from '../ui/ui-contract.ts'
17
+ import { stripPasteMarkers } from '../keyboard.ts'
18
+ import { useStableInput } from '../ui/use-stable-input.ts'
19
+
20
+ interface ApprovalOption {
21
+ readonly key: 'allow' | 'reject-note' | 'reject'
22
+ readonly label: string
23
+ readonly hotkey: string
24
+ }
25
+
26
+ /** The fixed decision list; answers stay in the binary answerer vocabulary. */
27
+ const APPROVAL_OPTIONS: readonly ApprovalOption[] = [
28
+ { key: 'allow', label: 'Yes, proceed', hotkey: 'y' },
29
+ { key: 'reject-note', label: 'No, and tell it what to do differently', hotkey: 'n' },
30
+ { key: 'reject', label: 'No, continue without running it', hotkey: 'd' },
31
+ ]
32
+
33
+ /**
34
+ * The approval dialog (Codex ApprovalOverlay contract): a bold question
35
+ * header, the bounded command body with an explicit overflow marker, a
36
+ * numbered option list with a `›` cursor, single-key shortcuts, and digits
37
+ * for direct selection. Askers queue FIFO — the count rides the header.
38
+ * The upstream answerer vocabulary stays binary (`allowed-once` /
39
+ * `rejected`): "tell it what to do differently" rejects and hands the
40
+ * composer back with a hint notice, exactly Codex's decline-then-type flow.
41
+ */
42
+ export function ApprovalBar({ snapshot, locked, notify, interrupt, summarize }: {
43
+ snapshot: ApprovalSnapshot
44
+ locked: boolean
45
+ notify: (text: string, tone?: NoticeTone) => void
46
+ /** Cancel the running turn (Ctrl+C), matching the composer's busy branch. */
47
+ interrupt: () => boolean
48
+ /** Render as the bounded one-line form even on tall terminals (another
49
+ * human-asked surface already owns the full panel budget). */
50
+ summarize?: boolean
51
+ }): ReactElement | undefined {
52
+ const stdout = useStdout().stdout
53
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
54
+ const [cursor, setCursor] = useState(0)
55
+ const pending = snapshot.pending
56
+ const active = !locked && pending !== undefined && !snapshot.answered
57
+ const body = useMemo<readonly StyledLine[]>(() => pending === undefined || pending.command === ''
58
+ ? []
59
+ : textLines(pending.command, viewport.contentColumns, 'dim'), [pending, viewport.contentColumns])
60
+
61
+ useEffect(() => {
62
+ setCursor(0)
63
+ }, [pending])
64
+
65
+ const decide = (option: ApprovalOption): void => {
66
+ const ask = snapshot.pending
67
+ if (ask === undefined || snapshot.answered) return
68
+ if (option.key === 'allow') {
69
+ ask.answer('allowed-once')
70
+ return
71
+ }
72
+ ask.answer('rejected')
73
+ if (option.key === 'reject-note') {
74
+ notify(t('notice.rejected'), 'warning')
75
+ }
76
+ }
77
+
78
+ useInput((input, key) => {
79
+ const ask = snapshot.pending
80
+ if (ask === undefined || snapshot.answered) return
81
+ // Ctrl+C keeps its app-wide meaning while the ask owns the keys: cancel
82
+ // the running turn (the ask's abort signal withdraws the question).
83
+ // Without this branch the keystroke died here silently — the ask was the
84
+ // only reachable surface and offered no way out.
85
+ if (key.ctrl && input === 'c') {
86
+ interrupt()
87
+ return
88
+ }
89
+ if (key.upArrow) {
90
+ setCursor(current => (current + APPROVAL_OPTIONS.length - 1) % APPROVAL_OPTIONS.length)
91
+ return
92
+ }
93
+ if (key.downArrow) {
94
+ setCursor(current => (current + 1) % APPROVAL_OPTIONS.length)
95
+ return
96
+ }
97
+ if (key.return) {
98
+ decide(APPROVAL_OPTIONS[cursor])
99
+ return
100
+ }
101
+ if (key.escape) {
102
+ decide(APPROVAL_OPTIONS[2])
103
+ return
104
+ }
105
+ if (input === 'y' || input === 'Y') {
106
+ decide(APPROVAL_OPTIONS[0])
107
+ return
108
+ }
109
+ if (input === 'n' || input === 'N') {
110
+ decide(APPROVAL_OPTIONS[1])
111
+ return
112
+ }
113
+ if (input === 'd' || input === 'D') {
114
+ decide(APPROVAL_OPTIONS[2])
115
+ return
116
+ }
117
+ if (/^[1-9]$/u.test(input)) {
118
+ const index = Number(input) - 1
119
+ if (index < APPROVAL_OPTIONS.length) decide(APPROVAL_OPTIONS[index])
120
+ }
121
+ }, { isActive: active })
122
+
123
+ if (pending === undefined) return undefined
124
+ const queuedSuffix = snapshot.queued > 0 ? ` · +${snapshot.queued} queued` : ''
125
+ if (viewport.maxHeight === 0 || viewport.compact || summarize === true) {
126
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(t('approval.compact', { queued: queuedSuffix }), viewport.contentColumns))
127
+ }
128
+ // Body budget: title + options + footer consume fixed rows; the command
129
+ // preview shrinks with an explicit overflow marker (Codex's "[… N lines]").
130
+ const reservedRows = 3 + APPROVAL_OPTIONS.length
131
+ const bodyBudget = Math.max(1, viewport.bodyRows - reservedRows)
132
+ const visibleBody = body.slice(0, bodyBudget)
133
+ const overflow = body.length - visibleBody.length
134
+ return createElement(
135
+ Box,
136
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
137
+ createElement(
138
+ Text,
139
+ { color: inkColor(getPalette().warn), bold: true, wrap: 'truncate-end' },
140
+ truncateColumns(`${pending.headline}${queuedSuffix}`, viewport.contentColumns),
141
+ ),
142
+ createElement(PanelGap, { visible: viewport.gapRows > 0 && body.length > 0 }),
143
+ ...visibleBody.map((line, index) => createElement(StyledRows, { key: `body-${index}`, lines: [line] })),
144
+ ...(overflow > 0
145
+ ? [createElement(Text, { key: 'overflow', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(t('approval.overflow', { count: overflow }), viewport.contentColumns))]
146
+ : []),
147
+ ...(body.length > 0 ? [createElement(PanelGap, { visible: viewport.gapRows > 0 })] : []),
148
+ ...APPROVAL_OPTIONS.map((option, index) => {
149
+ const selected = !snapshot.answered && index === cursor
150
+ return createElement(
151
+ Text,
152
+ {
153
+ key: option.key,
154
+ color: selected ? inkColor(getPalette().brandBright) : inkColor(getPalette().text),
155
+ bold: selected || undefined,
156
+ wrap: 'truncate-end',
157
+ },
158
+ truncateColumns(`${selected ? '›' : ' '} ${index + 1}. ${option.label} (${option.hotkey})`, viewport.contentColumns),
159
+ )
160
+ }),
161
+ createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(snapshot.answered
162
+ ? t('approval.submitted')
163
+ : t('approval.footer'), viewport.contentColumns)),
164
+ )
165
+ }
166
+
167
+ interface QuestionDraftState {
168
+ readonly selected: readonly number[]
169
+ readonly custom: string
170
+ readonly cursor: number
171
+ readonly mode: 'options' | 'custom'
172
+ readonly scroll: number
173
+ readonly manualScroll: boolean
174
+ readonly followCustomTail: boolean
175
+ readonly committed: boolean
176
+ }
177
+
178
+ function initialQuestionDraft(question: AskUserQuestionItem | undefined): QuestionDraftState {
179
+ const hasOptions = (question?.options?.length ?? 0) > 0
180
+ return {
181
+ selected: [],
182
+ custom: '',
183
+ cursor: 0,
184
+ mode: hasOptions ? 'options' : 'custom',
185
+ scroll: 0,
186
+ manualScroll: false,
187
+ followCustomTail: !hasOptions,
188
+ committed: false,
189
+ }
190
+ }
191
+
192
+ function answerFromQuestionDraft(question: AskUserQuestionItem, draft: QuestionDraftState): AskUserQuestionAnswerItem {
193
+ // Multi-select changes are answers as soon as a value is toggled, matching
194
+ // Claude-Code's draft store. `committed` still records an explicit Enter so
195
+ // an intentionally empty answer can be submitted, while navigation away
196
+ // from a non-empty draft never discards the user's selection.
197
+ const hasAnswer = question.multiSelect === true
198
+ ? draft.committed || draft.selected.length > 0 || draft.custom.trim() !== ''
199
+ : draft.committed
200
+ if (!hasAnswer) {
201
+ return { id: question.id, selected: [] }
202
+ }
203
+ const options = question.options ?? []
204
+ const selected = draft.selected
205
+ .map(at => options[at]?.label)
206
+ .filter((label): label is string => label !== undefined)
207
+ const custom = draft.custom.trim()
208
+ return { id: question.id, selected, ...(custom === '' ? {} : { custom }) }
209
+ }
210
+
211
+ /**
212
+ * The ask_user_question bar: walks one request question by question,
213
+ * retaining an independent draft for every question. Options use Space/1-9
214
+ * to toggle a multi-select, Enter to confirm, and arrows/Ctrl+P/N to move
215
+ * between questions. Plan reviews arrive through the same service with a
216
+ * `plan-review` intent — the approve option gets a ✓ mark, the answer
217
+ * encoding stays identical.
218
+ */
219
+ export function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapshot: QuestionSnapshot; locked: boolean }): ReactElement | undefined {
220
+ const stdout = useStdout().stdout
221
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
222
+ const pending = snapshot.pending
223
+ const request = pending?.request
224
+ const [index, setIndex] = useState(0)
225
+ const [drafts, setDrafts] = useState<readonly QuestionDraftState[]>(() => request?.questions.map(question => initialQuestionDraft(question)) ?? [])
226
+ const [submitted, setSubmitted] = useState(false)
227
+ const draftsRef = useRef<readonly QuestionDraftState[]>([])
228
+ const indexRef = useRef(0)
229
+ draftsRef.current = drafts
230
+ indexRef.current = index
231
+
232
+ // A new request resets the walk; questions without options start in the
233
+ // custom-answer box (a free-form question). Depend on the request rather
234
+ // than its wrapper snapshot: external stores may refresh that wrapper while
235
+ // a question is still active, and a reset must never become a render loop.
236
+ useEffect(() => {
237
+ const next = request?.questions.map(question => initialQuestionDraft(question)) ?? []
238
+ draftsRef.current = next
239
+ indexRef.current = 0
240
+ setDrafts(next)
241
+ setIndex(0)
242
+ setSubmitted(false)
243
+ }, [request])
244
+
245
+ const question = pending?.request.questions[index]
246
+ const options = useMemo(() => question?.options ?? [], [question])
247
+ const isPlan = question?.intent?.kind === 'plan-review'
248
+ const isMulti = question?.multiSelect === true
249
+ const currentDraft = drafts[index] ?? initialQuestionDraft(question)
250
+ const { cursor, selected, mode, custom, scroll, manualScroll, followCustomTail } = currentDraft
251
+ const active = !locked && pending !== undefined && question !== undefined && !submitted
252
+ const rendered = useMemo(() => {
253
+ if (question === undefined) return { lines: [] as readonly StyledLine[], optionRows: [] as readonly number[] }
254
+ const lines: StyledLine[] = []
255
+ const optionRows: number[] = []
256
+ if (question.header !== undefined) {
257
+ lines.push(...styledLines([lineSegment(question.header, 'bold')], viewport.contentColumns))
258
+ }
259
+ lines.push(...textLines(question.question, viewport.contentColumns))
260
+ if (question.detail !== undefined) {
261
+ lines.push(...(isPlan
262
+ ? markdownLines(question.detail, viewport.contentColumns)
263
+ : textLines(question.detail, viewport.contentColumns, 'dim')))
264
+ }
265
+ if (submitted) {
266
+ lines.push(...textLines(' submitted…', viewport.contentColumns, 'dim'))
267
+ } else if (mode === 'custom' || options.length === 0) {
268
+ lines.push(...styledLines([
269
+ lineSegment(' custom: ', 'brand'),
270
+ lineSegment(custom, 'plain'),
271
+ lineSegment('▌', 'brand'),
272
+ ], viewport.contentColumns))
273
+ } else {
274
+ options.forEach((option, at) => {
275
+ optionRows.push(lines.length)
276
+ const chosen = isMulti && selected.includes(at)
277
+ const approve = isPlan && question.intent?.approve === option.label
278
+ const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
279
+ const style: LineStyle = at === cursor ? 'brand' : chosen || approve ? 'success' : 'plain'
280
+ lines.push(...styledLines([
281
+ lineSegment(mark, style),
282
+ // Claude-Code numbering: the digit addresses the row from the
283
+ // keyboard, so the prefix advertises the binding it enables.
284
+ lineSegment(at < 9 ? `${at + 1}. ` : '', 'dim'),
285
+ lineSegment(option.label, style),
286
+ lineSegment(option.description === undefined ? '' : ` — ${option.description}`, 'dim'),
287
+ ], viewport.contentColumns))
288
+ })
289
+ }
290
+ return { lines, optionRows }
291
+ }, [question, isPlan, submitted, mode, options, custom, isMulti, selected, cursor, viewport.contentColumns])
292
+ // Keeping a focused option visible is derived from the current render. It
293
+ // deliberately does not write state from an effect: keyboard selection
294
+ // then has one update path, rather than a cursor update repeatedly causing
295
+ // a post-render scroll update (and, under rapid input, an update-depth
296
+ // loop). Page scrolling explicitly takes ownership until focus moves again.
297
+ const focusedRow = rendered.optionRows[cursor] ?? 0
298
+ const automaticScroll = mode === 'options' && options.length > 0 && !manualScroll
299
+ ? revealRow(scroll, focusedRow, rendered.lines.length, viewport.bodyRows)
300
+ : (mode === 'custom' || options.length === 0) && followCustomTail
301
+ ? Math.max(0, rendered.lines.length - viewport.bodyRows)
302
+ : scroll
303
+ const visibleScroll = clampScroll(automaticScroll, rendered.lines.length, viewport.bodyRows)
304
+
305
+ const updateDrafts = (update: (current: readonly QuestionDraftState[]) => readonly QuestionDraftState[]): void => {
306
+ const next = update(draftsRef.current)
307
+ draftsRef.current = next
308
+ setDrafts(next)
309
+ }
310
+
311
+ const updateCurrentDraft = (update: (current: QuestionDraftState) => QuestionDraftState): void => {
312
+ const currentIndex = indexRef.current
313
+ updateDrafts(current => current.map((draft, at) => at === currentIndex ? update(draft) : draft))
314
+ }
315
+
316
+ const moveQuestion = (direction: -1 | 1): void => {
317
+ const total = request?.questions.length ?? 0
318
+ if (total <= 1) return
319
+ const currentIndex = indexRef.current
320
+ const nextIndex = Math.max(0, Math.min(total - 1, currentIndex + direction))
321
+ if (nextIndex === currentIndex) return
322
+ indexRef.current = nextIndex
323
+ setIndex(nextIndex)
324
+ }
325
+
326
+ const commit = (answer: AskUserQuestionAnswerItem): void => {
327
+ if (pending === undefined || question === undefined) return
328
+ const currentIndex = indexRef.current
329
+ const optionLabels = new Set(answer.selected)
330
+ const selectedIndices = (question.options ?? [])
331
+ .map((option, at) => optionLabels.has(option.label) ? at : -1)
332
+ .filter((at): at is number => at >= 0)
333
+ const nextDrafts = draftsRef.current.map((draft, at) => at === currentIndex
334
+ ? { ...draft, selected: selectedIndices, custom: answer.custom ?? '', committed: true }
335
+ : draft)
336
+ draftsRef.current = nextDrafts
337
+ setDrafts(nextDrafts)
338
+ const total = pending.request.questions.length
339
+ if (currentIndex + 1 >= total) {
340
+ setSubmitted(true)
341
+ store.submit(pending, {
342
+ answers: pending.request.questions.map((item, at) => answerFromQuestionDraft(item, nextDrafts[at] ?? initialQuestionDraft(item))),
343
+ })
344
+ return
345
+ }
346
+ const nextIndex = currentIndex + 1
347
+ indexRef.current = nextIndex
348
+ setIndex(nextIndex)
349
+ }
350
+
351
+ const commitOption = (): void => {
352
+ if (pending === undefined || question === undefined) return
353
+ const currentIndex = indexRef.current
354
+ const current = draftsRef.current[currentIndex] ?? initialQuestionDraft(question)
355
+ if (isMulti) {
356
+ const labels = current.selected
357
+ .map(at => options[at]?.label)
358
+ .filter((label): label is string => label !== undefined)
359
+ const customText = current.custom.trim()
360
+ commit({ id: question.id, selected: labels, ...(customText === '' ? {} : { custom: customText }) })
361
+ return
362
+ }
363
+ const option = options[current.cursor]
364
+ if (option === undefined) return
365
+ commit({ id: question.id, selected: [option.label] })
366
+ }
367
+
368
+ /**
369
+ * A question with choices has two local focus surfaces, just like Codex:
370
+ * the choice list and the optional custom-answer editor. Returning to the
371
+ * list keeps the user's current choice and multi-select state, but drops the
372
+ * transient custom draft so a second Escape can cancel the question.
373
+ */
374
+ const returnToOptions = (): void => {
375
+ if (options.length === 0) return
376
+ updateCurrentDraft(current => ({ ...current, mode: 'options', custom: '', scroll: 0, manualScroll: false, followCustomTail: false }))
377
+ }
378
+
379
+ useStableInput((input, key) => {
380
+ if (pending === undefined || question === undefined || submitted) return
381
+ if (viewport.maxHeight === 0) {
382
+ // Options are not rendered at this height, so blind picks stay
383
+ // disabled; only the explicit cancel remains available.
384
+ if (key.escape || (key.ctrl && input === 'c')) store.cancel(pending)
385
+ return
386
+ }
387
+ if (key.ctrl && input === 'c') {
388
+ store.cancel(pending)
389
+ return
390
+ }
391
+ if (key.escape) {
392
+ if (mode === 'custom' && options.length > 0) {
393
+ returnToOptions()
394
+ return
395
+ }
396
+ store.cancel(pending)
397
+ return
398
+ }
399
+ if (key.tab && key.shift) {
400
+ moveQuestion(-1)
401
+ return
402
+ }
403
+ if (key.leftArrow || (key.ctrl && input === 'p')) {
404
+ moveQuestion(-1)
405
+ return
406
+ }
407
+ if (key.rightArrow || (key.ctrl && input === 'n')) {
408
+ moveQuestion(1)
409
+ return
410
+ }
411
+ if (key.pageUp) {
412
+ updateCurrentDraft(current => ({
413
+ ...current,
414
+ manualScroll: true,
415
+ followCustomTail: false,
416
+ scroll: moveScroll(visibleScroll, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows),
417
+ }))
418
+ return
419
+ }
420
+ if (key.pageDown) {
421
+ updateCurrentDraft(current => ({
422
+ ...current,
423
+ manualScroll: true,
424
+ followCustomTail: false,
425
+ scroll: moveScroll(visibleScroll, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows),
426
+ }))
427
+ return
428
+ }
429
+ if (mode === 'custom' || options.length === 0) {
430
+ if (key.tab && options.length > 0) {
431
+ returnToOptions()
432
+ return
433
+ }
434
+ if (key.upArrow) {
435
+ updateCurrentDraft(current => ({
436
+ ...current,
437
+ followCustomTail: false,
438
+ scroll: moveScroll(visibleScroll, -1, rendered.lines.length, viewport.bodyRows),
439
+ }))
440
+ return
441
+ }
442
+ if (key.downArrow) {
443
+ updateCurrentDraft(current => ({
444
+ ...current,
445
+ followCustomTail: false,
446
+ scroll: moveScroll(visibleScroll, 1, rendered.lines.length, viewport.bodyRows),
447
+ }))
448
+ return
449
+ }
450
+ if (key.return) {
451
+ if (custom.trim() === '' && options.length > 0) {
452
+ commitOption()
453
+ return
454
+ }
455
+ commit({
456
+ id: question.id,
457
+ selected: isMulti
458
+ ? selected.map(at => options[at]?.label).filter((label): label is string => label !== undefined)
459
+ : [],
460
+ ...(custom.trim() === '' ? {} : { custom: custom.trim() }),
461
+ })
462
+ return
463
+ }
464
+ if (key.backspace || key.delete) {
465
+ if (custom === '' && options.length > 0) {
466
+ returnToOptions()
467
+ return
468
+ }
469
+ updateCurrentDraft(current => ({ ...current, custom: deleteLastGrapheme(current.custom), committed: false }))
470
+ return
471
+ }
472
+ if (input !== '' && !key.ctrl && !key.meta) {
473
+ // Panel drafts see paste markers as literal text (Ink strips only the
474
+ // leading ESC); strip them so a pasted answer never persists "[200~".
475
+ const text = stripPasteMarkers(input)
476
+ if (text !== '') updateCurrentDraft(current => ({ ...current, custom: current.custom + text, committed: false }))
477
+ }
478
+ return
479
+ }
480
+ if (key.upArrow) {
481
+ updateCurrentDraft(current => ({
482
+ ...current,
483
+ cursor: (current.cursor + options.length - 1) % options.length,
484
+ manualScroll: false,
485
+ }))
486
+ return
487
+ }
488
+ if (key.downArrow) {
489
+ updateCurrentDraft(current => ({
490
+ ...current,
491
+ cursor: (current.cursor + 1) % options.length,
492
+ manualScroll: false,
493
+ }))
494
+ return
495
+ }
496
+ if (key.return) {
497
+ commitOption()
498
+ return
499
+ }
500
+ if (key.tab || input === 'c' || input === 'C') {
501
+ updateCurrentDraft(current => ({ ...current, mode: 'custom', manualScroll: false, followCustomTail: true }))
502
+ return
503
+ }
504
+ if (input === ' ' && isMulti) {
505
+ updateCurrentDraft(current => ({
506
+ ...current,
507
+ selected: current.selected.includes(current.cursor)
508
+ ? current.selected.filter(at => at !== current.cursor)
509
+ : [...current.selected, current.cursor],
510
+ committed: false,
511
+ }))
512
+ return
513
+ }
514
+ // Claude-Code option numbers: the digit addresses a row directly — a
515
+ // toggle in multi-select, an immediate pick in single-select.
516
+ if (/^[1-9]$/.test(input)) {
517
+ const at = Number(input) - 1
518
+ if (at >= options.length) return
519
+ if (isMulti) {
520
+ updateCurrentDraft(current => ({
521
+ ...current,
522
+ selected: current.selected.includes(at)
523
+ ? current.selected.filter(row => row !== at)
524
+ : [...current.selected, at],
525
+ committed: false,
526
+ }))
527
+ } else {
528
+ const option = options[at]
529
+ if (option !== undefined) commit({ id: question.id, selected: [option.label] })
530
+ }
531
+ }
532
+ }, active)
533
+
534
+ if (pending === undefined || question === undefined) return undefined
535
+ if (viewport.maxHeight === 0 || viewport.compact) {
536
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(isPlan ? t('question.compact.plan') : t('question.compact.normal'), viewport.contentColumns))
537
+ }
538
+ const footerBase = submitted
539
+ ? t('question.submitted')
540
+ : mode === 'custom'
541
+ ? options.length === 0
542
+ ? t('question.customNoOptions')
543
+ : t('question.customOptions')
544
+ : options.length === 0
545
+ ? t('question.customNoOptions')
546
+ : isMulti
547
+ ? t('question.multiOptions')
548
+ : t('question.singleOptions')
549
+ const footer = pending.request.questions.length > 1 && !submitted
550
+ ? `${footerBase}${t('question.switch')}`
551
+ : footerBase
552
+ return createElement(
553
+ Box,
554
+ { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep) },
555
+ createElement(
556
+ Text,
557
+ { color: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep), bold: true, wrap: 'truncate-end' },
558
+ truncateColumns(`${isPlan ? t('question.title.plan') : t('question.title.normal')} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns),
559
+ ),
560
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
561
+ createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
562
+ createElement(PanelGap, { visible: viewport.gapRows > 0 }),
563
+ createElement(Text, { wrap: 'truncate-end' }, dim(truncateColumns(footer, viewport.contentColumns))),
564
+ )
565
+ }
566
+
567
+ /** The /model panel: a scrolling list over the advisory model directory. */