dsh-code 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
+ import { readdir, writeFile } from "node:fs/promises";
3
4
  import { basename, join } from "node:path";
4
- import { createElement, useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
6
  import z from "@deepseek-ai/schemastery";
6
7
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
7
8
  import { assertNever, boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
8
9
  import { SessionId } from "@deepseek-ai/dsh-session";
9
- import { Box, Text, render, useInput } from "ink";
10
+ import { Box, Static, Text, render, useInput, useStdout } from "ink";
10
11
  import chalk from "chalk";
12
+ import { formatSessionReferenceMention, parseSessionReferenceText } from "@deepseek-ai/dsh-session-reference";
13
+ import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
11
14
  import { isUserInvocable } from "@deepseek-ai/dsh-skill";
12
15
  //#region src/theme.ts
13
16
  /**
@@ -64,37 +67,909 @@ const TUI_RGB = {
64
67
  245,
65
68
  158,
66
69
  11
70
+ ],
71
+ /** Default foreground text — `--dsw-static-neutral-50`. */
72
+ text: [
73
+ 236,
74
+ 240,
75
+ 246
76
+ ],
77
+ /** Inline/fenced code — soft sky blue, distinct from brand accents. */
78
+ code: [
79
+ 125,
80
+ 211,
81
+ 252
67
82
  ]
68
83
  };
69
84
  /** Paint with the primary brand blue: whale, wordmark, tool names, accents. */
70
85
  function brand(text) {
71
86
  return chalk.rgb(...TUI_RGB.brand)(text);
72
87
  }
73
- /** Paint muted captions, hints, and meta lines. */
74
- function dim(text) {
75
- return chalk.rgb(...TUI_RGB.dim)(text);
88
+ /** Paint muted captions, hints, and meta lines. */
89
+ function dim(text) {
90
+ return chalk.rgb(...TUI_RGB.dim)(text);
91
+ }
92
+ /** Paint failures and error entries. */
93
+ function error(text) {
94
+ return chalk.rgb(...TUI_RGB.error)(text);
95
+ }
96
+ //#endregion
97
+ //#region src/whale-glyph.ts
98
+ /** Half-block whale glyph rows; render with the brand color. */
99
+ const WHALE_GLYPH = [
100
+ " ▄▄▄▄▄▄▄▄█ ▄█▄ ▄",
101
+ " ▄▄██████████▄▄ ▀███▄████",
102
+ "▄███████████████▄ ███▀▀▀ ",
103
+ "██ ▀▀█████▄▀██████ ",
104
+ "██▄ ▀████▄▄████ ",
105
+ " ██▄ ▀██████▀ ",
106
+ " ▀██▄▄ ██▄ ▀███▄▄ ",
107
+ " ▀▀███████▀▀ ▀▀▀ "
108
+ ];
109
+ //#endregion
110
+ //#region src/render/tool-preview.ts
111
+ /**
112
+ * Bounded preview line for a tool invocation's raw JSON arguments: the first
113
+ * human-meaningful string among the well-known keys (command, path, query, …)
114
+ * with a fallback to the bounded raw JSON. Shared by the tool card in the
115
+ * transcript and the approval bar's command preview.
116
+ *
117
+ * @module @deepseek-ai/dsh-code/render/tool-preview
118
+ */
119
+ /** Keys searched in declaration order when building a preview. */
120
+ const PREVIEW_KEYS = [
121
+ "command",
122
+ "cmd",
123
+ "description",
124
+ "path",
125
+ "pattern",
126
+ "query"
127
+ ];
128
+ /**
129
+ * Resolve one bounded preview for raw tool arguments.
130
+ * @param args - raw JSON arguments string as the model produced it.
131
+ * @param toolName - the tool the arguments belong to (fallback label).
132
+ * @returns the preview line; empty when nothing useful resolves.
133
+ */
134
+ function toolArgumentsPreview(args, toolName) {
135
+ if (args === "") return toolName;
136
+ try {
137
+ const parsed = JSON.parse(args);
138
+ if (parsed !== null && typeof parsed === "object") {
139
+ const record = parsed;
140
+ for (const key of PREVIEW_KEYS) {
141
+ const value = record[key];
142
+ if (typeof value === "string" && value !== "") return value;
143
+ }
144
+ }
145
+ } catch {}
146
+ return args.length > 80 ? `${args.slice(0, 77)}...` : args;
147
+ }
148
+ //#endregion
149
+ //#region src/render/tool-detail.ts
150
+ /**
151
+ * Expansion payloads for tool cards (the Ctrl+O verbose transcript): the
152
+ * TUI-side consumption of the harness presentation contract. Mutation and
153
+ * read tools persist a structured `tool/result.meta` (`diffs`, read
154
+ * windows, web sources) exactly so a capable UI can replay richer cards than
155
+ * the model-facing text; this module narrows that opaque JSON defensively —
156
+ * mirroring the upstream validators — and pre-formats bounded, render-ready
157
+ * rows. Malformed or absent metadata always degrades to the bounded raw
158
+ * result text, never throws during replay.
159
+ *
160
+ * @module @deepseek-ai/dsh-code/render/tool-detail
161
+ */
162
+ /** Budgets keeping one expanded card bounded on a terminal. */
163
+ const MAX_DIFF_LINES = 200;
164
+ const MAX_READ_LINES = 120;
165
+ const MAX_SOURCES = 10;
166
+ const MAX_RAW_CHARS = 6e3;
167
+ const MAX_LINE_COLUMNS = 240;
168
+ /** Truncate one line to the visible-column budget with an ellipsis marker. */
169
+ function clipLine(text) {
170
+ return text.length > MAX_LINE_COLUMNS ? `${text.slice(0, 239)}…` : text;
171
+ }
172
+ /** Split text into lines, dropping the trailing empty element of a final newline. */
173
+ function toLines(text) {
174
+ const split = text.split("\n");
175
+ return split.length > 0 && split[split.length - 1] === "" ? split.slice(0, -1) : split;
176
+ }
177
+ /**
178
+ * Render one change as removed-then-added rows, hunked by common prefix and
179
+ * suffix. A null before-image (file create) renders as pure additions. The
180
+ * budget caps emitted rows and reports the cut, so a whole-file overwrite
181
+ * never floods the transcript.
182
+ * @param oldText - prior content, or null for a create.
183
+ * @param newText - content after the change.
184
+ * @param budget - maximum rows to emit.
185
+ * @returns the bounded rows and whether they were cut.
186
+ */
187
+ function diffRows(oldText, newText, budget) {
188
+ const oldLines = oldText === null ? [] : toLines(oldText);
189
+ const newLines = toLines(newText);
190
+ let prefix = 0;
191
+ while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1;
192
+ let suffix = 0;
193
+ while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix += 1;
194
+ const removed = oldLines.slice(prefix, oldLines.length - suffix);
195
+ const added = newLines.slice(prefix, newLines.length - suffix);
196
+ const rows = [...removed.map((text) => ({
197
+ mark: "-",
198
+ text: clipLine(text)
199
+ })), ...added.map((text) => ({
200
+ mark: "+",
201
+ text: clipLine(text)
202
+ }))];
203
+ if (rows.length <= budget) return {
204
+ lines: rows,
205
+ truncated: false
206
+ };
207
+ return {
208
+ lines: rows.slice(0, budget),
209
+ truncated: true
210
+ };
211
+ }
212
+ /** Whether `value` is a valid upstream FileDiff (defensive narrowing). */
213
+ function isFileDiff(value) {
214
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
215
+ const { path, oldText, newText } = value;
216
+ return typeof path === "string" && (oldText === null || typeof oldText === "string") && typeof newText === "string";
217
+ }
218
+ /** Whether `value` is a valid read-window line (defensive narrowing). */
219
+ function isReadLine(value) {
220
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
221
+ const { number, text } = value;
222
+ return typeof number === "number" && Number.isInteger(number) && number >= 1 && typeof text === "string";
223
+ }
224
+ /** Whether `value` is a valid web source (defensive narrowing). */
225
+ function isWebSource(value) {
226
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
227
+ const { url, title, snippet } = value;
228
+ return typeof url === "string" && (title === void 0 || typeof title === "string") && (snippet === void 0 || typeof snippet === "string");
229
+ }
230
+ /**
231
+ * Narrow the opaque `tool/result.meta` into one bounded expansion payload,
232
+ * mirroring the upstream presenters' degradation ladder: diffs (write/edit),
233
+ * read windows (read), sources (web_search), fetch summaries (web_fetch), and
234
+ * the bounded raw result text as the universal fallback.
235
+ * @param meta - the persisted presentation metadata, when the tool attached one.
236
+ * @param rawText - the joined text blocks of the result message.
237
+ * @returns the expansion payload, or undefined when nothing renderable exists.
238
+ */
239
+ function toolResultDetail(meta, rawText) {
240
+ if (typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
241
+ const record = meta;
242
+ const diffs = record["diffs"];
243
+ if (Array.isArray(diffs) && diffs.length > 0 && diffs.every(isFileDiff)) {
244
+ const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / diffs.length));
245
+ return {
246
+ kind: "diff",
247
+ diffs: diffs.map((diff) => ({
248
+ path: diff.path,
249
+ ...diffRows(diff.oldText, diff.newText, budget)
250
+ }))
251
+ };
252
+ }
253
+ const { path, offset, lines, totalLines } = record;
254
+ if (typeof path === "string" && Number.isInteger(offset) && offset >= 1 && Number.isInteger(totalLines) && totalLines >= 0 && Array.isArray(lines) && lines.every(isReadLine)) {
255
+ const window = lines;
256
+ const truncated = window.length > MAX_READ_LINES;
257
+ return {
258
+ kind: "read",
259
+ path,
260
+ offset,
261
+ lines: (truncated ? window.slice(0, MAX_READ_LINES) : window).map((line) => ({
262
+ number: line.number,
263
+ text: clipLine(line.text)
264
+ })),
265
+ totalLines,
266
+ truncated
267
+ };
268
+ }
269
+ const sources = record["sources"];
270
+ if (Array.isArray(sources) && sources.every(isWebSource)) {
271
+ const truncated = sources.length > MAX_SOURCES;
272
+ return {
273
+ kind: "web-search",
274
+ sources: (truncated ? sources.slice(0, MAX_SOURCES) : sources).map((source) => ({
275
+ url: source.url,
276
+ title: typeof source.title === "string" ? source.title : void 0,
277
+ snippet: typeof source.snippet === "string" ? clipLine(source.snippet) : ""
278
+ })),
279
+ truncated
280
+ };
281
+ }
282
+ const { url, statusCode } = record;
283
+ if (typeof url === "string" && typeof statusCode === "number") return {
284
+ kind: "web-fetch",
285
+ url,
286
+ statusCode: Math.trunc(statusCode)
287
+ };
288
+ }
289
+ if (rawText === "") return void 0;
290
+ const truncated = rawText.length > MAX_RAW_CHARS;
291
+ return {
292
+ kind: "raw",
293
+ text: truncated ? rawText.slice(0, MAX_RAW_CHARS) : rawText,
294
+ truncated
295
+ };
296
+ }
297
+ //#endregion
298
+ //#region src/render/projection.ts
299
+ /**
300
+ * Pure session-event-to-view projection for the TUI transcript: one reducer
301
+ * over {@link SessionEvent}s producing the ordered entries the renderer draws.
302
+ * Rendering never reads the session directly — this module owns the view
303
+ * model, so tests drive it with plain event arrays.
304
+ *
305
+ * @module @deepseek-ai/dsh-tui/render/projection
306
+ */
307
+ /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
308
+ const MAX_STREAMING_CHARS = 65536;
309
+ /** Append one delta without retaining an unbounded duplicate of the live reply. */
310
+ function appendStreamingTail(current, delta) {
311
+ const next = current + delta;
312
+ return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-65536);
313
+ }
314
+ /** Join the text blocks of a content list; non-text blocks contribute nothing. */
315
+ function textOf(content) {
316
+ return content.filter((block) => block.type === "text").map((block) => block.text).join("");
317
+ }
318
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
319
+ function reasoningOf(content) {
320
+ return content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
321
+ }
322
+ /** A fresh, empty transcript view. */
323
+ function createTranscriptView() {
324
+ return {
325
+ entries: [],
326
+ streaming: "",
327
+ streamingReasoning: "",
328
+ todos: [],
329
+ busy: false,
330
+ busySince: 0,
331
+ model: "",
332
+ plan: false,
333
+ permission: "",
334
+ title: "",
335
+ sandbox: "",
336
+ goal: void 0,
337
+ stats: {
338
+ turns: 0,
339
+ steps: 0,
340
+ llmMs: 0,
341
+ toolMs: 0,
342
+ usage: {
343
+ inputTokens: 0,
344
+ outputTokens: 0,
345
+ cacheReadTokens: 0
346
+ },
347
+ lastPromptTokens: 0,
348
+ contextWindow: 0,
349
+ ttftMs: 0,
350
+ ttftSteps: 0,
351
+ decodeMs: 0,
352
+ decodeTokens: 0
353
+ },
354
+ anchors: {
355
+ stepStart: /* @__PURE__ */ new Map(),
356
+ toolStart: /* @__PURE__ */ new Map(),
357
+ firstChunkAt: /* @__PURE__ */ new Map(),
358
+ compactionTokens: /* @__PURE__ */ new Map(),
359
+ lastPruneTokens: 0,
360
+ turnFiles: /* @__PURE__ */ new Map()
361
+ }
362
+ };
363
+ }
364
+ /**
365
+ * Fold one session event into an updated view (copy-on-write).
366
+ * @param view - the view before the event.
367
+ * @param event - one durable session event from `session/event` or the log.
368
+ * @returns the view after the event; the input view is never mutated.
369
+ */
370
+ function projectEvent(view, event) {
371
+ switch (event.type) {
372
+ case "user/message": {
373
+ const message = event.data;
374
+ if (message.source.kind === "user") return {
375
+ ...view,
376
+ entries: [...view.entries, {
377
+ kind: "user",
378
+ text: textOf(message.content),
379
+ notice: false
380
+ }]
381
+ };
382
+ const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
383
+ return {
384
+ ...view,
385
+ entries: [...view.entries, {
386
+ kind: "user",
387
+ text: boundContextSummary(notice),
388
+ notice: true
389
+ }]
390
+ };
391
+ }
392
+ case "assistant/chunk": {
393
+ const chunk = event.data.chunk;
394
+ const key = `${event.data.turn}:${event.data.step}`;
395
+ const delta = chunk.type === "text-delta" || chunk.type === "reasoning-delta" ? chunk.text : "";
396
+ let stats = view.stats;
397
+ if (delta !== "" && !view.anchors.firstChunkAt.has(key)) {
398
+ view.anchors.firstChunkAt.set(key, event.time);
399
+ const started = view.anchors.stepStart.get(key);
400
+ if (started !== void 0) stats = {
401
+ ...stats,
402
+ ttftMs: stats.ttftMs + Math.max(0, event.time - started),
403
+ ttftSteps: stats.ttftSteps + 1
404
+ };
405
+ }
406
+ if (chunk.type === "text-delta") return {
407
+ ...view,
408
+ streaming: appendStreamingTail(view.streaming, chunk.text),
409
+ stats
410
+ };
411
+ if (chunk.type === "reasoning-delta") return {
412
+ ...view,
413
+ streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text),
414
+ stats
415
+ };
416
+ return view;
417
+ }
418
+ case "assistant/message": {
419
+ const key = `${event.data.turn}:${event.data.step}`;
420
+ const started = view.anchors.stepStart.get(key);
421
+ view.anchors.stepStart.delete(key);
422
+ const firstChunk = view.anchors.firstChunkAt.get(key);
423
+ view.anchors.firstChunkAt.delete(key);
424
+ const usage = event.data.usage;
425
+ const totals = view.stats.usage;
426
+ return {
427
+ ...view,
428
+ streaming: "",
429
+ streamingReasoning: "",
430
+ entries: [...view.entries, {
431
+ kind: "assistant",
432
+ text: textOf(event.data.message.content),
433
+ reasoning: reasoningOf(event.data.message.content)
434
+ }],
435
+ stats: {
436
+ ...view.stats,
437
+ llmMs: view.stats.llmMs + (started === void 0 ? 0 : Math.max(0, event.time - started)),
438
+ usage: usage === void 0 ? totals : {
439
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
440
+ outputTokens: totals.outputTokens + usage.outputTokens,
441
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0)
442
+ },
443
+ lastPromptTokens: usage === void 0 ? view.stats.lastPromptTokens : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
444
+ decodeMs: view.stats.decodeMs + (firstChunk === void 0 ? 0 : Math.max(0, event.time - firstChunk)),
445
+ decodeTokens: view.stats.decodeTokens + (firstChunk === void 0 || usage === void 0 ? 0 : usage.outputTokens)
446
+ }
447
+ };
448
+ }
449
+ case "tool/call": {
450
+ const data = event.data;
451
+ view.anchors.toolStart.set(data.callId, event.time);
452
+ return {
453
+ ...view,
454
+ entries: [...view.entries, {
455
+ kind: "tool",
456
+ callId: data.callId,
457
+ name: data.name,
458
+ arguments: data.arguments,
459
+ preview: toolArgumentsPreview(data.arguments, data.name),
460
+ state: "running",
461
+ summary: "",
462
+ detail: void 0
463
+ }]
464
+ };
465
+ }
466
+ case "tool/result": {
467
+ const block = event.data.message.content[0];
468
+ const started = view.anchors.toolStart.get(block.toolCallId);
469
+ view.anchors.toolStart.delete(block.toolCallId);
470
+ const rawText = textOf(block.content);
471
+ const summary = boundContextSummary(rawText);
472
+ const detail = toolResultDetail(event.data.meta, rawText);
473
+ if (detail?.kind === "diff") {
474
+ const set = view.anchors.turnFiles.get(event.data.turn) ?? /* @__PURE__ */ new Set();
475
+ for (const diff of detail.diffs) set.add(diff.path);
476
+ view.anchors.turnFiles.set(event.data.turn, set);
477
+ }
478
+ const entries = view.entries.map((entry) => {
479
+ if (entry.kind !== "tool" || entry.callId !== block.toolCallId) return entry;
480
+ return {
481
+ ...entry,
482
+ state: block.isError === true ? "error" : "done",
483
+ summary,
484
+ detail
485
+ };
486
+ });
487
+ return {
488
+ ...view,
489
+ entries,
490
+ stats: {
491
+ ...view.stats,
492
+ toolMs: view.stats.toolMs + (started === void 0 ? 0 : Math.max(0, event.time - started))
493
+ }
494
+ };
495
+ }
496
+ case "todo/write": return {
497
+ ...view,
498
+ todos: event.data.todos
499
+ };
500
+ case "turn/start": return {
501
+ ...view,
502
+ busy: true,
503
+ busySince: view.busy ? view.busySince : event.time,
504
+ todos: [],
505
+ stats: {
506
+ ...view.stats,
507
+ turns: view.stats.turns + 1
508
+ }
509
+ };
510
+ case "step/start":
511
+ view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time);
512
+ return {
513
+ ...view,
514
+ stats: {
515
+ ...view.stats,
516
+ steps: view.stats.steps + 1
517
+ }
518
+ };
519
+ case "turn/end": {
520
+ const reason = event.data.reason;
521
+ const appended = [];
522
+ if (reason.kind === "error") appended.push({
523
+ kind: "error",
524
+ text: `${reason.error.code}: ${reason.error.message}`
525
+ });
526
+ else {
527
+ const marker = reason.kind === "aborted" ? reason.reason.kind === "user" ? "turn cancelled by the user" : `turn cancelled (${reason.reason.kind})` : reason.kind === "max-tokens" ? "turn hit the output-token ceiling (max-tokens)" : reason.kind === "blocked" ? "turn ended blocked" : reason.kind === "interrupted" ? "turn was interrupted by a restart" : void 0;
528
+ if (marker !== void 0) appended.push({
529
+ kind: "turn-marker",
530
+ text: marker
531
+ });
532
+ }
533
+ const files = view.anchors.turnFiles.get(event.data.turn);
534
+ view.anchors.turnFiles.delete(event.data.turn);
535
+ if (files !== void 0 && files.size > 0) appended.push({
536
+ kind: "files",
537
+ paths: [...files].slice(0, 12)
538
+ });
539
+ if (appended.length === 0) return {
540
+ ...view,
541
+ busy: false,
542
+ busySince: 0
543
+ };
544
+ return {
545
+ ...view,
546
+ busy: false,
547
+ busySince: 0,
548
+ entries: [...view.entries, ...appended]
549
+ };
550
+ }
551
+ case "llm/retry": {
552
+ const data = event.data;
553
+ return {
554
+ ...view,
555
+ entries: [...view.entries, {
556
+ kind: "retry",
557
+ retryId: data.retryId,
558
+ attempt: data.retry,
559
+ max: "maxRetries" in data ? data.maxRetries : data.retry,
560
+ code: data.failure.code,
561
+ delayMs: data.delayMs,
562
+ state: "running"
563
+ }]
564
+ };
565
+ }
566
+ case "llm/retry-started": {
567
+ const data = event.data;
568
+ const entries = view.entries.map((entry) => {
569
+ if (entry.kind !== "retry" || entry.retryId !== data.retryId) return entry;
570
+ return {
571
+ ...entry,
572
+ state: "done"
573
+ };
574
+ });
575
+ return {
576
+ ...view,
577
+ entries
578
+ };
579
+ }
580
+ case "sandbox/mode": return {
581
+ ...view,
582
+ sandbox: event.data.mode
583
+ };
584
+ case "goal/change": {
585
+ const data = event.data;
586
+ const clip = (text) => text.length > 60 ? `${text.slice(0, 59)}…` : text;
587
+ if (data.operation === "clear") return {
588
+ ...view,
589
+ goal: void 0,
590
+ entries: [...view.entries, {
591
+ kind: "turn-marker",
592
+ text: "◎ goal cleared"
593
+ }]
594
+ };
595
+ const goal = {
596
+ objective: data.goal.objective,
597
+ phase: data.goal.phase,
598
+ rounds: data.roundsStarted,
599
+ max: data.goal.maxGoalRounds,
600
+ blocked: data.goal.blockedReason?.message ?? ""
601
+ };
602
+ const line = data.operation === "create" ? `◎ goal: ${clip(data.goal.objective)}` : data.operation === "complete" ? "◎ goal complete" : data.operation === "pause" ? "◎ goal paused" : data.operation === "resume" ? "◎ goal resumed" : data.operation === "block" ? `◎ goal blocked: ${clip(goal.blocked)}` : void 0;
603
+ return {
604
+ ...view,
605
+ goal,
606
+ entries: line === void 0 ? view.entries : [...view.entries, {
607
+ kind: "turn-marker",
608
+ text: line
609
+ }]
610
+ };
611
+ }
612
+ case "session/title": return {
613
+ ...view,
614
+ title: event.data.title
615
+ };
616
+ case "compaction/summary":
617
+ view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount);
618
+ return view;
619
+ case "compaction/prune": return {
620
+ ...view,
621
+ anchors: {
622
+ ...view.anchors,
623
+ lastPruneTokens: event.data.shadowedTokenCount
624
+ }
625
+ };
626
+ case "compaction/end": {
627
+ const ok = event.data.error === void 0;
628
+ const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens;
629
+ view.anchors.compactionTokens.delete(event.data.compactionId);
630
+ return {
631
+ ...view,
632
+ entries: [...view.entries, {
633
+ kind: "compaction",
634
+ ok,
635
+ tokens,
636
+ error: event.data.error ?? ""
637
+ }]
638
+ };
639
+ }
640
+ case "request/context": return {
641
+ ...view,
642
+ stats: {
643
+ ...view.stats,
644
+ contextWindow: event.data.contextWindow ?? view.stats.contextWindow
645
+ }
646
+ };
647
+ case "request/header": {
648
+ const config = event.data.header.config;
649
+ return {
650
+ ...view,
651
+ model: `${config.provider}/${config.model}`
652
+ };
653
+ }
654
+ case "plan/mode": return {
655
+ ...view,
656
+ plan: event.data.active
657
+ };
658
+ case "permission/preset": return {
659
+ ...view,
660
+ permission: event.data.preset
661
+ };
662
+ case "command/run": {
663
+ const data = event.data;
664
+ return {
665
+ ...view,
666
+ entries: [...view.entries, {
667
+ kind: "command",
668
+ commandId: data.commandId,
669
+ name: data.name,
670
+ args: data.args ?? "",
671
+ state: "running",
672
+ summary: ""
673
+ }]
674
+ };
675
+ }
676
+ case "command/done": {
677
+ const data = event.data;
678
+ const entries = view.entries.map((entry) => {
679
+ if (entry.kind !== "command" || entry.commandId !== data.commandId) return entry;
680
+ return {
681
+ ...entry,
682
+ state: data.kind === "success" ? "done" : "error",
683
+ summary: boundContextSummary(data.text ?? "")
684
+ };
685
+ });
686
+ return {
687
+ ...view,
688
+ entries
689
+ };
690
+ }
691
+ default: return view;
692
+ }
693
+ }
694
+ /**
695
+ * Fold a replayed event history into one view.
696
+ * @param events - events in `seq` order.
697
+ * @returns the folded view.
698
+ */
699
+ function projectEvents(events) {
700
+ return events.reduce(projectEvent, createTranscriptView());
701
+ }
702
+ /**
703
+ * How many leading transcript entries can never change again: only a
704
+ * `running` tool or retry can still mutate in place — everything before the
705
+ * first one (including a completed tail: later events only APPEND new rows)
706
+ * is final. The renderer currently draws the whole transcript dynamically
707
+ * (a `<Static>` flush proved unstable with CJK wrapping on real terminals);
708
+ * this boundary stays as the append-only contract for when flushing is
709
+ * reintroduced.
710
+ * @param entries - the view's transcript entries in order.
711
+ * @returns the count of entries safe to flush (0 for an empty transcript).
712
+ */
713
+ function settledEntryCount(entries) {
714
+ for (let index = 0; index < entries.length; index++) {
715
+ const entry = entries[index];
716
+ if (entry.kind === "tool" && entry.state === "running") return index;
717
+ if (entry.kind === "retry" && entry.state === "running") return index;
718
+ }
719
+ return entries.length;
76
720
  }
