dsh-code 0.1.0 → 0.2.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/src/app.ts CHANGED
@@ -1,31 +1,48 @@
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'
17
+ import {
18
+ createElement, useEffect, useRef, useState, useSyncExternalStore, type ReactElement,
19
+ } from 'react'
16
20
  import { Box, Text, useInput } 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 { TUI_RGB, brand, dim, error as paintError, warn } from './theme.ts'
19
25
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
20
26
  import type { TranscriptStore } from './store.ts'
21
27
  import type { TranscriptEntry } from './render/projection.ts'
28
+ import type { ApprovalStore } from './approval.ts'
29
+ import type { CommandsView } from './commands.ts'
30
+ import type { ModelDirectory, ModelRow } from './models.ts'
31
+ import type { SkillsView, SkillRow } from './skills.ts'
22
32
  import { buildStatusGroups, type StatusFacts } from './render/status.ts'
33
+ import { displayText } from './render/text.ts'
23
34
 
24
35
  /** Props the runner hands the app; callbacks stay owned by the runner. */
25
36
  export interface AppProps {
26
37
  /** Event-fed transcript store for the live session. */
27
38
  store: TranscriptStore
28
- /** `provider/model` selection serving this session. */
39
+ /** Approval-question store fed by the answerer listener. */
40
+ approval: ApprovalStore
41
+ /** Live slash-command descriptor list (completion candidates). */
42
+ commands: CommandsView
43
+ /** Live user-invocable skill catalog (completion candidates). */
44
+ skills: SkillsView
45
+ /** `provider/model` selection serving this session (updated on /model). */
29
46
  model: string
30
47
  /** Working-directory basename the session serves. */
31
48
  cwd: string
@@ -33,10 +50,22 @@ export interface AppProps {
33
50
  branch: string
34
51
  /** Short session identifier. */
35
52
  sessionId: string
36
- /** Submit one human prompt; the runner folds it into the session. */
37
- onSubmit(text: string): void
53
+ /** Whether this session was resumed from persistence. */
54
+ resumed: boolean
55
+ /** Submit one line: slash commands to the registry, other text to the agent. */
56
+ dispatch(text: string): void
57
+ /** Submit steering: consumed at the running turn's next step boundary. */
58
+ steer(text: string): void
59
+ /** Interrupt the running turn (Esc); true when a turn was cancelled. */
60
+ interrupt(): boolean
38
61
  /** Quit: unmount, flush, and request process exit. */
39
- onQuit(): void
62
+ quit(): void
63
+ /** Load the selectable model directory (called when /model opens). */
64
+ loadModels(): Promise<ModelDirectory>
65
+ /** Apply one /model selection; returns the display label. */
66
+ selectModel(row: ModelRow): string
67
+ /** Registers the app's notice channel with the runner (called once on mount). */
68
+ onBridgeReady(bridge: { notify(text: string): void }): void
40
69
  }
41
70
 
42
71
  /** Ink `color` string for one palette triple. */
@@ -48,9 +77,9 @@ function inkColor(triple: readonly [number, number, number]): string {
48
77
  function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
49
78
  switch (entry.kind) {
50
79
  case 'user':
51
- return createElement(Text, null, brand('❯ '), entry.text)
80
+ return createElement(Text, null, brand('❯ '), displayText(entry.text))
52
81
  case 'assistant':
53
- return createElement(Text, null, entry.text)
82
+ return createElement(Text, null, displayText(entry.text))
54
83
  case 'tool': {
55
84
  const mark = entry.state === 'running'
56
85
  ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
@@ -63,18 +92,34 @@ function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
63
92
  mark,
64
93
  ' ',
65
94
  brand(entry.name),
66
- entry.summary === '' ? '' : ` ${dim(entry.summary)}`,
95
+ entry.summary === '' ? '' : ` ${dim(displayText(entry.summary))}`,
96
+ )
97
+ }
98
+ case 'command': {
99
+ const mark = entry.state === 'running'
100
+ ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
101
+ : entry.state === 'error'
102
+ ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
103
+ : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
104
+ return createElement(
105
+ Text,
106
+ null,
107
+ mark,
108
+ ' ',
109
+ brand(`/${entry.name}`),
110
+ entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
111
+ entry.summary === '' ? '' : ` ${dim(displayText(entry.summary))}`,
67
112
  )
68
113
  }
69
114
  case 'error':
70
- return createElement(Text, null, paintError(entry.text))
115
+ return createElement(Text, null, paintError(displayText(entry.text)))
71
116
  default:
72
117
  return assertNever(entry, 'transcript entry kind')
73
118
  }
74
119
  }
