dsh-code 0.2.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.
package/src/app.ts CHANGED
@@ -15,20 +15,25 @@
15
15
  */
16
16
 
17
17
  import {
18
- createElement, useEffect, useRef, useState, useSyncExternalStore, type ReactElement,
18
+ createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
19
19
  } from 'react'
20
- import { Box, Text, useInput } from 'ink'
20
+ import { Box, Text, useInput, useStdout } from 'ink'
21
21
  import { assertNever } from '@deepseek-ai/dsh-llm'
22
22
  import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
23
23
  import type { TodoItem } from '@deepseek-ai/dsh-session'
24
+ import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
24
25
  import { TUI_RGB, brand, dim, error as paintError, warn } from './theme.ts'
25
26
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
26
27
  import type { TranscriptStore } from './store.ts'
27
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'
28
31
  import type { ApprovalStore } from './approval.ts'
29
32
  import type { CommandsView } from './commands.ts'
30
33
  import type { ModelDirectory, ModelRow } from './models.ts'
34
+ import type { QuestionStore } from './questions.ts'
31
35
  import type { SkillsView, SkillRow } from './skills.ts'
36
+ import type { MentionCandidate } from './mentions.ts'
32
37
  import { buildStatusGroups, type StatusFacts } from './render/status.ts'
33
38
  import { displayText } from './render/text.ts'
34
39
 
@@ -38,6 +43,8 @@ export interface AppProps {
38
43
  store: TranscriptStore
39
44
  /** Approval-question store fed by the answerer listener. */
40
45
  approval: ApprovalStore
46
+ /** ask_user_question store fed by the single UI provider. */
47
+ questions: QuestionStore
41
48
  /** Live slash-command descriptor list (completion candidates). */
42
49
  commands: CommandsView
43
50
  /** Live user-invocable skill catalog (completion candidates). */
@@ -62,8 +69,12 @@ export interface AppProps {
62
69
  quit(): void
63
70
  /** Load the selectable model directory (called when /model opens). */
64
71
  loadModels(): Promise<ModelDirectory>
72
+ /** Load @mention candidates for the typed query (files + sessions). */
73
+ loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
65
74
  /** Apply one /model selection; returns the display label. */
66
75
  selectModel(row: ModelRow): string
76
+ /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
77
+ cyclePermission(): string
67
78
  /** Registers the app's notice channel with the runner (called once on mount). */
68
79
  onBridgeReady(bridge: { notify(text: string): void }): void
69
80
  }
@@ -73,42 +84,173 @@ function inkColor(triple: readonly [number, number, number]): string {
73
84
  return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`
74
85
  }
75
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
+
76
182
  /** One settled transcript row. */
77
- function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
183
+ function EntryLine({ entry, showReasoning }: { entry: TranscriptEntry; showReasoning: boolean }): ReactElement {
78
184
  switch (entry.kind) {
79
185
  case 'user':
80
- return createElement(Text, null, brand('❯ '), displayText(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))
81
191
  case 'assistant':
82
- return createElement(Text, null, displayText(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
+ )
83
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.
84
209
  const mark = entry.state === 'running'
85
- ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
210
+ ? createElement(Pulse)
86
211
  : entry.state === 'error'
87
212
  ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
88
213
  : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
89
214
  return createElement(
90
- Text,
91
- null,
92
- mark,
93
- ' ',
94
- brand(entry.name),
95
- entry.summary === '' ? '' : ` ${dim(displayText(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
+ ),
96
232
  )
97
233
  }
98
234
  case 'command': {
99
235
  const mark = entry.state === 'running'
100
- ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '◐')
236
+ ? createElement(Pulse)
101
237
  : entry.state === 'error'
102
238
  ? createElement(Text, { color: inkColor(TUI_RGB.error) }, '⨯')
103
239
  : createElement(Text, { color: inkColor(TUI_RGB.success) }, '⏺')
104
240
  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))}`,
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)}`),
112
254
  )
113
255
  }
114
256
  case 'error':
@@ -118,8 +260,24 @@ function EntryLine({ entry }: { entry: TranscriptEntry }): ReactElement {
118
260
  }
119
261
  }
120
262
 
121
- /** The whale wordmark header in DeepSeek blue, hugging its content width. */
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
+ */
122
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
+ }
123
281
  return createElement(
124
282
  Box,
125
283
  // alignSelf shrinks the border to the whale-plus-wordmark content instead
@@ -135,11 +293,7 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
135
293
  Box,
136
294
  { flexDirection: 'column', justifyContent: 'center' },
137
295
  createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, 'DeepSeek Harness'),
138
- createElement(
139
- Text,
140
- { dimColor: true },
141
- resumed ? 'resumed session · /help commands · Esc interrupt' : '/help commands · Esc interrupt · Ctrl+C quit',
142
- ),
296
+ createElement(Text, { dimColor: true }, hint),
143
297
  ),
