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.
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/lib/index.mjs +6679 -5232
- package/lib/startup.mjs +1 -1
- package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
- package/lib/types/app.d.ts +32 -45
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/completion.d.ts +29 -0
- package/lib/types/composer.d.ts +150 -0
- package/lib/types/git-workflow.d.ts +6 -0
- package/lib/types/index.d.ts +6 -188
- package/lib/types/locales/en.d.ts +65 -5
- package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
- package/lib/types/panels/completion-panel.d.ts +13 -0
- package/lib/types/panels/interaction-bars.d.ts +38 -0
- package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +19 -38
- package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
- package/lib/types/panels/model-panels.d.ts +86 -0
- package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
- package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +22 -1
- package/lib/types/provider-settings.d.ts +11 -0
- package/lib/types/render/inspector.d.ts +8 -0
- package/lib/types/render/status.d.ts +4 -1
- package/lib/types/render/text.d.ts +4 -0
- package/lib/types/runner/harness-gate.d.ts +83 -0
- package/lib/types/runner/input-history.d.ts +31 -0
- package/lib/types/runner/mode-cycle.d.ts +44 -0
- package/lib/types/runner/preferences.d.ts +40 -0
- package/lib/types/runner/quit.d.ts +27 -0
- package/lib/types/runner/search-rows.d.ts +38 -0
- package/lib/types/runner/session-io.d.ts +46 -0
- package/lib/types/runner/session-target.d.ts +42 -0
- package/lib/types/runner/startup-config.d.ts +33 -0
- package/lib/types/runner/submissions.d.ts +87 -0
- package/lib/types/session/attach.d.ts +39 -0
- package/lib/types/{history.d.ts → session/history.d.ts} +10 -0
- package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +25 -1
- package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +8 -0
- package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
- package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
- package/lib/types/settings-file.d.ts +10 -0
- package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
- package/lib/types/ui/panel-gap.d.ts +6 -0
- package/lib/types/ui/query-editor.d.ts +10 -0
- package/lib/types/ui/styled-rows.d.ts +8 -0
- package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
- package/lib/types/ui/ui-contract.d.ts +12 -0
- package/lib/types/ui/use-frames.d.ts +6 -0
- package/lib/types/ui/use-stable-input.d.ts +7 -0
- package/lib/types/version.d.ts +2 -0
- package/package.json +7 -5
- package/src/app.ts +707 -4108
- package/src/attachments.ts +65 -19
- package/src/completion.ts +117 -0
- package/src/composer.ts +1956 -0
- package/src/git-workflow.ts +18 -0
- package/src/index.ts +164 -645
- package/src/input-split.ts +24 -4
- package/src/internals.ts +1 -1
- package/src/locales/en.ts +66 -5
- package/src/locales/zh.ts +66 -5
- package/src/{authorization-panel.ts → panels/authorization-panel.ts} +30 -8
- package/src/panels/completion-panel.ts +79 -0
- package/src/panels/interaction-bars.ts +567 -0
- package/src/{kernel-panels.ts → panels/kernel-panels.ts} +142 -90
- package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
- package/src/panels/model-panels.ts +1021 -0
- package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
- package/src/{update-panel.ts → panels/update-panel.ts} +117 -10
- package/src/provider-settings.ts +38 -0
- package/src/rainbow.ts +13 -3
- package/src/render/inspector.ts +23 -0
- package/src/render/status.ts +80 -29
- package/src/render/text.ts +10 -1
- package/src/runner/harness-gate.ts +168 -0
- package/src/runner/input-history.ts +77 -0
- package/src/runner/mode-cycle.ts +49 -0
- package/src/runner/preferences.ts +67 -0
- package/src/runner/quit.ts +53 -0
- package/src/runner/search-rows.ts +55 -0
- package/src/runner/session-io.ts +206 -0
- package/src/runner/session-target.ts +81 -0
- package/src/runner/startup-config.ts +54 -0
- package/src/runner/submissions.ts +157 -0
- package/src/session/attach.ts +87 -0
- package/src/{fork.ts → session/fork.ts} +11 -7
- package/src/{history.ts → session/history.ts} +14 -0
- package/src/{session-directory.ts → session/session-directory.ts} +83 -4
- package/src/{session-switch.ts → session/session-switch.ts} +14 -0
- package/src/{store.ts → session/store.ts} +20 -2
- package/src/{subagents.ts → session/subagents.ts} +12 -1
- package/src/settings-file.ts +19 -1
- package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
- package/src/ui/panel-gap.ts +9 -0
- package/src/ui/query-editor.ts +16 -0
- package/src/ui/styled-rows.ts +124 -0
- package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
- package/src/ui/ui-contract.ts +10 -0
- package/src/ui/use-frames.ts +23 -0
- package/src/ui/use-stable-input.ts +17 -0
- package/src/version.ts +5 -0
- /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. */
|