75
120
 
76
121
  /** The whale wordmark header in DeepSeek blue, hugging its content width. */
77
- function Header(): ReactElement {
122
+ function Header({ resumed }: { resumed: boolean }): ReactElement {
78
123
  return createElement(
79
124
  Box,
80
125
  // alignSelf shrinks the border to the whale-plus-wordmark content instead
@@ -90,11 +135,50 @@ function Header(): ReactElement {
90
135
  Box,
91
136
  { flexDirection: 'column', justifyContent: 'center' },
92
137
  createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
93
- createElement(Text, { dimColor: true }, '/help commands · Ctrl+C quit'),
138
+ createElement(
139
+ Text,
140
+ { dimColor: true },
141
+ resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit',
142
+ ),
94
143
  ),
95
144
  )
96
145
  }
97
146
 
147
+ /** Todo status glyph: web TodoPanel's three-state marker. */
148
+ function todoMark(status: TodoItem['status']): string {
149
+ return status === 'completed' ? '✓' : status === 'in_progress' ? '●' : '○'
150
+ }
151
+
152
+ /** Inline todo list (web TodoPanel's compact terminal form). */
153
+ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | undefined {
154
+ if (todos.length === 0) return undefined
155
+ const completed = todos.filter(todo => todo.status === 'completed').length
156
+ const inProgress = todos.filter(todo => todo.status === 'in_progress').length
157
+ const pending = todos.length - completed - inProgress
158
+ return createElement(
159
+ Box,
160
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1 },
161
+ createElement(
162
+ Text,
163
+ { color: inkColor(TUI_RGB.brand), bold: true },
164
+ `todos ${completed}/${todos.length}`,
165
+ createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
166
+ ),
167
+ ...todos.map((todo, index) => createElement(
168
+ Text,
169
+ {
170
+ key: index,
171
+ color: todo.status === 'completed'
172
+ ? inkColor(TUI_RGB.success)
173
+ : todo.status === 'in_progress'
174
+ ? inkColor(TUI_RGB.brandBright)
175
+ : inkColor(TUI_RGB.dim),
176
+ },
177
+ `${todoMark(todo.status)} ${displayText(todo.content)}`,
178
+ )),
179
+ )
180
+ }
181
+
98
182
  /**
99
183
  * The footer status line: Claude-Code-style identity facts (model, working
100
184
  * directory, git branch, session) beside the web composer's session figures
@@ -119,83 +203,409 @@ function StatusLine({ facts, stats, busy }: {
119
203
  return createElement(Box, { paddingX: 1 }, ...children)
120
204
  }
121
205
 
122
- /** The prompt box: slash commands handled locally, other text submitted. */
123
- function Input({ busy, onSubmit, onQuit }: {
206
+ /** The y/n approval bar rendered while an approval ask is pending. */
207
+ function ApprovalBar({ approval }: { approval: ApprovalStore }): ReactElement | undefined {
208
+ const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot)
209
+ if (snapshot.pending === undefined) return undefined
210
+ const { pending, answered } = snapshot
211
+ return createElement(
212
+ Box,
213
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1 },
214
+ createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true }, '⏸ waiting for approval'),
215
+ createElement(Text, null, warn(displayText(pending.headline))),
216
+ pending.command === '' ? undefined : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)),
217
+ answered
218
+ ? createElement(Text, { dimColor: true }, ' submitted…')
219
+ : createElement(Text, { dimColor: true }, dim(' y allow once · n reject')),
220
+ )
221
+ }
222
+
223
+ /** The /model panel: a scrolling list over the advisory model directory. */
224
+ function ModelPanel({ directory, error, onSelect, onClose }: {
225
+ directory: ModelDirectory | undefined
226
+ error: string | undefined
227
+ onSelect(row: ModelRow): void
228
+ onClose(): void
229
+ }): ReactElement {
230
+ const [cursor, setCursor] = useState(0)
231
+ useInput((input, key) => {
232
+ if (key.escape || input === 'q') {
233
+ onClose()
234
+ return
235
+ }
236
+ const rows = directory?.rows ?? []
237
+ if (key.upArrow) {
238
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
239
+ return
240
+ }
241
+ if (key.downArrow) {
242
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
243
+ return
244
+ }
245
+ if (key.return && rows[cursor] !== undefined) {
246
+ onSelect(rows[cursor])
247
+ }
248
+ })
249
+ const rows = directory?.rows ?? []
250
+ const window = 8
251
+ const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window))
252
+ const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window)
253
+ return createElement(
254
+ Box,
255
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1 },
256
+ createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, '/model — select the model for the next step'),
257
+ directory === undefined && error === undefined
258
+ ? createElement(Text, { dimColor: true }, ' loading models…')
259
+ : undefined,
260
+ error !== undefined
261
+ ? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`)
262
+ : undefined,
263
+ ...visible.map((row) => {
264
+ const index = rows.indexOf(row)
265
+ const label = displayText(`${row.providerName} · ${row.modelName}`)
266
+ return createElement(
267
+ Text,
268
+ {
269
+ key: `${row.provider}/${row.model}`,
270
+ color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
271
+ },
272
+ `${index === cursor ? '❯ ' : ' '}${label}`,
273
+ )
274
+ }),
275
+ createElement(Text, { dimColor: true }, dim(' ↑↓ move · enter select · esc close')),
276
+ )
277
+ }
278
+
279
+ /** One completion candidate row. */
280
+ interface CompletionCandidate {
281
+ /** Insertion text for the command name (with leading slash). */
282
+ label: string
283
+ /** Human-readable description shown beside the label. */
284
+ description: string
285
+ /** Candidate origin; skills land the same literal text but route through the prompt. */
286
+ origin: 'command' | 'skill'
287
+ }
288
+
289
+ /**
290
+ * Resolve completion candidates for the current input: TUI-local commands,
291
+ * the live registry descriptors, and user-invocable skills, filtered by the
292
+ * typed prefix. Command names win collisions (the dispatch tries the
293
+ * registry first and only then falls through to the skill gesture).
294
+ */
295
+ function completionCandidates(
296
+ value: string,
297
+ descriptors: readonly CommandDescriptor[],
298
+ skills: readonly SkillRow[],
299
+ ): readonly CompletionCandidate[] {
300
+ if (!value.startsWith('/')) return []
301
+ const prefix = value.slice(1).split(' ')[0] ?? ''
302
+ const local: CompletionCandidate[] = [
303
+ { label: '/help', description: 'show commands', origin: 'command' },
304
+ { label: '/model', description: 'switch the model', origin: 'command' },
305
+ { label: '/clear', description: 'clear the screen', origin: 'command' },
306
+ { label: '/quit', description: 'exit', origin: 'command' },
307
+ ]
308
+ const registry = descriptors.map((descriptor): CompletionCandidate => ({
309
+ label: `/${descriptor.name}`,
310
+ description: descriptor.description,
311
+ origin: 'command',
312
+ }))
313
+ const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
314
+ const skillRows = skills
315
+ .filter(skill => !taken.has(skill.name))
316
+ .map((skill): CompletionCandidate => ({
317
+ label: `/${skill.name}`,
318
+ description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
319
+ origin: 'skill',
320
+ }))
321
+ const all = [...local, ...registry, ...skillRows]
322
+ if (prefix === '') return all.slice(0, 10)
323
+ return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
324
+ }
325
+
326
+ /**
327
+ * The prompt box: TUI-local slash commands handled locally, other lines
328
+ * dispatched; input editing keeps a cursor with history and completion.
329
+ */
330
+ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }: {
124
331
  busy: boolean
125
- onSubmit(text: string): void
126
- onQuit(): void
332
+ descriptors: readonly CommandDescriptor[]
333
+ skills: readonly SkillRow[]
334
+ dispatch(text: string): void
335
+ steer(text: string): void
336
+ interrupt(): boolean
337
+ quit(): void
338
+ openModel(): void
339
+ notify(text: string): void
127
340
  }): ReactElement {
128
341
  const [value, setValue] = useState('')
129
- const [notices, setNotices] = useState<readonly string[]>([])
342
+ const [cursor, setCursor] = useState(0)
343
+ const history = useRef<readonly string[]>([])
344
+ const historyIndex = useRef<number | null>(null)
345
+ const draft = useRef('')
346
+ const [completionIndex, setCompletionIndex] = useState(0)
347
+ const candidates = completionCandidates(value, descriptors, skills)
348
+ const completionActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
349
+
130
350
  useInput((input, key) => {
131
- if (key.ctrl && (input === 'c' || input === 'd')) {
132
- onQuit()
351
+ // Ctrl+C is three-state (community-TUI convention): a running turn is
352
+ // cancelled, a non-empty draft is cleared, and only an idle empty input
353
+ // exits. Ctrl+D always means exit but refuses mid-turn.
354
+ if (key.ctrl && input === 'c') {
355
+ if (busy) {
356
+ interrupt()
357
+ } else if (value !== '') {
358
+ setValue('')
359
+ setCursor(0)
360
+ setCompletionIndex(0)
361
+ } else {
362
+ quit()
363
+ }
364
+ return
365
+ }
366
+ if (key.ctrl && input === 'd') {
367
+ if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)')
368
+ else quit()
369
+ return
370
+ }
371
+ if (key.escape) {
372
+ if (busy) interrupt()
133
373
  return
134
374
  }
135
375
  if (key.return) {
376
+ // Multi-line editing: most terminals send the same byte for
377
+ // shift+enter as enter, so newline insertion rides alt/meta+enter
378
+ // and ctrl+j (the two distinguishable bindings); a bare return submits.
379
+ if (key.meta || (key.ctrl && input === 'j')) {
380
+ setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
381
+ setCursor(cursor + 1)
382
+ return
383
+ }
136
384
  const text = value.trim()
137
385
  setValue('')
386
+ setCursor(0)
387
+ setCompletionIndex(0)
138
388
  if (text === '') return
389
+ history.current = [...history.current, text]
390
+ historyIndex.current = null
139
391
  if (text === '/quit') {
140
- onQuit()
392
+ quit()
141
393
  return
142
394
  }
143
395
  if (text === '/help') {
144
- setNotices([...notices, '/help show commands · /clear clear the screen · /quit exit'])
396
+ notify('/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn')
145
397
  return
146
398
  }
147
399
  if (text === '/clear') {
148
- setNotices([])
149
400
  console.clear()
150
401
  return
151
402
  }
152
- if (busy) {
153
- setNotices([...notices, 'the agent is working — wait for the turn to finish'])
403
+ if (text === '/model' || text.startsWith('/model ')) {
404
+ openModel()
405
+ return
406
+ }
407
+ if (busy && !text.startsWith('/')) {
408
+ // A running turn is steered, not blocked: the inbox delivers this
409
+ // text at the next step boundary (Esc/Ctrl+C still cancels outright).
410
+ // Slash lines keep the registry path — commands run out of band.
411
+ steer(text)
412
+ return
413
+ }
414
+ dispatch(text)
415
+ return
416
+ }
417
+ if (completionActive && key.upArrow) {
418
+ setCompletionIndex(index => (index + candidates.length - 1) % candidates.length)
419
+ return
420
+ }
421
+ if (completionActive && key.downArrow) {
422
+ setCompletionIndex(index => (index + 1) % candidates.length)
423
+ return
424
+ }
425
+ if (key.upArrow) {
426
+ const entries = history.current
427
+ if (entries.length === 0) return
428
+ const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1)
429
+ if (historyIndex.current === null) draft.current = value
430
+ historyIndex.current = next
431
+ setValue(entries[next] ?? '')
432
+ setCursor((entries[next] ?? '').length)
433
+ return
434
+ }
435
+ if (key.downArrow) {
436
+ const entries = history.current
437
+ if (historyIndex.current === null) return
438
+ const next = historyIndex.current + 1
439
+ if (next >= entries.length) {
440
+ historyIndex.current = null
441
+ setValue(draft.current)
442
+ setCursor(draft.current.length)
154
443
  return
155
444
  }
156
- onSubmit(text)
445
+ historyIndex.current = next
446
+ setValue(entries[next] ?? '')
447
+ setCursor((entries[next] ?? '').length)
448
+ return
449
+ }
450
+ if (key.tab && completionActive) {
451
+ const candidate = candidates[completionIndex % candidates.length]
452
+ if (candidate !== undefined) {
453
+ setValue(`${candidate.label} `)
454
+ setCursor(candidate.label.length + 1)
455
+ setCompletionIndex(0)
456
+ }
157
457
  return
158
458
  }
159
459
  if (key.backspace || key.delete) {
160
- setValue(value.slice(0, -1))
460
+ if (cursor > 0) {
461
+ setValue(value.slice(0, cursor - 1) + value.slice(cursor))
462
+ setCursor(cursor - 1)
463
+ setCompletionIndex(0)
464
+ }
465
+ return
466
+ }
467
+ if (key.leftArrow) {
468
+ setCursor(Math.max(0, cursor - 1))
469
+ return
470
+ }
471
+ if (key.rightArrow) {
472
+ setCursor(Math.min(value.length, cursor + 1))
473
+ return
474
+ }
475
+ if (key.ctrl && input === 'u') {
476
+ setValue('')
477
+ setCursor(0)
478
+ return
479
+ }
480
+ if (key.ctrl && input === 'a') {
481
+ setCursor(0)
161
482
  return
162
483
  }
163
- if (input !== '') {
164
- setValue(value + input)
484
+ if (key.ctrl && input === 'e') {
485
+ setCursor(value.length)
486
+ return
487
+ }
488
+ if (input !== '' && !key.ctrl && !key.meta) {
489
+ setValue(value.slice(0, cursor) + input + value.slice(cursor))
490
+ setCursor(cursor + input.length)
491
+ setCompletionIndex(0)
165
492
  }
166
493
  })
494
+
495
+ const shown = completionActive && !busy
167
496
  return createElement(
168
497
  Box,
169
498
  { flexDirection: 'column' },
170
- ...notices.map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
499
+ shown
500
+ ? createElement(
501
+ Box,
502
+ { flexDirection: 'column', marginLeft: 1 },
503
+ ...candidates.map((candidate, index) => createElement(
504
+ Text,
505
+ {
506
+ key: candidate.label,
507
+ color: index === completionIndex % candidates.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
508
+ },
509
+ `${index === completionIndex % candidates.length ? '❯ ' : ' '}${candidate.label} ${dim(displayText(candidate.description))}`,
510
+ )),
511
+ createElement(Text, { dimColor: true }, dim(' ↑↓ choose · tab complete')),
512
+ )
513
+ : undefined,
514
+ busy && value === ''
515
+ ? createElement(Text, { dimColor: true }, dim(' enter steers the running turn · esc or ctrl+c cancels'))
516
+ : undefined,
171
517
  createElement(
172
518
  Box,
173
519
  null,
174
520
  createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
175
- createElement(Text, null, value),
521
+ createElement(Text, null, value.slice(0, cursor)),
522
+ createElement(Text, { inverse: true }, value.slice(cursor, cursor + 1) === '' ? ' ' : value.slice(cursor, cursor + 1)),
523
+ createElement(Text, null, value.slice(cursor + 1)),
176
524
  ),
177
525
  )
178
526
  }
179
527
 
180
528
  /** 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)
529
+ export function App(props: AppProps): ReactElement {
530
+ const view = useSyncExternalStore(props.store.subscribe, props.store.getView)
531
+ const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors)
532
+ const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows)
533
+ const [modelLabel, setModelLabel] = useState(props.model)
534
+ const [modelOpen, setModelOpen] = useState(false)
535
+ const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
536
+ const [modelError, setModelError] = useState<string | undefined>(undefined)
537
+ const [notices, setNotices] = useState<readonly string[]>([])
538
+ const notify = (text: string): void => {
539
+ setNotices(current => [...current, text])
540
+ }
541
+
542
+ useEffect(() => {
543
+ props.onBridgeReady({ notify })
544
+ }, [])
545
+ useEffect(() => {
546
+ if (!modelOpen || directory !== undefined) return
547
+ let cancelled = false
548
+ setModelError(undefined)
549
+ props.loadModels().then((loaded) => {
550
+ if (!cancelled) setDirectory(loaded)
551
+ }, (error: unknown) => {
552
+ if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
553
+ })
554
+ return () => {
555
+ cancelled = true
556
+ }
557
+ }, [modelOpen])
558
+
559
+ const busy = view.busy
183
560
  return createElement(
184
561
  Box,
185
562
  { flexDirection: 'column' },
186
- createElement(Header),
563
+ createElement(Header, { resumed: props.resumed }),
187
564
  createElement(
188
565
  Box,
189
566
  { flexDirection: 'column', paddingX: 1 },
190
567
  ...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,
568
+ view.streaming !== '' ? createElement(Text, null, displayText(view.streaming)) : undefined,
569
+ busy && view.streaming === '' ? createElement(Text, { dimColor: true }, 'thinking…') : undefined,
570
+ ),
571
+ createElement(TodoPanel, { todos: view.todos }),
572
+ createElement(ApprovalBar, { approval: props.approval }),
573
+ modelOpen
574
+ ? createElement(ModelPanel, {
575
+ directory,
576
+ error: modelError,
577
+ onSelect: (row: ModelRow) => {
578
+ setModelLabel(props.selectModel(row))
579
+ notify(`model → next step uses ${row.provider}/${row.model}`)
580
+ setModelOpen(false)
581
+ },
582
+ onClose: () => {
583
+ setModelOpen(false)
584
+ },
585
+ })
586
+ : undefined,
587
+ createElement(
588
+ Box,
589
+ { flexDirection: 'column' },
590
+ ...notices.slice(-3).map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
193
591
  ),
194
- createElement(Input, { busy: view.busy, onSubmit, onQuit }),
592
+ createElement(Input, {
593
+ busy,
594
+ descriptors,
595
+ skills,
596
+ dispatch: props.dispatch,
597
+ steer: props.steer,
598
+ interrupt: props.interrupt,
599
+ quit: props.quit,
600
+ openModel: () => {
601
+ setModelOpen(true)
602
+ },
603
+ notify,
604
+ }),
195
605
  createElement(StatusLine, {
196
- facts: { model, cwd, branch, sessionId },
606
+ facts: { model: modelLabel, cwd: props.cwd, branch: props.branch, sessionId: props.sessionId },
197
607
  stats: view.stats,
198
- busy: view.busy,
608
+ busy,
199
609
  }),
200
610
  )
201
611
  }