144
298
  )
145
299
  }
@@ -157,7 +311,7 @@ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | un
157
311
  const pending = todos.length - completed - inProgress
158
312
  return createElement(
159
313
  Box,
160
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1 },
314
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brandDeep), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
161
315
  createElement(
162
316
  Text,
163
317
  { color: inkColor(TUI_RGB.brand), bold: true },
@@ -193,24 +347,40 @@ function StatusLine({ facts, stats, busy }: {
193
347
  const groups = buildStatusGroups(facts, stats)
194
348
  const children: ReactElement[] = [
195
349
  busy
196
- ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, '● ')
197
- : createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○ '),
350
+ ? createElement(Pulse)
351
+ : createElement(Text, { color: inkColor(TUI_RGB.brand) }, '○'),
352
+ createElement(Text, null, ' '),
198
353
  ]
199
354
  groups.forEach((group, index) => {
200
355
  if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(' | ')))
201
356
  children.push(createElement(Text, { dimColor: true }, group))
202
357
  })
203
- 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
+ )
204
364
  }
205
365
 
206
366
  /** The y/n approval bar rendered while an approval ask is pending. */
207
- function ApprovalBar({ approval }: { approval: ApprovalStore }): ReactElement | undefined {
367
+ function ApprovalBar({ approval, locked }: { approval: ApprovalStore; locked: boolean }): ReactElement | undefined {
208
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
+ })
209
379
  if (snapshot.pending === undefined) return undefined
210
380
  const { pending, answered } = snapshot
211
381
  return createElement(
212
382
  Box,
213
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1 },
383
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.warn), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
214
384
  createElement(Text, { color: inkColor(TUI_RGB.warn), bold: true }, '⏸ waiting for approval'),
215
385
  createElement(Text, null, warn(displayText(pending.headline))),
216
386
  pending.command === '' ? undefined : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)),
@@ -220,6 +390,172 @@ function ApprovalBar({ approval }: { approval: ApprovalStore }): ReactElement |
220
390
  )
221
391
  }
222
392
 
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
+
223
559
  /** The /model panel: a scrolling list over the advisory model directory. */
224
560
  function ModelPanel({ directory, error, onSelect, onClose }: {
225
561
  directory: ModelDirectory | undefined
@@ -252,7 +588,7 @@ function ModelPanel({ directory, error, onSelect, onClose }: {
252
588
  const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window)
253
589
  return createElement(
254
590
  Box,
255
- { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1 },
591
+ { flexDirection: 'column', paddingX: 1, borderStyle: 'round', borderColor: inkColor(TUI_RGB.brand), alignSelf: 'flex-start', marginLeft: 1, marginTop: 1 },
256
592
  createElement(Text, { color: inkColor(TUI_RGB.brand), bold: true }, '/model — select the model for the next step'),
257
593
  directory === undefined && error === undefined
258
594
  ? createElement(Text, { dimColor: true }, ' loading models…')
@@ -283,7 +619,7 @@ interface CompletionCandidate {
283
619
  /** Human-readable description shown beside the label. */
284
620
  description: string
285
621
  /** Candidate origin; skills land the same literal text but route through the prompt. */
286
- origin: 'command' | 'skill'
622
+ origin: 'command' | 'skill' | 'mention'
287
623
  }
288
624
 
289
625
  /**
@@ -305,11 +641,17 @@ function completionCandidates(
305
641
  { label: '/clear', description: 'clear the screen', origin: 'command' },
306
642
  { label: '/quit', description: 'exit', origin: 'command' },
307
643
  ]
308
- const registry = descriptors.map((descriptor): CompletionCandidate => ({
309
- label: `/${descriptor.name}`,
310
- description: descriptor.description,
311
- origin: 'command',
312
- }))
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
+ }))
313
655
  const taken = new Set([...local, ...registry].map(candidate => candidate.label.slice(1)))
314
656
  const skillRows = skills
315
657
  .filter(skill => !taken.has(skill.name))
@@ -323,11 +665,55 @@ function completionCandidates(
323
665
  return all.filter(candidate => candidate.label.slice(1).startsWith(prefix)).slice(0, 10)
324
666
  }
325
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
+
326
709
  /**
327
710
  * The prompt box: TUI-local slash commands handled locally, other lines
328
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.
329
714
  */
330
- function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }: {
715
+ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify, toggleReasoning, loadMentions, cyclePermission, onMenuState }: {
716
+ active: boolean
331
717
  busy: boolean
332
718
  descriptors: readonly CommandDescriptor[]
333
719
  skills: readonly SkillRow[]
@@ -337,6 +723,10 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
337
723
  quit(): void
338
724
  openModel(): void
339
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
340
730
  }): ReactElement {
341
731
  const [value, setValue] = useState('')
342
732
  const [cursor, setCursor] = useState(0)
@@ -345,9 +735,75 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
345
735
  const draft = useRef('')
346
736
  const [completionIndex, setCompletionIndex] = useState(0)
347
737
  const candidates = completionCandidates(value, descriptors, skills)
348
- const completionActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
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])
349
792
 
350
793
  useInput((input, key) => {
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
+ }
351
807
  // Ctrl+C is three-state (community-TUI convention): a running turn is
352
808
  // cancelled, a non-empty draft is cleared, and only an idle empty input
353
809
  // exits. Ctrl+D always means exit but refuses mid-turn.
@@ -393,7 +849,7 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
393
849
  return
394
850
  }
395
851
  if (text === '/help') {
396
- notify('/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn')
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')
397
853
  return
398
854
  }
399
855
  if (text === '/clear') {
@@ -414,12 +870,12 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
414
870
  dispatch(text)
415
871
  return
416
872
  }
417
- if (completionActive && key.upArrow) {
418
- setCompletionIndex(index => (index + candidates.length - 1) % candidates.length)
873
+ if (menuActive && key.upArrow) {
874
+ setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
419
875
  return
420
876
  }
421
- if (completionActive && key.downArrow) {
422
- setCompletionIndex(index => (index + 1) % candidates.length)
877
+ if (menuActive && key.downArrow) {
878
+ setCompletionIndex(index => (index + 1) % menuRows.length)
423
879
  return
424
880
  }
425
881
  if (key.upArrow) {
@@ -447,13 +903,26 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
447
903
  setCursor((entries[next] ?? '').length)
448
904
  return
449
905
  }
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)
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
+ }
456
924
  }