77
- /** Paint failures and error entries. */
78
- function error(text) {
79
- return chalk.rgb(...TUI_RGB.error)(text);
721
+ //#endregion
722
+ //#region src/render/markdown.ts
723
+ /** Plain segment helper. */
724
+ function seg(text, style = "plain") {
725
+ return {
726
+ text,
727
+ style
728
+ };
729
+ }
730
+ /** Visible width of a run in columns (CJK counts double). */
731
+ function visibleColumns(text) {
732
+ let columns = 0;
733
+ for (const char of text) {
734
+ const code = char.codePointAt(0) ?? 0;
735
+ columns += code > 11903 ? 2 : 1;
736
+ }
737
+ return columns;
738
+ }
739
+ /** Walk a segment list, breaking it into lines that fit `width` columns. */
740
+ function wrapSegments(segments, width) {
741
+ const lines = [];
742
+ let current = [];
743
+ let used = 0;
744
+ for (const segment of segments) {
745
+ const words = segment.text.split(/( )/u);
746
+ for (const word of words) {
747
+ if (word === "") continue;
748
+ const columns = visibleColumns(word);
749
+ if (used + columns > width && used > 0) {
750
+ lines.push(current);
751
+ current = [];
752
+ used = 0;
753
+ }
754
+ current.push({
755
+ text: word,
756
+ style: segment.style
757
+ });
758
+ used += columns;
759
+ }
760
+ }
761
+ if (current.length > 0) lines.push(current);
762
+ return lines.map((line) => {
763
+ const last = line[line.length - 1];
764
+ if (last !== void 0 && last.text === " " && line.length > 1) return line.slice(0, -1);
765
+ return line;
766
+ });
767
+ }
768
+ /** Join adjacent same-style runs so the app renders fewer elements. */
769
+ function merge(segments) {
770
+ const merged = [];
771
+ for (const segment of segments) {
772
+ const last = merged[merged.length - 1];
773
+ if (last !== void 0 && last.style === segment.style) merged[merged.length - 1] = {
774
+ text: last.text + segment.text,
775
+ style: last.style
776
+ };
777
+ else merged.push({ ...segment });
778
+ }
779
+ return merged;
780
+ }
781
+ /**
782
+ * Parse inline markdown in one line of text. Link destinations render as a
783
+ * dim `(url)` suffix — the visible text keeps the accent.
784
+ */
785
+ function parseInline(text) {
786
+ const runs = [];
787
+ let rest = text;
788
+ while (rest !== "") {
789
+ const code = /^`([^`]+)`/u.exec(rest);
790
+ if (code !== null) {
791
+ runs.push({
792
+ text: code[1] ?? "",
793
+ style: "code"
794
+ });
795
+ rest = rest.slice(code[0].length);
796
+ continue;
797
+ }
798
+ const boldItalic = /^\*\*\*([^*]+)\*\*\*/u.exec(rest);
799
+ if (boldItalic !== null) {
800
+ runs.push({
801
+ text: boldItalic[1] ?? "",
802
+ style: "boldItalic"
803
+ });
804
+ rest = rest.slice(boldItalic[0].length);
805
+ continue;
806
+ }
807
+ const bold = /^\*\*([^*]+)\*\*/u.exec(rest);
808
+ if (bold !== null) {
809
+ runs.push({
810
+ text: bold[1] ?? "",
811
+ style: "bold"
812
+ });
813
+ rest = rest.slice(bold[0].length);
814
+ continue;
815
+ }
816
+ const italic = /^\*([^*]+)\*/u.exec(rest) ?? /^_([^_]+)_/u.exec(rest);
817
+ if (italic !== null) {
818
+ runs.push({
819
+ text: italic[1] ?? "",
820
+ style: "italic"
821
+ });
822
+ rest = rest.slice(italic[0].length);
823
+ continue;
824
+ }
825
+ const strike = /^~~([^~]+)~~/u.exec(rest);
826
+ if (strike !== null) {
827
+ runs.push({
828
+ text: strike[1] ?? "",
829
+ style: "strike"
830
+ });
831
+ rest = rest.slice(strike[0].length);
832
+ continue;
833
+ }
834
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)/u.exec(rest);
835
+ if (link !== null) {
836
+ const label = link[1] ?? "";
837
+ const url = link[2] ?? "";
838
+ runs.push({
839
+ text: label,
840
+ style: "accent"
841
+ });
842
+ runs.push({
843
+ text: ` (${url})`,
844
+ style: "dim"
845
+ });
846
+ rest = rest.slice(link[0].length);
847
+ continue;
848
+ }
849
+ const next = rest.search(/[*_`~[]/u);
850
+ if (next === -1) {
851
+ runs.push({
852
+ text: rest,
853
+ style: "plain"
854
+ });
855
+ break;
856
+ }
857
+ if (next > 0) {
858
+ runs.push({
859
+ text: rest.slice(0, next),
860
+ style: "plain"
861
+ });
862
+ rest = rest.slice(next);
863
+ continue;
864
+ }
865
+ runs.push({
866
+ text: rest.slice(0, 1),
867
+ style: "plain"
868
+ });
869
+ rest = rest.slice(1);
870
+ }
871
+ return runs;
80
872
  }
81
- /** Paint warnings. */
82
- function warn(text) {
83
- return chalk.rgb(...TUI_RGB.warn)(text);
873
+ const HEADING = /^(#{1,6})\s+(.*)$/u;
874
+ const FENCE = /^```([^\s`]*)\s*$/u;
875
+ const RULE = /^(?:---|\*\*\*|___)\s*$/u;
876
+ const QUOTE = /^>\s?(.*)$/u;
877
+ const UNORDERED = /^\s*[-*+]\s+(.*)$/u;
878
+ const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u;
879
+ /** Render markdown text into styled lines of at most `width` columns. */
880
+ function renderMarkdown(text, width) {
881
+ const lines = [];
882
+ const push = (segments) => {
883
+ for (const wrapped of wrapSegments(segments, Math.max(10, width))) lines.push({ segments: merge(wrapped) });
884
+ };
885
+ const source = text.replaceAll("\r", "").split("\n");
886
+ let index = 0;
887
+ while (index < source.length) {
888
+ const line = source[index] ?? "";
889
+ index += 1;
890
+ const fence = FENCE.exec(line);
891
+ if (fence !== null) {
892
+ const language = fence[1] ?? "";
893
+ if (language !== "") push([seg(` ${language}`, "dim")]);
894
+ while (index < source.length && !FENCE.test(source[index] ?? "")) {
895
+ push([seg(` ${source[index] ?? ""}`, "code")]);
896
+ index += 1;
897
+ }
898
+ index += 1;
899
+ continue;
900
+ }
901
+ if (line.trim() === "") continue;
902
+ if (RULE.test(line.trim())) {
903
+ push([seg(` ${"─".repeat(Math.max(1, Math.floor(width / 4)))}`, "dim")]);
904
+ continue;
905
+ }
906
+ const heading = HEADING.exec(line);
907
+ if (heading !== null) {
908
+ push([seg(heading[2] ?? "", "accent")]);
909
+ continue;
910
+ }
911
+ const quote = QUOTE.exec(line);
912
+ if (quote !== null) {
913
+ push([seg(" │ ", "accent"), ...parseInline(quote[1] ?? "").map((run) => seg(run.text, run.style === "plain" ? "dim" : run.style))]);
914
+ continue;
915
+ }
916
+ const ordered = ORDERED.exec(line);
917
+ if (ordered !== null) {
918
+ push([seg(` ${ordered[1] ?? ""}. `, "accent"), ...parseInline(ordered[2] ?? "").map((run) => seg(run.text, run.style))]);
919
+ continue;
920
+ }
921
+ const unordered = UNORDERED.exec(line);
922
+ if (unordered !== null) {
923
+ push([seg(" • ", "accent"), ...parseInline(unordered[1] ?? "").map((run) => seg(run.text, run.style))]);
924
+ continue;
925
+ }
926
+ const paragraph = [line];
927
+ while (index < source.length && (source[index] ?? "").trim() !== "") {
928
+ paragraph.push(source[index] ?? "");
929
+ index += 1;
930
+ }
931
+ const runs = [];
932
+ for (let at = 0; at < paragraph.length; at += 1) {
933
+ if (at > 0) runs.push({
934
+ text: " ",
935
+ style: "plain"
936
+ });
937
+ runs.push(...parseInline(paragraph[at] ?? ""));
938
+ }
939
+ push(runs.map((run) => seg(run.text, run.style)));
940
+ }
941
+ return lines;
84
942
  }
85
943
  //#endregion
