dsh-code 0.1.0 → 0.3.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 (43) hide show
  1. package/README.md +17 -4
  2. package/README.zh.md +17 -4
  3. package/cordis.patch.yml +22 -5
  4. package/lib/index.mjs +1888 -100
  5. package/lib/invariant.mjs +1 -1
  6. package/lib/startup.mjs +70 -0
  7. package/lib/types/app.d.ts +64 -9
  8. package/lib/types/approval.d.ts +57 -0
  9. package/lib/types/commands.d.ts +37 -0
  10. package/lib/types/index.d.ts +19 -7
  11. package/lib/types/invariant.d.ts +2 -2
  12. package/lib/types/mentions.d.ts +70 -0
  13. package/lib/types/models.d.ts +37 -0
  14. package/lib/types/questions.d.ts +48 -0
  15. package/lib/types/render/animations.d.ts +15 -0
  16. package/lib/types/render/markdown.d.ts +27 -0
  17. package/lib/types/render/projection.d.ts +37 -3
  18. package/lib/types/render/status.d.ts +4 -0
  19. package/lib/types/render/text.d.ts +18 -0
  20. package/lib/types/render/tool-preview.d.ts +15 -0
  21. package/lib/types/skills.d.ts +45 -0
  22. package/lib/types/startup.d.ts +44 -0
  23. package/lib/types/store.d.ts +8 -2
  24. package/lib/types/theme.d.ts +4 -0
  25. package/package.json +36 -3
  26. package/src/app.ts +971 -57
  27. package/src/approval.ts +126 -0
  28. package/src/commands.ts +71 -0
  29. package/src/index.ts +353 -40
  30. package/src/invariant.ts +3 -3
  31. package/src/mentions.ts +193 -0
  32. package/src/models.ts +66 -0
  33. package/src/questions.ts +143 -0
  34. package/src/render/animations.ts +22 -0
  35. package/src/render/markdown.ts +235 -0
  36. package/src/render/projection.ts +117 -10
  37. package/src/render/status.ts +14 -2
  38. package/src/render/text.ts +24 -0
  39. package/src/render/tool-preview.ts +34 -0
  40. package/src/skills.ts +104 -0
  41. package/src/startup.ts +91 -0
  42. package/src/store.ts +10 -4
  43. package/src/theme.ts +4 -0
package/src/app.ts CHANGED
@@ -1,31 +1,55 @@
1
1
  /**
2
2
  * The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
3
- * transcript, the streaming line, local notices, and the input box. All state
4
- * arrives through the transcript store (derived from the durable session log)
5
- * plus local input state; the app owns no session mutation of its own.
3
+ * transcript, the todo panel, the streaming line, the approval bar, the model
4
+ * panel, local notices, and the input box with history and slash-command
5
+ * completion. All state arrives through the transcript store (derived from
6
+ * the durable session log) plus local input state; the app owns no session
7
+ * mutation of its own.
6
8
  *
7
9
  * Element construction uses `createElement` (not JSX): the `dsh` source launch
8
10
  * compiles this file through tsx's ESM-only hook, which does not adopt this
9
11
  * package's `jsx: react-jsx` compiler option, and the classic JSX runtime
10
12
  * would demand a React global.
11
13
  *
12
- * @module @deepseek-ai/dsh-tui/app
14
+ * @module @deepseek-ai/dsh-code/app
13
15
  */
14
16
 
15
- import { createElement, useState, useSyncExternalStore, type ReactElement } from 'react'
16
- import { Box, Text, useInput } from 'ink'
17
+ import {
18
+ createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
19
+ } from 'react'
20
+ import { Box, Text, useInput, useStdout } from 'ink'
17
21
  import { assertNever } from '@deepseek-ai/dsh-llm'
18
- import { TUI_RGB, brand, dim, error as paintError } from './theme.ts'
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'
25
+ import { TUI_RGB, brand, dim, error as paintError, warn } from './theme.ts'
19
26
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
20
27
  import type { TranscriptStore } from './store.ts'
21
28
  import type { TranscriptEntry } from './render/projection.ts'
29
+ import { renderMarkdown, type MdSegment, visibleColumns } from './render/markdown.ts'
30
+ import { caretVisible, pulseFrame } from './render/animations.ts'
31
+ import type { ApprovalStore } from './approval.ts'
32
+ import type { CommandsView } from './commands.ts'
33
+ import type { ModelDirectory, ModelRow } from './models.ts'
34
+ import type { QuestionStore } from './questions.ts'
35
+ import type { SkillsView, SkillRow } from './skills.ts'
36
+ import type { MentionCandidate } from './mentions.ts'
22
37
  import { buildStatusGroups, type StatusFacts } from './render/status.ts'
38
+ import { displayText } from './render/text.ts'
23
39
 
24
40
  /** Props the runner hands the app; callbacks stay owned by the runner. */