925
+ setCompletionIndex(0)
457
926
  return
458
927
  }
459
928
  if (key.backspace || key.delete) {
@@ -492,35 +961,27 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
492
961
  }
493
962
  })
494
963
 
495
- const shown = completionActive && !busy
496
964
  return createElement(
497
965
  Box,
498
- { flexDirection: 'column' },
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,
966
+ { flexDirection: 'column', marginTop: 1 },
514
967
  busy && value === ''
515
968
  ? createElement(Text, { dimColor: true }, dim(' enter steers the running turn · esc or ctrl+c cancels'))
516
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…`.
517
974
  createElement(
518
975
  Box,
519
- null,
976
+ { borderStyle: 'round', borderColor: inkColor(TUI_RGB.dim), paddingX: 1 },
520
977
  createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? '… ' : '❯ '),
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)),
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)),
524
985
  ),
525
986
  )
526
987
  }
@@ -557,6 +1018,24 @@ export function App(props: AppProps): ReactElement {
557
1018
  }, [modelOpen])
558
1019
 
559
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
+ })
560
1039
  return createElement(
561
1040
  Box,
562
1041
  { flexDirection: 'column' },
@@ -564,12 +1043,22 @@ export function App(props: AppProps): ReactElement {
564
1043
  createElement(
565
1044
  Box,
566
1045
  { flexDirection: 'column', paddingX: 1 },
567
- ...view.entries.map((entry, index) => createElement(EntryLine, { key: index, entry })),
568
- view.streaming !== '' ? createElement(Text, null, displayText(view.streaming)) : undefined,
569
- 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,
570
1058
  ),
571
1059
  createElement(TodoPanel, { todos: view.todos }),
572
- createElement(ApprovalBar, { approval: props.approval }),
1060
+ createElement(QuestionBar, { store: props.questions, locked: modelOpen }),
1061
+ createElement(ApprovalBar, { approval: props.approval, locked: modelOpen || questionPending }),
573
1062
  modelOpen
574
1063
  ? createElement(ModelPanel, {
575
1064
  directory,
@@ -590,6 +1079,7 @@ export function App(props: AppProps): ReactElement {
590
1079
  ...notices.slice(-3).map((notice, index) => createElement(Text, { key: index, dimColor: true }, notice)),
591
1080
  ),
592
1081
  createElement(Input, {
1082
+ active: inputActive,
593
1083
  busy,
594
1084
  descriptors,
595
1085
  skills,
@@ -601,11 +1091,25 @@ export function App(props: AppProps): ReactElement {
601
1091
  setModelOpen(true)
602
1092
  },
603
1093
  notify,
1094
+ toggleReasoning: () => {
1095
+ setShowReasoning(current => !current)
1096
+ },
1097
+ loadMentions: props.loadMentions,
1098
+ cyclePermission: props.cyclePermission,
1099
+ onMenuState: setMenuState,
604
1100
  }),
605
1101
  createElement(StatusLine, {
606
- facts: { model: modelLabel, cwd: props.cwd, branch: props.branch, sessionId: props.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
+ },
607
1110
  stats: view.stats,
608
1111
  busy,
609
1112
  }),
1113
+ createElement(CompletionMenu, { state: menuState }),
610
1114
  )
611
1115
  }