86
- //#region src/whale-glyph.ts
87
- /** Half-block whale glyph rows; render with the brand color. */
88
- const WHALE_GLYPH = [
89
- " ▄▄▄▄▄▄▄▄█ ▄█▄ ▄",
90
- " ▄▄██████████▄▄ ▀███▄████",
91
- "▄███████████████▄ ███▀▀▀ ",
92
- "██ ▀▀█████▄▀██████ ",
93
- "██▄ ▀████▄▄████ ",
94
- " ██▄ ▀██████▀ ",
95
- " ▀██▄▄ ██▄ ▀███▄▄ ",
96
- " ▀▀███████▀▀ ▀▀▀ "
944
+ //#region src/render/animations.ts
945
+ /**
946
+ * Terminal animation frame tables derived from the web design language:
947
+ * the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
948
+ * steps, 1s cycle) becomes the single-cell stepped pulse below, and the
949
+ * streaming caret blink is the Claude-Code convention. Pure functions only —
950
+ * the Ink layer owns timers and colors.
951
+ *
952
+ * @module @deepseek-ai/dsh-code/render/animations
953
+ */
954
+ /** Single-cell stepped pulse: flat holds mirroring the web's 125ms keyframes. */
955
+ const PULSE_FRAMES = [
956
+ "█",
957
+ "█",
958
+ "▆",
959
+ "▃",
960
+ "▁",
961
+ "▃",
962
+ "▆",
963
+ "█"
97
964
  ];
965
+ /** Pulse frame for a monotonic tick. */
966
+ function pulseFrame(tick) {
967
+ return PULSE_FRAMES[tick % PULSE_FRAMES.length] ?? PULSE_FRAMES[0];
968
+ }
969
+ /** Caret visibility: half the ticks on, half off (530ms blink). */
970
+ function caretVisible(tick) {
971
+ return tick % 2 === 0;
972
+ }
98
973
  //#endregion
99
974
  //#region src/render/status.ts
100
975
  /**
@@ -121,6 +996,17 @@ function formatDuration(ms) {
121
996
  return `${Math.floor(whole / 60)}m${whole % 60}s`;
122
997
  }
123
998
  /**
999
+ * Compact decode rate: one decimal under a hundred, whole below a thousand,
1000
+ * then thousands (15.3 / 124 / 1.2K).
1001
+ * @param n - tokens per second.
1002
+ * @returns display string.
1003
+ */
1004
+ function formatRate(n) {
1005
+ if (n < 100) return String(Math.round(n * 10) / 10);
1006
+ if (n < 1e3) return String(Math.round(n));
1007
+ return `${Math.round(n / 100) / 10}K`;
1008
+ }
1009
+ /**
124
1010
  * Cache-hit share of billed prompt-side input.
125
1011
  * @param usage - cumulative token totals.
126
1012
  * @returns rounded integer percent, or null when no input was billed.
@@ -139,22 +1025,31 @@ function buildStatusGroups(facts, stats) {
139
1025
  const identity = [
140
1026
  facts.model,
141
1027
  facts.cwd,
142
- facts.branch === "" ? void 0 : `⑂ ${facts.branch}`
1028
+ facts.branch === "" ? void 0 : `⑂ ${facts.branch}`,
1029
+ facts.plan ? "⧉ plan" : void 0
143
1030
  ].filter((part) => part !== void 0 && part !== "");
144
1031
  if (identity.length > 0) groups.push(identity.join(" · "));
145
1032
  if (stats.turns > 0 || stats.steps > 0) {
146
1033
  groups.push(`T${stats.turns} · S${stats.steps}`);
147
1034
  const durations = [];
148
1035
  if (stats.llmMs > 0) durations.push(`llm ${formatDuration(stats.llmMs)}`);
1036
+ if (stats.ttftSteps > 0) durations.push(`ttft ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
1037
+ if (stats.decodeMs > 0 && stats.decodeTokens > 0) durations.push(`${formatRate(stats.decodeTokens / (stats.decodeMs / 1e3))} tok/s`);
149
1038
  if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`);
150
1039
  if (durations.length > 0) groups.push(durations.join(" · "));
151
1040
  }
152
1041
  const cacheHit = cacheHitPercent(stats.usage);
153
1042
  if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
154
1043
  if (cacheHit !== null) groups.push(`cache ${cacheHit}%`);
1044
+ if (stats.contextWindow > 0 && stats.lastPromptTokens > 0) groups.push(`ctx ${Math.min(999, Math.round(stats.lastPromptTokens / stats.contextWindow * 100))}%`);
155
1045
  groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`);
156
1046
  }
157
- if (facts.sessionId !== "") groups.push(facts.sessionId);
1047
+ const label = facts.title !== void 0 && facts.title !== "" ? facts.title.length > 48 ? `${facts.title.slice(0, 47)}…` : facts.title : facts.sessionId;
1048
+ if (label !== "") groups.push(label);
1049
+ if (facts.permission !== void 0 && facts.permission !== "") groups.push(facts.permission);
1050
+ const sandbox = facts.sandbox ?? "";
1051
+ if (sandbox !== "" && sandbox.toLowerCase() !== facts.permission.toLowerCase()) groups.push(`sandbox ${sandbox}`);
1052
+ if (facts.goal !== void 0) groups.push(facts.goal.phase === "active" ? `◎ r${facts.goal.rounds}/${facts.goal.max}` : `◎ ${facts.goal.phase}`);
158
1053
  return groups;
159
1054
  }
160
1055
  //#endregion
@@ -181,6 +1076,244 @@ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
181
1076
  function displayText(text) {
182
1077
  return text.replace(CONTROL_ESCAPE, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
183
1078
  }
1079
+ /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
1080
+ function cellWidth(text) {
1081
+ let columns = 0;
1082
+ for (const char of text) columns += (char.codePointAt(0) ?? 0) > 11903 ? 2 : 1;
1083
+ return columns;
1084
+ }
1085
+ /** Read one Unicode character immediately before `end`. */
1086
+ function previousCharacter(text, end) {
1087
+ const last = text.charCodeAt(end - 1);
1088
+ if (last >= 56320 && last <= 57343 && end >= 2) {
1089
+ const first = text.charCodeAt(end - 2);
1090
+ if (first >= 55296 && first <= 56319) return {
1091
+ char: text.slice(end - 2, end),
1092
+ start: end - 2
1093
+ };
1094
+ }
1095
+ return {
1096
+ char: text.slice(end - 1, end),
1097
+ start: end - 1
1098
+ };
1099
+ }
1100
+ /**
1101
+ * Keep only the newest display-safe text that fits a terminal rectangle.
1102
+ * The scan walks backward and stops as soon as the suffix is full, so a long
1103
+ * reasoning stream does not rescan its entire accumulated prefix per chunk.
1104
+ * Explicit newlines and terminal wrapping both consume rows.
1105
+ * @param text - raw externally sourced text.
1106
+ * @param columns - available terminal columns.
1107
+ * @param rows - available terminal rows.
1108
+ * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
1109
+ */
1110
+ function displayTail(text, columns, rows) {
1111
+ const columnLimit = Math.max(1, Math.floor(columns));
1112
+ const rowLimit = Math.max(1, Math.floor(rows));
1113
+ const reversed = [];
1114
+ let row = 1;
1115
+ let used = 0;
1116
+ let end = text.length;
1117
+ while (end > 0) {
1118
+ const previous = previousCharacter(text, end);
1119
+ if (previous.char === "\n") {
1120
+ if (row >= rowLimit) break;
1121
+ reversed.push("\n");
1122
+ row += 1;
1123
+ used = 0;
1124
+ end = previous.start;
1125
+ continue;
1126
+ }
1127
+ const safe = displayText(previous.char);
1128
+ const width = cellWidth(safe);
1129
+ if (used > 0 && used + width > columnLimit) {
1130
+ if (row >= rowLimit) break;
1131
+ reversed.push("\n");
1132
+ row += 1;
1133
+ used = 0;
1134
+ }
1135
+ const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit);
1136
+ if (row + extraRows > rowLimit) break;
1137
+ row += extraRows;
1138
+ reversed.push(safe);
1139
+ used = extraRows === 0 ? used + width : width - extraRows * columnLimit;
1140
+ end = previous.start;
1141
+ }
1142
+ return {
1143
+ text: reversed.reverse().join(""),
1144
+ truncated: end > 0
1145
+ };
1146
+ }
1147
+ //#endregion
1148
+ //#region src/render/inspector.ts
1149
+ /** The three-row read-only composer frame plus its one-row status footer. */
1150
+ const INSPECTOR_CHROME_ROWS = 4;
1151
+ /**
1152
+ * Keep the inspector plus its persistent status/composer chrome below
1153
+ * `stdout.rows`: at equality Ink clears the terminal and rewrites all
1154
+ * accumulated `<Static>` output on every frame.
1155
+ */
1156
+ function panelViewport(columns, rows) {
1157
+ const safeColumns = Math.max(1, Math.floor(columns));
1158
+ const safeRows = Math.max(1, Math.floor(rows));
1159
+ const maxHeight = Math.max(0, Math.min(safeRows - 2 - INSPECTOR_CHROME_ROWS, Math.floor(safeRows / 2)));
1160
+ const compact = maxHeight < 5 || safeColumns < 8;
1161
+ return {
1162
+ maxHeight,
1163
+ bodyRows: compact ? 0 : maxHeight - 4,
1164
+ contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
1165
+ compact
1166
+ };
1167
+ }
1168
+ /** Backward-compatible name for the Ctrl+O-specific caller and tests. */
1169
+ function inspectorViewport(columns, rows) {
1170
+ return panelViewport(columns, rows);
1171
+ }
1172
+ /** Clamp a first-visible row to the range representable by one viewport. */
1173
+ function clampScroll(offset, totalRows, visibleRows) {
1174
+ const last = Math.max(0, Math.max(0, Math.floor(totalRows)) - Math.max(0, Math.floor(visibleRows)));
1175
+ return Math.max(0, Math.min(Math.floor(offset), last));
1176
+ }
1177
+ /** Move a viewport by a signed row delta without escaping its content. */
1178
+ function moveScroll(offset, delta, totalRows, visibleRows) {
1179
+ return clampScroll(offset + delta, totalRows, visibleRows);
1180
+ }
1181
+ /** Keep one focused row visible while preserving the current window when possible. */
1182
+ function revealRow(offset, row, totalRows, visibleRows) {
1183
+ const size = Math.max(1, Math.floor(visibleRows));
1184
+ const target = Math.max(0, Math.min(Math.floor(row), Math.max(0, totalRows - 1)));
1185
+ if (target < offset) return clampScroll(target, totalRows, size);
1186
+ if (target >= offset + size) return clampScroll(target - size + 1, totalRows, size);
1187
+ return clampScroll(offset, totalRows, size);
1188
+ }
1189
+ /** Center a selected list row where possible, clamped at both ends. */
1190
+ function selectionWindow(cursor, totalRows, visibleRows) {
1191
+ return clampScroll(cursor - Math.floor(Math.max(1, visibleRows) / 2), totalRows, visibleRows);
1192
+ }
1193
+ /** Follow appended history only while the inspector cursor was at the tail. */
1194
+ function followInspectorCursor(cursor, previousLength, nextLength) {
1195
+ const nextLast = Math.max(0, nextLength - 1);
1196
+ if (cursor >= Math.max(0, previousLength - 1)) return nextLast;
1197
+ return Math.min(cursor, nextLast);
1198
+ }
1199
+ //#endregion
1200
+ //#region src/render/lines.ts
1201
+ /** Construct one segment without leaking mutable objects into cached rows. */
1202
+ function lineSegment(text, style = "plain") {
1203
+ return {
1204
+ text,
1205
+ style
1206
+ };
1207
+ }
1208
+ /** Append a character while merging adjacent runs with the same style. */
1209
+ function appendSegment(target, text, style) {
1210
+ const previous = target[target.length - 1];
1211
+ if (previous?.style === style) {
1212
+ target[target.length - 1] = {
1213
+ text: previous.text + text,
1214
+ style
1215
+ };
1216
+ return;
1217
+ }
1218
+ target.push({
1219
+ text,
1220
+ style
1221
+ });
1222
+ }
1223
+ /**
1224
+ * Sanitize and hard-wrap styled content into exact physical rows.
1225
+ * Tabs become two visible spaces because terminal tab stops are contextual
1226
+ * and therefore cannot participate in a deterministic row budget.
1227
+ */
1228
+ function styledLines(segments, columns) {
1229
+ const width = Math.max(1, Math.floor(columns));
1230
+ const lines = [];
1231
+ let current = [];
1232
+ let used = 0;
1233
+ const flush = () => {
1234
+ lines.push({ segments: current });
1235
+ current = [];
1236
+ used = 0;
1237
+ };
1238
+ for (const segment of segments) {
1239
+ const safe = displayText(segment.text).replaceAll(" ", " ").replaceAll("\r", "");
1240
+ for (const char of safe) {
1241
+ if (char === "\n") {
1242
+ flush();
1243
+ continue;
1244
+ }
1245
+ const cells = visibleColumns(char);
1246
+ if (used > 0 && used + cells > width) flush();
1247
+ appendSegment(current, char, segment.style);
1248
+ used += cells;
1249
+ }
1250
+ }
1251
+ if (current.length > 0 || lines.length === 0) flush();
1252
+ return lines;
1253
+ }
1254
+ /** Plain/dim text convenience over {@link styledLines}. */
1255
+ function textLines(text, columns, style = "plain") {
1256
+ return styledLines([lineSegment(text, style)], columns);
1257
+ }
1258
+ /** Markdown rows re-hardened so a single long word cannot escape the budget. */
1259
+ function markdownLines(text, columns) {
1260
+ const width = Math.max(1, Math.floor(columns));
1261
+ return renderMarkdown(displayText(text), Math.max(10, width)).flatMap((line) => styledLines(line.segments.map((segment) => lineSegment(segment.text, segment.style)), width));
1262
+ }
1263
+ /** Expanded structured tool detail as scrollable, width-safe rows. */
1264
+ function toolDetailLines(detail, columns) {
1265
+ switch (detail.kind) {
1266
+ case "diff": return detail.diffs.flatMap((diff) => [...styledLines([
1267
+ lineSegment(" ── ", "dim"),
1268
+ lineSegment(diff.path, "dim"),
1269
+ lineSegment(diff.truncated ? " (diff truncated)" : "", "dim")
1270
+ ], columns), ...diff.lines.flatMap((line) => styledLines([lineSegment(` ${line.mark}${line.text}`, line.mark === "+" ? "success" : line.mark === "-" ? "error" : "dim")], columns))]);
1271
+ case "read": return [...textLines(` ── ${detail.path} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1].number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? " (window truncated)" : ""}`, columns, "dim"), ...detail.lines.flatMap((line) => textLines(` ${String(line.number).padStart(5, " ")} | ${line.text}`, columns, "dim"))];
1272
+ case "web-search": return [...detail.sources.flatMap((source) => [...styledLines([lineSegment(` ? ${source.title ?? source.url}`, "brand"), lineSegment(` - ${source.url}`, "dim")], columns), ...source.snippet === "" ? [] : textLines(` ${source.snippet}`, columns, "dim")]), ...textLines(` ${detail.sources.length} sources${detail.truncated ? " (capped)" : ""}`, columns, "dim")];
1273
+ case "web-fetch": return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, "dim");
1274
+ case "raw": return [...textLines(` ${detail.text}`, columns, "dim"), ...textLines(detail.truncated ? " … (output truncated)" : " (end of output)", columns, "dim")];
1275
+ default: return detail;
1276
+ }
1277
+ }
1278
+ /**
1279
+ * Convert one durable transcript entry to its complete scrollable row model.
1280
+ * The source entry stays intact; only the caller's visible slice is rendered.
1281
+ */
1282
+ function transcriptEntryLines(entry, columns) {
1283
+ const width = Math.max(1, Math.floor(columns));
1284
+ switch (entry.kind) {
1285
+ case "user": return styledLines([lineSegment(entry.notice ? "⤷ " : "❯ ", entry.notice ? "dim" : "brand"), lineSegment(entry.text, entry.notice ? "dim" : "plain")], width);
1286
+ case "assistant": return [...entry.reasoning === "" ? [] : styledLines([lineSegment(" ✻ ", "dimItalic"), lineSegment(entry.reasoning, "dimItalic")], width), ...markdownLines(entry.text, width)];
1287
+ case "tool": {
1288
+ const mark = entry.state === "running" ? "●" : entry.state === "error" ? "⨯" : "⏺";
1289
+ const markStyle = entry.state === "running" ? "brand" : entry.state === "error" ? "error" : "success";
1290
+ return [
1291
+ ...styledLines([
1292
+ lineSegment(`${mark} `, markStyle),
1293
+ lineSegment(entry.name, "brand"),
1294
+ lineSegment(entry.preview === "" ? "" : ` ${entry.preview}`, "dim")
1295
+ ], width),
1296
+ ...entry.summary === "" ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === "error" ? "error" : "dim"),
1297
+ ...entry.detail === void 0 ? [] : toolDetailLines(entry.detail, width)
1298
+ ];
1299
+ }
1300
+ case "command": {
1301
+ const mark = entry.state === "running" ? "●" : entry.state === "error" ? "⨯" : "⏺";
1302
+ const markStyle = entry.state === "running" ? "brand" : entry.state === "error" ? "error" : "success";
1303
+ return [...styledLines([
1304
+ lineSegment(`${mark} `, markStyle),
1305
+ lineSegment(`/${entry.name}`, "brand"),
1306
+ lineSegment(entry.args === "" ? "" : ` ${entry.args}`, "dim")
1307
+ ], width), ...entry.summary === "" ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === "error" ? "error" : "dim")];
1308
+ }
1309
+ case "turn-marker": return textLines(` ⏹ ${entry.text}`, width, "dim");
1310
+ case "compaction": return textLines(entry.ok ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens` : ` ⧉ compaction failed: ${entry.error}`, width, "dim");
1311
+ case "retry": return textLines(` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`, width, entry.state === "running" ? "warn" : "dim");
1312
+ case "files": return entry.paths.length === 0 ? textLines(" ⎄ no changed files", width, "dim") : [...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? "" : "s"}`, width, "dim"), ...entry.paths.flatMap((path) => textLines(` ${path}`, width, "dim"))];
1313
+ case "error": return textLines(entry.text, width, "error");
1314
+ default: return entry;
1315
+ }
1316
+ }
184
1317
  //#endregion
185
1318
  //#region src/app.ts
186
1319
  /**
@@ -198,29 +1331,315 @@ function displayText(text) {
198
1331
  *
199
1332
  * @module @deepseek-ai/dsh-code/app
200
1333
  */
1334
+ /** Match Codex's settled-resize window before rebuilding terminal scrollback. */
1335
+ const RESIZE_REFLOW_DELAY_MS = 75;
1336
+ /** Reset region/style, clear the visible screen and scrollback, then home. */
1337
+ const RESIZE_REFLOW_CLEAR = "\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[3J\x1B[H";
201
1338
  /** Ink `color` string for one palette triple. */