25
41
  export interface AppProps {
26
42
  /** Event-fed transcript store for the live session. */
27
43
  store: TranscriptStore
28
- /** `provider/model` selection serving this session. */
44
+ /** Approval-question store fed by the answerer listener. */
45
+ approval: ApprovalStore
46
+ /** ask_user_question store fed by the single UI provider. */
47
+ questions: QuestionStore
48
+ /** Live slash-command descriptor list (completion candidates). */
49
+ commands: CommandsView
50
+ /** Live user-invocable skill catalog (completion candidates). */
51
+ skills: SkillsView
52
+ /** `provider/model` selection serving this session (updated on /model). */
29
53
  model: string
30
54
  /** Working-directory basename the session serves. */
31
55
  cwd: string
@@ -33,10 +57,26 @@ export interface AppProps {
33
57
  branch: string
34
58
  /** Short session identifier. */
35
59
  sessionId: string
36
- /** Submit one human prompt; the runner folds it into the session. */
37
- onSubmit(text: string): void
60
+ /** Whether this session was resumed from persistence. */
61
+ resumed: boolean
62
+ /** Submit one line: slash commands to the registry, other text to the agent. */
63
+ dispatch(text: string): void
64
+ /** Submit steering: consumed at the running turn's next step boundary. */
65
+ steer(text: string): void
66
+ /** Interrupt the running turn (Esc); true when a turn was cancelled. */
67
+ interrupt(): boolean
38
68
  /** Quit: unmount, flush, and request process exit. */
39
- onQuit(): void
69
+ quit(): void
70
+ /** Load the selectable model directory (called when /model opens). */
71
+ loadModels(): Promise<ModelDirectory>
72
+ /** Load @mention candidates for the typed query (files + sessions). */
73
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
74
+ /** Apply one /model selection; returns the display label. */
75
+ selectModel(row: ModelRow): string
76
+ /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
77
+ cyclePermission(): string
78
+ /** Registers the app's notice channel with the runner (called once on mount). */
79
+ onBridgeReady(bridge: { notify(text: string): void }): void
40
80
  }
41
81
 
42
82
  /** Ink `color` string for one palette triple. */
@@ -44,37 +84,200 @@ function inkColor(triple: readonly [number, number, number]): string {
44
84
  return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
45
85
  }
46
86
 
87
+ /** Truncate text to a visible-column budget, appending … when cut. */
88
+ function truncateColumns(text: string, max: number): string {
89
+ let columns = 0
90
+ let out = ''
91
+ for (const char of text) {
92
+ const code = char.codePointAt(0) ?? 0
93
+ const width = code > 0x2e7f ? 2 : 1
94
+ if (columns + width > max) return `${out}…`
95
+ out += char
96
+ columns += width
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** Pad text with spaces to a visible-column target (menu name column). */
102
+ function padColumns(text: string, width: number): string {
103
+ return text + ' '.repeat(Math.max(0, width - visibleColumns(text)))
104
+ }
105
+
106
+ /** Interval-driven frame counter for one self-contained animated leaf. */
107
+ function useFrames(intervalMs: number): number {
108
+ const [tick, setTick] = useState(0)
109
+ useEffect(() => {
110
+ const id = setInterval(() => setTick(current => current + 1), intervalMs)
111
+ return () => {
112
+ clearInterval(id)
113
+ }
114
+ }, [intervalMs])
115
+ return tick
116
+ }
117
+
118
+ /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
119
+ function Pulse(): ReactElement {
120
+ const tick = useFrames(125)
121
+ return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick))
122
+ }
123
+
124
+ /** Blinking block caret appended to streaming text. */
125
+ function Caret(): ReactElement {
126
+ const tick = useFrames(530)
127
+ return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
128
+ }
129
+
130
+ /** Blinking input cursor: inverse block while the caret phase is on. */
131
+ function CursorBlock({ char }: { char: string }): ReactElement {
132
+ const tick = useFrames(530)
133
+ return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
134
+ }
135
+
136
+ /** Ink props for one markdown style class. */
137
+ function segmentProps(style: MdSegment['style']): {
138
+ color: string | undefined
139
+ bold: boolean | undefined
140
+ italic: boolean | undefined
141
+ strikethrough: boolean | undefined
142
+ } {
143
+ switch (style) {
144
+ case 'accent':
145
+ return { color: inkColor(TUI_RGB.brandBright), bold: undefined, italic: undefined, strikethrough: undefined }
146
+ case 'code':
147
+ return { color: inkColor(TUI_RGB.code), bold: undefined, italic: undefined, strikethrough: undefined }
148
+ case 'dim':
149
+ return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: undefined }
150
+ case 'bold':
151
+ return { color: undefined, bold: true, italic: undefined, strikethrough: undefined }
152
+ case 'italic':
153
+ return { color: undefined, bold: undefined, italic: true, strikethrough: undefined }
154
+ case 'boldItalic':
155
+ return { color: undefined, bold: true, italic: true, strikethrough: undefined }
156
+ case 'strike':
157
+ return { color: inkColor(TUI_RGB.dim), bold: undefined, italic: undefined, strikethrough: true }
158
+ default:
159
+ return { color: undefined, bold: undefined, italic: undefined, strikethrough: undefined }
160
+ }
161
+ }
162
+
163
+ /** One settled markdown document rendered as styled lines at the terminal width. */
164
+ function MarkdownBody({ text }: { text: string }): ReactElement {
165
+ const columns = useStdout().stdout?.columns ?? 80
166
+ // Cached by (text, width): settled replies re-layout only when either moves.
167
+ const lines = useMemo(
168
+ () => renderMarkdown(displayText(text), Math.max(20, columns - 2)),
169
+ [text, columns],
170
+ )
171
+ return createElement(
172
+ Box,
173
+ { flexDirection: 'column' },
174
+ ...lines.map((line, index) => createElement(
175
+ Text,
176
+ { key: index },
177
+ ...line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
178
+ )),
179
+ )
180
+ }
181
+
47
182
  /** One settled transcript row. */
48
- function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
183
+ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReasoning: boolean }): ReactElement {
49
184
  switch (entry.kind) {
50
185
  case 'user':
51
- return createElement(Text, null, brand('❯ '), entry.text)
186
+ // Collapsed injected context reads as a dim ↳ row; only direct human
187
+ // prompts get the brand ❯ (they are different surfaces, not the same).
188
+ return entry.notice
189
+ ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
190
+ : createElement(Text, null, brand('❯ '), displayText(entry.text))
52
191
  case 'assistant':
53
- return createElement(Text, null, entry.text)
192
+ // Claude-Code-style thinking: a dim ✻ marker collapsed, the reasoning
193
+ // text dim-italic expanded (Ctrl+R toggles globally). The collapsed
194
+ // row is static — an animated counter inside the text would jitter the
195
+ // line width every frame.
196
+ return createElement(
197
+ Box,
198
+ { flexDirection: 'column' },
199
+ entry.reasoning === ''
200
+ ? undefined
201
+ : showReasoning
202
+ ? createElement(Text, { dimColor: true, italic: true }, ` ✻ ${displayText(entry.reasoning)}`)
203
+ : createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
204
+ createElement(MarkdownBody, { text: entry.text }),
205
+ )
54
206
  case 'tool': {
207
+ // Claude-Code-style tool card: the invocation row plus a nested ⎿
208
+ // result line, so the summary reads under its call instead of inline.
55
209
  const mark = entry.state === 'running'
56
- ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
210
+ ? createElement(Pulse)
57
211
  : entry.state === 'error'
58
212
  ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
59
213
  : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
60
214
  return createElement(
61
- Text,
62
- null,
63
- mark,
64
- ' ',
65
- brand(entry.name),
66
- entry.summary === '' ? '' : ` ${dim(entry.summary)}`,
215
+ Box,
216
+ { flexDirection: 'column' },
217
+ createElement(
218
+ Text,
219
+ null,
220
+ mark,
221
+ ' ',
222
+ brand(entry.name),
223
+ entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
224
+ ),
225
+ entry.summary === ''
226
+ ? undefined
227
+ : createElement(
228
+ Text,
229
+ { color: entry.state === 'error' ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim) },
230
+ ` ⎿ ${displayText(entry.summary)}`,
231
+ ),
232
+ )
233
+ }
234
+ case 'command': {
235
+ const mark = entry.state === 'running'
236
+ ? createElement(Pulse)
237
+ : entry.state === 'error'
238
+ ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
239
+ : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
240
+ return createElement(
241
+ Box,
242
+ { flexDirection: 'column' },
243
+ createElement(
244
+ Text,
245
+ null,
246
+ mark,
247
+ ' ',
248
+ brand(`/${entry.name}`),
249
+ entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
250
+ ),
251
+ entry.summary === ''
252
+ ? undefined
253
+ : createElement(Text, { color: inkColor(TUI_RGB.dim) }, ` ⎿ ${displayText(entry.summary)}`),
67
254
  )
68
255
  }
69
256
  case 'error':
70
- return createElement(Text, null, paintError(entry.text))
257
+ return createElement(Text, null, paintError(displayText(entry.text)))
71
258
  default:
72
259
  return assertNever(entry, 'transcript entry kind')
73
260
  }
74
261
  }
75
262
 
76
- /** The whale wordmark header in DeepSeek blue, hugging its content width. */
77
- function Header(): ReactElement {
263
+ /**
264
+ * The whale wordmark header in DeepSeek blue, hugging its content width.
265
+ * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
266
+ * short to show it whole (or mid-resize) the clipped pairs garble the
267
+ * screen — below the height floor the header collapses to a single-line
268
+ * wordmark that stays correct at any size.
269
+ */
270
+ function Header({ resumed }: { resumed: boolean }): ReactElement {
271
+ const rows = useStdout().stdout?.rows ?? 40
272
+ const hint = resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit'
273
+ if (rows < 20) {
274
+ return createElement(
275
+ Box,
276
+ { flexDirection: 'row', gap: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), paddingX: 1, alignSelf: 'flex-start' },
277
+ createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
278
+ createElement(Text, { dimColor: true }, hint),
279
+ )
280
+ }
78
281
  return createElement(
79
282
  Box,
80
283
  // alignSelf shrinks the border to the whale-plus-wordmark content instead
@@ -90,8 +293,43 @@ function Header(): ReactElement {
90
293
  Box,
91
294
  { flexDirection: 'column', justifyContent: 'center' },
92
295
  createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
93
- createElement(Text, { dimColor: true }, '/help commands · Ctrl+C quit'),
296
+ createElement(Text, { dimColor: true }, hint),
297
+ ),
298
+ )
299
+ }
300
+
301
+ /** Todo status glyph: web TodoPanel's three-state marker. */
302
+ function todoMark(status: TodoItem['status']): string {
303
+ return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
304
+ }
305
+
306
+ /** Inline todo list (web TodoPanel's compact terminal form). */
307
+ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
308
+ if (todos.length === 0) return undefined
309
+ const completed = todos.filter(todo => todo.status === 'completed').length
310
+ const inProgress = todos.filter(todo => todo.status === 'in_progress').length
311
+ const pending = todos.length - completed - inProgress
312
+ return createElement(
313
+ Box,
314
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
315
+ createElement(
316
+ Text,
317
+ { color: inkColor(TUI_RGB.brand), bold: true },
318
+ `todos ${completed}/${todos.length}`,
319
+ createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
94
320
  ),
321
+ ...todos.map((todo, index) => createElement(
322
+ Text,
323
+ {
324
+ key: index,
325
+ color: todo.status === 'completed'
326
+ ? inkColor(TUI_RGB.success)
327
+ : todo.status === 'in_progress'
328
+ ? inkColor(TUI_RGB.brandBright)
329
+ : inkColor(TUI_RGB.dim),
330
+ },
331
+ `${todoMark(todo.status)} ${displayText(todo.content)}`,
332
+ )),
95
333
  )
96
334
  }
97
335
 
@@ -109,93 +347,769 @@ function StatusLine({ facts, stats, busy }: {
109
347
  const groups = buildStatusGroups(facts, stats)
110
348
  const children: ReactElement[] = [
111
349
  busy
112
- ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '● ')
113
- : createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○ '),
350
+ ? createElement(Pulse)
351
+ : createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○'),
352
+ createElement(Text, null, ' '),
114
353
  ]
115
354
  groups.forEach((group, index) => {
116
355
  if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(' | ')))
117
356
  children.push(createElement(Text, { dimColor: true }, group))
118
357
  })
119
- return createElement(Box, { paddingX: 1 }, ...children)
358
+ // Left-aligned status bar; the top margin keeps it clear of the input box.
359
+ return createElement(
360
+ Box,
361
+ { paddingX: 1, marginTop: 1 },
362
+ ...children,
363
+ )
364
+ }
365
+
366
+ /** The y/n approval bar rendered while an approval ask is pending. */
367
+ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
368
+ const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
369
+ useInput((input) => {
370
+ if (locked || snapshot.pending === undefined || snapshot.answered) return
371
+ if (input === 'y' || input === 'Y') {
372
+ snapshot.pending.answer('allowed-once')
373
+ return
374
+ }
375
+ if (input === 'n' || input === 'N') {
376
+ snapshot.pending.answer('rejected')
377
+ }
378
+ })
379
+ if (snapshot.pending === undefined) return undefined
380
+ const { pending, answered } = snapshot
381
+ return createElement(
382
+ Box,
383
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
384
+ createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true }, '⏸ waiting for approval'),
385
+ createElement(Text, null, warn(displayText(pending.headline))),
386
+ pending.command === '' ? undefined : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)),
387
+ answered
388
+ ? createElement(Text, { dimColor: true }, ' submitted…')
389
+ : createElement(Text, { dimColor: true }, dim(' y allow once · n reject')),
390
+ )
120
391
  }