202
1339
  function inkColor(triple) {
203
1340
  return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`;
204
1341
  }
1342
+ /** Truncate text to a visible-column budget, appending … when cut. */
1343
+ function truncateColumns(text, max) {
1344
+ let columns = 0;
1345
+ let out = "";
1346
+ for (const char of text) {
1347
+ const width = (char.codePointAt(0) ?? 0) > 11903 ? 2 : 1;
1348
+ if (columns + width > max) return `${out}…`;
1349
+ out += char;
1350
+ columns += width;
1351
+ }
1352
+ return out;
1353
+ }
1354
+ /** Pad text with spaces to a visible-column target (menu name column). */
1355
+ function padColumns(text, width) {
1356
+ return text + " ".repeat(Math.max(0, width - visibleColumns(text)));
1357
+ }
1358
+ /** Interval-driven frame counter for one self-contained animated leaf. */
1359
+ function useFrames(intervalMs) {
1360
+ const [tick, setTick] = useState(0);
1361
+ useEffect(() => {
1362
+ const id = setInterval(() => setTick((current) => current + 1), intervalMs);
1363
+ return () => {
1364
+ clearInterval(id);
1365
+ };
1366
+ }, [intervalMs]);
1367
+ return tick;
1368
+ }
1369
+ /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
1370
+ function Pulse() {
1371
+ const tick = useFrames(125);
1372
+ return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick));
1373
+ }
1374
+ /** Blinking block caret appended to streaming text. */
1375
+ function Caret() {
1376
+ const tick = useFrames(530);
1377
+ return createElement(Text, null, caretVisible(tick) ? "▍" : " ");
1378
+ }
1379
+ /** Blinking input cursor: inverse block while the caret phase is on. */
1380
+ function CursorBlock({ char }) {
1381
+ const tick = useFrames(530);
1382
+ return createElement(Text, { inverse: caretVisible(tick) || void 0 }, char);
1383
+ }
1384
+ /** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
1385
+ function runClock(ms) {
1386
+ const total = Math.max(0, Math.floor(ms / 1e3));
1387
+ const minutes = Math.floor(total / 60);
1388
+ const seconds = total % 60;
1389
+ return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, "0")}s` : `${seconds}s`;
1390
+ }
1391
+ /**
1392
+ * The busy line, web TurnStatus contract: the plain `Deep diving...` label,
1393
+ * with the elapsed clock appended only once the turn has clearly been running
1394
+ * (15s) — anchored to `turn/start` so a resumed mid-turn keeps the real time.
1395
+ */
1396
+ function DeepDivingLine({ since }) {
1397
+ useFrames(1e3);
1398
+ const elapsed = since === 0 ? 0 : Date.now() - since;
1399
+ return createElement(Text, { dimColor: true }, elapsed >= 15e3 ? `Deep diving... ${runClock(elapsed)}` : "Deep diving...");
1400
+ }
1401
+ /**
1402
+ * The streaming buffer rendered with a hard size cap: the live region must
1403
+ * ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
1404
+ * than the screen freezes (cursor-up past the top, garbage, no scroll). The
1405
+ * cap counts explicit newlines and terminal wrapping, slicing from the END so
1406
+ * the freshest tokens stay visible while a long reply streams; the complete
1407
+ * text lands in the flushed scrollback once the turn assembles it.
1408
+ */
1409
+ function StreamTail({ text, dim, maxRows, prefix, children }) {
1410
+ const columns = useStdout().stdout?.columns ?? 80;
1411
+ const safeRows = Math.max(1, maxRows);
1412
+ const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ""));
1413
+ const initial = displayTail(text, contentColumns, safeRows);
1414
+ const tail = initial.truncated && safeRows > 1 ? displayTail(text, contentColumns, safeRows - 1) : initial;
1415
+ return createElement(Box, { flexDirection: "column" }, tail.truncated && safeRows > 1 ? createElement(Text, { dimColor: true }, " …") : void 0, createElement(Text, { dimColor: dim || void 0 }, prefix, tail.text, children));
1416
+ }
1417
+ /** Ink props for one markdown style class. */
1418
+ function segmentProps(style) {
1419
+ switch (style) {
1420
+ case "accent": return {
1421
+ color: inkColor(TUI_RGB.brandBright),
1422
+ bold: void 0,
1423
+ italic: void 0,
1424
+ strikethrough: void 0
1425
+ };
1426
+ case "code": return {
1427
+ color: inkColor(TUI_RGB.code),
1428
+ bold: void 0,
1429
+ italic: void 0,
1430
+ strikethrough: void 0
1431
+ };
1432
+ case "dim": return {
1433
+ color: inkColor(TUI_RGB.dim),
1434
+ bold: void 0,
1435
+ italic: void 0,
1436
+ strikethrough: void 0
1437
+ };
1438
+ case "bold": return {
1439
+ color: void 0,
1440
+ bold: true,
1441
+ italic: void 0,
1442
+ strikethrough: void 0
1443
+ };
1444
+ case "italic": return {
1445
+ color: void 0,
1446
+ bold: void 0,
1447
+ italic: true,
1448
+ strikethrough: void 0
1449
+ };
1450
+ case "boldItalic": return {
1451
+ color: void 0,
1452
+ bold: true,
1453
+ italic: true,
1454
+ strikethrough: void 0
1455
+ };
1456
+ case "strike": return {
1457
+ color: inkColor(TUI_RGB.dim),
1458
+ bold: void 0,
1459
+ italic: void 0,
1460
+ strikethrough: true
1461
+ };
1462
+ default: return {
1463
+ color: void 0,
1464
+ bold: void 0,
1465
+ italic: void 0,
1466
+ strikethrough: void 0
1467
+ };
1468
+ }
1469
+ }
1470
+ /** Ink props for the richer line model used by bounded scrolling panels. */
1471
+ function lineStyleProps(style) {
1472
+ switch (style) {
1473
+ case "brand": return {
1474
+ color: inkColor(TUI_RGB.brandBright),
1475
+ bold: void 0,
1476
+ italic: void 0,
1477
+ strikethrough: void 0,
1478
+ dimColor: void 0
1479
+ };
1480
+ case "success": return {
1481
+ color: inkColor(TUI_RGB.success),
1482
+ bold: void 0,
1483
+ italic: void 0,
1484
+ strikethrough: void 0,
1485
+ dimColor: void 0
1486
+ };
1487
+ case "error": return {
1488
+ color: inkColor(TUI_RGB.error),
1489
+ bold: void 0,
1490
+ italic: void 0,
1491
+ strikethrough: void 0,
1492
+ dimColor: void 0
1493
+ };
1494
+ case "warn": return {
1495
+ color: inkColor(TUI_RGB.warn),
1496
+ bold: void 0,
1497
+ italic: void 0,
1498
+ strikethrough: void 0,
1499
+ dimColor: void 0
1500
+ };
1501
+ case "dimItalic": return {
1502
+ color: void 0,
1503
+ bold: void 0,
1504
+ italic: true,
1505
+ strikethrough: void 0,
1506
+ dimColor: true
1507
+ };
1508
+ default: return {
1509
+ ...segmentProps(style),
1510
+ dimColor: void 0
1511
+ };
1512
+ }
1513
+ }
1514
+ /** Render width-safe rows; every child is exactly one terminal row. */
1515
+ function StyledRows({ lines }) {
1516
+ return createElement(Box, { flexDirection: "column" }, ...lines.map((line, index) => createElement(Text, {
1517
+ key: index,
1518
+ wrap: "truncate-end"
1519
+ }, line.segments.length === 0 ? " " : line.segments.map((segment, at) => createElement(Text, {
1520
+ key: at,
1521
+ ...lineStyleProps(segment.style)
1522
+ }, segment.text)))));
1523
+ }
1524
+ /** One settled markdown document rendered as styled lines at the terminal width. */
1525
+ function MarkdownBody({ text }) {
1526
+ const columns = useStdout().stdout?.columns ?? 80;
1527
+ const lines = useMemo(() => renderMarkdown(displayText(text), Math.max(20, columns - 2)), [text, columns]);
1528
+ return createElement(Box, { flexDirection: "column" }, ...lines.map((line, index) => createElement(Text, { key: index }, ...line.segments.map((segment, at) => createElement(Text, {
1529
+ key: at,
1530
+ ...segmentProps(segment.style)
1531
+ }, segment.text)))));
1532
+ }
1533
+ /**
1534
+ * One expanded tool-card body for the verbose transcript (Ctrl+O): the
1535
+ * presentation contract's structured cards — inline diffs, read windows,
1536
+ * web sources — rendered as plain terminal rows, degradation-safe against
1537
+ * replayed metadata.
1538
+ */
1539
+ function ToolDetailBody({ detail }) {
1540
+ switch (detail.kind) {
1541
+ case "diff": return createElement(Box, { flexDirection: "column" }, ...detail.diffs.map((diff, index) => createElement(Box, {
1542
+ key: index,
1543
+ flexDirection: "column"
1544
+ }, createElement(Text, {
1545
+ dimColor: true,
1546
+ wrap: "truncate-end"
1547
+ }, ` ── ${displayText(diff.path)}${diff.truncated ? " (diff truncated)" : ""}`), ...diff.lines.map((line, at) => createElement(Text, {
1548
+ key: at,
1549
+ color: line.mark === "+" ? inkColor(TUI_RGB.success) : line.mark === "-" ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
1550
+ wrap: "truncate-end"
1551
+ }, ` ${line.mark}${displayText(line.text)}`)))));
1552
+ case "read": return createElement(Box, { flexDirection: "column" }, createElement(Text, {
1553
+ dimColor: true,
1554
+ wrap: "truncate-end"
1555
+ }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1].number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? " (window truncated)" : ""}`), ...detail.lines.map((line, at) => createElement(Text, {
1556
+ key: at,
1557
+ dimColor: true,
1558
+ wrap: "truncate-end"
1559
+ }, ` ${String(line.number).padStart(5, " ")} | ${displayText(line.text)}`)));
1560
+ case "web-search": return createElement(Box, { flexDirection: "column" }, ...detail.sources.map((source, at) => createElement(Text, {
1561
+ key: at,
1562
+ wrap: "truncate-end"
1563
+ }, brand(` ? ${displayText(source.title === void 0 ? source.url : source.title)}`), createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)))), createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? " (capped)" : ""}`)));
1564
+ case "web-fetch": return createElement(Text, {
1565
+ dimColor: true,
1566
+ wrap: "truncate-end"
1567
+ }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`));
1568
+ case "raw": return createElement(Box, { flexDirection: "column" }, ...displayText(detail.text).split("\n").slice(0, 40).map((line, at) => createElement(Text, {
1569
+ key: at,
1570
+ dimColor: true,
1571
+ wrap: "truncate-end"
1572
+ }, ` ${line}`)), createElement(Text, { dimColor: true }, detail.truncated ? " … (output truncated)" : " (end of output)"));
1573
+ default: return assertNever(detail, "tool detail kind");
1574
+ }
1575
+ }
205
1576
  /** One settled transcript row. */
206
- function EntryLine({ entry }) {
1577
+ function EntryLine({ entry, showReasoning, verbose }) {
207
1578
  switch (entry.kind) {
208
- case "user": return createElement(Text, null, brand("❯ "), displayText(entry.text));
209
- case "assistant": return createElement(Text, null, displayText(entry.text));
1579
+ case "user": return entry.notice ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`) : createElement(Text, null, brand("❯ "), displayText(entry.text));
1580
+ case "assistant": return createElement(Box, { flexDirection: "column" }, entry.reasoning === "" ? void 0 : showReasoning ? createElement(Text, {
1581
+ dimColor: true,
1582
+ italic: true
1583
+ }, ` ✻ ${displayText(entry.reasoning)}`) : createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`), createElement(MarkdownBody, { text: entry.text }));
210
1584
  case "tool": {
211
- const mark = entry.state === "running" ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "◐") : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
212
- return createElement(Text, null, mark, " ", brand(entry.name), entry.summary === "" ? "" : ` ${dim(displayText(entry.summary))}`);
1585
+ const mark = entry.state === "running" ? createElement(Pulse) : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
1586
+ return createElement(Box, { flexDirection: "column" }, createElement(Text, { wrap: verbose ? "truncate-end" : void 0 }, mark, " ", brand(entry.name), entry.preview === "" ? "" : ` ${dim(displayText(entry.preview))}`), entry.summary === "" ? void 0 : createElement(Text, {
1587
+ color: entry.state === "error" ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
1588
+ wrap: verbose ? "truncate-end" : void 0
1589
+ }, ` ⎿ ${displayText(entry.summary)}`), verbose && entry.detail !== void 0 ? createElement(ToolDetailBody, { detail: entry.detail }) : void 0);
213
1590
  }
214
1591
  case "command": {
215
- const mark = entry.state === "running" ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "◐") : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
216
- return createElement(Text, null, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`, entry.summary === "" ? "" : ` ${dim(displayText(entry.summary))}`);
1592
+ const mark = entry.state === "running" ? createElement(Pulse) : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
1593
+ return createElement(Box, { flexDirection: "column" }, createElement(Text, { wrap: verbose ? "truncate-end" : void 0 }, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`), entry.summary === "" ? void 0 : createElement(Text, {
1594
+ color: inkColor(TUI_RGB.dim),
1595
+ wrap: verbose ? "truncate-end" : void 0
1596
+ }, ` ⎿ ${displayText(entry.summary)}`));
1597
+ }
1598
+ case "turn-marker": return createElement(Text, {
1599
+ dimColor: true,
1600
+ wrap: verbose ? "truncate-end" : void 0
1601
+ }, ` ⏹ ${displayText(entry.text)}`);
1602
+ case "compaction": return createElement(Text, {
1603
+ dimColor: true,
1604
+ wrap: verbose ? "truncate-end" : void 0
1605
+ }, entry.ok ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens` : ` ⧉ compaction failed: ${displayText(entry.error)}`);
1606
+ case "retry": return createElement(Text, {
1607
+ color: entry.state === "running" ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim),
1608
+ wrap: verbose ? "truncate-end" : void 0
1609
+ }, ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`);
1610
+ case "files": {
1611
+ const shown = entry.paths.slice(0, 3).map((path) => displayText(path)).join(" · ");
1612
+ const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : "";
1613
+ return createElement(Text, {
1614
+ dimColor: true,
1615
+ wrap: verbose ? "truncate-end" : void 0
1616
+ }, ` ⎄ ${shown}${more}`);
217
1617
  }
218
- case "error": return createElement(Text, null, error(displayText(entry.text)));
1618
+ case "error": return createElement(Text, { wrap: verbose ? "truncate-end" : void 0 }, error(displayText(entry.text)));
219
1619
  default: return assertNever(entry, "transcript entry kind");
220
1620
  }
221
1621
  }
222
- /** The whale wordmark header in DeepSeek blue, hugging its content width. */
1622
+ /**
1623
+ * The whale wordmark header in DeepSeek blue, hugging its content width.
1624
+ * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
1625
+ * short to show it whole (or mid-resize) the clipped pairs garble the
1626
+ * screen — below the height floor the header collapses to a single-line
1627
+ * wordmark that stays correct at any size.
1628
+ */
223
1629
  function Header({ resumed }) {
1630
+ const rows = useStdout().stdout?.rows ?? 40;
1631
+ const hint = resumed ? "resumed session · /help commands · Esc interrupt" : "/help commands · Esc interrupt · Ctrl+C quit";
1632
+ if (rows < 20) return createElement(Box, {
1633
+ flexDirection: "row",
1634
+ gap: 1,
1635
+ borderStyle: "round",
1636
+ borderColor: inkColor(TUI_RGB.brand),
1637
+ paddingX: 1,
1638
+ alignSelf: "flex-start"
1639
+ }, createElement(Text, {
1640
+ color: inkColor(TUI_RGB.brand),
1641
+ bold: true
1642
+ }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, hint));
224
1643
  return createElement(Box, {
225
1644
  flexDirection: "row",
226
1645
  gap: 1,
@@ -240,32 +1659,24 @@ function Header({ resumed }) {
240
1659
  }, createElement(Text, {
241
1660
  color: inkColor(TUI_RGB.brand),
242
1661
  bold: true
243
- }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, resumed ? "resumed session · /help commands · Esc interrupt" : "/help commands · Esc interrupt · Ctrl+C quit")));
1662
+ }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, hint)));
244
1663
  }
245
1664
  /** Todo status glyph: web TodoPanel's three-state marker. */
246
1665
  function todoMark(status) {
247
1666
  return status === "completed" ? "✓" : status === "in_progress" ? "●" : "○";
248
1667
  }
249
- /** Inline todo list (web TodoPanel's compact terminal form). */
1668
+ /** One-row todo summary: task count cannot grow the live Ink tree. */
250
1669
  function TodoPanel({ todos }) {
251
1670
  if (todos.length === 0) return void 0;
252
1671
  const completed = todos.filter((todo) => todo.status === "completed").length;
253
1672
  const inProgress = todos.filter((todo) => todo.status === "in_progress").length;
254
1673
  const pending = todos.length - completed - inProgress;
255
- return createElement(Box, {
256
- flexDirection: "column",
257
- paddingX: 1,
258
- borderStyle: "round",
259
- borderColor: inkColor(TUI_RGB.brandDeep),
260
- alignSelf: "flex-start",
261
- marginLeft: 1
262
- }, createElement(Text, {
1674
+ const current = todos.find((todo) => todo.status === "in_progress");
1675
+ return createElement(Box, { paddingX: 1 }, createElement(Text, {
263
1676
  color: inkColor(TUI_RGB.brand),
264
- bold: true
265
- }, `todos ${completed}/${todos.length}`, createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`)), ...todos.map((todo, index) => createElement(Text, {
266
- key: index,
267
- color: todo.status === "completed" ? inkColor(TUI_RGB.success) : todo.status === "in_progress" ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
268
- }, `${todoMark(todo.status)} ${displayText(todo.content)}`)));
1677
+ bold: true,
1678
+ wrap: "truncate-end"
1679
+ }, `todos ${completed}/${todos.length}`, createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`), current === void 0 ? "" : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`)));
269
1680
  }
270
1681
  /**
271
1682
  * The footer status line: Claude-Code-style identity facts (model, working
@@ -275,39 +1686,304 @@ function TodoPanel({ todos }) {
275
1686
  */
276
1687
  function StatusLine({ facts, stats, busy }) {
277
1688
  const groups = buildStatusGroups(facts, stats);
278
- const children = [busy ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "● ") : createElement(Text, { color: inkColor(TUI_RGB.brand) }, "○ ")];
279
- groups.forEach((group, index) => {
280
- if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(" | ")));
281
- children.push(createElement(Text, { dimColor: true }, group));
282
- });
283
- return createElement(Box, { paddingX: 1 }, ...children);
1689
+ return createElement(Box, { paddingLeft: 2 }, createElement(Text, {
1690
+ dimColor: true,
1691
+ wrap: "truncate-end"
1692
+ }, busy ? "● " : "○ ", groups.join(" | ")));
284
1693
  }
285
1694
  /** The y/n approval bar rendered while an approval ask is pending. */
286
- function ApprovalBar({ approval }) {
1695
+ function ApprovalBar({ approval, locked }) {
287
1696
  const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot);
1697
+ const stdout = useStdout().stdout;
1698
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
1699
+ const [scroll, setScroll] = useState(0);
1700
+ const pending = snapshot.pending;
1701
+ const active = !locked && snapshot.pending !== void 0 && !snapshot.answered;
1702
+ const content = useMemo(() => pending === void 0 ? [] : [...styledLines([lineSegment(pending.headline, "warn")], viewport.contentColumns), ...pending.command === "" ? [] : textLines(` ${pending.command}`, viewport.contentColumns, "dim")], [pending, viewport.contentColumns]);
1703
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows);
1704
+ useEffect(() => {
1705
+ setScroll(0);
1706
+ }, [pending]);
1707
+ useEffect(() => {
1708
+ if (visibleScroll !== scroll) setScroll(visibleScroll);
1709
+ }, [visibleScroll, scroll]);
1710
+ useInput((input, key) => {
1711
+ if (snapshot.pending === void 0) return;
1712
+ if (key.upArrow) {
1713
+ setScroll((current) => moveScroll(current, -1, content.length, viewport.bodyRows));
1714
+ return;
1715
+ }
1716
+ if (key.downArrow) {
1717
+ setScroll((current) => moveScroll(current, 1, content.length, viewport.bodyRows));
1718
+ return;
1719
+ }
1720
+ if (key.pageUp) {
1721
+ setScroll((current) => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows));
1722
+ return;
1723
+ }
1724
+ if (key.pageDown) {
1725
+ setScroll((current) => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows));
1726
+ return;
1727
+ }
1728
+ if (snapshot.answered) return;
1729
+ if (input === "y" || input === "Y") {
1730
+ snapshot.pending.answer("allowed-once");
1731
+ return;
1732
+ }
1733
+ if (input === "n" || input === "N") snapshot.pending.answer("rejected");
1734
+ }, { isActive: active });
288
1735
  if (snapshot.pending === void 0) return void 0;