121
392
 
122
- /** The prompt box: slash commands handled locally, other text submitted. */
123
- function Input({ busy, onSubmit, onQuit }: {
393
+ /**
394
+ * The ask_user_question bar: walks one request question by question,
395
+ * renders the option menu (Claude-Code style: arrows move, space toggles a
396
+ * multi-select, enter submits, `c` opens the custom-answer box, Esc
397
+ * interrupts the question as aborted). Plan reviews arrive through the same
398
+ * service with a `plan-review` intent — the approve option gets a ✓ mark,
399
+ * the answer encoding stays identical.
400
+ */
401
+ function QuestionBar({ store, locked }: { store: QuestionStore; locked: boolean }): ReactElement | undefined {
402
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
403
+ const pending = snapshot.pending
404
+ const [index, setIndex] = useState(0)
405
+ const [cursor, setCursor] = useState(0)
406
+ const [selected, setSelected] = useState<readonly number[]>([])
407
+ const [mode, setMode] = useState<'options' | 'custom'>('options')
408
+ const [custom, setCustom] = useState('')
409
+ const [answers, setAnswers] = useState<readonly AskUserQuestionAnswerItem[]>([])
410
+ const [submitted, setSubmitted] = useState(false)
411
+
412
+ // A new request resets the walk; questions without options start in the
413
+ // custom-answer box (a free-form question).
414
+ useEffect(() => {
415
+ const question = pending?.request.questions[0]
416
+ setIndex(0)
417
+ setCursor(0)
418
+ setSelected([])
419
+ setMode(question?.options === undefined || question.options.length === 0 ? 'custom' : 'options')
420
+ setCustom('')
421
+ setAnswers([])
422
+ setSubmitted(false)
423
+ }, [pending])
424
+
425
+ const question = pending?.request.questions[index]
426
+ const options = question?.options ?? []
427
+ const isPlan = question?.intent?.kind === 'plan-review'
428
+ const isMulti = question?.multiSelect === true
429
+
430
+ const commit = (answer: AskUserQuestionAnswerItem): void => {
431
+ if (pending === undefined) return
432
+ const next = [...answers, answer]
433
+ const total = pending.request.questions.length
434
+ if (index + 1 >= total) {
435
+ setSubmitted(true)
436
+ store.submit(pending, { answers: next })
437
+ return
438
+ }
439
+ setAnswers(next)
440
+ setIndex(index + 1)
441
+ setCursor(0)
442
+ setSelected([])
443
+ setMode('options')
444
+ setCustom('')
445
+ }
446
+
447
+ const commitOption = (): void => {
448
+ if (pending === undefined || question === undefined) return
449
+ if (isMulti) {
450
+ const labels = selected
451
+ .map(at => options[at]?.label)
452
+ .filter((label): label is string => label !== undefined)
453
+ const customText = custom.trim()
454
+ commit({ id: question.id, selected: labels, ...(customText === '' ? {} : { custom: customText }) })
455
+ return
456
+ }
457
+ const option = options[cursor]
458
+ if (option === undefined) return
459
+ commit({ id: question.id, selected: [option.label] })
460
+ }
461
+
462
+ useInput((input, key) => {
463
+ if (locked || pending === undefined || question === undefined || submitted) return
464
+ if (key.escape) {
465
+ store.cancel(pending)
466
+ return
467
+ }
468
+ if (mode === 'custom' || options.length === 0) {
469
+ if (key.return) {
470
+ if (custom.trim() === '' && options.length > 0) {
471
+ commitOption()
472
+ return
473
+ }
474
+ commit({
475
+ id: question.id,
476
+ selected: isMulti
477
+ ? selected.map(at => options[at]?.label).filter((label): label is string => label !== undefined)
478
+ : [],
479
+ ...(custom.trim() === '' ? {} : { custom: custom.trim() }),
480
+ })
481
+ return
482
+ }
483
+ if (key.backspace) {
484
+ setCustom(current => current.slice(0, -1))
485
+ return
486
+ }
487
+ if (input !== '' && !key.ctrl && !key.meta) {
488
+ setCustom(current => current + input)
489
+ }
490
+ return
491
+ }
492
+ if (key.upArrow) {
493
+ setCursor(current => (current + options.length - 1) % options.length)
494
+ return
495
+ }
496
+ if (key.downArrow) {
497
+ setCursor(current => (current + 1) % options.length)
498
+ return
499
+ }
500
+ if (key.return) {
501
+ commitOption()
502
+ return
503
+ }
504
+ if (key.tab || input === 'c' || input === 'C') {
505
+ setMode('custom')
506
+ return
507
+ }
508
+ if (input === ' ' && isMulti) {
509
+ setSelected(current => current.includes(cursor) ? current.filter(at => at !== cursor) : [...current, cursor])
510
+ }
511
+ })
512
+
513
+ if (pending === undefined || question === undefined) return undefined
514
+ return createElement(
515
+ Box,
516
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
517
+ createElement(
518
+ Text,
519
+ { color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep), bold: true },
520
+ isPlan ? `📋 plan review (${index + 1}/${pending.request.questions.length})` : `❓ question ${index + 1}/${pending.request.questions.length}`,
521
+ ),
522
+ question.header === undefined ? undefined : createElement(Text, { bold: true }, displayText(question.header)),
523
+ createElement(Text, null, displayText(question.question)),
524
+ question.detail === undefined
525
+ ? undefined
526
+ : isPlan
527
+ ? createElement(MarkdownBody, { text: question.detail })
528
+ : createElement(Text, { dimColor: true }, displayText(question.detail)),
529
+ submitted
530
+ ? createElement(Text, { dimColor: true }, ' submitted…')
531
+ : createElement(
532
+ Box,
533
+ { flexDirection: 'column', marginLeft: 1 },
534
+ ...(mode === 'custom' || options.length === 0
535
+ ? [
536
+ createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` custom: ${custom}${submitted ? '' : '▌'}`),
537
+ createElement(Text, { dimColor: true }, dim(' type your answer · enter submit · esc interrupt')),
538
+ ]
539
+ : options.map((option, at) => {
540
+ const chosen = isMulti && selected.includes(at)
541
+ const approve = isPlan && question.intent?.approve === option.label
542
+ const mark = approve ? '✓ ' : chosen ? '◉ ' : at === cursor ? '❯ ' : ' '
543
+ return createElement(
544
+ Text,
545
+ {
546
+ key: at,
547
+ color: at === cursor ? inkColor(TUI_RGB.brandBright) : chosen || approve ? inkColor(TUI_RGB.success) : inkColor(TUI_RGB.text),
548
+ },
549
+ `${mark}${displayText(option.label)}${option.description === undefined ? '' : dim(` — ${displayText(option.description)}`)}`,
550
+ )
551
+ })),
552
+ createElement(Text, { dimColor: true }, dim(isMulti
553
+ ? ' ↑↓ move · space toggle · enter submit · c custom · esc interrupt'
554
+ : ' ↑↓ move · enter submit · c custom · esc interrupt')),
555
+ ),
556
+ )
557
+ }
558
+
559
+ /** The /model panel: a scrolling list over the advisory model directory. */
560
+ function ModelPanel({ directory, error, onSelect, onClose }: {
561
+ directory: ModelDirectory | undefined
562
+ error: string | undefined
563
+ onSelect(row: ModelRow): void
564
+ onClose(): void
565
+ }): ReactElement {
566
+ const [cursor, setCursor] = useState(0)
567
+ useInput((input, key) => {
568
+ if (key.escape || input === 'q') {
569
+ onClose()
570
+ return
571
+ }
572
+ const rows = directory?.rows ?? []
573
+ if (key.upArrow) {
574
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
575
+ return
576
+ }
577
+ if (key.downArrow) {
578
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
579
+ return
580
+ }
581
+ if (key.return && rows[cursor] !== undefined) {
582
+ onSelect(rows[cursor])
583
+ }
584
+ })
585
+ const rows = directory?.rows ?? []
586
+ const window = 8
587
+ const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window))
588
+ const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window)
589
+ return createElement(
590
+ Box,
591
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
592
+ createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, '/model — select the model for the next step'),
593
+ directory === undefined && error === undefined
594
+ ? createElement(Text, { dimColor: true }, ' loading models…')
595
+ : undefined,
596
+ error !== undefined
597
+ ? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`)
598
+ : undefined,
599
+ ...visible.map((row) => {
600
+ const index = rows.indexOf(row)
601
+ const label = displayText(`${row.providerName} · ${row.modelName}`)
602
+ return createElement(
603
+ Text,
604
+ {
605
+ key: `${row.provider}/${row.model}`,
606
+ color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
607
+ },
608
+ `${index === cursor ? '❯ ' : ' '}${label}`,
609
+ )
610
+ }),
611
+ createElement(Text, { dimColor: true }, dim(' ↑↓ move · enter select · esc close')),
612
+ )
613
+ }
614
+
615
+ /** One completion candidate row. */
616
+ interface CompletionCandidate {
617
+ /** Insertion text for the command name (with leading slash). */
618
+ label: string
619
+ /** Human-readable description shown beside the label. */
620
+ description: string
621
+ /** Candidate origin; skills land the same literal text but route through the prompt. */
622
+ origin: 'command' | 'skill' | 'mention'
623
+ }
624
+
625
+ /**
626
+ * Resolve completion candidates for the current input: TUI-local commands,
627
+ * the live registry descriptors, and user-invocable skills, filtered by the
628
+ * typed prefix. Command names win collisions (the dispatch tries the
629
+ * registry first and only then falls through to the skill gesture).
630
+ */
631
+ function completionCandidates(
632
+ value: string,
633
+ descriptors: readonly CommandDescriptor[],
634
+ skills: readonly SkillRow[],
635
+ ): readonly CompletionCandidate[] {
636
+ if (!value.startsWith('/')) return []
637
+ const prefix = value.slice(1).split(' ')[0] ?? ''
638
+ const local: CompletionCandidate[] = [
639
+ { label: '/help', description: 'show commands', origin: 'command' },
640
+ { label: '/model', description: 'switch the model', origin: 'command' },
641
+ { label: '/clear', description: 'clear the screen', origin: 'command' },
642
+ { label: '/quit', description: 'exit', origin: 'command' },
643
+ ]
644
+ // Local commands shadow registry names (e.g. the plugin-registered
645
+ // /permission is served by the registry itself, never duplicated here),
646
+ // so collisions cannot render two rows with the same key.
647
+ const localNames = new Set(local.map(candidate => candidate.label.slice(1)))
648
+ const registry = descriptors
649
+ .filter(descriptor => !localNames.has(descriptor.name))
650
+ .map((descriptor): CompletionCandidate => ({
651
+ label: `/${descriptor.name}`,
652
+ description: descriptor.description,
653
+ origin: 'command',
654
+ }))
655
+ const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
656
+ const skillRows = skills
657
+ .filter(skill => !taken.has(skill.name))
658
+ .map((skill): CompletionCandidate => ({
659
+ label: `/${skill.name}`,
660
+ description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
661
+ origin: 'skill',
662
+ }))
663
+ const all = [...local, ...registry, ...skillRows]
664
+ if (prefix === '') return all.slice(0, 10)
665
+ return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
666
+ }
667
+
668
+ /** The completion menu snapshot the input editor publishes to the app. */
669
+ export interface MenuState {
670
+ /** Whether the menu is on screen (slash or @mention). */
671
+ active: boolean
672
+ /** Whether the menu is driven by an @mention token. */
673
+ mention: boolean
674
+ /** Highlighted candidate index (wraps by row count). */
675
+ index: number
676
+ /** Rendered rows in display order. */
677
+ rows: readonly CompletionCandidate[]
678
+ }
679
+
680
+ /**
681
+ * The completion menu, rendered after the status line — the very last
682
+ * element in the tree. Being last in the layout flow, opening or closing it
683
+ * moves nothing above it: the transcript, input box, and status line all
684
+ * stay put (the Claude-Code dropdown treatment adapted to Ink, whose
685
+ * absolute positioning cannot place children above their parent).
686
+ */
687
+ function CompletionMenu({ state }: { state: MenuState }): ReactElement | undefined {
688
+ if (!state.active) return undefined
689
+ const columns = useStdout().stdout?.columns ?? 80
690
+ const nameWidth = Math.min(18, Math.max(0, ...state.rows.map(row => visibleColumns(row.label))) + 2)
691
+ const descBudget = Math.max(24, columns - nameWidth - 8)
692
+ return createElement(
693
+ Box,
694
+ { flexDirection: 'column', marginTop: 1, marginLeft: 2 },
695
+ ...(state.rows.length === 0
696
+ ? [createElement(Text, { key: 'loading', dimColor: true }, 'searching…')]
697
+ : state.rows.map((candidate, index) => createElement(
698
+ Text,
699
+ {
700
+ key: candidate.label,
701
+ color: index === state.index % state.rows.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
702
+ },
703
+ `${index === state.index % state.rows.length ? '❯ ' : ' '}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`,
704
+ ))),
705
+ createElement(Text, { dimColor: true }, dim(state.mention ? '↑↓ choose · tab insert' : '↑↓ choose · tab complete')),
706
+ )
707
+ }
708
+
709
+ /**
710
+ * The prompt box: TUI-local slash commands handled locally, other lines
711
+ * dispatched; input editing keeps a cursor with history and completion.
712
+ * While a modal (approval / question / model panel) owns the keys, the
713
+ * box passes every key through untouched.
714
+ */
715
+ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify, toggleReasoning, loadMentions, cyclePermission, onMenuState }: {
716
+ active: boolean
124
717
  busy: boolean
125
- onSubmit(text: string): void
126
- onQuit(): void
718
+ descriptors: readonly CommandDescriptor[]
719
+ skills: readonly SkillRow[]
720
+ dispatch(text: string): void
721
+ steer(text: string): void
722
+ interrupt(): boolean
723
+ quit(): void
724
+ openModel(): void
725
+ notify(text: string): void
726
+ toggleReasoning(): void
727
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
728
+ cyclePermission(): string
729
+ onMenuState(state: MenuState): void
127
730
  }): ReactElement {
128
731
  const [value, setValue] = useState('')
129
- const [notices, setNotices] = useState<readonly string[]>([])
732
+ const [cursor, setCursor] = useState(0)
733
+ const history = useRef<readonly string[]>([])
734
+ const historyIndex = useRef<number | null>(null)
735
+ const draft = useRef('')
736
+ const [completionIndex, setCompletionIndex] = useState(0)
737
+ const candidates = completionCandidates(value, descriptors, skills)
738
+ const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
739
+
740
+ // @mention token: the last `@word` on the cursor's line before the cursor.
741
+ const beforeCursor = value.slice(0, cursor)
742
+ const lastLine = beforeCursor.split('\n').at(-1) ?? ''
743
+ const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine)
744
+ const mentionToken = tokenMatch === null
745
+ ? undefined
746
+ : { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
747
+ const mentionActive = mentionToken !== undefined
748
+ const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
749
+
750
+ useEffect(() => {
751
+ if (!mentionActive) {
752
+ setMentionRows([])
753
+ return
754
+ }
755
+ const controller = new AbortController()
756
+ setMentionRows([])
757
+ loadMentions(mentionToken.query, controller.signal).then(
758
+ rows => setMentionRows(rows),
759
+ () => {},
760
+ )
761
+ return () => {
762
+ controller.abort()
763
+ }
764
+ }, [mentionActive, mentionToken?.query])
765
+
766
+ const menuActive = (slashActive || mentionActive) && !busy
767
+ const menuRows: readonly CompletionCandidate[] = mentionActive
768
+ ? mentionRows.map(row => ({
769
+ label: row.label.startsWith('@')
770
+ ? row.label
771
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
772
+ description: row.description,
773
+ origin: 'mention',
774
+ }))
775
+ : candidates
776
+
777
+ // The menu renders at the very bottom of the app (after the status line),
778
+ // where opening it moves nothing above it — the App needs this snapshot.
779
+ // Notify only on change; an unconditional set would re-render in a loop.
780
+ const menuStateKey = useRef('')
781
+ useEffect(() => {
782
+ const key = JSON.stringify([menuActive, mentionActive, completionIndex, menuRows.map(row => row.label)])
783
+ if (key === menuStateKey.current) return
784
+ menuStateKey.current = key
785
+ onMenuState({
786
+ active: menuActive,
787
+ mention: mentionActive,
788
+ index: completionIndex,
789
+ rows: menuRows,
790
+ })
791
+ }, [menuActive, mentionActive, completionIndex, menuRows, onMenuState])
792
+
130
793
  useInput((input, key) => {
131
- if (key.ctrl && (input === 'c' || input === 'd')) {
132
- onQuit()
794
+ // Modal ownership: approval/question/model dialogs consume all keys.
795
+ if (!active) return
796
+ // Shift+Tab cycles the permission preset (Claude-Code convention).
797
+ if (key.tab && key.shift) {
798
+ const next = cyclePermission()
799
+ if (next !== '') notify(`permission → ${next}`)
800
+ return
801
+ }
802
+ // Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
803
+ if (key.ctrl && input === 'r') {
804
+ toggleReasoning()
805
+ return
806
+ }
807
+ // Ctrl+C is three-state (community-TUI convention): a running turn is
808
+ // cancelled, a non-empty draft is cleared, and only an idle empty input
809
+ // exits. Ctrl+D always means exit but refuses mid-turn.
810
+ if (key.ctrl && input === 'c') {
811
+ if (busy) {
812
+ interrupt()
813
+ } else if (value !== '') {
814
+ setValue('')
815
+ setCursor(0)
816
+ setCompletionIndex(0)
817
+ } else {
818
+ quit()
819
+ }
820
+ return
821
+ }
822
+ if (key.ctrl && input === 'd') {
823
+ if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)')
824
+ else quit()
825
+ return
826
+ }
827
+ if (key.escape) {
828
+ if (busy) interrupt()
133
829
  return
134
830
  }
135
831
  if (key.return) {
832
+ // Multi-line editing: most terminals send the same byte for
833
+ // shift+enter as enter, so newline insertion rides alt/meta+enter
834
+ // and ctrl+j (the two distinguishable bindings); a bare return submits.
835
+ if (key.meta || (key.ctrl && input === 'j')) {
836
+ setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
837
+ setCursor(cursor + 1)
838
+ return
839
+ }
136
840
  const text = value.trim()
137
841
  setValue('')
842
+ setCursor(0)
843
+ setCompletionIndex(0)
138
844
  if (text === '') return
845
+ history.current = [...history.current, text]
846
+ historyIndex.current = null
139
847
  if (text === '/quit') {
140
- onQuit()
848
+ quit()
141
849
  return
142
850
  }
143
851
  if (text === '/help') {
144
- setNotices([...notices, '/help show commands · /clear clear the screen · /quit exit'])
852
+ notify('/model switch · /clear clear the screen · /quit exit · Ctrl+R toggle thinking · Shift+Tab cycle permission · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn')
145
853
  return
146
854
  }
147
855
  if (text === '/clear') {
148
- setNotices([])
149
856
  console.clear()
150
857
  return
151
858
  }
152
- if (busy) {
153
- setNotices([...notices, 'the agent is working — wait for the turn to finish'])
859
+ if (text === '/model' || text.startsWith('/model ')) {
860
+ openModel()
154
861
  return
155
862
  }
156
- onSubmit(text)
863
+ if (busy && !text.startsWith('/')) {
864
+ // A running turn is steered, not blocked: the inbox delivers this
865
+ // text at the next step boundary (Esc/Ctrl+C still cancels outright).
866
+ // Slash lines keep the registry path — commands run out of band.
867
+ steer(text)
868
+ return
869
+ }
870
+ dispatch(text)
871
+ return
872
+ }
873
+ if (menuActive && key.upArrow) {
874
+ setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
875
+ return
876
+ }
877
+ if (menuActive && key.downArrow) {
878
+ setCompletionIndex(index => (index + 1) % menuRows.length)
879
+ return
880
+ }
881
+ if (key.upArrow) {
882
+ const entries = history.current
883
+ if (entries.length === 0) return
884
+ const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1)
885
+ if (historyIndex.current === null) draft.current = value
886
+ historyIndex.current = next
887
+ setValue(entries[next] ?? '')
888
+ setCursor((entries[next] ?? '').length)
889
+ return
890
+ }
891
+ if (key.downArrow) {
892
+ const entries = history.current
893
+ if (historyIndex.current === null) return
894
+ const next = historyIndex.current + 1
895
+ if (next >= entries.length) {
896
+ historyIndex.current = null
897
+ setValue(draft.current)
898
+ setCursor(draft.current.length)
899
+ return
900
+ }
901
+ historyIndex.current = next
902
+ setValue(entries[next] ?? '')
903
+ setCursor((entries[next] ?? '').length)
904
+ return
905
+ }
906
+ if (key.tab && menuActive) {
907
+ if (mentionActive && mentionToken !== undefined) {
908
+ const row = mentionRows[completionIndex % mentionRows.length]
909
+ if (row !== undefined) {
910
+ // Session rows carry the canonical @[label](dsh-session:…) token;
911
+ // file rows insert `@path` (directories keep their trailing slash).
912
+ const insertion = row.label.startsWith('@')
913
+ ? row.label
914
+ : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
915
+ setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
916
+ setCursor(mentionToken.start + insertion.length)
917
+ }
918
+ } else {
919
+ const candidate = candidates[completionIndex % candidates.length]
920
+ if (candidate !== undefined) {
921
+ setValue(`${candidate.label} `)
922
+ setCursor(candidate.label.length + 1)
923
+ }
924
+ }
925
+ setCompletionIndex(0)
157
926
  return
158
927
  }
159
928
  if (key.backspace || key.delete) {
160
- setValue(value.slice(0, -1))
929
+ if (cursor > 0) {
930
+ setValue(value.slice(0, cursor - 1) + value.slice(cursor))
931
+ setCursor(cursor - 1)
932
+ setCompletionIndex(0)
933
+ }
934
+ return
935
+ }
936
+ if (key.leftArrow) {
937
+ setCursor(Math.max(0, cursor - 1))
161
938
  return
162
939
  }
163
- if (input !== '') {
164
- setValue(value + input)
940
+ if (key.rightArrow) {
941
+ setCursor(Math.min(value.length, cursor + 1))
942
+ return
943
+ }
944
+ if (key.ctrl && input === 'u') {
945
+ setValue('')
946
+ setCursor(0)
947
+ return
948
+ }
949
+ if (key.ctrl && input === 'a') {
950
+ setCursor(0)
951
+ return
952
+ }
953
+ if (key.ctrl && input === 'e') {
954
+ setCursor(value.length)
955
+ return
956
+ }
957
+ if (input !== '' && !key.ctrl && !key.meta) {
958
+ setValue(value.slice(0, cursor) + input + value.slice(cursor))
959
+ setCursor(cursor + input.length)
960
+ setCompletionIndex(0)
165
961
  }
166
962
  })
963
+
167
964
  return createElement(
168
965
  Box,
169
- { flexDirection: 'column' },
170
- ...notices.map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
966
+ { flexDirection: 'column', marginTop: 1 },
967
+ busy && value === ''
968
+ ? createElement(Text, { dimColor: true }, dim(' enter steers the running turn · esc or ctrl+c cancels'))
969
+ : undefined,
970
+ // The framed input box: a visible boundary so the prompt never blends
971
+ // into the transcript above it; the cursor block sits immediately after
972
+ // the prompt marker (leftmost), with the dim placeholder trailing it —
973
+ // no extra space, so the empty state reads `❯ ▮type a message…`.
171
974
  createElement(
172
975
  Box,
173
- null,
976
+ { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
174
977
  createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
175
- createElement(Text, null, value),
978
+ value === ''
979
+ ? undefined
980
+ : createElement(Text, null, value.slice(0, cursor)),
981
+ createElement(CursorBlock, { char: value.slice(cursor, cursor + 1) === '' ? ' ' : value.slice(cursor, cursor + 1) }),
982
+ value === '' && !busy
983
+ ? createElement(Text, { dimColor: true }, 'type a message · / commands · @ mentions')
984
+ : createElement(Text, null, value.slice(cursor + 1)),
176
985
  ),
177
986
  )
178
987
  }
179
988
 
180
989
  /** The whole terminal app; state arrives via the store, output via Ink. */
181
- export function App({ store, model, cwd, branch, sessionId, onSubmit, onQuit }: AppProps): ReactElement {
182
- const view = useSyncExternalStore(store.subscribe, store.getView)
990
+ export function App(props: AppProps): ReactElement {
991
+ const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
992
+ const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
993
+ const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
994
+ const [modelLabel, setModelLabel] = useState(props.model)
995
+ const [modelOpen, setModelOpen] = useState(false)
996
+ const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
997
+ const [modelError, setModelError] = useState<string | undefined>(undefined)
998
+ const [notices, setNotices] = useState<readonly string[]>([])
999
+ const notify = (text: string): void => {
1000
+ setNotices(current => [...current, text])
1001
+ }
1002
+
1003
+ useEffect(() => {
1004
+ props.onBridgeReady({ notify })
1005
+ }, [])
1006
+ useEffect(() => {
1007
+ if (!modelOpen || directory !== undefined) return
1008
+ let cancelled = false
1009
+ setModelError(undefined)
1010
+ props.loadModels().then((loaded) => {
1011
+ if (!cancelled) setDirectory(loaded)
1012
+ }, (error: unknown) => {
1013
+ if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
1014
+ })
1015
+ return () => {
1016
+ cancelled = true
1017
+ }
1018
+ }, [modelOpen])
1019
+
1020
+ const busy = view.busy
1021
+ const [showReasoning, setShowReasoning] = useState(false)
1022
+ const [menuState, setMenuState] = useState<MenuState>({ active: false, mention: false, index: 0, rows: [] })
1023
+ const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot)
1024
+ const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot)
1025
+ // While any modal owns the keys, the prompt box passes everything through.
1026
+ const inputActive = !modelOpen && approvalSnapshot.pending === undefined && questionSnapshot.pending === undefined
1027
+ // Layered ownership: question > approval > model panel; each bar answers
1028
+ // only while no higher-priority modal is on screen.
1029
+ const questionPending = questionSnapshot.pending !== undefined
1030
+ // Claude-Code spacing: one blank row before each user prompt (except the
1031
+ // first) separates replies from the next turn.
1032
+ const transcriptRows: ReactElement[] = []
1033
+ view.entries.forEach((entry, index) => {
1034
+ if (entry.kind === 'user' && index > 0) {
1035
+ transcriptRows.push(createElement(Text, { key: `gap-${index}` }, ' '))
1036
+ }
1037
+ transcriptRows.push(createElement(EntryLine, { key: index, entry, showReasoning }))
1038
+ })
183
1039
  return createElement(
184
1040
  Box,
185
1041
  { flexDirection: 'column' },
186
- createElement(Header),
1042
+ createElement(Header, { resumed: props.resumed }),
187
1043
  createElement(
188
1044
  Box,
189
1045
  { flexDirection: 'column', paddingX: 1 },
190
- ...view.entries.map((entry, index) => createElement(EntryLine, { key: index, entry })),
191
- view.streaming !== '' ? createElement(Text, null, view.streaming) : undefined,
192
- view.busy && view.streaming === '' ? createElement(Text, { dimColor: true }, 'thinking…') : undefined,
1046
+ ...transcriptRows,
1047
+ view.streamingReasoning !== ''
1048
+ ? createElement(
1049
+ Text,
1050
+ { dimColor: true, italic: true },
1051
+ showReasoning ? ` ✻ ${displayText(view.streamingReasoning)}` : ' ✻ Thinking…',
1052
+ )
1053
+ : undefined,
1054
+ view.streaming !== ''
1055
+ ? createElement(Text, null, displayText(view.streaming), busy ? createElement(Caret) : undefined)
1056
+ : undefined,
1057
+ busy && view.streaming === '' && view.streamingReasoning === '' ? createElement(Text, { dimColor: true }, 'Deep diving...') : undefined,
1058
+ ),
1059
+ createElement(TodoPanel, { todos: view.todos }),
1060
+ createElement(QuestionBar, { store: props.questions, locked: modelOpen }),
1061
+ createElement(ApprovalBar, { approval: props.approval, locked: modelOpen || questionPending }),
1062
+ modelOpen
1063
+ ? createElement(ModelPanel, {
1064
+ directory,
1065
+ error: modelError,
1066
+ onSelect: (row: ModelRow) => {
1067
+ setModelLabel(props.selectModel(row))
1068
+ notify(`model → next step uses ${row.provider}/${row.model}`)
1069
+ setModelOpen(false)
1070
+ },
1071
+ onClose: () => {
1072
+ setModelOpen(false)
1073
+ },
1074
+ })
1075
+ : undefined,
1076
+ createElement(
1077
+ Box,
1078
+ { flexDirection: 'column' },
1079
+ ...notices.slice(-3).map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
193
1080
  ),
194
- createElement(Input, { busy: view.busy, onSubmit, onQuit }),
1081
+ createElement(Input, {
1082
+ active: inputActive,
1083
+ busy,
1084
+ descriptors,
1085
+ skills,
1086
+ dispatch: props.dispatch,
1087
+ steer: props.steer,
1088
+ interrupt: props.interrupt,
1089
+ quit: props.quit,
1090
+ openModel: () => {
1091
+ setModelOpen(true)
1092
+ },
1093
+ notify,
1094
+ toggleReasoning: () => {
1095
+ setShowReasoning(current => !current)
1096
+ },
1097
+ loadMentions: props.loadMentions,
1098
+ cyclePermission: props.cyclePermission,
1099
+ onMenuState: setMenuState,
1100
+ }),
195
1101
  createElement(StatusLine, {
196
- facts: { model, cwd, branch, sessionId },
1102
+ facts: {
1103
+ model: modelLabel,
1104
+ cwd: props.cwd,
1105
+ branch: props.branch,
1106
+ sessionId: props.sessionId,
1107
+ plan: view.plan,
1108
+ permission: view.permission,
1109
+ },
197
1110
  stats: view.stats,
198
- busy: view.busy,
1111
+ busy,
199
1112
  }),
1113
+ createElement(CompletionMenu, { state: menuState }),
200
1114
  )
201
1115
  }