289
- const { pending, answered } = snapshot;
1736
+ if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
1737
+ if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("approval · y allow · n reject", viewport.contentColumns));
1738
+ const { answered } = snapshot;
1739
+ return createElement(Box, {
1740
+ flexDirection: "column",
1741
+ paddingX: 1,
1742
+ borderStyle: "round",
1743
+ borderColor: inkColor(TUI_RGB.warn)
1744
+ }, createElement(Text, {
1745
+ color: inkColor(TUI_RGB.warn),
1746
+ bold: true,
1747
+ wrap: "truncate-end"
1748
+ }, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)), createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), createElement(Text, {
1749
+ dimColor: true,
1750
+ wrap: "truncate-end"
1751
+ }, dim(truncateColumns(answered ? "submitted…" : "↑↓/pgup/pgdn scroll · y allow once · n reject", viewport.contentColumns))));
1752
+ }
1753
+ /**
1754
+ * The ask_user_question bar: walks one request question by question,
1755
+ * renders the option menu (Claude-Code style: arrows move, space toggles a
1756
+ * multi-select, enter submits, `c` opens the custom-answer box, Esc
1757
+ * interrupts the question as aborted). Plan reviews arrive through the same
1758
+ * service with a `plan-review` intent — the approve option gets a ✓ mark,
1759
+ * the answer encoding stays identical.
1760
+ */
1761
+ function QuestionBar({ store, locked }) {
1762
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
1763
+ const stdout = useStdout().stdout;
1764
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
1765
+ const pending = snapshot.pending;
1766
+ const [index, setIndex] = useState(0);
1767
+ const [cursor, setCursor] = useState(0);
1768
+ const [selected, setSelected] = useState([]);
1769
+ const [mode, setMode] = useState("options");
1770
+ const [custom, setCustom] = useState("");
1771
+ const [answers, setAnswers] = useState([]);
1772
+ const [submitted, setSubmitted] = useState(false);
1773
+ const [scroll, setScroll] = useState(0);
1774
+ useEffect(() => {
1775
+ const question = pending?.request.questions[0];
1776
+ setIndex(0);
1777
+ setCursor(0);
1778
+ setSelected([]);
1779
+ setMode(question?.options === void 0 || question.options.length === 0 ? "custom" : "options");
1780
+ setCustom("");
1781
+ setAnswers([]);
1782
+ setSubmitted(false);
1783
+ setScroll(0);
1784
+ }, [pending]);
1785
+ const question = pending?.request.questions[index];
1786
+ const options = question?.options ?? [];
1787
+ const isPlan = question?.intent?.kind === "plan-review";
1788
+ const isMulti = question?.multiSelect === true;
1789
+ const active = !locked && pending !== void 0 && question !== void 0 && !submitted;
1790
+ const rendered = useMemo(() => {
1791
+ if (question === void 0) return {
1792
+ lines: [],
1793
+ optionRows: []
1794
+ };
1795
+ const lines = [];
1796
+ const optionRows = [];
1797
+ if (question.header !== void 0) lines.push(...styledLines([lineSegment(question.header, "bold")], viewport.contentColumns));
1798
+ lines.push(...textLines(question.question, viewport.contentColumns));
1799
+ if (question.detail !== void 0) lines.push(...isPlan ? markdownLines(question.detail, viewport.contentColumns) : textLines(question.detail, viewport.contentColumns, "dim"));
1800
+ if (submitted) lines.push(...textLines(" submitted…", viewport.contentColumns, "dim"));
1801
+ else if (mode === "custom" || options.length === 0) lines.push(...styledLines([
1802
+ lineSegment(" custom: ", "brand"),
1803
+ lineSegment(custom, "plain"),
1804
+ lineSegment("▌", "brand")
1805
+ ], viewport.contentColumns));
1806
+ else options.forEach((option, at) => {
1807
+ optionRows.push(lines.length);
1808
+ const chosen = isMulti && selected.includes(at);
1809
+ const approve = isPlan && question.intent?.approve === option.label;
1810
+ const mark = approve ? "✓ " : chosen ? "◉ " : at === cursor ? "❯ " : " ";
1811
+ const style = at === cursor ? "brand" : chosen || approve ? "success" : "plain";
1812
+ lines.push(...styledLines([
1813
+ lineSegment(mark, style),
1814
+ lineSegment(option.label, style),
1815
+ lineSegment(option.description === void 0 ? "" : ` — ${option.description}`, "dim")
1816
+ ], viewport.contentColumns));
1817
+ });
1818
+ return {
1819
+ lines,
1820
+ optionRows
1821
+ };
1822
+ }, [
1823
+ question,
1824
+ isPlan,
1825
+ submitted,
1826
+ mode,
1827
+ options,
1828
+ custom,
1829
+ isMulti,
1830
+ selected,
1831
+ cursor,
1832
+ viewport.contentColumns
1833
+ ]);
1834
+ const visibleScroll = clampScroll(scroll, rendered.lines.length, viewport.bodyRows);
1835
+ useEffect(() => {
1836
+ if (visibleScroll !== scroll) setScroll(visibleScroll);
1837
+ }, [visibleScroll, scroll]);
1838
+ useEffect(() => {
1839
+ if (mode === "options" && options.length > 0) {
1840
+ const focused = rendered.optionRows[cursor] ?? 0;
1841
+ setScroll((current) => revealRow(current, focused, rendered.lines.length, viewport.bodyRows));
1842
+ return;
1843
+ }
1844
+ setScroll(Math.max(0, rendered.lines.length - viewport.bodyRows));
1845
+ }, [
1846
+ cursor,
1847
+ mode,
1848
+ custom.length,
1849
+ rendered.lines.length,
1850
+ viewport.bodyRows
1851
+ ]);
1852
+ const commit = (answer) => {
1853
+ if (pending === void 0) return;
1854
+ const next = [...answers, answer];
1855
+ const total = pending.request.questions.length;
1856
+ if (index + 1 >= total) {
1857
+ setSubmitted(true);
1858
+ store.submit(pending, { answers: next });
1859
+ return;
1860
+ }
1861
+ setAnswers(next);
1862
+ const nextIndex = index + 1;
1863
+ const nextQuestion = pending.request.questions[nextIndex];
1864
+ setIndex(nextIndex);
1865
+ setCursor(0);
1866
+ setSelected([]);
1867
+ setMode(nextQuestion?.options === void 0 || nextQuestion.options.length === 0 ? "custom" : "options");
1868
+ setCustom("");
1869
+ setScroll(0);
1870
+ };
1871
+ const commitOption = () => {
1872
+ if (pending === void 0 || question === void 0) return;
1873
+ if (isMulti) {
1874
+ const labels = selected.map((at) => options[at]?.label).filter((label) => label !== void 0);
1875
+ const customText = custom.trim();
1876
+ commit({
1877
+ id: question.id,
1878
+ selected: labels,
1879
+ ...customText === "" ? {} : { custom: customText }
1880
+ });
1881
+ return;
1882
+ }
1883
+ const option = options[cursor];
1884
+ if (option === void 0) return;
1885
+ commit({
1886
+ id: question.id,
1887
+ selected: [option.label]
1888
+ });
1889
+ };
1890
+ useInput((input, key) => {
1891
+ if (pending === void 0 || question === void 0 || submitted) return;
1892
+ if (key.escape) {
1893
+ store.cancel(pending);
1894
+ return;
1895
+ }
1896
+ if (key.pageUp) {
1897
+ setScroll((current) => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows));
1898
+ return;
1899
+ }
1900
+ if (key.pageDown) {
1901
+ setScroll((current) => moveScroll(current, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows));
1902
+ return;
1903
+ }
1904
+ if (mode === "custom" || options.length === 0) {
1905
+ if (key.upArrow) {
1906
+ setScroll((current) => moveScroll(current, -1, rendered.lines.length, viewport.bodyRows));
1907
+ return;
1908
+ }
1909
+ if (key.downArrow) {
1910
+ setScroll((current) => moveScroll(current, 1, rendered.lines.length, viewport.bodyRows));
1911
+ return;
1912
+ }
1913
+ if (key.return) {
1914
+ if (custom.trim() === "" && options.length > 0) {
1915
+ commitOption();
1916
+ return;
1917
+ }
1918
+ commit({
1919
+ id: question.id,
1920
+ selected: isMulti ? selected.map((at) => options[at]?.label).filter((label) => label !== void 0) : [],
1921
+ ...custom.trim() === "" ? {} : { custom: custom.trim() }
1922
+ });
1923
+ return;
1924
+ }
1925
+ if (key.backspace) {
1926
+ setCustom((current) => current.slice(0, -1));
1927
+ return;
1928
+ }
1929
+ if (input !== "" && !key.ctrl && !key.meta) setCustom((current) => current + input);
1930
+ return;
1931
+ }
1932
+ if (key.upArrow) {
1933
+ setCursor((current) => (current + options.length - 1) % options.length);
1934
+ return;
1935
+ }
1936
+ if (key.downArrow) {
1937
+ setCursor((current) => (current + 1) % options.length);
1938
+ return;
1939
+ }
1940
+ if (key.return) {
1941
+ commitOption();
1942
+ return;
1943
+ }
1944
+ if (key.tab || input === "c" || input === "C") {
1945
+ setMode("custom");
1946
+ return;
1947
+ }
1948
+ if (input === " " && isMulti) setSelected((current) => current.includes(cursor) ? current.filter((at) => at !== cursor) : [...current, cursor]);
1949
+ }, { isActive: active });
1950
+ if (pending === void 0 || question === void 0) return void 0;
1951
+ if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
1952
+ if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns(isPlan ? "plan review · esc cancel" : "question · esc cancel", viewport.contentColumns));
1953
+ const footer = submitted ? "submitted…" : mode === "custom" || options.length === 0 ? "↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt" : isMulti ? "↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt" : "↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt";
290
1954
  return createElement(Box, {
291
1955
  flexDirection: "column",
292
1956
  paddingX: 1,
293
1957
  borderStyle: "round",
294
- borderColor: inkColor(TUI_RGB.warn),
295
- alignSelf: "flex-start",
296
- marginLeft: 1
1958
+ borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep)
297
1959
  }, createElement(Text, {
298
- color: inkColor(TUI_RGB.warn),
299
- bold: true
300
- }, "⏸ waiting for approval"), createElement(Text, null, warn(displayText(pending.headline))), pending.command === "" ? void 0 : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)), answered ? createElement(Text, { dimColor: true }, " submitted…") : createElement(Text, { dimColor: true }, dim(" y allow once · n reject")));
1960
+ color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep),
1961
+ bold: true,
1962
+ wrap: "truncate-end"
1963
+ }, truncateColumns(`${isPlan ? "📋 plan review" : "❓ question"} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns)), createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), createElement(Text, {
1964
+ dimColor: true,
1965
+ wrap: "truncate-end"
1966
+ }, dim(truncateColumns(footer, viewport.contentColumns))));
301
1967
  }
302
1968
  /** The /model panel: a scrolling list over the advisory model directory. */
303
1969
  function ModelPanel({ directory, error, onSelect, onClose }) {
304
1970
  const [cursor, setCursor] = useState(0);
1971
+ const stdout = useStdout().stdout;
1972
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
1973
+ const rows = directory?.rows ?? [];
1974
+ useEffect(() => {
1975
+ if (rows.length === 0) {
1976
+ if (cursor !== 0) setCursor(0);
1977
+ return;
1978
+ }
1979
+ if (cursor >= rows.length) setCursor(rows.length - 1);
1980
+ }, [rows.length, cursor]);
305
1981
  useInput((input, key) => {
306
1982
  if (key.escape || input === "q") {
307
1983
  onClose();
308
1984
  return;
309
1985
  }
310
- const rows = directory?.rows ?? [];
1986
+ if (rows.length === 0) return;
311
1987
  if (key.upArrow) {
312
1988
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
313
1989
  return;
@@ -316,31 +1992,301 @@ function ModelPanel({ directory, error, onSelect, onClose }) {
316
1992
  setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
317
1993
  return;
318
1994
  }
1995
+ if (key.pageUp) {
1996
+ setCursor((current) => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)));
1997
+ return;
1998
+ }
1999
+ if (key.pageDown) {
2000
+ setCursor((current) => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)));
2001
+ return;
2002
+ }
2003
+ if (input === "g") {
2004
+ setCursor(0);
2005
+ return;
2006
+ }
2007
+ if (input === "G") {
2008
+ setCursor(rows.length - 1);
2009
+ return;
2010
+ }
319
2011
  if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
320
2012
  });
321
- const rows = directory?.rows ?? [];
322
- const window = 8;
323
- const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window));
324
- const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window);
2013
+ if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
2014
+ if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("/model · esc/q close", viewport.contentColumns));
2015
+ const first = selectionWindow(cursor, rows.length, viewport.bodyRows);
2016
+ const visible = rows.slice(first, first + viewport.bodyRows);
325
2017
  return createElement(Box, {
326
2018
  flexDirection: "column",
327
2019
  paddingX: 1,
328
2020
  borderStyle: "round",
329
- borderColor: inkColor(TUI_RGB.brand),
330
- alignSelf: "flex-start",
331
- marginLeft: 1
2021
+ borderColor: inkColor(TUI_RGB.brand)
332
2022
  }, createElement(Text, {
333
2023
  color: inkColor(TUI_RGB.brand),
334
- bold: true
335
- }, "/model — select the model for the next step"), directory === void 0 && error === void 0 ? createElement(Text, { dimColor: true }, " loading models…") : void 0, error !== void 0 ? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`) : void 0, ...visible.map((row) => {
2024
+ bold: true,
2025
+ wrap: "truncate-end"
2026
+ }, truncateColumns(`/model — select model${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)), directory === void 0 && error === void 0 ? createElement(Text, {
2027
+ dimColor: true,
2028
+ wrap: "truncate-end"
2029
+ }, " loading models…") : void 0, error !== void 0 ? createElement(Text, {
2030
+ color: inkColor(TUI_RGB.error),
2031
+ wrap: "truncate-end"
2032
+ }, truncateColumns(` ${displayText(error)}`, viewport.contentColumns)) : void 0, ...visible.map((row) => {
336
2033
  const index = rows.indexOf(row);
337
2034
  const label = displayText(`${row.providerName} · ${row.modelName}`);
338
2035
  return createElement(Text, {
339
2036
  key: `${row.provider}/${row.model}`,
340
- color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
341
- }, `${index === cursor ? "❯ " : " "}${label}`);
342
- }), createElement(Text, { dimColor: true }, dim(" ↑↓ move · enter select · esc close")));
2037
+ color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
2038
+ wrap: "truncate-end"
2039
+ }, truncateColumns(`${index === cursor ? " " : " "}${label}`, viewport.contentColumns));
2040
+ }), createElement(Text, {
2041
+ dimColor: true,
2042
+ wrap: "truncate-end"
2043
+ }, dim(truncateColumns("↑↓ move · pgup/pgdn page · g/G ends · enter select · esc/q close", viewport.contentColumns))));
2044
+ }
2045
+ /**
2046
+ * The /help overlay: one scrolling card with the keyboard map, the TUI-local
2047
+ * commands, the live registry commands, and the user-invocable skills — the
2048
+ * real command surface, replacing the one-line notice.
2049
+ */
2050
+ function HelpPanel({ descriptors, skills, onClose }) {
2051
+ const stdout = useStdout().stdout;
2052
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
2053
+ const [scroll, setScroll] = useState(0);
2054
+ const nameWidth = 18;
2055
+ const descBudget = Math.max(1, viewport.contentColumns - nameWidth - 2);
2056
+ const row = (label, description) => createElement(Text, {
2057
+ dimColor: true,
2058
+ wrap: "truncate-end"
2059
+ }, ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`);
2060
+ const content = [
2061
+ createElement(Text, {
2062
+ key: "keys-title",
2063
+ bold: true,
2064
+ wrap: "truncate-end"
2065
+ }, " keys"),
2066
+ createElement(Text, {
2067
+ key: "key-submit",
2068
+ dimColor: true,
2069
+ wrap: "truncate-end"
2070
+ }, " enter submit · alt+enter / ctrl+j newline · up/down history · tab complete"),
2071
+ createElement(Text, {
2072
+ key: "key-mentions",
2073
+ dimColor: true,
2074
+ wrap: "truncate-end"
2075
+ }, " tab also completes bare workspace paths · @ mentions files and sessions"),
2076
+ createElement(Text, {
2077
+ key: "key-inspector",
2078
+ dimColor: true,
2079
+ wrap: "truncate-end"
2080
+ }, " ctrl+o history details · ctrl+r thinking · shift+tab permission preset"),
2081
+ createElement(Text, {
2082
+ key: "key-cancel",
2083
+ dimColor: true,
2084
+ wrap: "truncate-end"
2085
+ }, " esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit"),
2086
+ createElement(Text, {
2087
+ key: "key-edit",
2088
+ dimColor: true,
2089
+ wrap: "truncate-end"
2090
+ }, " ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends"),
2091
+ createElement(Text, {
2092
+ key: "commands-title",
2093
+ bold: true,
2094
+ wrap: "truncate-end"
2095
+ }, " commands"),
2096
+ createElement(Box, { key: "local-help" }, row("/help", "show this overlay")),
2097
+ createElement(Box, { key: "local-model" }, row("/model", "switch the model")),
2098
+ createElement(Box, { key: "local-clear" }, row("/clear", "clear the screen")),
2099
+ createElement(Box, { key: "local-export" }, row("/export", "export the transcript to markdown (/export [path])")),
2100
+ createElement(Box, { key: "local-title" }, row("/title", "rename this session (/title <text>)")),
2101
+ createElement(Box, { key: "local-quit" }, row("/quit", "exit")),
2102
+ ...descriptors.map((descriptor) => createElement(Text, {
2103
+ key: `command-${descriptor.name}`,
2104
+ dimColor: true,
2105
+ wrap: "truncate-end"
2106
+ }, ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`)),
2107
+ ...skills.length === 0 ? [] : [createElement(Text, {
2108
+ key: "skills-title",
2109
+ bold: true,
2110
+ wrap: "truncate-end"
2111
+ }, " skills")],
2112
+ ...skills.map((skill) => createElement(Text, {
2113
+ key: `skill-${skill.name}`,
2114
+ dimColor: true,
2115
+ wrap: "truncate-end"
2116
+ }, ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`))
2117
+ ];
2118
+ const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows);
2119
+ const scrollBy = (delta) => {
2120
+ setScroll((current) => moveScroll(current, delta, content.length, viewport.bodyRows));
2121
+ };
2122
+ useEffect(() => {
2123
+ if (visibleScroll !== scroll) setScroll(visibleScroll);
2124
+ }, [visibleScroll, scroll]);
2125
+ useInput((input, key) => {
2126
+ if (key.escape || input === "q") {
2127
+ onClose();
2128
+ return;
2129
+ }
2130
+ if (key.upArrow) scrollBy(-1);
2131
+ else if (key.downArrow) scrollBy(1);
2132
+ else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1));
2133
+ else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1));
2134
+ else if (input === "g") setScroll(0);
2135
+ else if (input === "G") setScroll(Math.max(0, content.length - viewport.bodyRows));
2136
+ });
2137
+ if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
2138
+ if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("/help · esc/q close", viewport.contentColumns));
2139
+ return createElement(Box, {
2140
+ flexDirection: "column",
2141
+ paddingX: 1,
2142
+ borderStyle: "round",
2143
+ borderColor: inkColor(TUI_RGB.brand)
2144
+ }, createElement(Text, {
2145
+ color: inkColor(TUI_RGB.brand),
2146
+ bold: true,
2147
+ wrap: "truncate-end"
2148
+ }, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)), ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows), createElement(Text, {
2149
+ dimColor: true,
2150
+ wrap: "truncate-end"
2151
+ }, dim(truncateColumns("↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close", viewport.contentColumns))));
2152
+ }
2153
+ /** Collapse arbitrary metadata to one terminal row before verbose rendering. */
2154
+ function verboseLine(text, columns) {
2155
+ return truncateColumns(displayText(text).replace(/\n/gu, " ↵ ").replace(/\t/gu, " "), Math.max(1, columns));
2156
+ }
2157
+ /** One-row editor window keeping the logical cursor visible in long drafts. */
2158
+ function editorWindow(value, cursor, columns) {
2159
+ const width = Math.max(1, columns);
2160
+ const normalize = (text) => displayText(text).replace(/\n/gu, "↵").replace(/\t/gu, " ");
2161
+ const caretSource = value.slice(cursor, cursor + 1);
2162
+ const caret = caretSource === "" ? " " : normalize(caretSource);
2163
+ const remaining = Math.max(0, width - visibleColumns(caret));
2164
+ const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))));
2165
+ const beforeBudget = Math.max(0, remaining - afterBudget);
2166
+ return {
2167
+ before: beforeBudget === 0 ? "" : displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text,
2168
+ caret,
2169
+ after: afterBudget === 0 ? "" : truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
2170
+ };
2171
+ }
2172
+ /**
2173
+ * The Ctrl+O transcript inspector: one selected durable entry at a time,
2174
+ * with independent history selection and content scrolling. The complete
2175
+ * retained entry is converted to physical rows, but only one viewport slice
2176
+ * reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
2177
+ */
2178
+ function VerbosePanel({ entries, onClose }) {
2179
+ const stdout = useStdout().stdout;
2180
+ const viewport = inspectorViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
2181
+ const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1));
2182
+ const [scroll, setScroll] = useState(0);
2183
+ const savedScroll = useRef(/* @__PURE__ */ new Map());
2184
+ const cursorRef = useRef(cursor);
2185
+ const previousLength = useRef(entries.length);
2186
+ const entry = entries[cursor];
2187
+ const allLines = useMemo(() => entry === void 0 ? [] : transcriptEntryLines(entry, viewport.contentColumns), [entry, viewport.contentColumns]);
2188
+ const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows);
2189
+ useEffect(() => {
2190
+ cursorRef.current = cursor;
2191
+ }, [cursor]);
2192
+ useEffect(() => {
2193
+ const current = cursorRef.current;
2194
+ const next = followInspectorCursor(current, previousLength.current, entries.length);
2195
+ if (next !== current) {
2196
+ savedScroll.current.set(current, visibleScroll);
2197
+ setCursor(next);
2198
+ setScroll(savedScroll.current.get(next) ?? 0);
2199
+ }
2200
+ previousLength.current = entries.length;
2201
+ }, [entries.length]);
2202
+ useEffect(() => {
2203
+ const clamped = clampScroll(scroll, allLines.length, viewport.bodyRows);
2204
+ if (clamped !== scroll) setScroll(clamped);
2205
+ savedScroll.current.set(cursor, clamped);
2206
+ }, [
2207
+ cursor,
2208
+ scroll,
2209
+ allLines.length,
2210
+ viewport.bodyRows
2211
+ ]);
2212
+ const selectEntry = (next) => {
2213
+ if (entries.length === 0) return;
2214
+ const selected = Math.max(0, Math.min(entries.length - 1, next));
2215
+ if (selected === cursor) return;
2216
+ savedScroll.current.set(cursor, visibleScroll);
2217
+ setCursor(selected);
2218
+ setScroll(savedScroll.current.get(selected) ?? 0);
2219
+ };
2220
+ const scrollBy = (delta) => {
2221
+ setScroll((current) => moveScroll(current, delta, allLines.length, viewport.bodyRows));
2222
+ };
2223
+ useInput((input, key) => {
2224
+ if (key.escape || input === "q" || key.ctrl && input === "o") {
2225
+ onClose();
2226
+ return;
2227
+ }
2228
+ if (entries.length === 0) return;
2229
+ if (key.leftArrow) {
2230
+ selectEntry(cursor - 1);
2231
+ return;
2232
+ }
2233
+ if (key.rightArrow) {
2234
+ selectEntry(cursor + 1);
2235
+ return;
2236
+ }
2237
+ if (key.upArrow) {
2238
+ scrollBy(-1);
2239
+ return;
2240
+ }
2241
+ if (key.downArrow) {
2242
+ scrollBy(1);
2243
+ return;
2244
+ }
2245
+ if (key.pageUp) {
2246
+ scrollBy(-Math.max(1, viewport.bodyRows - 1));
2247
+ return;
2248
+ }
2249
+ if (key.pageDown) {
2250
+ scrollBy(Math.max(1, viewport.bodyRows - 1));
2251
+ return;
2252
+ }
2253
+ if (input === "g") {
2254
+ setScroll(0);
2255
+ return;
2256
+ }
2257
+ if (input === "G") setScroll(Math.max(0, allLines.length - viewport.bodyRows));
2258
+ });
2259
+ if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
2260
+ if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("history details · ctrl+o / esc / q close", viewport.contentColumns));
2261
+ const title = entries.length === 0 ? "history details · empty" : `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`;
2262
+ const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows);
2263
+ return createElement(Box, {
2264
+ flexDirection: "column",
2265
+ paddingX: 1,
2266
+ borderStyle: "round",
2267
+ borderColor: inkColor(TUI_RGB.brand)
2268
+ }, createElement(Text, {
2269
+ color: inkColor(TUI_RGB.brand),
2270
+ bold: true,
2271
+ wrap: "truncate-end"
2272
+ }, truncateColumns(title, viewport.contentColumns)), createElement(Box, { flexDirection: "column" }, entry === void 0 ? createElement(Text, { dimColor: true }, " no durable entries yet") : createElement(StyledRows, { lines: visible })), createElement(Text, {
2273
+ dimColor: true,
2274
+ wrap: "truncate-end"
2275
+ }, dim(truncateColumns("←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close", viewport.contentColumns))));
343
2276
  }
2277
+ /** Streaming chunks preserve `entries` identity, so the open inspector stays inert. */
2278
+ const MemoVerbosePanel = memo(VerbosePanel);
2279
+ /** Stable append-only boundary: modal updates must never revisit Static rows. */
2280
+ function staticRow(item) {
2281
+ return item;
2282
+ }
2283
+ function StaticTranscript({ items }) {
2284
+ return createElement(Static, {
2285
+ items,
2286
+ children: staticRow
2287
+ });
2288
+ }
2289
+ const MemoStaticTranscript = memo(StaticTranscript);
344
2290
  /**
345
2291
  * Resolve completion candidates for the current input: TUI-local commands,
346
2292
  * the live registry descriptors, and user-invocable skills, filtered by the
@@ -366,13 +2312,24 @@ function completionCandidates(value, descriptors, skills) {
366
2312
  description: "clear the screen",
367
2313
  origin: "command"
368
2314
  },
2315
+ {
2316
+ label: "/export",
2317
+ description: "export the transcript to markdown",
2318
+ origin: "command"
2319
+ },
2320
+ {
2321
+ label: "/title",
2322
+ description: "rename this session",
2323
+ origin: "command"
2324
+ },
369
2325
  {
370
2326
  label: "/quit",
371
2327
  description: "exit",
372
2328
  origin: "command"
373
2329
  }
374
2330
  ];
375
- const registry = descriptors.map((descriptor) => ({
2331
+ const localNames = new Set(local.map((candidate) => candidate.label.slice(1)));
2332
+ const registry = descriptors.filter((descriptor) => !localNames.has(descriptor.name)).map((descriptor) => ({
376
2333
  label: `/${descriptor.name}`,
377
2334
  description: descriptor.description,
378
2335
  origin: "command"
@@ -392,10 +2349,52 @@ function completionCandidates(value, descriptors, skills) {
392
2349
  return all.filter((candidate) => candidate.label.slice(1).startsWith(prefix)).slice(0, 10);
393
2350
  }
394
2351
  /**
2352
+ * The completion menu, rendered inside the composer's subtree directly above
2353
+ * the framed box — attached the way Claude-Code anchors its dropdown. Opening
2354
+ * it grows the stack downward: the composer stays the last element on screen
2355
+ * and everything above (the flushed static transcript, the status line) never
2356
+ * moves. Props-only (no lifted state): the menu is a pure view of the input
2357
+ * editor's live completion state, so no cross-component effect ever resyncs
2358
+ * it (a state lift here previously deadlocked the menu after a resize).
2359
+ */
2360
+ function CompletionMenu({ active, mention, index, rows }) {
2361
+ const stdout = useStdout().stdout;
2362
+ const columns = stdout?.columns ?? 80;
2363
+ const terminalRows = stdout?.rows ?? 30;
2364
+ if (!active) return void 0;
2365
+ const nameWidth = Math.min(18, Math.max(0, ...rows.map((row) => visibleColumns(row.label))) + 2);
2366
+ const descBudget = Math.max(24, columns - nameWidth - 8);
2367
+ const showFooter = terminalRows >= 12;
2368
+ const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10)));
2369
+ const selected = rows.length === 0 ? 0 : index % rows.length;
2370
+ const first = selectionWindow(selected, rows.length, limit);
2371
+ const visible = rows.slice(first, first + limit);
2372
+ return createElement(Box, {
2373
+ flexDirection: "column",
2374
+ marginLeft: 2
2375
+ }, ...rows.length === 0 ? [createElement(Text, {
2376
+ key: "loading",
2377
+ dimColor: true
2378
+ }, "searching…")] : visible.map((candidate, at) => {
2379
+ const absolute = first + at;
2380
+ return createElement(Text, {
2381
+ key: candidate.label,
2382
+ color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
2383
+ wrap: "truncate-end"
2384
+ }, `${absolute === selected ? "❯ " : " "}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`);
2385
+ }), showFooter ? createElement(Text, {
2386
+ dimColor: true,
2387
+ wrap: "truncate-end"
2388
+ }, dim(mention ? "↑↓ choose · tab insert" : "↑↓ choose · tab complete")) : void 0);
2389
+ }
2390
+ /**
395
2391
  * The prompt box: TUI-local slash commands handled locally, other lines
396
2392
  * dispatched; input editing keeps a cursor with history and completion.
2393
+ * While a modal (approval / question / model panel) owns the keys, the
2394
+ * box passes every key through untouched.
397
2395
  */
398
- function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }) {
2396
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, notify, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }) {
2397
+ const columns = useStdout().stdout?.columns ?? 80;
399
2398
  const [value, setValue] = useState("");
400
2399
  const [cursor, setCursor] = useState(0);
401
2400
  const history = useRef([]);
@@ -403,8 +2402,78 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
403
2402
  const draft = useRef("");
404
2403
  const [completionIndex, setCompletionIndex] = useState(0);
405
2404
  const candidates = completionCandidates(value, descriptors, skills);
406
- const completionActive = candidates.length > 0 && value.startsWith("/") && !value.includes(" ") && !value.includes("\n");
2405
+ const slashActive = candidates.length > 0 && value.startsWith("/") && !value.includes(" ") && !value.includes("\n");
2406
+ const beforeCursor = value.slice(0, cursor);
2407
+ const lastLine = beforeCursor.split("\n").at(-1) ?? "";
2408
+ const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine);
2409
+ const mentionToken = tokenMatch === null ? void 0 : {
2410
+ start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0),
2411
+ query: tokenMatch[2] ?? ""
2412
+ };
2413
+ const mentionActive = mentionToken !== void 0;
2414
+ const [mentionRows, setMentionRows] = useState([]);
2415
+ const bareTokenMatch = /([^\s]+)$/u.exec(lastLine);
2416
+ const bareToken = bareTokenMatch === null ? "" : bareTokenMatch[1] ?? "";
2417
+ const pathActive = !mentionActive && !bareToken.startsWith("/") && (bareToken.includes("/") || bareToken === "." || bareToken === "..");
2418
+ const pathTokenStart = beforeCursor.length - bareToken.length;
2419
+ const [pathRows, setPathRows] = useState([]);
2420
+ useEffect(() => {
2421
+ if (!active || !pathActive) {
2422
+ setPathRows([]);
2423
+ return;
2424
+ }
2425
+ const controller = new AbortController();
2426
+ setPathRows([]);
2427
+ loadMentions(bareToken, controller.signal).then((rows) => setPathRows(rows.filter((row) => row.kind !== "session")), () => {});
2428
+ return () => {
2429
+ controller.abort();
2430
+ };
2431
+ }, [
2432
+ active,
2433
+ pathActive,
2434
+ bareToken
2435
+ ]);
2436
+ useEffect(() => {
2437
+ if (!active || !mentionActive) {
2438
+ setMentionRows([]);
2439
+ return;
2440
+ }
2441
+ const controller = new AbortController();
2442
+ setMentionRows([]);
2443
+ loadMentions(mentionToken.query, controller.signal).then((rows) => setMentionRows(rows), () => {});
2444
+ return () => {
2445
+ controller.abort();
2446
+ };
2447
+ }, [
2448
+ active,
2449
+ mentionActive,
2450
+ mentionToken?.query
2451
+ ]);
2452
+ const menuActive = (slashActive || mentionActive || pathActive) && !busy;
2453
+ const menuRows = mentionActive ? mentionRows.map((row) => ({
2454
+ label: row.label.startsWith("@") ? row.label : `@${row.label}${row.kind === "directory" ? "/" : ""}`,
2455
+ description: row.description,
2456
+ origin: "mention"
2457
+ })) : pathActive ? pathRows.map((row) => ({
2458
+ label: row.label,
2459
+ description: row.description,
2460
+ origin: "path"
2461
+ })) : candidates;
407
2462
  useInput((input, key) => {
2463
+ if (!active) return;
2464
+ if (key.tab && key.shift) {
2465
+ const next = cyclePermission();
2466
+ if (next !== "") notify(`permission → ${next}`);
2467
+ return;
2468
+ }
2469
+ if (key.ctrl && input === "r") {
2470
+ toggleReasoning();
2471
+ return;
2472
+ }
2473
+ if (key.ctrl && input === "o") {
2474
+ openVerbose();
2475
+ return;
2476
+ }
408
2477
  if (key.ctrl && input === "c") {
409
2478
  if (busy) interrupt();
410
2479
  else if (value !== "") {
@@ -441,11 +2510,20 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
441
2510
  return;
442
2511
  }
443
2512
  if (text === "/help") {
444
- notify("/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn");
2513
+ openHelp();
445
2514
  return;
446
2515
  }
447
2516
  if (text === "/clear") {
448
- console.clear();
2517
+ refresh();
2518
+ clearView();
2519
+ return;
2520
+ }
2521
+ if (text === "/export" || text.startsWith("/export ")) {
2522
+ exportTranscript(text.slice(8));
2523
+ return;
2524
+ }
2525
+ if (text === "/title" || text.startsWith("/title ")) {
2526
+ notify(renameTitle(text.slice(7)));
449
2527
  return;
450
2528
  }
451
2529
  if (text === "/model" || text.startsWith("/model ")) {
@@ -459,12 +2537,12 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
459
2537
  dispatch(text);
460
2538
  return;
461
2539
  }
462
- if (completionActive && key.upArrow) {
463
- setCompletionIndex((index) => (index + candidates.length - 1) % candidates.length);
2540
+ if (menuActive && key.upArrow) {
2541
+ setCompletionIndex((index) => (index + menuRows.length - 1) % menuRows.length);
464
2542
  return;
465
2543
  }
466
- if (completionActive && key.downArrow) {
467
- setCompletionIndex((index) => (index + 1) % candidates.length);
2544
+ if (menuActive && key.downArrow) {
2545
+ setCompletionIndex((index) => (index + 1) % menuRows.length);
468
2546
  return;
469
2547
  }
470
2548
  if (key.upArrow) {
@@ -492,13 +2570,29 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
492
2570
  setCursor((entries[next] ?? "").length);
493
2571
  return;
494
2572
  }
495
- if (key.tab && completionActive) {
496
- const candidate = candidates[completionIndex % candidates.length];
497
- if (candidate !== void 0) {
498
- setValue(`${candidate.label} `);
499
- setCursor(candidate.label.length + 1);
500
- setCompletionIndex(0);
2573
+ if (key.tab && menuActive) {
2574
+ if (mentionActive && mentionToken !== void 0) {
2575
+ const row = mentionRows[completionIndex % mentionRows.length];
2576
+ if (row !== void 0) {
2577
+ const insertion = row.label.startsWith("@") ? row.label : `@${row.label}${row.kind === "directory" ? "/" : ""}`;
2578
+ setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor));
2579
+ setCursor(mentionToken.start + insertion.length);
2580
+ }
2581
+ } else if (pathActive) {
2582
+ const row = pathRows[completionIndex % Math.max(1, pathRows.length)];
2583
+ if (row !== void 0) {
2584
+ const insertion = row.kind === "directory" ? `${row.label}/` : row.label;
2585
+ setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor));
2586
+ setCursor(pathTokenStart + insertion.length);
2587
+ }
2588
+ } else {
2589
+ const candidate = candidates[completionIndex % candidates.length];
2590
+ if (candidate !== void 0) {
2591
+ setValue(`${candidate.label} `);
2592
+ setCursor(candidate.label.length + 1);
2593
+ }
501
2594
  }
2595
+ setCompletionIndex(0);
502
2596
  return;
503
2597
  }
504
2598
  if (key.backspace || key.delete) {
@@ -522,6 +2616,14 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
522
2616
  setCursor(0);
523
2617
  return;
524
2618
  }
2619
+ if (key.ctrl && input === "k") {
2620
+ setValue(value.slice(0, cursor));
2621
+ return;
2622
+ }
2623
+ if (key.ctrl && input === "l") {
2624
+ refresh();
2625
+ return;
2626
+ }
525
2627
  if (key.ctrl && input === "a") {
526
2628
  setCursor(0);
527
2629
  return;
@@ -536,13 +2638,25 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
536
2638
  setCompletionIndex(0);
537
2639
  }
538
2640
  });
539
- return createElement(Box, { flexDirection: "column" }, completionActive && !busy ? createElement(Box, {
540
- flexDirection: "column",
541
- marginLeft: 1
542
- }, ...candidates.map((candidate, index) => createElement(Text, {
543
- key: candidate.label,
544
- color: index === completionIndex % candidates.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
545
- }, `${index === completionIndex % candidates.length ? "❯ " : " "}${candidate.label} ${dim(displayText(candidate.description))}`)), createElement(Text, { dimColor: true }, dim(" ↑↓ choose · tab complete"))) : void 0, busy && value === "" ? createElement(Text, { dimColor: true }, dim(" enter steers the running turn · esc or ctrl+c cancels")) : void 0, createElement(Box, null, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), createElement(Text, null, value.slice(0, cursor)), createElement(Text, { inverse: true }, value.slice(cursor, cursor + 1) === "" ? " " : value.slice(cursor, cursor + 1)), createElement(Text, null, value.slice(cursor + 1))));
2641
+ if (frozen) {
2642
+ const frozen = value === "" ? "type a message" : verboseLine(value, Math.max(1, columns - 6));
2643
+ return createElement(Box, {
2644
+ borderStyle: "round",
2645
+ borderColor: inkColor(TUI_RGB.dim),
2646
+ paddingX: 1
2647
+ }, createElement(Text, { wrap: "truncate-end" }, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), frozen));
2648
+ }
2649
+ const editor = editorWindow(value, cursor, Math.max(1, columns - 6));
2650
+ return createElement(Box, { flexDirection: "column" }, createElement(CompletionMenu, {
2651
+ active: menuActive,
2652
+ mention: mentionActive,
2653
+ index: completionIndex,
2654
+ rows: menuRows
2655
+ }), createElement(Box, {
2656
+ borderStyle: "round",
2657
+ borderColor: inkColor(TUI_RGB.dim),
2658
+ paddingX: 1
2659
+ }, createElement(Text, { wrap: "truncate-end" }, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), value === "" ? void 0 : editor.before, createElement(CursorBlock, { char: editor.caret }), value === "" && !busy ? createElement(Text, { dimColor: true }, "type a message · / commands · @ mentions") : editor.after)));
546
2660
  }
547
2661
  /** The whole terminal app; state arrives via the store, output via Ink. */
548
2662
  function App(props) {
@@ -574,13 +2688,124 @@ function App(props) {
574
2688
  };
575
2689
  }, [modelOpen]);
576
2690
  const busy = view.busy;
577
- return createElement(Box, { flexDirection: "column" }, createElement(Header, { resumed: props.resumed }), createElement(Box, {
2691
+ const [showReasoning, setShowReasoning] = useState(false);
2692
+ const [verboseOpen, setVerboseOpen] = useState(false);
2693
+ const [helpOpen, setHelpOpen] = useState(false);
2694
+ const [refreshEpoch, setRefreshEpoch] = useState(0);
2695
+ const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot);
2696
+ const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot);
2697
+ const approvalPending = approvalSnapshot.pending !== void 0;
2698
+ const questionPending = questionSnapshot.pending !== void 0;
2699
+ const inputActive = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending;
2700
+ useEffect(() => {
2701
+ if (!approvalPending && !questionPending) return;
2702
+ setModelOpen(false);
2703
+ setHelpOpen(false);
2704
+ setVerboseOpen(false);
2705
+ }, [approvalPending, questionPending]);
2706
+ const settled = useMemo(() => settledEntryCount(view.entries), [view.entries]);
2707
+ const settledRows = useMemo(() => {
2708
+ const rows = [createElement(Header, {
2709
+ key: "header",
2710
+ resumed: props.resumed
2711
+ })];
2712
+ view.entries.slice(0, settled).forEach((entry, index) => {
2713
+ const row = createElement(EntryLine, {
2714
+ entry,
2715
+ showReasoning,
2716
+ verbose: false
2717
+ });
2718
+ if (entry.kind === "user" && index > 0) rows.push(createElement(Box, {
2719
+ key: `gap-${index}`,
2720
+ paddingX: 1
2721
+ }, createElement(Text, null, " ")));
2722
+ rows.push(createElement(Box, {
2723
+ key: index,
2724
+ paddingX: 1
2725
+ }, row));
2726
+ });
2727
+ return rows;
2728
+ }, [
2729
+ view.entries,
2730
+ settled,
2731
+ showReasoning,
2732
+ props.resumed
2733
+ ]);
2734
+ const appStdout = useStdout().stdout;
2735
+ const [terminalSize, setTerminalSize] = useState(() => ({
2736
+ columns: appStdout?.columns ?? 80,
2737
+ rows: appStdout?.rows ?? 30
2738
+ }));
2739
+ const terminalSizeRef = useRef(terminalSize);
2740
+ useEffect(() => {
2741
+ if (appStdout === void 0) return;
2742
+ let replayTimer;
2743
+ const handleResize = () => {
2744
+ const next = {
2745
+ columns: appStdout.columns ?? 80,
2746
+ rows: appStdout.rows ?? 30
2747
+ };
2748
+ if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return;
2749
+ terminalSizeRef.current = next;
2750
+ setTerminalSize(next);
2751
+ if (replayTimer !== void 0) clearTimeout(replayTimer);
2752
+ replayTimer = setTimeout(() => {
2753
+ appStdout.write(RESIZE_REFLOW_CLEAR);
2754
+ setRefreshEpoch((epoch) => epoch + 1);
2755
+ }, RESIZE_REFLOW_DELAY_MS);
2756
+ };
2757
+ appStdout.on("resize", handleResize);
2758
+ return () => {
2759
+ appStdout.off("resize", handleResize);
2760
+ if (replayTimer !== void 0) clearTimeout(replayTimer);
2761
+ };
2762
+ }, [appStdout]);
2763
+ const terminalRows = terminalSize.rows;
2764
+ const terminalColumns = terminalSize.columns;
2765
+ const dynamicRows = Math.max(1, terminalRows - 12);
2766
+ const streamingActive = view.streaming !== "" || view.streamingReasoning !== "";
2767
+ const deepDivingVisible = busy && !streamingActive;
2768
+ const allLiveLines = useMemo(() => view.entries.slice(settled).flatMap((entry) => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))), [
2769
+ view.entries,
2770
+ settled,
2771
+ terminalColumns
2772
+ ]);
2773
+ const liveBudget = streamingActive ? Math.max(1, Math.floor(dynamicRows / 3)) : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0));
2774
+ const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget);
2775
+ const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length);
2776
+ const reasoningRows = view.streamingReasoning === "" ? 0 : view.streaming === "" ? streamRows : streamRows <= 1 ? 0 : showReasoning ? Math.max(1, Math.floor(streamRows / 3)) : 1;
2777
+ const answerRows = view.streaming === "" ? 0 : Math.max(1, streamRows - reasoningRows);
2778
+ const transcriptVisible = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending;
2779
+ const modalVisible = modelOpen || helpOpen || verboseOpen && !approvalPending && !questionPending || approvalPending || questionPending;
2780
+ const closeInspector = useCallback(() => {
2781
+ setVerboseOpen(false);
2782
+ }, []);
2783
+ const refreshScreen = () => {
2784
+ if (appStdout !== void 0) appStdout.write("\x1B[2J\x1B[3J\x1B[H");
2785
+ setRefreshEpoch((epoch) => epoch + 1);
2786
+ };
2787
+ return createElement(Box, { flexDirection: "column" }, createElement(MemoStaticTranscript, {
2788
+ key: refreshEpoch,
2789
+ items: settledRows
2790
+ }), transcriptVisible ? createElement(Box, {
578
2791
  flexDirection: "column",
579
2792
  paddingX: 1
580
- }, ...view.entries.map((entry, index) => createElement(EntryLine, {
581
- key: index,
582
- entry
583
- })), view.streaming !== "" ? createElement(Text, null, displayText(view.streaming)) : void 0, busy && view.streaming === "" ? createElement(Text, { dimColor: true }, "thinking…") : void 0), createElement(TodoPanel, { todos: view.todos }), createElement(ApprovalBar, { approval: props.approval }), modelOpen ? createElement(ModelPanel, {
2793
+ }, visibleLiveLines.length === 0 ? void 0 : createElement(StyledRows, { lines: visibleLiveLines }), view.streamingReasoning !== "" && reasoningRows > 0 ? createElement(StreamTail, {
2794
+ text: showReasoning ? view.streamingReasoning : "Thinking…",
2795
+ prefix: " ✻ ",
2796
+ dim: true,
2797
+ maxRows: reasoningRows
2798
+ }) : void 0, view.streaming !== "" && answerRows > 0 ? createElement(StreamTail, {
2799
+ text: view.streaming,
2800
+ dim: false,
2801
+ maxRows: answerRows
2802
+ }, busy ? createElement(Caret) : void 0) : void 0, deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : void 0) : void 0, transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : void 0, createElement(QuestionBar, {
2803
+ store: props.questions,
2804
+ locked: false
2805
+ }), createElement(ApprovalBar, {
2806
+ approval: props.approval,
2807
+ locked: questionPending
2808
+ }), modelOpen && !approvalPending && !questionPending ? createElement(ModelPanel, {
584
2809
  directory,
585
2810
  error: modelError,
586
2811
  onSelect: (row) => {
@@ -591,10 +2816,22 @@ function App(props) {
591
2816
  onClose: () => {
592
2817
  setModelOpen(false);
593
2818
  }
594
- }) : void 0, createElement(Box, { flexDirection: "column" }, ...notices.slice(-3).map((notice, index) => createElement(Text, {
2819
+ }) : void 0, helpOpen && !approvalPending && !questionPending ? createElement(HelpPanel, {
2820
+ descriptors,
2821
+ skills,
2822
+ onClose: () => {
2823
+ setHelpOpen(false);
2824
+ }
2825
+ }) : void 0, verboseOpen && !approvalPending && !questionPending ? createElement(MemoVerbosePanel, {
2826
+ entries: view.entries,
2827
+ onClose: closeInspector
2828
+ }) : void 0, transcriptVisible ? createElement(Box, { flexDirection: "column" }, ...notices.slice(-1).map((notice, index) => createElement(Text, {
595
2829
  key: index,
596
- dimColor: true
597
- }, notice))), createElement(Input, {
2830
+ dimColor: true,
2831
+ wrap: "truncate-end"
2832
+ }, displayText(notice)))) : void 0, createElement(Box, { flexDirection: "column" }, createElement(Input, {
2833
+ active: inputActive,
2834
+ frozen: modalVisible,
598
2835
  busy,
599
2836
  descriptors,
600
2837
  skills,
@@ -605,17 +2842,43 @@ function App(props) {
605
2842
  openModel: () => {
606
2843
  setModelOpen(true);
607
2844
  },
608
- notify
2845
+ openHelp: () => {
2846
+ setHelpOpen(true);
2847
+ },
2848
+ notify,
2849
+ openVerbose: () => {
2850
+ setVerboseOpen(true);
2851
+ },
2852
+ clearView: () => {
2853
+ props.store.reset();
2854
+ },
2855
+ refresh: refreshScreen,
2856
+ toggleReasoning: () => {
2857
+ setShowReasoning((current) => !current);
2858
+ },
2859
+ loadMentions: props.loadMentions,
2860
+ cyclePermission: props.cyclePermission,
2861
+ exportTranscript: props.exportTranscript,
2862
+ renameTitle: props.renameTitle
609
2863
  }), createElement(StatusLine, {
610
2864
  facts: {
611
2865
  model: modelLabel,
612
2866
  cwd: props.cwd,
613
2867
  branch: props.branch,
614
- sessionId: props.sessionId
2868
+ sessionId: props.sessionId,
2869
+ title: view.title,
2870
+ plan: view.plan,
2871
+ permission: view.permission,
2872
+ sandbox: view.sandbox,
2873
+ goal: view.goal === void 0 ? void 0 : {
2874
+ phase: view.goal.phase,
2875
+ rounds: view.goal.rounds,
2876
+ max: view.goal.max
2877
+ }
615
2878
  },
616
2879
  stats: view.stats,
617
2880
  busy
618
- }));
2881
+ })));
619
2882
  }
620
2883
  //#endregion
621
2884
  //#region src/approval.ts
@@ -776,253 +3039,254 @@ async function loadModelDirectory(ctx) {
776
3039
  const llm = ctx.get("llm");
777
3040
  if (llm === void 0) return {
778
3041
  rows: [],
779
- failures: []
780
- };
781
- const providers = llm.listProviders();
782
- const listed = await Promise.all(providers.map(async (provider) => {
783
- try {
784
- const models = await llm.listModels(provider.id);
785
- return {
786
- provider: provider.id,
787
- providerName: provider.name,
788
- models: models.map((model) => ({
789
- provider: provider.id,
790
- providerName: provider.name,
791
- model: model.id,
792
- modelName: model.name
793
- }))
794
- };
795
- } catch {
796
- return {
797
- provider: provider.id,
798
- providerName: provider.name,
799
- models: [],
800
- failed: true
801
- };
802
- }
803
- }));
804
- return {
805
- rows: listed.flatMap((entry) => entry.models),
806
- failures: listed.filter((entry) => "failed" in entry && entry.failed === true).map((entry) => entry.provider)
807
- };
808
- }
809
- //#endregion
810
- //#region src/render/projection.ts
811
- /**
812
- * Pure session-event-to-view projection for the TUI transcript: one reducer
813
- * over {@link SessionEvent}s producing the ordered entries the renderer draws.
814
- * Rendering never reads the session directly — this module owns the view
815
- * model, so tests drive it with plain event arrays.
816
- *
817
- * @module @deepseek-ai/dsh-tui/render/projection
818
- */
819
- /** Join the text blocks of a content list; non-text blocks contribute nothing. */
820
- function textOf(content) {
821
- return content.filter((block) => block.type === "text").map((block) => block.text).join("");
822
- }
823
- /** A fresh, empty transcript view. */
824
- function createTranscriptView() {
825
- return {
826
- entries: [],
827
- streaming: "",
828
- todos: [],
829
- busy: false,
830
- model: "",
831
- stats: {
832
- turns: 0,
833
- steps: 0,
834
- llmMs: 0,
835
- toolMs: 0,
836
- usage: {
837
- inputTokens: 0,
838
- outputTokens: 0,
839
- cacheReadTokens: 0
840
- }
841
- },
842
- anchors: {
843
- stepStart: /* @__PURE__ */ new Map(),
844
- toolStart: /* @__PURE__ */ new Map()
845
- }
846
- };
847
- }
848
- /**
849
- * Fold one session event into an updated view (copy-on-write).
850
- * @param view - the view before the event.
851
- * @param event - one durable session event from `session/event` or the log.
852
- * @returns the view after the event; the input view is never mutated.
853
- */
854
- function projectEvent(view, event) {
855
- switch (event.type) {
856
- case "user/message": {
857
- const message = event.data;
858
- if (message.source.kind === "user") return {
859
- ...view,
860
- entries: [...view.entries, {
861
- kind: "user",
862
- text: textOf(message.content)
863
- }]
864
- };
865
- const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
866
- return {
867
- ...view,
868
- entries: [...view.entries, {
869
- kind: "user",
870
- text: boundContextSummary(notice)
871
- }]
872
- };
873
- }
874
- case "assistant/chunk": {
875
- const chunk = event.data.chunk;
876
- if (chunk.type !== "text-delta") return view;
3042
+ failures: []
3043
+ };
3044
+ const providers = llm.listProviders();
3045
+ const listed = await Promise.all(providers.map(async (provider) => {
3046
+ try {
3047
+ const models = await llm.listModels(provider.id);
877
3048
  return {
878
- ...view,
879
- streaming: view.streaming + chunk.text
3049
+ provider: provider.id,
3050
+ providerName: provider.name,
3051
+ models: models.map((model) => ({
3052
+ provider: provider.id,
3053
+ providerName: provider.name,
3054
+ model: model.id,
3055
+ modelName: model.name
3056
+ }))
880
3057
  };
881
- }
882
- case "assistant/message": {
883
- const key = `${event.data.turn}:${event.data.step}`;
884
- const started = view.anchors.stepStart.get(key);
885
- view.anchors.stepStart.delete(key);
886
- const usage = event.data.usage;
887
- const totals = view.stats.usage;
3058
+ } catch {
888
3059
  return {
889
- ...view,
890
- streaming: "",
891
- entries: [...view.entries, {
892
- kind: "assistant",
893
- text: textOf(event.data.message.content)
894
- }],
895
- stats: {
896
- ...view.stats,
897
- llmMs: view.stats.llmMs + (started === void 0 ? 0 : Math.max(0, event.time - started)),
898
- usage: usage === void 0 ? totals : {
899
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
900
- outputTokens: totals.outputTokens + usage.outputTokens,
901
- cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0)
902
- }
903
- }
3060
+ provider: provider.id,
3061
+ providerName: provider.name,
3062
+ models: [],
3063
+ failed: true
904
3064
  };
905
3065
  }
906
- case "tool/call": {
907
- const data = event.data;
908
- view.anchors.toolStart.set(data.callId, event.time);
909
- return {
910
- ...view,
911
- entries: [...view.entries, {
912
- kind: "tool",
913
- callId: data.callId,
914
- name: data.name,
915
- arguments: data.arguments,
916
- state: "running",
917
- summary: ""
918
- }]
919
- };
3066
+ }));
3067
+ return {
3068
+ rows: listed.flatMap((entry) => entry.models),
3069
+ failures: listed.filter((entry) => "failed" in entry && entry.failed === true).map((entry) => entry.provider)
3070
+ };
3071
+ }
3072
+ //#endregion
3073
+ //#region src/mentions.ts
3074
+ /**
3075
+ * Workspace @mention support: file candidates from a bounded async scan of
3076
+ * the session cwd, session candidates from the opt-in `sessionReferenceResolver`
3077
+ * service, and submission preparation through its `prepare()` API. Picked
3078
+ * session mentions land as canonical `@[label](dsh-session:…)` tokens; on
3079
+ * submit the text is parsed back into readable `@label` text plus structured
3080
+ * references, snapshots are injected via `agent.inject()` before the readable
3081
+ * message wakes the driver (`followup` idle, `steer` running) — exactly the
3082
+ * upstream README's wiring.
3083
+ *
3084
+ * @module @deepseek-ai/dsh-code/mentions
3085
+ */
3086
+ /** Directories never entered and files never listed during the scan. */
3087
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
3088
+ ".git",
3089
+ "node_modules",
3090
+ "lib",
3091
+ "dist",
3092
+ "out",
3093
+ ".omc",
3094
+ "coverage"
3095
+ ]);
3096
+ const MAX_FILES = 4e3;
3097
+ const MAX_DEPTH = 12;
3098
+ /** Bounded async BFS scan of a workspace; unreadable entries are skipped. */
3099
+ async function scanWorkspaceFiles(root, signal) {
3100
+ const found = [];
3101
+ const pending = [{
3102
+ absolute: root,
3103
+ relative: "",
3104
+ depth: 0
3105
+ }];
3106
+ const aborted = () => signal?.aborted === true;
3107
+ while (pending.length > 0 && found.length < MAX_FILES && !aborted()) {
3108
+ const current = pending.shift();
3109
+ if (current === void 0) break;
3110
+ let entries;
3111
+ try {
3112
+ entries = await readdir(current.absolute, { withFileTypes: true });
3113
+ } catch {
3114
+ continue;
920
3115
  }
921
- case "tool/result": {
922
- const block = event.data.message.content[0];
923
- const started = view.anchors.toolStart.get(block.toolCallId);
924
- view.anchors.toolStart.delete(block.toolCallId);
925
- const summary = boundContextSummary(textOf(block.content));
926
- const entries = view.entries.map((entry) => {
927
- if (entry.kind !== "tool" || entry.callId !== block.toolCallId) return entry;
928
- return {
929
- ...entry,
930
- state: block.isError === true ? "error" : "done",
931
- summary
932
- };
3116
+ for (const entry of entries) {
3117
+ if (found.length >= MAX_FILES || aborted()) return found;
3118
+ if (entry.name.startsWith(".")) continue;
3119
+ const relative = current.relative === "" ? entry.name : `${current.relative}/${entry.name}`;
3120
+ if (entry.isDirectory()) {
3121
+ if (SKIP_DIRS.has(entry.name) || current.depth + 1 > MAX_DEPTH) continue;
3122
+ pending.push({
3123
+ absolute: join(current.absolute, entry.name),
3124
+ relative,
3125
+ depth: current.depth + 1
3126
+ });
3127
+ } else if (entry.isFile()) found.push({
3128
+ path: relative,
3129
+ kind: "file"
933
3130
  });
934
- return {
935
- ...view,
936
- entries,
937
- stats: {
938
- ...view.stats,
939
- toolMs: view.stats.toolMs + (started === void 0 ? 0 : Math.max(0, event.time - started))
940
- }
941
- };
942
- }
943
- case "todo/write": return {
944
- ...view,
945
- todos: event.data.todos
946
- };
947
- case "turn/start": return {
948
- ...view,
949
- busy: true,
950
- todos: [],
951
- stats: {
952
- ...view.stats,
953
- turns: view.stats.turns + 1
954
- }
955
- };
956
- case "step/start":
957
- view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time);
958
- return {
959
- ...view,
960
- stats: {
961
- ...view.stats,
962
- steps: view.stats.steps + 1
963
- }
964
- };
965
- case "turn/end": {
966
- const reason = event.data.reason;
967
- if (reason.kind !== "error") return {
968
- ...view,
969
- busy: false
970
- };
971
- return {
972
- ...view,
973
- busy: false,
974
- entries: [...view.entries, {
975
- kind: "error",
976
- text: `${reason.error.code}: ${reason.error.message}`
977
- }]
978
- };
979
3131
  }
980
- case "request/header": {
981
- const config = event.data.header.config;
982
- return {
983
- ...view,
984
- model: `${config.provider}/${config.model}`
3132
+ }
3133
+ return found.sort((left, right) => left.path < right.path ? -1 : 1);
3134
+ }
3135
+ /** True when every query character appears in order in the haystack. */
3136
+ function isSubsequence(query, haystack) {
3137
+ let at = 0;
3138
+ for (const char of haystack) {
3139
+ if (char === query[at]) at += 1;
3140
+ if (at >= query.length) return true;
3141
+ }
3142
+ return at >= query.length;
3143
+ }
3144
+ /** Rank one file path against the typed query (community-TUI scoring shape). */
3145
+ function scoreFile(path, query) {
3146
+ const name = path.slice(path.lastIndexOf("/") + 1);
3147
+ if (query === "") return 0;
3148
+ if (name === query) return 1e3;
3149
+ if (name.startsWith(query)) return 900;
3150
+ if (name.includes(query)) return 700;
3151
+ if (path.includes(query)) return 500;
3152
+ if (isSubsequence(query, name)) return 300;
3153
+ return 0;
3154
+ }
3155
+ /**
3156
+ * Create the mention API for one agent's workspace. A missing
3157
+ * session-reference service degrades to file mentions only (the scan still
3158
+ * works); `prepare` then passes text through untouched.
3159
+ * @param ctx - context carrying the optional `sessionReferenceResolver`.
3160
+ * @param agent - the session owner; excluded from its own candidates.
3161
+ * @param cwd - workspace root to scan.
3162
+ */
3163
+ function createMentions(ctx, agent, cwd) {
3164
+ const resolver = ctx.get("sessionReferenceResolver");
3165
+ let filesPromise;
3166
+ return {
3167
+ files() {
3168
+ filesPromise ??= scanWorkspaceFiles(cwd);
3169
+ return filesPromise;
3170
+ },
3171
+ async candidates(query, signal) {
3172
+ const needle = query.trim();
3173
+ const [files, sessions] = await Promise.all([this.files(), resolver === void 0 ? Promise.resolve([]) : resolver.listCandidates(agent, needle, 10, signal).catch(() => [])]);
3174
+ const fileRows = files.filter((candidate) => scoreFile(candidate.path, needle) > 0).sort((left, right) => scoreFile(right.path, needle) - scoreFile(left.path, needle)).slice(0, 20).map((candidate) => ({
3175
+ label: candidate.path,
3176
+ description: candidate.kind === "directory" ? "Folder" : "File",
3177
+ kind: candidate.kind
3178
+ }));
3179
+ return [...sessions.map((candidate) => ({
3180
+ label: formatSessionReferenceMention(candidate),
3181
+ description: `Session · ${candidate.cwd ?? "(no cwd)"}`,
3182
+ kind: "session"
3183
+ })), ...fileRows];
3184
+ },
3185
+ parse(text) {
3186
+ return parseSessionReferenceText(text);
3187
+ },
3188
+ async prepare(parsed, signal) {
3189
+ if (parsed.references.length === 0 || resolver === void 0) return {
3190
+ text: parsed.text,
3191
+ references: parsed.references
985
3192
  };
986
- }
987
- case "command/run": {
988
- const data = event.data;
3193
+ const prepared = await resolver.prepare(agent, [{
3194
+ type: "text",
3195
+ text: parsed.text
3196
+ }], parsed.references, signal);
989
3197
  return {
990
- ...view,
991
- entries: [...view.entries, {
992
- kind: "command",
993
- commandId: data.commandId,
994
- name: data.name,
995
- args: data.args ?? "",
996
- state: "running",
997
- summary: ""
998
- }]
3198
+ text: prepared.content.filter((block) => block.type === "text").map((block) => block.text).join(""),
3199
+ references: parsed.references,
3200
+ additionalContext: prepared.additionalContext
999
3201
  };
1000
- }
1001
- case "command/done": {
1002
- const data = event.data;
1003
- const entries = view.entries.map((entry) => {
1004
- if (entry.kind !== "command" || entry.commandId !== data.commandId) return entry;
1005
- return {
1006
- ...entry,
1007
- state: data.kind === "success" ? "done" : "error",
1008
- summary: boundContextSummary(data.text ?? "")
1009
- };
3202
+ },
3203
+ sessionMention(candidate) {
3204
+ return formatSessionReferenceMention({
3205
+ sessionId: candidate.sessionId,
3206
+ label: candidate.label
1010
3207
  });
1011
- return {
1012
- ...view,
1013
- entries
1014
- };
1015
3208
  }
1016
- default: return view;
1017
- }
3209
+ };
1018
3210
  }
3211
+ //#endregion
3212
+ //#region src/questions.ts
3213
+ const ABORT_ERROR = new UserQuestionError("ask_user_question was interrupted before the user answered", "ASK_ABORTED");
1019
3214
  /**
1020
- * Fold a replayed event history into one view.
1021
- * @param events - events in `seq` order.
1022
- * @returns the folded view.
3215
+ * Mount the single `ctx.userQuestions` UI provider over a FIFO queue.
3216
+ * @param ctx - context carrying the `userQuestions` service (dsh-base).
3217
+ * @returns the store the renderer subscribes to; a context without the
3218
+ * service yields a permanently empty store.
1023
3219
  */
1024
- function projectEvents(events) {
1025
- return events.reduce(projectEvent, createTranscriptView());
3220
+ function mountQuestionProvider(ctx) {
3221
+ const service = ctx.get("userQuestions");
3222
+ let snapshot = { pending: void 0 };
3223
+ let active;
3224
+ const queue = [];
3225
+ const listeners = /* @__PURE__ */ new Set();
3226
+ const set = (next) => {
3227
+ snapshot = next;
3228
+ for (const listener of listeners) listener();
3229
+ };
3230
+ /** Settle the active request and show the next queued one, if any. */
3231
+ const advance = () => {
3232
+ const next = queue.shift();
3233
+ active = next;
3234
+ set({ pending: next });
3235
+ };
3236
+ if (service !== void 0) service.registerProvider({ ask(request) {
3237
+ return new Promise((resolve, reject) => {
3238
+ const pending = {
3239
+ request,
3240
+ resolve,
3241
+ reject
3242
+ };
3243
+ const onAbort = () => {
3244
+ if (active === pending) {
3245
+ active = void 0;
3246
+ set({ pending: void 0 });
3247
+ advance();
3248
+ } else {
3249
+ const at = queue.indexOf(pending);
3250
+ if (at >= 0) queue.splice(at, 1);
3251
+ }
3252
+ reject(ABORT_ERROR);
3253
+ };
3254
+ if (request.signal?.aborted === true) {
3255
+ reject(ABORT_ERROR);
3256
+ return;
3257
+ }
3258
+ request.signal?.addEventListener("abort", onAbort, { once: true });
3259
+ if (active === void 0) {
3260
+ active = pending;
3261
+ set({ pending });
3262
+ } else queue.push(pending);
3263
+ });
3264
+ } });
3265
+ return {
3266
+ subscribe(listener) {
3267
+ listeners.add(listener);
3268
+ return () => {
3269
+ listeners.delete(listener);
3270
+ };
3271
+ },
3272
+ getSnapshot() {
3273
+ return snapshot;
3274
+ },
3275
+ submit(pending, answers) {
3276
+ if (active !== pending) return;
3277
+ active = void 0;
3278
+ set({ pending: void 0 });
3279
+ pending.resolve(answers);
3280
+ advance();
3281
+ },
3282
+ cancel(pending) {
3283
+ if (active !== pending) return;
3284
+ active = void 0;
3285
+ set({ pending: void 0 });
3286
+ pending.reject(ABORT_ERROR);
3287
+ advance();
3288
+ }
3289
+ };
1026
3290
  }
1027
3291
  //#endregion
1028
3292
  //#region src/store.ts
@@ -1052,6 +3316,10 @@ function createTranscriptStore(replay) {
1052
3316
  if (next === view) return;
1053
3317
  view = next;
1054
3318
  for (const listener of listeners) listener();
3319
+ },
3320
+ reset() {
3321
+ view = createTranscriptView();
3322
+ for (const listener of listeners) listener();
1055
3323
  }
1056
3324
  };
1057
3325
  }
@@ -1108,6 +3376,73 @@ function watchSkills(ctx) {
1108
3376
  };
1109
3377
  }
1110
3378
  //#endregion
3379
+ //#region src/render/export.ts
3380
+ /**
3381
+ * Markdown export of one transcript view: the /export command's pure
3382
+ * formatter. Deterministic and side-effect free — the runner owns the file
3383
+ * write, so tests drive the builder with folded views directly.
3384
+ *
3385
+ * @module @deepseek-ai/dsh-code/render/export
3386
+ */
3387
+ /**
3388
+ * Render the transcript as a standalone markdown document.
3389
+ * @param view - the folded transcript view to export.
3390
+ * @param sessionId - the full session identity for the header.
3391
+ * @returns the complete markdown text.
3392
+ */
3393
+ function buildExportMarkdown(view, sessionId) {
3394
+ const out = [
3395
+ view.title === "" ? `# dsh session ${sessionId}` : `# ${view.title}`,
3396
+ `> session ${sessionId}`,
3397
+ ""
3398
+ ];
3399
+ for (const entry of view.entries) switch (entry.kind) {
3400
+ case "user":
3401
+ if (entry.notice) out.push(`> ⤷ context: ${entry.text}`, "");
3402
+ else out.push("## user", "", entry.text, "");
3403
+ break;
3404
+ case "assistant":
3405
+ if (entry.reasoning !== "") out.push("<details><summary>thinking</summary>", "", entry.reasoning, "", "</details>", "");
3406
+ out.push("## assistant", "", entry.text, "");
3407
+ break;
3408
+ case "tool":
3409
+ out.push(`### tool \`${entry.name}\``, "");
3410
+ if (entry.preview !== "") out.push(`- args: ${entry.preview}`);
3411
+ if (entry.summary !== "") out.push(`- ${entry.state === "error" ? "error" : "result"}: ${entry.summary}`);
3412
+ out.push("");
3413
+ break;
3414
+ case "command":
3415
+ out.push(`### /${entry.name}${entry.args === "" ? "" : ` ${entry.args}`}`, "");
3416
+ if (entry.summary !== "") out.push(`- ${entry.state === "error" ? "error" : "result"}: ${entry.summary}`);
3417
+ out.push("");
3418
+ break;
3419
+ case "error":
3420
+ out.push(`> ⨯ ${entry.text}`, "");
3421
+ break;
3422
+ case "turn-marker":
3423
+ out.push(`> ${entry.text}`, "");
3424
+ break;
3425
+ case "compaction":
3426
+ out.push(entry.ok ? `> compacted ~${entry.tokens} tokens` : `> compaction failed: ${entry.error}`, "");
3427
+ break;
3428
+ case "retry":
3429
+ out.push(`> retry ${entry.attempt}/${entry.max} (${entry.code})`, "");
3430
+ break;
3431
+ case "files":
3432
+ out.push(`> files changed: ${entry.paths.join(", ")}`, "");
3433
+ break;
3434
+ default: assertNever(entry, "transcript entry kind");
3435
+ }
3436
+ if (view.streaming !== "") out.push("## assistant (streaming)", "", view.streaming, "");
3437
+ const { stats } = view;
3438
+ out.push("---", "");
3439
+ out.push(`- model: ${view.model === "" ? "(none yet)" : view.model}`);
3440
+ out.push(`- turns: ${stats.turns} · steps: ${stats.steps}`);
3441
+ out.push(`- tokens: ↑${stats.usage.inputTokens} ↓${stats.usage.outputTokens} · cache read ${stats.usage.cacheReadTokens}`);
3442
+ out.push(`- todos: ${view.todos.length}`);
3443
+ return out.join("\n");
3444
+ }
3445
+ //#endregion
1111
3446
  //#region src/index.ts
1112
3447
  /**
1113
3448
  * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
@@ -1199,25 +3534,7 @@ function approvalCommandPreview(events, callId, toolName) {
1199
3534
  if (callId === void 0) return "";
1200
3535
  const entry = events.find((candidate) => candidate.kind === "tool" && candidate.callId === callId);
1201
3536
  if (entry === void 0) return "";
1202
- const args = entry.arguments ?? "";
1203
- try {
1204
- const parsed = JSON.parse(args);
1205
- if (parsed !== null && typeof parsed === "object") {
1206
- const record = parsed;
1207
- for (const key of [
1208
- "command",
1209
- "cmd",
1210
- "description",
1211
- "path",
1212
- "pattern",
1213
- "query"
1214
- ]) {
1215
- const value = record[key];
1216
- if (typeof value === "string" && value !== "") return value;
1217
- }
1218
- }
1219
- } catch {}
1220
- return args.length > 80 ? `${args.slice(0, 77)}...` : args === "" ? toolName : args;
3537
+ return toolArgumentsPreview(entry.arguments ?? "", toolName);
1221
3538
  }
1222
3539
  /**
1223
3540
  * Run the interactive terminal session: resolve the target session, create or
@@ -1298,6 +3615,8 @@ async function run(ctx, startup, io) {
1298
3615
  const skills = watchSkills(ctx);
1299
3616
  skills.setAgent(agent);
1300
3617
  const approval = mountApprovalAnswerer(ctx, (candidate) => candidate.id === agent.id, (request) => approvalCommandPreview(store.getView().entries, request.callId, request.toolName));
3618
+ const questions = mountQuestionProvider(ctx);
3619
+ const mentions = createMentions(ctx, agent, session.header.cwd ?? cwd);
1301
3620
  const bridge = { notify: () => {} };
1302
3621
  const mountRef = {};
1303
3622
  let quitting = false;
@@ -1312,37 +3631,70 @@ async function run(ctx, startup, io) {
1312
3631
  io.exit(0);
1313
3632
  });
1314
3633
  };
1315
- /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
1316
- const dispatch = (text) => {
3634
+ /** Run one slash line through the command registry (closed namespace). */
3635
+ const runSlash = (line) => {
3636
+ const registry = ctx.get("commands");
3637
+ if (registry === void 0) {
3638
+ bridge.notify("no command registry is mounted in this composition");
3639
+ return;
3640
+ }
3641
+ const controller = new AbortController();
3642
+ registry.execute(agent, line, controller.signal).then((execution) => {
3643
+ if (execution === void 0) agent.followup(createUserMessage({
3644
+ content: [{
3645
+ type: "text",
3646
+ text: line
3647
+ }],
3648
+ source: { kind: "user" }
3649
+ }));
3650
+ }, (error) => {
3651
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`);
3652
+ });
3653
+ };
3654
+ /** Deliver one readable line to the agent, expanding session mentions first. */
3655
+ const send = (text, mode) => {
1317
3656
  const line = text.trim();
1318
3657
  if (line === "") return;
1319
- if (isSlashLine(line)) {
1320
- const registry = ctx.get("commands");
1321
- if (registry === void 0) {
1322
- bridge.notify("no command registry is mounted in this composition");
1323
- return;
1324
- }
1325
- const controller = new AbortController();
1326
- registry.execute(agent, line, controller.signal).then((execution) => {
1327
- if (execution === void 0) agent.followup(createUserMessage({
1328
- content: [{
1329
- type: "text",
1330
- text: line
1331
- }],
1332
- source: { kind: "user" }
1333
- }));
1334
- }, (error) => {
1335
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`);
3658
+ if (isSlashLine(line) && mode === "followup") {
3659
+ runSlash(line);
3660
+ return;
3661
+ }
3662
+ let parsed;
3663
+ try {
3664
+ parsed = mentions.parse(line);
3665
+ } catch (error) {
3666
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`);
3667
+ return;
3668
+ }
3669
+ const deliver = (readable, context) => {
3670
+ if (context !== void 0) agent.inject(context);
3671
+ const message = createUserMessage({
3672
+ content: [{
3673
+ type: "text",
3674
+ text: readable
3675
+ }],
3676
+ source: { kind: "user" }
1336
3677
  });
3678
+ if (mode === "steer") {
3679
+ agent.steer(message);
3680
+ bridge.notify("steering queued — the next step sees it");
3681
+ } else agent.followup(message);
3682
+ };
3683
+ if (parsed.references.length === 0) {
3684
+ deliver(parsed.text);
1337
3685
  return;
1338
3686
  }
1339
- agent.followup(createUserMessage({
1340
- content: [{
1341
- type: "text",
1342
- text: line
1343
- }],
1344
- source: { kind: "user" }
1345
- }));
3687
+ const controller = new AbortController();
3688
+ mentions.prepare(parsed, controller.signal).then((prepared) => {
3689
+ deliver(prepared.text, prepared.additionalContext);
3690
+ }, (error) => {
3691
+ if (controller.signal.aborted) return;
3692
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`);
3693
+ });
3694
+ };
3695
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
3696
+ const dispatch = (text) => {
3697
+ send(text, "followup");
1346
3698
  };
1347
3699
  /**
1348
3700
  * Submit steering: a running driver consumes the text at its next step
@@ -1350,16 +3702,7 @@ async function run(ctx, startup, io) {
1350
3702
  * a turn, so this doubles as the busy-state submit path.
1351
3703
  */
1352
3704
  const steer = (text) => {
1353
- const line = text.trim();
1354
- if (line === "") return;
1355
- agent.steer(createUserMessage({
1356
- content: [{
1357
- type: "text",
1358
- text: line
1359
- }],
1360
- source: { kind: "user" }
1361
- }));
1362
- bridge.notify("steering queued — the next step sees it");
3705
+ send(text, "steer");
1363
3706
  };
1364
3707
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
1365
3708
  const interrupt = () => {
@@ -1368,6 +3711,23 @@ async function run(ctx, startup, io) {
1368
3711
  bridge.notify("turn cancelled — Ctrl+C or /quit to exit");
1369
3712
  return true;
1370
3713
  };
3714
+ /**
3715
+ * Cycle to the next permission preset (Shift+Tab, the Claude-Code
3716
+ * permission-mode convention mapped onto dsh presets). A session in a
3717
+ * custom knob state wraps to the first declared preset.
3718
+ */
3719
+ const cyclePermission = () => {
3720
+ const service = ctx.get("permissionPresets");
3721
+ if (service === void 0 || service.names.length === 0) {
3722
+ bridge.notify("permission presets are not mounted in this composition");
3723
+ return "";
3724
+ }
3725
+ const at = service.names.indexOf(service.current(session.events));
3726
+ const next = service.names[(at + 1) % service.names.length] ?? "";
3727
+ if (next === "") return "";
3728
+ service.set(session, next);
3729
+ return next;
3730
+ };
1371
3731
  /** Apply one /model selection: takes effect from the next assembled step. */
1372
3732
  const selectModel = (row) => {
1373
3733
  picked = {
@@ -1376,10 +3736,45 @@ async function run(ctx, startup, io) {
1376
3736
  };
1377
3737
  return `${row.provider}/${row.model}`;
1378
3738
  };
3739
+ /**
3740
+ * Export the folded transcript to a markdown file (/export). The default
3741
+ * target sits beside the session's cwd so the file lands in the user's
3742
+ * workspace; an absolute or cwd-relative argument overrides it.
3743
+ */
3744
+ const exportTranscript = async (argument) => {
3745
+ const wanted = argument.trim();
3746
+ const defaultName = `dsh-session-${session.id.slice(-8)}.md`;
3747
+ const target = wanted === "" ? join(cwd, defaultName) : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith("/") ? wanted : join(cwd, wanted);
3748
+ const markdown = buildExportMarkdown(store.getView(), session.id);
3749
+ try {
3750
+ await writeFile(target, `${markdown}\n`, "utf8");
3751
+ bridge.notify(`exported to ${target}`);
3752
+ } catch (error) {
3753
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`);
3754
+ }
3755
+ };
3756
+ /**
3757
+ * Rename the session (/title): a user title pins the session and stops
3758
+ * automatic generation (the service's own contract). The appended
3759
+ * `session/title` event flows back through the store into the status line.
3760
+ */
3761
+ const renameTitle = (argument) => {
3762
+ const title = argument.trim();
3763
+ if (title === "") return "usage: /title <text>";
3764
+ const service = ctx.get("sessionTitle");
3765
+ if (service === void 0) return "session titles are unavailable in this profile";
3766
+ try {
3767
+ service.rename(session, title);
3768
+ return `title → ${title}`;
3769
+ } catch (error) {
3770
+ return `rename failed: ${error instanceof Error ? error.message : String(error)}`;
3771
+ }
3772
+ };
1379
3773
  const initialModel = store.getView().model !== "" ? store.getView().model : `${defaults.provider}/${defaults.model}`;
1380
3774
  mountRef.current = io.mount(createElement(App, {
1381
3775
  store,
1382
3776
  approval,
3777
+ questions,
1383
3778
  commands,
1384
3779
  skills,
1385
3780
  model: initialModel,
@@ -1392,7 +3787,11 @@ async function run(ctx, startup, io) {
1392
3787
  interrupt,
1393
3788
  quit,
1394
3789
  loadModels: () => loadModelDirectory(ctx),
3790
+ loadMentions: mentions.candidates,
3791
+ cyclePermission,
1395
3792
  selectModel,
3793
+ exportTranscript,
3794
+ renameTitle,
1396
3795
  onBridgeReady: (instance) => {
1397
3796
  bridge.notify = instance.notify;
1398
3797
  }