@xmoon76/dsh-pi-tui 0.1.1 → 0.1.2

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/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { execSync, spawn } from "node:child_process";
5
5
  import * as fs from "node:fs";
6
- import { readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
+ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import * as path from "node:path";
@@ -21,7 +21,7 @@ import { foldSessionTitle } from "@deepseek-ai/dsh-session-title";
21
21
  import { Chalk } from "chalk";
22
22
  import { Marked, Tokenizer } from "marked";
23
23
  import { spawn as spawn$1 } from "child_process";
24
- import { readdirSync as readdirSync$1, statSync } from "fs";
24
+ import { readdirSync as readdirSync$1, statSync as statSync$1 } from "fs";
25
25
  import { homedir as homedir$1 } from "os";
26
26
  import { basename, dirname as dirname$1, join as join$1 } from "path";
27
27
  import { eastAsianWidth } from "get-east-asian-width";
@@ -81,7 +81,8 @@ const TUI_TOOL_TITLES = {
81
81
  workflow: "Workflow",
82
82
  "workflow-member": "Workflow Agent",
83
83
  error: "Error",
84
- interrupted: "Interrupted"
84
+ interrupted: "Interrupted",
85
+ ask_user_question: "Question"
85
86
  };
86
87
  /** Summary key preference per variant (args-derived). */
87
88
  const SUMMARY_KEYS = {
@@ -106,12 +107,6 @@ function firstLine(text) {
106
107
  const newline = text.indexOf("\n");
107
108
  return newline === -1 ? text : text.slice(0, newline);
108
109
  }
109
- /** The last line of a text (the Web's running-reasoning summary). */
110
- function latestLine(text) {
111
- const visible = text.trimEnd();
112
- const newline = visible.lastIndexOf("\n");
113
- return newline === -1 ? visible : visible.slice(newline + 1);
114
- }
115
110
  /**
116
111
  * Strip the workspace root from a workspace-rooted absolute path (display
117
112
  * only), exactly like the Web's relativizeToCwd: only the workspace-root
@@ -162,7 +157,8 @@ const TOOL_EMOJIS = {
162
157
  workflow: "🧵",
163
158
  "workflow-member": "🤖",
164
159
  error: "❌",
165
- interrupted: "⏹️"
160
+ interrupted: "⏹️",
161
+ ask_user_question: "❓"
166
162
  };
167
163
  /** Emoji per row variant, applied to every registered tool of that class. */
168
164
  const VARIANT_EMOJIS = {
@@ -187,6 +183,61 @@ function toolEmoji(name) {
187
183
  return VARIANT_EMOJIS[classifyTool(name)];
188
184
  }
189
185
  /**
186
+ * Parse the read tool's model-facing envelopes from a result: one
187
+ * `<path>…</path> <type>…</type> <content>…numbered lines…</content>` block
188
+ * per file read. A merged group card (groupConsecutiveReads) carries several
189
+ * consecutive envelopes, hence the plural form; the single-envelope helper
190
+ * below is the common-case convenience.
191
+ * @param result - the tool result text.
192
+ * @returns every parsed envelope; empty when the result is not a read envelope.
193
+ */
194
+ function parseReadEnvelopes(result) {
195
+ const out = [];
196
+ const block = /<path>([\s\S]*?)<\/path>\s*<type>[^<]*<\/type>\s*<content>([\s\S]*?)<\/content>/g;
197
+ let match;
198
+ while ((match = block.exec(result)) !== null) {
199
+ const path = match[1] ?? "";
200
+ const body = match[2] ?? "";
201
+ const lines = [];
202
+ let totalLines;
203
+ for (const raw of body.split("\n")) {
204
+ const numbered = /^(\d+): (.*)$/.exec(raw);
205
+ if (numbered !== null) {
206
+ lines.push({
207
+ number: Number(numbered[1]),
208
+ text: numbered[2] ?? ""
209
+ });
210
+ continue;
211
+ }
212
+ const end = /^\(End of file - total (\d+) lines\)$/.exec(raw);
213
+ if (end !== null) {
214
+ totalLines = Number(end[1]);
215
+ continue;
216
+ }
217
+ const showing = /^\(Showing lines \d+-\d+ of (\d+) lines/.exec(raw);
218
+ if (showing !== null) totalLines = Number(showing[1]);
219
+ }
220
+ out.push({
221
+ path,
222
+ lines,
223
+ ...totalLines === void 0 ? {} : { totalLines }
224
+ });
225
+ }
226
+ return out;
227
+ }
228
+ /**
229
+ * The folded preview suffix for a read card: `— {N} lines` when the envelope
230
+ * reports a total (or a line count), empty otherwise. A merged group card
231
+ * (multiple envelopes) yields nothing — its head already carries "N files".
232
+ */
233
+ function readFoldedPreview(result) {
234
+ const envelopes = parseReadEnvelopes(result);
235
+ if (envelopes.length !== 1) return "";
236
+ const envelope = envelopes[0];
237
+ const total = envelope.totalLines ?? (envelope.lines.length > 0 ? envelope.lines.length : void 0);
238
+ return total === void 0 ? "" : ` — ${total} lines`;
239
+ }
240
+ /**
190
241
  * The Web row-model header for one tool card: title = tool-owned title or the
191
242
  * variant design title; summary = SUMMARY_KEYS-derived args summary relativized
192
243
  * to the workspace root. Unknown variants carry the tool name alongside the
@@ -480,7 +531,7 @@ var TranscriptFolder = class {
480
531
  commandNames = /* @__PURE__ */ new Map();
481
532
  /** Workflow run cards by runId, for member/run settlement. */
482
533
  workflowRuns = /* @__PURE__ */ new Map();
483
- /** Workflow member cards by `${runId}/${seq}`, for agent-end settlement. */
534
+ /** Workflow member rows by `${runId}/${seq}`, for agent-end settlement. */
484
535
  workflowMembers = /* @__PURE__ */ new Map();
485
536
  /** The turn most recently opened by turn/start. */
486
537
  currentTurn = 0;
@@ -681,7 +732,8 @@ var TranscriptFolder = class {
681
732
  name: "workflow",
682
733
  args: event.data.name,
683
734
  result: "",
684
- status: "running"
735
+ status: "running",
736
+ members: []
685
737
  };
686
738
  this.workflowRuns.set(event.data.runId, card);
687
739
  this.items.push(card);
@@ -689,25 +741,20 @@ var TranscriptFolder = class {
689
741
  }
690
742
  case "tool-workflow/agent-start": {
691
743
  const { runId, seq, label, phase } = event.data;
692
- const card = {
693
- kind: "tool",
694
- turn: this.currentTurn,
695
- name: "workflow-member",
696
- args: label,
697
- result: phase ?? "",
744
+ const run = this.workflowRuns.get(runId);
745
+ const member = {
746
+ label,
747
+ ...phase === void 0 ? {} : { phase },
698
748
  status: "running"
699
749
  };
700
- this.workflowMembers.set(`${runId}/${seq}`, card);
701
- this.items.push(card);
750
+ this.workflowMembers.set(`${runId}/${seq}`, member);
751
+ run?.members?.push(member);
702
752
  break;
703
753
  }
704
754
  case "tool-workflow/agent-end": {
705
- const card = this.workflowMembers.get(`${event.data.runId}/${event.data.seq}`);
755
+ const member = this.workflowMembers.get(`${event.data.runId}/${event.data.seq}`);
706
756
  const outcome = event.data.outcome;
707
- if (card !== void 0) {
708
- card.status = outcome === "completed" ? "ok" : "error";
709
- card.result = outcome === "completed" ? "completed" : `outcome: ${outcome}`;
710
- }
757
+ if (member !== void 0) member.status = outcome === "completed" ? "ok" : "error";
711
758
  break;
712
759
  }
713
760
  case "tool-workflow/run-end": {
@@ -814,7 +861,8 @@ const EMPTY = {
814
861
  cacheHitPct: 0,
815
862
  inputTokens: 0,
816
863
  outputTokens: 0,
817
- cacheReadTokens: 0
864
+ cacheReadTokens: 0,
865
+ cacheWriteTokens: 0
818
866
  };
819
867
  /** Key identifying one step's model output (turn + step). */
820
868
  function stepKey(turn, step) {
@@ -827,10 +875,9 @@ function stepKey(turn, step) {
827
875
  */
828
876
  function computeStats(events) {
829
877
  const stats = { ...EMPTY };
830
- const stepStart = /* @__PURE__ */ new Map();
831
- const firstDelta = /* @__PURE__ */ new Map();
832
- const lastDelta = /* @__PURE__ */ new Map();
878
+ const perStep = /* @__PURE__ */ new Map();
833
879
  const outputMs = [];
880
+ let decodeTokens = 0;
834
881
  let firstTokenTotal = 0;
835
882
  let firstTokenCount = 0;
836
883
  for (const event of events) switch (event.type) {
@@ -839,50 +886,76 @@ function computeStats(events) {
839
886
  break;
840
887
  case "step/start":
841
888
  stats.steps += 1;
842
- stepStart.set(stepKey(event.data.turn, event.data.step), event.time);
889
+ perStep.set(stepKey(event.data.turn, event.data.step), { start: event.time });
843
890
  break;
844
891
  case "step/end": {
845
- const key = stepKey(event.data.turn, event.data.step);
846
- const start = stepStart.get(key);
847
- if (start !== void 0) stats.llmMs += event.time - start;
848
- const first = firstDelta.get(key);
849
- const last = lastDelta.get(key);
850
- if (first !== void 0 && last !== void 0) outputMs.push(last - first);
892
+ const timing = perStep.get(stepKey(event.data.turn, event.data.step));
893
+ if (timing === void 0) break;
894
+ settleStep(stats, timing, event.time, outputMs, (ms) => {
895
+ decodeTokens += ms;
896
+ });
897
+ perStep.delete(stepKey(event.data.turn, event.data.step));
851
898
  break;
852
899
  }
853
900
  case "assistant/chunk": {
854
901
  const { chunk } = event.data;
855
902
  if (chunk.type === "text-delta") {
856
- const key = stepKey(event.data.turn, event.data.step);
857
- if (!firstDelta.has(key)) {
858
- firstDelta.set(key, event.time);
859
- const start = stepStart.get(key);
903
+ const timing = perStep.get(stepKey(event.data.turn, event.data.step));
904
+ if (timing !== void 0 && timing.firstDelta === void 0) {
905
+ timing.firstDelta = event.time;
906
+ const start = timing.start;
860
907
  if (start !== void 0) {
861
908
  firstTokenTotal += event.time - start;
862
909
  firstTokenCount += 1;
863
910
  }
864
911
  }
865
- lastDelta.set(key, event.time);
866
- } else if (chunk.type === "usage") addUsage(stats, chunk.usage);
912
+ } else if (chunk.type === "usage") {
913
+ const key = stepKey(event.data.turn, event.data.step);
914
+ const timing = perStep.get(key);
915
+ if (timing !== void 0) {
916
+ if (timing.usage === void 0) timing.usage = chunk.usage;
917
+ } else addUsage(stats, chunk.usage);
918
+ }
867
919
  break;
868
920
  }
869
- case "assistant/message":
870
- if (event.data.usage !== void 0) addUsage(stats, event.data.usage);
921
+ case "assistant/message": {
922
+ const timing = perStep.get(stepKey(event.data.turn, event.data.step));
923
+ if (timing !== void 0) {
924
+ timing.completed = event.time;
925
+ if (event.data.usage !== void 0) timing.usage = event.data.usage;
926
+ } else if (event.data.usage !== void 0) addUsage(stats, event.data.usage);
871
927
  break;
928
+ }
872
929
  case "request/context": if (event.data.contextWindow !== void 0) stats.contextWindow = event.data.contextWindow;
873
930
  }
874
931
  if (firstTokenCount > 0) stats.firstTokenMsAvg = firstTokenTotal / firstTokenCount;
875
932
  const streamMs = outputMs.reduce((sum, ms) => sum + ms, 0);
876
- if (streamMs > 0) stats.tokensPerSec = Math.round(stats.outputTokens * 1e3 / streamMs);
877
- const totalInput = stats.inputTokens + stats.cacheReadTokens;
878
- if (totalInput > 0) stats.cacheHitPct = stats.cacheReadTokens * 100 / totalInput;
933
+ if (streamMs > 0) stats.tokensPerSec = Math.round(decodeTokens * 1e3 / streamMs);
934
+ const billedInput = stats.inputTokens + stats.cacheReadTokens + stats.cacheWriteTokens;
935
+ if (billedInput > 0) stats.cacheHitPct = stats.cacheReadTokens * 100 / billedInput;
879
936
  return stats;
880
937
  }
881
- /** Token totals from one usage record (cache reads counted separately). */
938
+ /** Settle one step at its end boundary: wall time, decode window, usage. */
939
+ function settleStep(stats, timing, endTime, outputMs, addDecodeTokens) {
940
+ const completed = timing.completed ?? endTime;
941
+ const start = timing.start;
942
+ if (start !== void 0) stats.llmMs += Math.max(0, completed - start);
943
+ const usage = timing.usage;
944
+ if (usage !== void 0) {
945
+ addUsage(stats, usage);
946
+ const first = timing.firstDelta;
947
+ if (first !== void 0) {
948
+ outputMs.push(Math.max(0, completed - first));
949
+ addDecodeTokens(usage.outputTokens);
950
+ }
951
+ }
952
+ }
953
+ /** Token totals from one usage record (cache fields counted separately). */
882
954
  function addUsage(stats, usage) {
883
955
  stats.inputTokens += usage.inputTokens;
884
956
  stats.outputTokens += usage.outputTokens;
885
957
  stats.cacheReadTokens += usage.cacheReadTokens ?? 0;
958
+ stats.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
886
959
  }
887
960
  /**
888
961
  * Incremental stats folding: apply appended events and read `snapshot()`
@@ -892,10 +965,9 @@ function addUsage(stats, usage) {
892
965
  */
893
966
  var StatsFolder = class {
894
967
  stats = { ...EMPTY };
895
- stepStart = /* @__PURE__ */ new Map();
896
- firstDelta = /* @__PURE__ */ new Map();
897
- lastDelta = /* @__PURE__ */ new Map();
968
+ perStep = /* @__PURE__ */ new Map();
898
969
  outputMs = [];
970
+ decodeTokens = 0;
899
971
  firstTokenTotal = 0;
900
972
  firstTokenCount = 0;
901
973
  /**
@@ -910,9 +982,9 @@ var StatsFolder = class {
910
982
  const derived = { ...this.stats };
911
983
  if (this.firstTokenCount > 0) derived.firstTokenMsAvg = this.firstTokenTotal / this.firstTokenCount;
912
984
  const streamMs = this.outputMs.reduce((sum, ms) => sum + ms, 0);
913
- if (streamMs > 0) derived.tokensPerSec = Math.round(derived.outputTokens * 1e3 / streamMs);
914
- const totalInput = derived.inputTokens + derived.cacheReadTokens;
915
- if (totalInput > 0) derived.cacheHitPct = derived.cacheReadTokens * 100 / totalInput;
985
+ if (streamMs > 0) derived.tokensPerSec = Math.round(this.decodeTokens * 1e3 / streamMs);
986
+ const billedInput = derived.inputTokens + derived.cacheReadTokens + derived.cacheWriteTokens;
987
+ if (billedInput > 0) derived.cacheHitPct = derived.cacheReadTokens * 100 / billedInput;
916
988
  return derived;
917
989
  }
918
990
  applyEvent(event) {
@@ -922,36 +994,49 @@ var StatsFolder = class {
922
994
  break;
923
995
  case "step/start":
924
996
  this.stats.steps += 1;
925
- this.stepStart.set(stepKey(event.data.turn, event.data.step), event.time);
997
+ this.perStep.set(stepKey(event.data.turn, event.data.step), { start: event.time });
926
998
  break;
927
999
  case "step/end": {
928
1000
  const key = stepKey(event.data.turn, event.data.step);
929
- const start = this.stepStart.get(key);
930
- if (start !== void 0) this.stats.llmMs += event.time - start;
931
- const first = this.firstDelta.get(key);
932
- const last = this.lastDelta.get(key);
933
- if (first !== void 0 && last !== void 0) this.outputMs.push(last - first);
1001
+ const timing = this.perStep.get(key);
1002
+ if (timing === void 0) break;
1003
+ settleStep(this.stats, timing, event.time, this.outputMs, (tokens) => {
1004
+ this.decodeTokens += tokens;
1005
+ });
1006
+ this.perStep.delete(key);
934
1007
  break;
935
1008
  }
936
1009
  case "assistant/chunk": {
937
1010
  const { chunk } = event.data;
938
1011
  if (chunk.type === "text-delta") {
939
1012
  const key = stepKey(event.data.turn, event.data.step);
940
- if (!this.firstDelta.has(key)) {
941
- this.firstDelta.set(key, event.time);
942
- const start = this.stepStart.get(key);
1013
+ const timing = this.perStep.get(key);
1014
+ if (timing !== void 0 && timing.firstDelta === void 0) {
1015
+ timing.firstDelta = event.time;
1016
+ const start = timing.start;
943
1017
  if (start !== void 0) {
944
1018
  this.firstTokenTotal += event.time - start;
945
1019
  this.firstTokenCount += 1;
946
1020
  }
947
1021
  }
948
- this.lastDelta.set(key, event.time);
949
- } else if (chunk.type === "usage") addUsage(this.stats, chunk.usage);
1022
+ } else if (chunk.type === "usage") {
1023
+ const key = stepKey(event.data.turn, event.data.step);
1024
+ const timing = this.perStep.get(key);
1025
+ if (timing !== void 0) {
1026
+ if (timing.usage === void 0) timing.usage = chunk.usage;
1027
+ } else addUsage(this.stats, chunk.usage);
1028
+ }
950
1029
  break;
951
1030
  }
952
- case "assistant/message":
953
- if (event.data.usage !== void 0) addUsage(this.stats, event.data.usage);
1031
+ case "assistant/message": {
1032
+ const key = stepKey(event.data.turn, event.data.step);
1033
+ const timing = this.perStep.get(key);
1034
+ if (timing !== void 0) {
1035
+ timing.completed = event.time;
1036
+ if (event.data.usage !== void 0) timing.usage = event.data.usage;
1037
+ } else if (event.data.usage !== void 0) addUsage(this.stats, event.data.usage);
954
1038
  break;
1039
+ }
955
1040
  case "request/context": if (event.data.contextWindow !== void 0) this.stats.contextWindow = event.data.contextWindow;
956
1041
  }
957
1042
  }
@@ -987,7 +1072,8 @@ function formatStats(stats) {
987
1072
  `↑${formatTokens(stats.inputTokens)}`,
988
1073
  `↓${formatTokens(stats.outputTokens)}`,
989
1074
  stats.cacheReadTokens > 0 ? `R${formatTokens(stats.cacheReadTokens)}` : "",
990
- stats.cacheReadTokens > 0 ? `CH${stats.cacheHitPct.toFixed(1)}%` : ""
1075
+ stats.cacheWriteTokens > 0 ? `W${formatTokens(stats.cacheWriteTokens)}` : "",
1076
+ stats.cacheReadTokens > 0 || stats.cacheWriteTokens > 0 ? `CH${stats.cacheHitPct.toFixed(1)}%` : ""
991
1077
  ].filter((part) => part !== "");
992
1078
  const ownParts = [
993
1079
  `LLM ${formatSeconds(stats.llmMs)}`,
@@ -1099,7 +1185,11 @@ const color = {
1099
1185
  warning: (text) => hex("warning")(text),
1100
1186
  error: (text) => hex("error")(text),
1101
1187
  roleUser: (text) => hex("roleUser")(text),
1102
- shellMode: (text) => hex("shellMode")(text)
1188
+ shellMode: (text) => hex("shellMode")(text),
1189
+ /** Plain italics (kimi thinking parity). */
1190
+ italic: (text) => chalk.italic(text),
1191
+ /** Dim + italic for reasoning: intermediate thinking never reads like output. */
1192
+ textDimItalic: (text) => chalk.italic.hex(currentPalette.textDim)(text)
1103
1193
  };
1104
1194
  /** SelectList palette from the semantic tokens. */
1105
1195
  const selectListTheme = {
@@ -1551,7 +1641,7 @@ var CombinedAutocompleteProvider = class {
1551
1641
  if (displayBase.startsWith("~/") || displayBase.startsWith("/")) {
1552
1642
  const baseDir = displayBase.startsWith("~/") ? this.expandHomePath(displayBase) : displayBase;
1553
1643
  try {
1554
- if (!statSync(baseDir).isDirectory()) return null;
1644
+ if (!statSync$1(baseDir).isDirectory()) return null;
1555
1645
  } catch {
1556
1646
  return null;
1557
1647
  }
@@ -1602,7 +1692,7 @@ var CombinedAutocompleteProvider = class {
1602
1692
  let isDirectory = entry.isDirectory();
1603
1693
  if (!isDirectory && entry.isSymbolicLink()) try {
1604
1694
  const fullPath = join$1(searchDir, entry.name);
1605
- isDirectory = statSync(fullPath).isDirectory();
1695
+ isDirectory = statSync$1(fullPath).isDirectory();
1606
1696
  } catch {}
1607
1697
  let relativePath;
1608
1698
  const name = entry.name;
@@ -1685,7 +1775,7 @@ var CombinedAutocompleteProvider = class {
1685
1775
  const baseDir = join$1(root, scoped.displayBase);
1686
1776
  let isDir = false;
1687
1777
  try {
1688
- isDir = statSync(baseDir).isDirectory();
1778
+ isDir = statSync$1(baseDir).isDirectory();
1689
1779
  } catch {
1690
1780
  isDir = false;
1691
1781
  }
@@ -8338,6 +8428,73 @@ var StrictStrikethroughTokenizer = class extends Tokenizer {
8338
8428
  };
8339
8429
  }
8340
8430
  };
8431
+ const FULLWIDTH_LEFT_PAREN = 65288;
8432
+ const FULLWIDTH_RIGHT_PAREN = 65289;
8433
+ const CJK_URL_TERMINATOR_REGEX = /[\u3000-\u303f\uff01-\uff07\uff0a-\uff0f\uff1a-\uff20\uff3b-\uff40\uff5b-\uff65\u2013\u2014\u2018\u2019\u201c\u201d\u2026]/;
8434
+ /**
8435
+ * Index at which to cut an autolink match, or -1 to keep it whole. Non-paren
8436
+ * CJK punctuation terminates the URL outside of full-width parens; full-width
8437
+ * parens only terminate it when unbalanced. Punctuation inside a balanced
8438
+ * parenthetical (e.g. the ,in (北京,1949年)) stays part of the URL —
8439
+ * prose parentheticals contain spaces and never survive marked's match this
8440
+ * far, so a balanced group is almost always deliberate URL content.
8441
+ */
8442
+ function findCjkUrlBoundary(match) {
8443
+ let parenDepth = 0;
8444
+ let unmatchedOpen = -1;
8445
+ for (let i = 0; i < match.length; i++) {
8446
+ const code = match.charCodeAt(i);
8447
+ if (code === FULLWIDTH_LEFT_PAREN) {
8448
+ parenDepth++;
8449
+ if (unmatchedOpen === -1) unmatchedOpen = i;
8450
+ } else if (code === FULLWIDTH_RIGHT_PAREN) {
8451
+ if (parenDepth === 0) return i;
8452
+ parenDepth--;
8453
+ if (parenDepth === 0) unmatchedOpen = -1;
8454
+ } else if (parenDepth === 0 && CJK_URL_TERMINATOR_REGEX.test(match[i])) return i;
8455
+ }
8456
+ return parenDepth > 0 ? unmatchedOpen : -1;
8457
+ }
8458
+ var CjkBoundaryUrlTokenizer = class extends StrictStrikethroughTokenizer {
8459
+ url(src) {
8460
+ const cap = this.rules.inline.url.exec(src);
8461
+ if (!cap) return;
8462
+ if (cap[2] === "@") {
8463
+ const text = cap[0];
8464
+ return {
8465
+ type: "link",
8466
+ raw: text,
8467
+ text,
8468
+ href: `mailto:${text}`,
8469
+ tokens: [{
8470
+ type: "text",
8471
+ raw: text,
8472
+ text
8473
+ }]
8474
+ };
8475
+ }
8476
+ const boundary = findCjkUrlBoundary(cap[0]);
8477
+ if (boundary !== -1) cap[0] = cap[0].slice(0, boundary);
8478
+ if (!cap[0]) return;
8479
+ let previous;
8480
+ do {
8481
+ previous = cap[0];
8482
+ cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? "";
8483
+ } while (previous !== cap[0]);
8484
+ const text = cap[0];
8485
+ return {
8486
+ type: "link",
8487
+ raw: text,
8488
+ text,
8489
+ href: cap[1] === "www." ? `http://${text}` : text,
8490
+ tokens: [{
8491
+ type: "text",
8492
+ raw: text,
8493
+ text
8494
+ }]
8495
+ };
8496
+ }
8497
+ };
8341
8498
  function isEscaped(source, index) {
8342
8499
  let backslashes = 0;
8343
8500
  for (let position = index - 1; position >= 0 && source[position] === "\\"; position--) backslashes++;
@@ -8453,7 +8610,7 @@ function trimPartialClosingFences(tokens) {
8453
8610
  token.text = token.text.slice(0, -lastLine.length).replace(/\n$/, "");
8454
8611
  }
8455
8612
  const markdownParser = new Marked();
8456
- markdownParser.setOptions({ tokenizer: new StrictStrikethroughTokenizer() });
8613
+ markdownParser.setOptions({ tokenizer: new CjkBoundaryUrlTokenizer() });
8457
8614
  markdownParser.use({ extensions: [...LATEX_MARKDOWN_EXTENSIONS] });
8458
8615
  var Markdown = class {
8459
8616
  text;
@@ -11837,22 +11994,204 @@ function renderDiffLines(text) {
11837
11994
  return text.split("\n").map(renderDiffLine);
11838
11995
  }
11839
11996
  /**
11997
+ * Above this combined input size the O(n·m) LCS table is skipped and the
11998
+ * diff degrades to a naive all-delete/all-add listing (still correct, just
11999
+ * without alignment).
12000
+ */
12001
+ const DIFF_LCS_MAX_LINES = 2e3;
12002
+ /**
12003
+ * Compute a line-level diff by DP longest-common-subsequence (kimi
12004
+ * `computeDiffLines` parity): context rows for identical lines, add/delete
12005
+ * rows for the differing runs, each carrying its source-side line number.
12006
+ * @param oldText - the before side.
12007
+ * @param newText - the after side.
12008
+ * @param oldStart - first line number of the old side (default 1).
12009
+ * @param newStart - first line number of the new side (default 1).
12010
+ * @returns the diff rows in document order.
12011
+ */
12012
+ function computeDiffLines(oldText, newText, oldStart = 1, newStart = 1) {
12013
+ const oldLines = oldText.split("\n");
12014
+ const newLines = newText.split("\n");
12015
+ const m = oldLines.length;
12016
+ const n = newLines.length;
12017
+ if (m + n > DIFF_LCS_MAX_LINES) {
12018
+ const out = [];
12019
+ for (let i = 0; i < m; i++) out.push({
12020
+ kind: "delete",
12021
+ lineNum: oldStart + i,
12022
+ code: oldLines[i]
12023
+ });
12024
+ for (let j = 0; j < n; j++) out.push({
12025
+ kind: "add",
12026
+ lineNum: newStart + j,
12027
+ code: newLines[j]
12028
+ });
12029
+ return out;
12030
+ }
12031
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
12032
+ for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = oldLines[i - 1] === newLines[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
12033
+ const reversed = [];
12034
+ let i = m;
12035
+ let j = n;
12036
+ while (i > 0 || j > 0) if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
12037
+ reversed.push({
12038
+ kind: "context",
12039
+ lineNum: newStart + j - 1,
12040
+ code: newLines[j - 1]
12041
+ });
12042
+ i--;
12043
+ j--;
12044
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
12045
+ reversed.push({
12046
+ kind: "add",
12047
+ lineNum: newStart + j - 1,
12048
+ code: newLines[j - 1]
12049
+ });
12050
+ j--;
12051
+ } else {
12052
+ reversed.push({
12053
+ kind: "delete",
12054
+ lineNum: oldStart + i - 1,
12055
+ code: oldLines[i - 1]
12056
+ });
12057
+ i--;
12058
+ }
12059
+ return reversed.reverse();
12060
+ }
12061
+ /**
12062
+ * Group change rows into clusters with `contextLines` of context on each
12063
+ * side, merging clusters whose unchanged gap is ≤ 2·contextLines.
12064
+ * @returns the clusters plus add/delete/change counts over all diff rows.
12065
+ */
12066
+ function buildDiffClusters(diffLines, contextLines) {
12067
+ const changeIndices = [];
12068
+ let added = 0;
12069
+ let removed = 0;
12070
+ for (const [i, line] of diffLines.entries()) if (line.kind === "add") {
12071
+ added++;
12072
+ changeIndices.push(i);
12073
+ } else if (line.kind === "delete") {
12074
+ removed++;
12075
+ changeIndices.push(i);
12076
+ }
12077
+ if (changeIndices.length === 0) return {
12078
+ clusters: [],
12079
+ changedCount: 0,
12080
+ addedCount: added,
12081
+ removedCount: removed
12082
+ };
12083
+ const clusters = [];
12084
+ const mergeGap = 2 * contextLines;
12085
+ let groupStart = changeIndices[0];
12086
+ let groupEnd = changeIndices[0];
12087
+ for (let k = 1; k < changeIndices.length; k++) {
12088
+ const idx = changeIndices[k];
12089
+ if (idx - groupEnd <= mergeGap) groupEnd = idx;
12090
+ else {
12091
+ clusters.push({
12092
+ start: Math.max(0, groupStart - contextLines),
12093
+ end: Math.min(diffLines.length - 1, groupEnd + contextLines)
12094
+ });
12095
+ groupStart = idx;
12096
+ groupEnd = idx;
12097
+ }
12098
+ }
12099
+ clusters.push({
12100
+ start: Math.max(0, groupStart - contextLines),
12101
+ end: Math.min(diffLines.length - 1, groupEnd + contextLines)
12102
+ });
12103
+ return {
12104
+ clusters,
12105
+ changedCount: changeIndices.length,
12106
+ addedCount: added,
12107
+ removedCount: removed
12108
+ };
12109
+ }
12110
+ /** One diff body row: dim line-number gutter plus the colored/plain code. */
12111
+ function formatDiffRow(line) {
12112
+ const gutter = color.textDim(`${String(line.lineNum).padStart(4)} `);
12113
+ if (line.kind === "add") return gutter + color.success(`+ ${line.code}`);
12114
+ if (line.kind === "delete") return gutter + color.error(`- ${line.code}`);
12115
+ return gutter + ` ${line.code}`;
12116
+ }
12117
+ /**
11840
12118
  * Render a result-side diff view (a `card: 'diff'` presentResult intent) as
11841
- * colored lines: one dimmed File: header per hunk, prior lines as `-` (red),
11842
- * new lines as `+` (green). Hunks whose oldText is null (creates) show only
11843
- * the new lines. Paths relativize against the workspace root when given.
12119
+ * colored lines: one `+N -M path` header per hunk (kimi parity; counts in
12120
+ * add/remove colors, path workspace-relative), then the LCS-aligned body
12121
+ * with context clustering — unchanged runs between clusters elide to a
12122
+ * `… N unchanged lines …` separator, and `maxLines` caps the body at a
12123
+ * cluster boundary with a `… N more changes hidden (hint)` footer. A hunk
12124
+ * with `oldText: null` (create) shows only new lines; an empty newText
12125
+ * (pure deletion) shows only old lines.
11844
12126
  * @param diffs - the diff view's hunks.
11845
12127
  * @param cwd - workspace root for path relativization; optional.
12128
+ * @param options - context/cap/hint tuning.
11846
12129
  * @returns the colored render lines.
11847
12130
  */
11848
- function renderDiffView(diffs, cwd) {
11849
- const lines = [];
12131
+ function renderDiffView(diffs, cwd, options = {}) {
12132
+ const contextLines = options.contextLines ?? 3;
12133
+ const cap = options.maxLines !== void 0 && options.maxLines >= 0 ? options.maxLines : Number.POSITIVE_INFINITY;
12134
+ const out = [];
11850
12135
  for (const hunk of diffs) {
11851
- lines.push(color.textDim("File: " + relativizeToCwd(hunk.path, cwd)));
11852
- if (hunk.oldText !== null) for (const line of hunk.oldText.split("\n")) lines.push(renderDiffLine("-" + line));
11853
- for (const line of hunk.newText.split("\n")) lines.push(renderDiffLine("+" + line));
12136
+ const oldSide = hunk.oldText === null || hunk.oldText === "" ? [] : hunk.oldText.split("\n");
12137
+ const newSide = hunk.newText === "" ? [] : hunk.newText.split("\n");
12138
+ const diffLines = oldSide.length === 0 ? newSide.map((code, index) => ({
12139
+ kind: "add",
12140
+ lineNum: index + 1,
12141
+ code
12142
+ })) : newSide.length === 0 ? oldSide.map((code, index) => ({
12143
+ kind: "delete",
12144
+ lineNum: index + 1,
12145
+ code
12146
+ })) : computeDiffLines(hunk.oldText, hunk.newText);
12147
+ const { clusters, changedCount, addedCount, removedCount } = buildDiffClusters(diffLines, contextLines);
12148
+ let header = "";
12149
+ if (addedCount > 0) header += color.success(`+${addedCount} `);
12150
+ if (removedCount > 0) header += color.error(`-${removedCount} `);
12151
+ header += relativizeToCwd(hunk.path, cwd);
12152
+ out.push(header);
12153
+ if (clusters.length === 0) continue;
12154
+ let body = 0;
12155
+ let prevEnd = -1;
12156
+ let truncated = false;
12157
+ let shownChanges = 0;
12158
+ outer: for (const cluster of clusters) {
12159
+ if (body >= cap) {
12160
+ truncated = true;
12161
+ break;
12162
+ }
12163
+ if (prevEnd >= 0) {
12164
+ const gap = cluster.start - prevEnd - 1;
12165
+ if (gap > 0) {
12166
+ if (body + 1 > cap) {
12167
+ truncated = true;
12168
+ break;
12169
+ }
12170
+ out.push(color.textMuted(` … ${gap} unchanged line${gap > 1 ? "s" : ""} …`));
12171
+ body++;
12172
+ }
12173
+ }
12174
+ for (let i = cluster.start; i <= cluster.end; i++) {
12175
+ if (body >= cap) {
12176
+ truncated = true;
12177
+ break outer;
12178
+ }
12179
+ const line = diffLines[i];
12180
+ out.push(formatDiffRow(line));
12181
+ body++;
12182
+ if (line.kind !== "context") shownChanges++;
12183
+ prevEnd = i;
12184
+ }
12185
+ }
12186
+ if (truncated) {
12187
+ const hidden = changedCount - shownChanges;
12188
+ if (hidden > 0) {
12189
+ const hint = options.expandHint ?? "click to expand";
12190
+ out.push(color.textMuted(` … ${hidden} more change${hidden > 1 ? "s" : ""} hidden (${hint})`));
12191
+ }
12192
+ }
11854
12193
  }
11855
- return lines;
12194
+ return out;
11856
12195
  }
11857
12196
  //#endregion
11858
12197
  //#region src/search.ts
@@ -11906,6 +12245,378 @@ var TranscriptSearchComponent = class {
11906
12245
  }
11907
12246
  };
11908
12247
  //#endregion
12248
+ //#region src/question.ts
12249
+ /**
12250
+ * The user-questions dialog flow (ask_user_question v2): a focusable overlay
12251
+ * component that walks one question at a time, collects drafts per question,
12252
+ * and submits the whole batch at the end — the Web QuestionComposer
12253
+ * semantics (per-question paging, batch submit, skip) with pi/kimi keyboard
12254
+ * ergonomics (↑↓ highlight, digits, real Input for free text, review page).
12255
+ *
12256
+ * Pure component: the app layer owns the promise/abort plumbing and routes
12257
+ * input here while the flow is active.
12258
+ * @module @xmoon76/dsh-pi-tui/question
12259
+ */
12260
+ /** The "type your own answer" row shown below options (pi isOther parity). */
12261
+ const OTHER_ROW = "\0other";
12262
+ /** The current question's tab label: `Q{n}` or Submit. */
12263
+ function tabLabel(index, total) {
12264
+ return index === total ? "Submit" : `Q${index + 1}`;
12265
+ }
12266
+ /** Split a conventional recommendation suffix off the DISPLAY label (the
12267
+ * answer value keeps the full label — Web parseRecommendedLabel parity). */
12268
+ function parseRecommended(label) {
12269
+ const suffix = /\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*$/i;
12270
+ return suffix.test(label) ? {
12271
+ label: label.replace(suffix, ""),
12272
+ recommended: true
12273
+ } : {
12274
+ label,
12275
+ recommended: false
12276
+ };
12277
+ }
12278
+ /**
12279
+ * The interactive question flow. Renders one question at a time with a tab
12280
+ * strip (answered marks), a navigable option list (↑↓/digits/Enter), a real
12281
+ * Input for free text, and a final review page (Submit/Cancel). Esc cancels
12282
+ * the whole flow; the app layer resolves the batch on done().
12283
+ */
12284
+ var QuestionFlow = class {
12285
+ questions;
12286
+ onDone;
12287
+ onCancel;
12288
+ drafts;
12289
+ /** Current question index; `questions.length` is the review page. */
12290
+ tab = 0;
12291
+ /** Highlighted option index within the current question. */
12292
+ cursor = 0;
12293
+ /** Free-text editing mode (the "Type something." row or an optionless question). */
12294
+ editingOther = false;
12295
+ otherInput = new Input();
12296
+ /** Review-page action highlight (0 = Submit, 1 = Cancel). */
12297
+ submitIdx = 0;
12298
+ focused = false;
12299
+ constructor(questions, onDone, onCancel) {
12300
+ this.questions = questions;
12301
+ this.onDone = onDone;
12302
+ this.onCancel = onCancel;
12303
+ this.drafts = questions.map(() => ({
12304
+ selected: /* @__PURE__ */ new Set(),
12305
+ custom: "",
12306
+ skipped: false
12307
+ }));
12308
+ this.otherInput.onSubmit = (value) => this.commitOther(value);
12309
+ this.otherInput.onEscape = () => this.exitOther();
12310
+ this.syncEditMode();
12311
+ }
12312
+ /** The rows of the current question (options plus the free-text row). */
12313
+ rows() {
12314
+ const question = this.questions[this.tab];
12315
+ if (question === void 0) return [];
12316
+ const rows = (question.options ?? []).map((option, index) => {
12317
+ const { label, recommended } = parseRecommended(option.label);
12318
+ const approve = question.intent?.approve;
12319
+ return {
12320
+ key: String(index),
12321
+ label,
12322
+ recommended: recommended || approve !== void 0 && option.label === approve,
12323
+ ...option.description === void 0 ? {} : { description: option.description }
12324
+ };
12325
+ });
12326
+ if ((question.options?.length ?? 0) > 0) rows.push({
12327
+ key: OTHER_ROW,
12328
+ label: "Type something.",
12329
+ recommended: false
12330
+ });
12331
+ return rows;
12332
+ }
12333
+ /** The current question's draft. */
12334
+ draft() {
12335
+ return this.drafts[this.tab];
12336
+ }
12337
+ /** Whether the current question has an answer (selected, custom, or skipped). */
12338
+ answered() {
12339
+ const draft = this.draft();
12340
+ if (draft === void 0) return false;
12341
+ return draft.selected.size > 0 || draft.custom !== "" || draft.skipped;
12342
+ }
12343
+ /** The free-text row of the current question (present when options exist). */
12344
+ otherRowIndex() {
12345
+ return this.rows().findIndex((row) => row.key === OTHER_ROW);
12346
+ }
12347
+ /** Enter on the highlighted row, or in text mode: commit and advance. */
12348
+ confirm() {
12349
+ const draft = this.draft();
12350
+ if (draft === void 0) return;
12351
+ const row = this.rows()[this.cursor];
12352
+ if (row !== void 0 && row.key !== OTHER_ROW) {
12353
+ const option = this.questions[this.tab]?.options?.[Number(row.key)];
12354
+ if (option !== void 0) {
12355
+ const label = option.label;
12356
+ if (this.questions[this.tab]?.multiSelect === true) {
12357
+ if (draft.selected.has(label)) draft.selected.delete(label);
12358
+ else draft.selected.add(label);
12359
+ draft.skipped = false;
12360
+ return;
12361
+ }
12362
+ draft.selected.clear();
12363
+ draft.selected.add(label);
12364
+ draft.skipped = false;
12365
+ this.advance();
12366
+ }
12367
+ return;
12368
+ }
12369
+ if (row?.key === OTHER_ROW) this.enterOther();
12370
+ }
12371
+ /** Move to the next question (or the review page on the last one). */
12372
+ advance() {
12373
+ this.tab += 1;
12374
+ this.cursor = 0;
12375
+ this.submitIdx = 0;
12376
+ this.syncEditMode();
12377
+ }
12378
+ /** Optionless questions edit text directly; options start in list mode. */
12379
+ syncEditMode() {
12380
+ const question = this.questions[this.tab];
12381
+ const optionless = question !== void 0 && (question.options?.length ?? 0) === 0;
12382
+ this.editingOther = optionless;
12383
+ if (optionless) {
12384
+ const draft = this.draft();
12385
+ this.otherInput.setValue(draft?.custom ?? "");
12386
+ } else {
12387
+ const recommended = this.rows().findIndex((row) => row.recommended);
12388
+ this.cursor = recommended >= 0 ? recommended : 0;
12389
+ }
12390
+ this.otherInput.focused = this.focused && this.editingOther;
12391
+ }
12392
+ /** Skip the current question (empty answer) and move on. */
12393
+ skip() {
12394
+ const draft = this.draft();
12395
+ if (draft === void 0) return;
12396
+ draft.selected.clear();
12397
+ draft.custom = "";
12398
+ draft.skipped = true;
12399
+ this.advance();
12400
+ }
12401
+ /** Enter the free-text editing mode for the current question. */
12402
+ enterOther() {
12403
+ this.editingOther = true;
12404
+ const draft = this.draft();
12405
+ this.otherInput.setValue(draft?.custom ?? "");
12406
+ this.otherInput.focused = this.focused;
12407
+ }
12408
+ /** Leave text mode back to the option list. */
12409
+ exitOther() {
12410
+ this.editingOther = false;
12411
+ this.otherInput.focused = false;
12412
+ }
12413
+ /** Commit the typed text into the draft and advance. */
12414
+ commitOther(value) {
12415
+ const draft = this.draft();
12416
+ if (draft === void 0) return;
12417
+ const text = value.trim();
12418
+ draft.custom = text;
12419
+ if (text === "") draft.skipped = true;
12420
+ else {
12421
+ if (this.questions[this.tab]?.multiSelect !== true) draft.selected.clear();
12422
+ draft.skipped = false;
12423
+ }
12424
+ this.exitOther();
12425
+ this.advance();
12426
+ }
12427
+ /** Submit the whole batch from the review page. */
12428
+ submit() {
12429
+ const answers = this.questions.map((question, index) => {
12430
+ const draft = this.drafts[index];
12431
+ if (draft.skipped) return {
12432
+ id: question.id,
12433
+ selected: []
12434
+ };
12435
+ const custom = draft.custom.trim();
12436
+ if (custom !== "" && question.multiSelect !== true) return {
12437
+ id: question.id,
12438
+ selected: [],
12439
+ custom
12440
+ };
12441
+ return {
12442
+ id: question.id,
12443
+ selected: [...draft.selected],
12444
+ ...custom === "" ? {} : { custom }
12445
+ };
12446
+ });
12447
+ this.onDone(answers);
12448
+ }
12449
+ handleInput(data) {
12450
+ if (data === "\0") return;
12451
+ if (this.editingOther) {
12452
+ if (data === "\r" || data === "\n") this.commitOther(this.otherInput.getValue());
12453
+ else if (data === "\x1B") this.exitOther();
12454
+ else if (data === "\x1B[D") {
12455
+ if (this.tab > 0) {
12456
+ this.tab -= 1;
12457
+ this.cursor = 0;
12458
+ this.submitIdx = 0;
12459
+ this.syncEditMode();
12460
+ }
12461
+ } else if (data === "\x1B[C") {
12462
+ if (this.tab < this.questions.length - 1) {
12463
+ this.tab += 1;
12464
+ this.cursor = 0;
12465
+ this.submitIdx = 0;
12466
+ this.syncEditMode();
12467
+ } else {
12468
+ this.tab = this.questions.length;
12469
+ this.submitIdx = 0;
12470
+ this.syncEditMode();
12471
+ }
12472
+ } else this.otherInput.handleInput(data);
12473
+ return;
12474
+ }
12475
+ const rows = this.rows();
12476
+ if (this.tab >= this.questions.length) {
12477
+ if (data === "\r" || data === "\n") {
12478
+ if (this.submitIdx === 0) this.submit();
12479
+ else this.onCancel();
12480
+ } else if (data === "\x1B") this.onCancel();
12481
+ else if (data === "\x1B[D" || data === "h") this.submitIdx = 0;
12482
+ else if (data === "\x1B[C" || data === "l") this.submitIdx = 1;
12483
+ else if (data === "b") {
12484
+ this.tab = Math.max(0, this.questions.length - 1);
12485
+ this.cursor = 0;
12486
+ this.submitIdx = 0;
12487
+ this.syncEditMode();
12488
+ }
12489
+ return;
12490
+ }
12491
+ const digit = /^[1-9]$/.exec(data);
12492
+ if (digit !== null) {
12493
+ const row = rows[Number(digit[0]) - 1];
12494
+ if (row !== void 0) {
12495
+ this.cursor = rows.indexOf(row);
12496
+ this.confirm();
12497
+ }
12498
+ return;
12499
+ }
12500
+ if (data === "\x1B[A" || data === "k") {
12501
+ if (rows.length > 0) this.cursor = (this.cursor - 1 + rows.length) % rows.length;
12502
+ return;
12503
+ }
12504
+ if (data === "\x1B[B" || data === "j") {
12505
+ if (rows.length > 0) this.cursor = (this.cursor + 1) % rows.length;
12506
+ return;
12507
+ }
12508
+ if (data === "\r" || data === "\n") {
12509
+ this.confirm();
12510
+ return;
12511
+ }
12512
+ if (data === "\x1B[D" || data === "h") {
12513
+ if (this.tab > 0) {
12514
+ this.tab -= 1;
12515
+ this.cursor = 0;
12516
+ this.submitIdx = 0;
12517
+ this.syncEditMode();
12518
+ }
12519
+ return;
12520
+ }
12521
+ if (data === "\x1B[C" || data === "l") {
12522
+ if (this.tab < this.questions.length - 1) {
12523
+ this.tab += 1;
12524
+ this.cursor = 0;
12525
+ this.submitIdx = 0;
12526
+ } else {
12527
+ this.tab = this.questions.length;
12528
+ this.cursor = 0;
12529
+ this.submitIdx = 0;
12530
+ this.syncEditMode();
12531
+ }
12532
+ return;
12533
+ }
12534
+ if (data === "s") {
12535
+ this.skip();
12536
+ return;
12537
+ }
12538
+ if (data === "\x1B") this.onCancel();
12539
+ }
12540
+ invalidate() {
12541
+ this.otherInput.invalidate();
12542
+ }
12543
+ render(width) {
12544
+ const safeWidth = Math.max(1, width);
12545
+ const lines = [];
12546
+ const tabs = this.questions.map((_, index) => {
12547
+ const draft = this.drafts[index];
12548
+ const answered = draft !== void 0 && (draft.selected.size > 0 || draft.custom !== "" || draft.skipped);
12549
+ const label = tabLabel(index, this.questions.length);
12550
+ const mark = answered ? color.success("✓") : color.textDim("○");
12551
+ return this.tab === index ? color.textStrong(` ${mark} ${label} `) : color.textDim(` ${mark} ${label} `);
12552
+ });
12553
+ const submitAnswered = this.drafts.every((draft) => draft.selected.size > 0 || draft.custom !== "" || draft.skipped);
12554
+ const submitTab = this.tab === this.questions.length;
12555
+ tabs.push(submitTab ? color.textStrong(` ${submitAnswered ? color.success("✓") : color.textDim("○")} Submit `) : color.textDim(` ${submitAnswered ? color.success("✓") : color.textDim("○")} Submit `));
12556
+ lines.push(tabs.join(""));
12557
+ if (this.tab >= this.questions.length) {
12558
+ lines.push("");
12559
+ lines.push(color.textStrong("Review your answer before submit"));
12560
+ this.questions.forEach((question, index) => {
12561
+ const draft = this.drafts[index];
12562
+ if (draft === void 0) return;
12563
+ const value = draft.skipped ? "(skipped)" : draft.custom !== "" && question.multiSelect !== true ? draft.custom : [...draft.selected].join(", ") + (draft.custom !== "" ? ` + ${draft.custom}` : "");
12564
+ lines.push(`${color.textDim(`Q${index + 1}`)} ${question.question}`);
12565
+ lines.push(` ${value === "" ? color.textDim("(no answer)") : value}`);
12566
+ });
12567
+ lines.push("");
12568
+ const submit = this.submitIdx === 0 ? color.textStrong("Submit") : color.textDim("Submit");
12569
+ const cancel = this.submitIdx === 1 ? color.textStrong("Cancel") : color.textDim("Cancel");
12570
+ lines.push(`${submit} ${cancel}`);
12571
+ lines.push(color.textDim("← → choose · b back · enter confirm · esc cancel"));
12572
+ return lines;
12573
+ }
12574
+ const question = this.questions[this.tab];
12575
+ const draft = this.draft();
12576
+ if (question === void 0 || draft === void 0) return lines;
12577
+ if (question.header !== void 0 && question.header !== "") lines.push(color.textDim(question.header));
12578
+ lines.push(color.textStrong(question.question));
12579
+ if (question.detail !== void 0 && question.detail !== "") for (const line of question.detail.split("\n")) lines.push(color.textDim(truncateToWidth(line, safeWidth, "…")));
12580
+ const rows = this.rows();
12581
+ const multi = question.multiSelect === true;
12582
+ this.editingOther && rows.some((row) => row.key === OTHER_ROW);
12583
+ if (rows.length > 0 && !(rows.length === 1 && rows[0]?.key === OTHER_ROW)) rows.forEach((row, index) => {
12584
+ if (row.key === OTHER_ROW && this.editingOther) {
12585
+ const prefix = color.primary("→");
12586
+ const inputLine = this.otherInput.render(Math.max(1, safeWidth - visibleWidth(prefix) - 2))[0] ?? "";
12587
+ const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
12588
+ lines.push(`${prefix} ${color.textDim(`[${index + 1}]`)} ${stripped}`);
12589
+ return;
12590
+ }
12591
+ const selected = row.key === OTHER_ROW ? draft.custom !== "" : draft.selected.has(question.options?.[Number(row.key)]?.label ?? "");
12592
+ const marker = multi || row.key === OTHER_ROW ? selected ? color.success("[✓]") : color.textDim("[ ]") : selected ? color.success(`[${Number(row.key) + 1}]`) : color.textDim(`[${Number(row.key) + 1}]`);
12593
+ const pointer = index === this.cursor ? color.primary("→") : " ";
12594
+ const badge = row.recommended ? ` ${color.primary("[recommended]")}` : "";
12595
+ const desc = row.description === void 0 ? "" : color.textDim(` — ${row.description}`);
12596
+ const label = index === this.cursor ? color.textStrong(row.label) : row.label;
12597
+ lines.push(`${pointer} ${marker} ${label}${badge}${desc}`);
12598
+ });
12599
+ else if (this.editingOther || (question.options?.length ?? 0) === 0) {
12600
+ const inputLine = this.otherInput.render(Math.max(1, safeWidth - 2))[0] ?? "";
12601
+ const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
12602
+ lines.push(` ${stripped}`);
12603
+ lines.push(color.textDim("type your answer, enter to confirm"));
12604
+ }
12605
+ if (draft.skipped) lines.push(color.textDim("(skipped)"));
12606
+ lines.push("");
12607
+ const hintParts = [
12608
+ "↑↓ navigate",
12609
+ "1-9 choose",
12610
+ multi ? "enter toggle" : "enter confirm",
12611
+ "←→ switch",
12612
+ "s skip",
12613
+ "esc cancel"
12614
+ ];
12615
+ lines.push(color.textDim(hintParts.join(" · ")));
12616
+ return lines;
12617
+ }
12618
+ };
12619
+ //#endregion
11909
12620
  //#region src/working.ts
11910
12621
  /**
11911
12622
  * The animated busy indicator shown on the row directly above the editor
@@ -11960,7 +12671,7 @@ var WorkingIndicator = class extends Text {
11960
12671
  }
11961
12672
  updateDisplay() {
11962
12673
  const frame = this.frames[this.currentFrame] ?? "";
11963
- const indicator = frame === "" ? "" : frame + " ";
12674
+ const indicator = frame === "" ? "" : frame + " ";
11964
12675
  this.setText(indicator + color.textDim(this.message));
11965
12676
  this.ui.requestRender();
11966
12677
  }
@@ -11996,12 +12707,13 @@ var Frame = class {
11996
12707
  render(width) {
11997
12708
  const inner = Math.max(1, Math.floor(width) - 4);
11998
12709
  const lines = this.child.render(inner).map((line) => truncateToWidth(line, inner, "…"));
11999
- const frameWidth = Math.min(inner, Math.max(1, ...lines.map((line) => visibleWidth(line)))) + 4;
12710
+ const contentWidth = Math.min(inner, Math.max(1, ...lines.map((line) => visibleWidth(line))));
12711
+ const frameWidth = contentWidth + 4;
12000
12712
  const b = color.border;
12001
12713
  const out = [b(`╭${"─".repeat(frameWidth - 2)}╮`)];
12002
12714
  for (const line of lines) {
12003
12715
  const vis = visibleWidth(line);
12004
- const pad = Math.max(0, inner - vis);
12716
+ const pad = Math.max(0, contentWidth - vis);
12005
12717
  out.push(`${b("│")} ${line}${" ".repeat(pad)} ${b("│")}`);
12006
12718
  }
12007
12719
  out.push(b(`╰${"─".repeat(frameWidth - 2)}╯`));
@@ -12013,11 +12725,22 @@ var Frame = class {
12013
12725
  * border below it (a fixed-width rule looked misaligned next to the frame). */
12014
12726
  var WelcomeCard = class {
12015
12727
  facts;
12728
+ idle = false;
12016
12729
  lastWidth = -1;
12017
12730
  cached = [];
12018
12731
  /** Replace the facts; the next render rebuilds the card. */
12019
12732
  setFacts(facts) {
12020
12733
  this.facts = facts;
12734
+ this.idle = false;
12735
+ this.cached = [];
12736
+ }
12737
+ /**
12738
+ * The pre-session state (deferred session creation): the card invites the
12739
+ * first message instead of naming a session that does not exist yet.
12740
+ */
12741
+ setIdle(idle) {
12742
+ if (this.idle === idle) return;
12743
+ this.idle = idle;
12021
12744
  this.cached = [];
12022
12745
  }
12023
12746
  invalidate() {
@@ -12025,29 +12748,47 @@ var WelcomeCard = class {
12025
12748
  }
12026
12749
  render(width) {
12027
12750
  const facts = this.facts;
12751
+ const b = color.border;
12752
+ const inner = Math.max(1, width - 4);
12753
+ const renderRow = (line) => wrapTextWithAnsi(line, inner).map((wrapped) => {
12754
+ const vis = visibleWidth(wrapped);
12755
+ return `${b("│")} ${wrapped}${" ".repeat(Math.max(0, inner - vis))} ${b("│")}`;
12756
+ });
12757
+ if (this.idle) {
12758
+ if (this.lastWidth === width && this.cached.length > 0) return this.cached;
12759
+ this.lastWidth = width;
12760
+ this.cached = [
12761
+ b(`╭${"─".repeat(Math.max(0, width - 2))}╮`),
12762
+ ...renderRow(color.textMuted("🐋 dsh-pi-tui — type a message to start a session")),
12763
+ b(`╰${"─".repeat(Math.max(0, width - 2))}╯`)
12764
+ ];
12765
+ return this.cached;
12766
+ }
12028
12767
  if (facts === void 0) return [];
12029
12768
  if (this.lastWidth === width && this.cached.length > 0) return this.cached;
12030
12769
  this.lastWidth = width;
12031
- const line1 = `🐋 session ${color.textDim(facts.sessionId)}`;
12032
- const line2 = [color.text(facts.model), facts.preset === void 0 ? "" : `preset ${color.textMuted(facts.preset)}`].filter((part) => part !== "").join(" · ");
12033
- const line3 = [color.textMuted(facts.cwd), `v${facts.version}`].filter((part) => part !== "").join(" · ");
12034
- const inner = Math.max(1, width - 4);
12035
- const b = color.border;
12770
+ const line1 = `🐋 session ${color.textMuted(facts.sessionId)}`;
12771
+ const line2 = [color.textMuted(facts.model), facts.preset === void 0 ? "" : `preset ${color.textMuted(facts.preset)}`].filter((part) => part !== "").join(" · ");
12772
+ const line3 = [color.textMuted(facts.cwd), color.textMuted(`v${facts.version}`)].filter((part) => part !== "").join(" · ");
12036
12773
  this.cached = [
12037
12774
  b(`╭${"─".repeat(Math.max(0, width - 2))}╮`),
12038
12775
  ...[
12039
12776
  line1,
12040
12777
  line2,
12041
12778
  line3
12042
- ].flatMap((line) => wrapTextWithAnsi(line, inner).map((wrapped) => {
12043
- const vis = visibleWidth(wrapped);
12044
- return `${b("│")} ${wrapped}${" ".repeat(Math.max(0, inner - vis))} ${b("│")}`;
12045
- })),
12779
+ ].flatMap(renderRow),
12046
12780
  b(`╰${"─".repeat(Math.max(0, width - 2))}╯`)
12047
12781
  ];
12048
12782
  return this.cached;
12049
12783
  }
12050
12784
  };
12785
+ /** One blank row between consecutive transcript blocks (kimi/pi Spacer(1) parity). */
12786
+ var Spacer = class {
12787
+ invalidate() {}
12788
+ render() {
12789
+ return [""];
12790
+ }
12791
+ };
12051
12792
  /** Pi-style context progress bar: `[███░░░░░░░░░] 25%`. */
12052
12793
  function contextBar(used, window) {
12053
12794
  const ratio = Math.min(1, Math.max(0, used / window));
@@ -12094,6 +12835,22 @@ var TuiApp = class TuiApp {
12094
12835
  todoPanelVisible = false;
12095
12836
  /** The todo panel Text; empty when hidden. */
12096
12837
  todoPanel;
12838
+ /**
12839
+ * The persistent dock strip directly above the todo panel: one line each
12840
+ * for the current permission, the live goal, the todo summary, and running
12841
+ * background tasks (kimi chrome parity). Empty lines drop out entirely.
12842
+ */
12843
+ dock;
12844
+ /** Active background tasks for the dock line (label + status). */
12845
+ dockTasks = [];
12846
+ /**
12847
+ * The queued-input pane below the todo panel (kimi QueuePane parity):
12848
+ * a border rule plus one `❯ text` row per pending message and a dim hint.
12849
+ * Renders nothing while the queue is empty.
12850
+ */
12851
+ queuePane;
12852
+ /** The pending inbox messages (next-turn followups and next-step steers). */
12853
+ queueItems = [];
12097
12854
  /** Whether the Ctrl+O expansion master switch is on. */
12098
12855
  isToolOutputExpanded() {
12099
12856
  return this.toolOutputExpanded;
@@ -12111,6 +12868,9 @@ var TuiApp = class TuiApp {
12111
12868
  searchComponent;
12112
12869
  /** Overlay handles currently mounted on the active screen, for mode switches. */
12113
12870
  overlayHandles = /* @__PURE__ */ new Set();
12871
+ /** Capturing overlays hidden beneath a newer one (modal stacking), keyed by
12872
+ * the newer overlay's handle; restored when it hides. */
12873
+ overlayDependents = /* @__PURE__ */ new Map();
12114
12874
  /** Footer state. */
12115
12875
  status = {
12116
12876
  model: "",
@@ -12121,7 +12881,7 @@ var TuiApp = class TuiApp {
12121
12881
  statsLine: ""
12122
12882
  };
12123
12883
  /** Header text (todo summary), kept for theme-swap repaints. */
12124
- headerText = "🐋 dsh-pi-tui";
12884
+ headerText = "🐋 dsh-pi-tui";
12125
12885
  /** Footer text, kept for theme-swap repaints. */
12126
12886
  footerText = "";
12127
12887
  /** Plan-mode badge state; appended to the header and footer when active. */
@@ -12135,6 +12895,9 @@ var TuiApp = class TuiApp {
12135
12895
  /** Transient error line shown under the transcript; cleared by the next
12136
12896
  * repaint or after {@link TuiApp.NOTIFY_DURATION_MS}, whichever comes first. */
12137
12897
  notifyText = "";
12898
+ /** Styling of the current notify line: info (default) is dim with a ℹ,
12899
+ * errors are red with a ✗. */
12900
+ notifyKind = "info";
12138
12901
  /** The pending auto-clear for {@link notifyText}, while one is armed. */
12139
12902
  notifyTimer;
12140
12903
  /** How long a notify line stays before it auto-clears, in ms. */
@@ -12175,21 +12938,48 @@ var TuiApp = class TuiApp {
12175
12938
  this.editorBorder = this.editor.borderColor;
12176
12939
  this.editor.onSubmit = (text) => {
12177
12940
  this.rememberInput(text);
12941
+ this.clearNotify();
12178
12942
  this.events.onSubmit(text);
12179
12943
  };
12180
- this.header = new Text("🐋 dsh-pi-tui", 0, 0);
12944
+ this.header = new Text("🐋 dsh-pi-tui", 0, 0);
12181
12945
  this.messagesView = new Container();
12946
+ this.dock = new Text("", 0, 0);
12182
12947
  this.todoPanel = new Text("", 0, 0);
12948
+ this.queuePane = new Text("", 0, 0);
12183
12949
  this.working = new WorkingIndicator(this.tui, options.workingIntervalMs === void 0 ? {} : { intervalMs: options.workingIntervalMs });
12184
12950
  this.footer = new Text("", 0, 0);
12185
12951
  this.tui.addChild(this.header);
12186
12952
  this.tui.addChild(this.messagesView);
12953
+ this.tui.addChild(this.dock);
12187
12954
  this.tui.addChild(this.todoPanel);
12955
+ this.tui.addChild(this.queuePane);
12188
12956
  this.tui.addChild(this.working);
12189
12957
  this.tui.addChild(this.editor);
12190
12958
  this.tui.addChild(this.footer);
12191
12959
  this.tui.setFocus(this.editor);
12192
- this.tui.addInputListener((data) => this.handleInput(data));
12960
+ this.tui.addInputListener((data) => this.routeInput(data));
12961
+ }
12962
+ /**
12963
+ * App-level input routing plus the autocomplete repaint. The editor's
12964
+ * slash-command autocomplete resolves on a promise microtask AFTER the
12965
+ * keystroke's own paint, and the editor's own render requests go to the
12966
+ * MAIN screen — which is STOPPED while the alt screen renders (fullscreen),
12967
+ * so its renderRequested flag is stuck true and every request is dropped:
12968
+ * the fresh suggestion list would never paint (the visible frame always
12969
+ * shows the previous query's results — typing /res keeps showing /reload
12970
+ * first). Schedule a forced frame on the ACTIVE screen once the provider's
12971
+ * continuation has applied the fresh list. Deliberately a dsh-side fix:
12972
+ * the vendored fork stays pristine (see AGENTS.md decision 8).
12973
+ * @param data - the raw input sequence.
12974
+ */
12975
+ routeInput(data) {
12976
+ const result = this.handleInput(data);
12977
+ queueMicrotask(() => {
12978
+ queueMicrotask(() => {
12979
+ if (this.editor.isShowingAutocomplete()) this.requestRender(true);
12980
+ });
12981
+ });
12982
+ return result;
12193
12983
  }
12194
12984
  /** Enter raw mode and start rendering. */
12195
12985
  start() {
@@ -12231,6 +13021,16 @@ var TuiApp = class TuiApp {
12231
13021
  this.startTranscriptSearch();
12232
13022
  return { consume: true };
12233
13023
  }
13024
+ if (matchesKey(data, "shift+tab")) {
13025
+ if (this.overlayHost.hasOverlayEntries) return void 0;
13026
+ this.events.onCyclePermission?.();
13027
+ return { consume: true };
13028
+ }
13029
+ if (matchesKey(data, "alt+up")) {
13030
+ if (this.overlayHost.hasOverlayEntries) return void 0;
13031
+ this.events.onDequeue?.();
13032
+ return { consume: true };
13033
+ }
12234
13034
  if (matchesKey(data, "escape")) {
12235
13035
  if (this.overlayHost.hasOverlayEntries) return void 0;
12236
13036
  if (this.events.onSingleEscape?.() === true) return { consume: true };
@@ -12257,7 +13057,6 @@ var TuiApp = class TuiApp {
12257
13057
  if (matchesKey(data, "ctrl+s")) {
12258
13058
  if (this.overlayHost.hasOverlayEntries) return { consume: true };
12259
13059
  const draft = this.editor.getText();
12260
- if (draft.trim() === "") return { consume: true };
12261
13060
  this.editor.setText("");
12262
13061
  this.events.onSteer?.(draft);
12263
13062
  return { consume: true };
@@ -12283,6 +13082,11 @@ var TuiApp = class TuiApp {
12283
13082
  /**
12284
13083
  * Show an overlay on the active screen and track its handle, so a
12285
13084
  * fullscreen toggle can hide every mounted overlay on the old screen.
13085
+ * Capturing overlays stack modally: every other capturing overlay is
13086
+ * hidden beneath the new one and restored when it hides — the fork's
13087
+ * compositor interleaves stacked boxes line by line (two different-width
13088
+ * dialogs would garble into one frame), and modal hiding is done here so
13089
+ * the vendored fork stays pristine.
12286
13090
  * @param component - the overlay content.
12287
13091
  * @param options - overlay sizing/positioning.
12288
13092
  * @returns the handle; hide() also forgets the handle.
@@ -12290,9 +13094,19 @@ var TuiApp = class TuiApp {
12290
13094
  showOverlayOnHost(component, options) {
12291
13095
  const handle = this.overlayHost.showOverlay(component, options);
12292
13096
  this.overlayHandles.add(handle);
13097
+ if (options?.nonCapturing !== true) {
13098
+ const hidden = /* @__PURE__ */ new Set();
13099
+ for (const other of this.overlayHandles) if (other !== handle && !other.isHidden()) {
13100
+ other.setHidden(true);
13101
+ hidden.add(other);
13102
+ }
13103
+ if (hidden.size > 0) this.overlayDependents.set(handle, hidden);
13104
+ }
12293
13105
  return {
12294
13106
  ...handle,
12295
13107
  hide: () => {
13108
+ this.overlayDependents.get(handle)?.forEach((other) => other.setHidden(false));
13109
+ this.overlayDependents.delete(handle);
12296
13110
  this.overlayHandles.delete(handle);
12297
13111
  handle.hide();
12298
13112
  }
@@ -12416,10 +13230,18 @@ var TuiApp = class TuiApp {
12416
13230
  component: this.fullscreenScroll,
12417
13231
  grow: 1
12418
13232
  },
13233
+ {
13234
+ component: this.dock,
13235
+ shrink: 0
13236
+ },
12419
13237
  {
12420
13238
  component: this.todoPanel,
12421
13239
  shrink: 0
12422
13240
  },
13241
+ {
13242
+ component: this.queuePane,
13243
+ shrink: 0
13244
+ },
12423
13245
  {
12424
13246
  component: this.working,
12425
13247
  shrink: 0
@@ -12434,7 +13256,7 @@ var TuiApp = class TuiApp {
12434
13256
  }
12435
13257
  ]);
12436
13258
  alt.setLayoutRoot(root);
12437
- alt.addInputListener((data) => this.handleInput(data));
13259
+ alt.addInputListener((data) => this.routeInput(data));
12438
13260
  this.tui.stop();
12439
13261
  alt.start();
12440
13262
  alt.setFocus(this.editor);
@@ -12496,7 +13318,6 @@ var TuiApp = class TuiApp {
12496
13318
  */
12497
13319
  setTranscript(messages) {
12498
13320
  this.messages = messages;
12499
- this.clearNotify();
12500
13321
  this.rebuildMessages();
12501
13322
  }
12502
13323
  /** Rebuild the message component tree from the current transcript state. */
@@ -12506,24 +13327,21 @@ var TuiApp = class TuiApp {
12506
13327
  const boundary = this.expandBoundary();
12507
13328
  const width = this.terminal.columns;
12508
13329
  const rows = [];
12509
- for (const message of this.messages) {
12510
- if (message.kind === "thinking" && this.hideThinking) continue;
12511
- const component = this.renderMessage(message, boundary);
12512
- this.messagesView.addChild(component);
12513
- rows.push({
12514
- message,
12515
- height: component.render(width).length
12516
- });
12517
- }
12518
- for (const message of this.localMessages) {
13330
+ const blocks = [...this.messages.filter((message) => !(message.kind === "thinking" && this.hideThinking)), ...this.localMessages];
13331
+ blocks.forEach((message, index) => {
12519
13332
  const component = this.renderMessage(message, boundary);
12520
13333
  this.messagesView.addChild(component);
13334
+ const height = component.render(width).length + (index < blocks.length - 1 ? 1 : 0);
12521
13335
  rows.push({
12522
13336
  message,
12523
- height: component.render(width).length
13337
+ height
12524
13338
  });
13339
+ if (index < blocks.length - 1) this.messagesView.addChild(new Spacer());
13340
+ });
13341
+ if (this.notifyText !== "") {
13342
+ const line = this.notifyKind === "info" ? color.textDim(`ℹ ${this.notifyText}`) : color.error(`✗ ${this.notifyText}`);
13343
+ this.messagesView.addChild(new Text(line, 0, 0));
12525
13344
  }
12526
- if (this.notifyText !== "") this.messagesView.addChild(new Text(color.error(`✗ ${this.notifyText}`), 0, 0));
12527
13345
  this.messageRows = rows;
12528
13346
  this.renderTodoPanel();
12529
13347
  this.requestRender();
@@ -12589,12 +13407,16 @@ var TuiApp = class TuiApp {
12589
13407
  this.requestRender();
12590
13408
  }
12591
13409
  /**
12592
- * Show a transient error line under the transcript. Cleared by the next
13410
+ * Show a transient line under the transcript. Cleared by the next
12593
13411
  * `setTranscript` repaint or after {@link TuiApp.NOTIFY_DURATION_MS},
12594
13412
  * whichever comes first, so a one-off notice never lingers forever.
13413
+ * @param text - the notice text.
13414
+ * @param kind - `'info'` renders dim with a ℹ (default, the common case);
13415
+ * `'error'` renders red with a ✗ — pass it explicitly for failures.
12595
13416
  */
12596
- notify(text) {
13417
+ notify(text, kind = "info") {
12597
13418
  this.notifyText = text;
13419
+ this.notifyKind = kind;
12598
13420
  if (this.notifyTimer !== void 0) clearTimeout(this.notifyTimer);
12599
13421
  this.notifyTimer = setTimeout(() => {
12600
13422
  this.notifyTimer = void 0;
@@ -12603,13 +13425,25 @@ var TuiApp = class TuiApp {
12603
13425
  }, this.notifyDurationMs);
12604
13426
  this.rebuildMessages();
12605
13427
  }
12606
- /** Clear the transient notify line and its pending auto-clear timer. */
13428
+ /** Clear the transient notify line and its pending auto-clear timer.
13429
+ * Called on fresh user input and session switches; repaints alone never
13430
+ * clear it (an active session would flash every notice away). Rebuilds
13431
+ * the transcript when a notice was actually showing. */
12607
13432
  clearNotify() {
13433
+ if (this.notifyText === "" && this.notifyTimer === void 0) return;
12608
13434
  this.notifyText = "";
12609
13435
  if (this.notifyTimer !== void 0) {
12610
13436
  clearTimeout(this.notifyTimer);
12611
13437
  this.notifyTimer = void 0;
12612
13438
  }
13439
+ this.rebuildMessages();
13440
+ }
13441
+ /**
13442
+ * Replace the editor draft. The runner restores a submission that the
13443
+ * divergence guard blocked, so the user's text survives for a retry.
13444
+ */
13445
+ setEditorText(text) {
13446
+ this.editor.setText(text);
12613
13447
  }
12614
13448
  /**
12615
13449
  * Set the session head rendered above the transcript: the session identity,
@@ -12621,6 +13455,14 @@ var TuiApp = class TuiApp {
12621
13455
  this.welcomeCard.setFacts(facts);
12622
13456
  this.rebuildMessages();
12623
13457
  }
13458
+ /**
13459
+ * Toggle the pre-session welcome state (deferred session creation): the
13460
+ * card invites the first message; the first real facts replace it.
13461
+ */
13462
+ setWelcomeIdle(idle) {
13463
+ this.welcomeCard.setIdle(idle);
13464
+ this.rebuildMessages();
13465
+ }
12624
13466
  /** The turn threshold at or above which collapsible entries expand. */
12625
13467
  expandBoundary() {
12626
13468
  if (!this.toolOutputExpanded || false) return Number.POSITIVE_INFINITY;
@@ -12632,29 +13474,49 @@ var TuiApp = class TuiApp {
12632
13474
  }
12633
13475
  /** Render one transcript message as a pi-tui component. */
12634
13476
  renderMessage(message, boundary) {
12635
- if (message.kind === "user") return new Text(`${color.roleUser("❯")} ${message.text}`, 0, 0);
13477
+ if (message.kind === "user") {
13478
+ const prefix = `${color.roleUser("❯")} `;
13479
+ const indent = " ".repeat(visibleWidth(prefix));
13480
+ return new Text(new Text(message.text, 0, 0).render(Math.max(1, this.terminal.columns - visibleWidth(prefix))).map((line, index) => (index === 0 ? prefix : indent) + line).join("\n"), 0, 0);
13481
+ }
12636
13482
  if (message.kind === "assistant") {
12637
- const row = new Container();
12638
- row.addChild(new Text(`${color.primary("🐋")} `, 0, 0));
12639
- row.addChild(new Markdown(message.text, 0, 0, markdownTheme));
12640
- return row;
13483
+ const prefix = `${color.primary("🐋")} `;
13484
+ const indent = " ".repeat(visibleWidth(prefix));
13485
+ return new Text(new Markdown(message.text, 0, 0, markdownTheme).render(Math.max(1, this.terminal.columns - visibleWidth(prefix))).map((line, index) => (index === 0 ? prefix : indent) + line).join("\n"), 0, 0);
13486
+ }
13487
+ if (message.kind === "thinking") {
13488
+ if (message.turn >= boundary || this.expandedOverride.get(message) === true) return new Text(color.textDimItalic(`🐳 ${message.text}`), 0, 0);
13489
+ const lines = message.text.split("\n").filter((line) => line !== "");
13490
+ const shown = message.running === true ? lines.slice(-2) : lines.slice(0, 2);
13491
+ const prefix = "🐳 ";
13492
+ const prefixWidth = visibleWidth(prefix);
13493
+ const contentWidth = Math.max(1, this.terminal.columns - prefixWidth);
13494
+ const rows = Array.from({ length: 2 }, (_, index) => {
13495
+ const text = shown[index] ?? "";
13496
+ const body = text === "" ? "" : truncateToWidth(text, contentWidth, "…");
13497
+ const pad = index === 0 ? prefix : " ".repeat(prefixWidth);
13498
+ return color.textDimItalic(pad + body);
13499
+ });
13500
+ const remaining = lines.length - 2;
13501
+ const hint = remaining > 0 ? `... (${remaining} more lines, ctrl+o to expand)` : "(ctrl+o to expand)";
13502
+ rows.push(color.textDim(`${" ".repeat(prefixWidth)}${truncateToWidth(hint, contentWidth, "…")}`));
13503
+ return new Text(rows.join("\n"), 0, 0);
12641
13504
  }
12642
- if (message.kind === "thinking") return new Text(message.turn >= boundary || this.expandedOverride.get(message) === true ? color.textDim(`🐳 ${message.text}`) : color.textDim(`🐳 ${message.running === true ? preview(latestLine(message.text), 1) : preview(firstLine(message.text), 1)} (ctrl+o to expand)`), 0, 0);
12643
13505
  if (message.kind === "system") {
12644
13506
  const expanded = message.turn >= boundary || this.expandedOverride.get(message) === true;
12645
13507
  if (message.label !== void 0) {
12646
13508
  const row = new Container();
12647
13509
  const emoji = message.emoji ?? "📎";
12648
13510
  if (expanded) {
12649
- row.addChild(new Text(color.textMuted(`${emoji} Context injection ${message.label}`), 0, 0));
13511
+ row.addChild(new Text(color.textMuted(`${emoji} Context injection ${message.label}`), 0, 0));
12650
13512
  row.addChild(new Text(color.textDim(message.text), 0, 0));
12651
13513
  } else {
12652
13514
  const summary = message.summary === void 0 ? "" : ` — ${message.summary}`;
12653
- row.addChild(new Text(color.textMuted(`${emoji} Context injection ${message.label}${summary} (ctrl+o to expand)`), 0, 0));
13515
+ row.addChild(new Text(truncateToWidth(color.textMuted(`${emoji} Context injection ${message.label}${summary} (ctrl+o to expand)`), this.terminal.columns, "…"), 0, 0));
12654
13516
  }
12655
13517
  return row;
12656
13518
  }
12657
- return new Text(expanded ? `${color.textMuted("§")} ${message.text}` : color.textMuted(`§ ${preview(message.text, 2)} (ctrl+o to expand)`), 0, 0);
13519
+ return new Text(expanded ? `${color.textMuted("§")} ${color.textDim(message.text)}` : color.textMuted(`§ ${truncateToWidth(preview(message.text, 2), Math.max(1, this.terminal.columns - 22), "…")} (ctrl+o to expand)`), 0, 0);
12658
13520
  }
12659
13521
  if (message.kind === "summary") return new Text(color.textDim(message.text), 0, 0);
12660
13522
  const card = new Container();
@@ -12662,13 +13524,13 @@ var TuiApp = class TuiApp {
12662
13524
  const summary = header.summary === "" ? "" : ` ${header.summary}`;
12663
13525
  const emoji = toolEmoji(message.name);
12664
13526
  const pill = message.status === "ok" ? color.success("[ok]") : message.status === "error" ? color.error("[error]") : color.textDim("[running]");
12665
- const head = `${emoji} ${header.title}${summary} ${pill}`;
13527
+ const head = `${color.textDim(`${emoji} ${header.title}${summary}`)} ${pill}`;
12666
13528
  if (message.turn >= boundary || this.expandedOverride.get(message) === true) {
12667
13529
  card.addChild(new Text(head, 0, 0));
12668
- this.renderToolBody(card, message);
13530
+ this.renderToolBody(card, message, this.expandedOverride.get(message) === true);
12669
13531
  } else {
12670
- const resultPreview = message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
12671
- card.addChild(new Text(`${head}${resultPreview}`, 0, 0));
13532
+ const resultPreview = message.name === "read" ? readFoldedPreview(message.result) : message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
13533
+ card.addChild(new Text(truncateToWidth(`${head}${resultPreview}`, this.terminal.columns, "…"), 0, 0));
12672
13534
  }
12673
13535
  return card;
12674
13536
  }
@@ -12677,18 +13539,47 @@ var TuiApp = class TuiApp {
12677
13539
  * the body follows the tool's own render intent (presentResult): a read
12678
13540
  * card shows numbered lines plus the relativized path and total line
12679
13541
  * count, a search card groups matches by file and marks truncation, a
12680
- * terminal card shows the output and exit status, and a diff card colors
12681
- * the hunks. Without a view the raw result text renders, diff-colored
12682
- * when it looks like one.
13542
+ * terminal card shows the output and exit status, and a diff card shows
13543
+ * the LCS-aligned, clustered diff (capped in the default view, full when
13544
+ * the card was explicitly expanded). Without a view the raw result text
13545
+ * renders, diff-colored when it looks like one; a diff-card call whose
13546
+ * result carried no view reuses the call-time diff so the block never
13547
+ * collapses (kimi parity).
12683
13548
  * @param card - the card container to fill.
12684
13549
  * @param message - the tool message.
13550
+ * @param explicitlyExpanded - whether the user expanded this card by hand.
12685
13551
  */
12686
- renderToolBody(card, message) {
13552
+ renderToolBody(card, message, explicitlyExpanded) {
13553
+ if (message.name === "workflow" && message.members !== void 0) {
13554
+ const groups = /* @__PURE__ */ new Map();
13555
+ for (const member of message.members) {
13556
+ const key = member.phase ?? "";
13557
+ const list = groups.get(key);
13558
+ if (list === void 0) groups.set(key, [member]);
13559
+ else list.push(member);
13560
+ }
13561
+ for (const [phase, members] of groups) {
13562
+ if (phase !== "") card.addChild(new Text(color.textMuted(` ${phase}`), 0, 0));
13563
+ for (const member of members) {
13564
+ const mark = member.status === "ok" ? color.success("•") : member.status === "error" ? color.error("✗") : color.primary("●");
13565
+ const statusText = member.status === "ok" ? "completed" : member.status === "error" ? "failed" : "running";
13566
+ card.addChild(new Text(` ${mark} ${member.label} — ${color.textDim(statusText)}`, 0, 0));
13567
+ }
13568
+ }
13569
+ return;
13570
+ }
12687
13571
  if (message.status === "running") {
12688
13572
  const callView = this.present?.call(message.name, message.args);
12689
- if (callView !== void 0 && callView.card === "generic" && callView.rawInput !== void 0) {
12690
- const raw = typeof callView.rawInput === "string" ? callView.rawInput : JSON.stringify(callView.rawInput, null, 2);
12691
- card.addChild(new Text(color.textDim(raw), 0, 0));
13573
+ if (callView !== void 0) {
13574
+ if (callView.card === "diff" && callView.diffs.length > 0) {
13575
+ this.renderDiffBody(card, callView.diffs, explicitlyExpanded);
13576
+ return;
13577
+ }
13578
+ if (callView.card === "generic" && callView.rawInput !== void 0) {
13579
+ const raw = typeof callView.rawInput === "string" ? callView.rawInput : JSON.stringify(callView.rawInput, null, 2);
13580
+ card.addChild(new Text(color.textDim(raw), 0, 0));
13581
+ return;
13582
+ }
12692
13583
  }
12693
13584
  return;
12694
13585
  }
@@ -12723,16 +13614,52 @@ var TuiApp = class TuiApp {
12723
13614
  return;
12724
13615
  }
12725
13616
  case "diff":
12726
- for (const line of renderDiffView(resultView.diffs, this.workspaceRoot)) card.addChild(new Text(line, 0, 0));
13617
+ this.renderDiffBody(card, resultView.diffs, explicitlyExpanded);
12727
13618
  return;
12728
13619
  }
12729
- if (isDiffResult(message.name, message.result)) for (const line of renderDiffLines(message.result)) card.addChild(new Text(line, 0, 0));
12730
- else card.addChild(new Text(color.textDim(message.result), 0, 0));
13620
+ if (resultView === void 0) {
13621
+ const callView = this.present?.call(message.name, message.args);
13622
+ if (callView !== void 0 && callView.card === "diff" && callView.diffs.length > 0) {
13623
+ this.renderDiffBody(card, callView.diffs, explicitlyExpanded);
13624
+ return;
13625
+ }
13626
+ }
13627
+ if (message.name === "read") {
13628
+ const envelopes = parseReadEnvelopes(message.result);
13629
+ if (envelopes.length > 0) {
13630
+ if (envelopes.length === 1) {
13631
+ const envelope = envelopes[0];
13632
+ for (const line of envelope.lines) card.addChild(new Text(color.textDim(` ${line.number} │ ${line.text}`), 0, 0));
13633
+ if (envelope.path !== "") card.addChild(new Text(color.textMuted(` path: ${relativizeToCwd(envelope.path, this.workspaceRoot)}`), 0, 0));
13634
+ if (envelope.totalLines !== void 0) card.addChild(new Text(color.textMuted(` total lines: ${envelope.totalLines}`), 0, 0));
13635
+ } else envelopes.forEach((envelope, index) => {
13636
+ const branch = index === envelopes.length - 1 ? "└─" : "├─";
13637
+ const count = envelope.totalLines ?? (envelope.lines.length > 0 ? envelope.lines.length : void 0);
13638
+ card.addChild(new Text(color.textDim(` ${branch} ${relativizeToCwd(envelope.path, this.workspaceRoot)}${count === void 0 ? "" : ` · ${count} lines`}`), 0, 0));
13639
+ });
13640
+ return;
13641
+ }
13642
+ }
13643
+ if (isDiffResult(message.name, message.result)) for (const line of renderDiffLines(message.result)) card.addChild(new Text(line, 0, 0));
13644
+ else card.addChild(new Text(color.textDim(message.result), 0, 0));
13645
+ }
13646
+ /**
13647
+ * Fill a tool card with a diff body: LCS-aligned clustered lines, capped
13648
+ * at {@link DIFF_PREVIEW_LINES} in the default recent-turn view and full
13649
+ * when the card was explicitly expanded (kimi parity).
13650
+ * @param card - the card container to fill.
13651
+ * @param diffs - the diff hunks.
13652
+ * @param explicitlyExpanded - explicit expansion disables the cap.
13653
+ */
13654
+ renderDiffBody(card, diffs, explicitlyExpanded) {
13655
+ for (const line of renderDiffView(diffs, this.workspaceRoot, { maxLines: explicitlyExpanded ? void 0 : 10 })) card.addChild(new Text(line, 0, 0));
12731
13656
  }
12732
13657
  /** Request a render on the active screen. Public so in-place submenu
12733
- * components (async content swaps) can trigger the next frame. */
12734
- requestRender() {
12735
- (this.fullscreen ?? this.tui).requestRender();
13658
+ * components (async content swaps) can trigger the next frame. `force`
13659
+ * bypasses the render throttle (used to repaint the autocomplete list on
13660
+ * the keystroke's own frame). */
13661
+ requestRender(force = false) {
13662
+ (this.fullscreen ?? this.tui).requestRender(force);
12736
13663
  }
12737
13664
  /**
12738
13665
  * Reflect the todo list in the header line: active (non-completed) count
@@ -12750,6 +13677,7 @@ var TuiApp = class TuiApp {
12750
13677
  this.todoText = ` · ${active.length} active · ${label}`;
12751
13678
  }
12752
13679
  this.renderHeader();
13680
+ this.renderDock();
12753
13681
  if (this.todoPanelVisible) this.renderTodoPanel();
12754
13682
  }
12755
13683
  /** Toggle the todo panel between the transcript and the editor. */
@@ -12802,7 +13730,7 @@ var TuiApp = class TuiApp {
12802
13730
  renderHeader() {
12803
13731
  const badge = this.planMode ? ` ${color.warning("[plan]")}` : "";
12804
13732
  const title = this.sessionTitleText === "" ? "" : ` · ${color.textMuted(this.sessionTitleText)}`;
12805
- this.headerText = `🐋 dsh-pi-tui${title}${this.todoText}${badge}`;
13733
+ this.headerText = `🐋 dsh-pi-tui${title}${this.todoText}${badge}`;
12806
13734
  this.header.setText(this.headerText);
12807
13735
  this.requestRender();
12808
13736
  }
@@ -12818,6 +13746,87 @@ var TuiApp = class TuiApp {
12818
13746
  ...status
12819
13747
  };
12820
13748
  this.renderFooter();
13749
+ this.renderDock();
13750
+ }
13751
+ /**
13752
+ * Replace the active background-task list for the dock line.
13753
+ * @param tasks - active jobs (label + lifecycle status), empty to hide.
13754
+ */
13755
+ setTasks(tasks) {
13756
+ this.dockTasks = tasks;
13757
+ this.renderDock();
13758
+ }
13759
+ /**
13760
+ * Replace the pending inbox rows for the queue pane (kimi QueuePane
13761
+ * parity): a border rule, one `❯ text` row per message, and a dim hint.
13762
+ * An empty queue renders nothing at all.
13763
+ * @param items - pending followups/steers, in delivery order.
13764
+ */
13765
+ setQueueItems(items) {
13766
+ this.queueItems = items;
13767
+ this.renderQueuePane();
13768
+ }
13769
+ /** Rebuild the queue pane text from the current inbox rows. */
13770
+ renderQueuePane() {
13771
+ const items = this.queueItems;
13772
+ if (items.length === 0) {
13773
+ this.queuePane.setText("");
13774
+ this.requestRender();
13775
+ return;
13776
+ }
13777
+ const width = Math.max(1, this.terminal.columns);
13778
+ const lines = [color.border("─".repeat(width))];
13779
+ for (const item of items) {
13780
+ const prefix = `${color.accent("❯")} `;
13781
+ const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth(prefix)), "…");
13782
+ lines.push(prefix + truncated);
13783
+ }
13784
+ lines.push(color.textDim(truncateToWidth(` ctrl+s to steer all · alt+↑ to edit all · /queue for per-item actions`, Math.max(1, width - 2), "…")));
13785
+ this.queuePane.setText(lines.join("\n"));
13786
+ this.requestRender();
13787
+ }
13788
+ /**
13789
+ * Replace the editor draft wholesale (the Alt+↑ dequeue path pulls every
13790
+ * queued message back into the editor for editing).
13791
+ */
13792
+ setDraft(text) {
13793
+ this.editor.setText(text);
13794
+ this.requestRender();
13795
+ }
13796
+ /** The editor's current draft text (the Alt+↑ dequeue merge reads it). */
13797
+ getDraft() {
13798
+ return this.editor.getText();
13799
+ }
13800
+ /**
13801
+ * Rebuild the persistent dock strip above the todo panel: goal, todo
13802
+ * summary, and background tasks — one truncated line each, only while
13803
+ * non-empty (kimi chrome parity). The dock is the "at a glance" surface
13804
+ * under the transcript; the full todo list stays on Ctrl+T. The
13805
+ * permission preset badges in the footer only, so the mode has exactly
13806
+ * one home (no dock/footer duplication).
13807
+ */
13808
+ renderDock() {
13809
+ const lines = [];
13810
+ if (this.status.goal !== void 0 && this.status.goal !== "") lines.push(color.text(`⚑ ${this.status.goal}`));
13811
+ if (this.todoItems.length > 0) {
13812
+ const active = this.todoItems.filter((todo) => todo.status !== "completed");
13813
+ const done = this.todoItems.length - active.length;
13814
+ const first = active[0];
13815
+ const label = first === void 0 ? "" : first.content.length > 40 ? `${first.content.slice(0, 40)}…` : first.content;
13816
+ const summary = [
13817
+ done > 0 ? `${done} done` : "",
13818
+ active.length > 0 ? `${active.length} active` : "",
13819
+ label
13820
+ ].filter((part) => part !== "").join(" · ");
13821
+ lines.push(color.textDim(`☑ ${summary}`));
13822
+ }
13823
+ if (this.dockTasks.length > 0) {
13824
+ const first = this.dockTasks[0];
13825
+ const label = first.label.length > 40 ? `${first.label.slice(0, 40)}…` : first.label;
13826
+ lines.push(color.textDim(`⏳ ${this.dockTasks.length} task${this.dockTasks.length === 1 ? "" : "s"} · ${label}`));
13827
+ }
13828
+ this.dock.setText(lines.join("\n"));
13829
+ this.requestRender();
12821
13830
  }
12822
13831
  /** Footer density presets: full keeps the stats line, compact drops it. */
12823
13832
  footerPreset = "full";
@@ -12834,6 +13843,7 @@ var TuiApp = class TuiApp {
12834
13843
  renderFooter() {
12835
13844
  const context = this.status.contextTokens !== void 0 && this.status.contextWindow !== void 0 && this.status.contextWindow > 0 ? contextBar(this.status.contextTokens, this.status.contextWindow) : "";
12836
13845
  const line1 = [
13846
+ this.status.permission === "danger-full-access" ? color.warning("[yolo]") : this.status.permission === "read-only" ? color.textMuted("[read-only]") : this.status.permission === "workspace-write" ? color.text("[workspace-write]") : this.status.permission === "custom" ? color.warning("[custom]") : "",
12837
13847
  this.planMode ? color.warning("[plan]") : "",
12838
13848
  this.status.goal === void 0 || this.status.goal === "" ? "" : color.primary(this.status.goal),
12839
13849
  this.status.model === "" ? "" : `[${this.status.model}]`,
@@ -13045,11 +14055,15 @@ var TuiApp = class TuiApp {
13045
14055
  return;
13046
14056
  }
13047
14057
  const state = {
13048
- questions,
13049
- index: 0,
13050
- selected: /* @__PURE__ */ new Map(),
13051
- custom: /* @__PURE__ */ new Map(),
13052
- customText: "",
14058
+ flow: new QuestionFlow(questions.map((question) => ({
14059
+ id: question.id,
14060
+ question: question.question,
14061
+ ...question.header !== void 0 ? { header: question.header } : {},
14062
+ ...question.detail !== void 0 ? { detail: question.detail } : {},
14063
+ ...question.options !== void 0 ? { options: question.options } : {},
14064
+ ...question.multiSelect !== void 0 ? { multiSelect: question.multiSelect } : {},
14065
+ ...question.intent !== void 0 ? { intent: question.intent } : {}
14066
+ })), (answers) => this.settleQuestions(state, answers), () => this.settleQuestions(state, void 0)),
13053
14067
  resolve,
13054
14068
  reject,
13055
14069
  signal
@@ -13059,123 +14073,36 @@ var TuiApp = class TuiApp {
13059
14073
  return;
13060
14074
  }
13061
14075
  if (signal !== void 0) {
13062
- const onAbort = () => this.settleQuestions(state, "cancelled");
14076
+ const onAbort = () => this.settleQuestions(state, void 0);
13063
14077
  state.onAbort = onAbort;
13064
14078
  signal.addEventListener("abort", onAbort, { once: true });
13065
14079
  }
13066
14080
  this.activeQuestions = state;
13067
- this.renderQuestion(state);
13068
- });
13069
- }
13070
- /** Build and mount the dialog for the state's current question. */
13071
- renderQuestion(state) {
13072
- const question = state.questions[state.index];
13073
- if (question === void 0) {
13074
- this.settleQuestions(state, "done");
13075
- return;
13076
- }
13077
- const dialog = new Box(1, 1);
13078
- if (question.header !== void 0 && question.header !== "") dialog.addChild(new Text(color.textDim(question.header)));
13079
- dialog.addChild(new Text(question.question));
13080
- const options = question.options ?? [];
13081
- if (options.length > 0) options.forEach((option, index) => {
13082
- const checked = state.selected.get(question.id)?.has(option.label) === true ? color.success("✓") : " ";
13083
- dialog.addChild(new Text(`${checked} ${index + 1}) ${option.label}${option.description === void 0 ? "" : ` — ${option.description}`}`));
13084
- });
13085
- else if (question.multiSelect !== true) {
13086
- dialog.addChild(new Text(`> ${state.customText}`));
13087
- dialog.addChild(new Text("(type an answer, enter to confirm)"));
13088
- }
13089
- dialog.addChild(new Text(""));
13090
- const verb = question.multiSelect === true ? "toggle" : "select";
13091
- dialog.addChild(new Text(`[1-9] ${verb} [enter] confirm [esc] cancel (${state.index + 1}/${state.questions.length})`));
13092
- state.handle?.hide();
13093
- state.handle = this.showOverlayOnHost(new Frame(dialog), {
13094
- width: 72,
13095
- maxHeight: 24
14081
+ state.handle = this.showOverlayOnHost(new Frame(state.flow), {
14082
+ width: 76,
14083
+ maxHeight: 26
14084
+ });
13096
14085
  });
13097
14086
  }
13098
- /** Route a key while a question is showing; every key is consumed. */
14087
+ /** Route a key while a question flow is showing; every key is consumed. */
13099
14088
  handleQuestionKey(data) {
13100
14089
  const state = this.activeQuestions;
13101
14090
  if (state === void 0) return void 0;
13102
- const question = state.questions[state.index];
13103
- if (question === void 0) {
13104
- this.settleQuestions(state, "done");
13105
- return { consume: true };
13106
- }
13107
- const options = question.options ?? [];
13108
- const digit = /^[1-9]$/.exec(data);
13109
- if (digit !== null) {
13110
- const option = options[Number(digit[0]) - 1];
13111
- if (option !== void 0) {
13112
- const selected = state.selected.get(question.id) ?? /* @__PURE__ */ new Set();
13113
- if (question.multiSelect === true) {
13114
- if (selected.has(option.label)) selected.delete(option.label);
13115
- else selected.add(option.label);
13116
- } else {
13117
- selected.clear();
13118
- selected.add(option.label);
13119
- }
13120
- state.selected.set(question.id, selected);
13121
- this.renderQuestion(state);
13122
- }
13123
- return { consume: true };
13124
- }
13125
- if (matchesKey(data, "enter")) {
13126
- if (options.length > 0) {
13127
- state.index += 1;
13128
- this.renderQuestion(state);
13129
- } else if (question.multiSelect !== true) {
13130
- state.custom.set(question.id, state.customText);
13131
- state.customText = "";
13132
- state.index += 1;
13133
- this.renderQuestion(state);
13134
- }
13135
- return { consume: true };
13136
- }
13137
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
13138
- this.settleQuestions(state, "cancelled");
13139
- return { consume: true };
13140
- }
13141
- if (options.length === 0 && question.multiSelect !== true) {
13142
- if (data === "" || data === "\b") {
13143
- state.customText = [...state.customText].slice(0, -1).join("");
13144
- this.renderQuestion(state);
13145
- } else {
13146
- let appended = false;
13147
- for (const char of data) if (char.charCodeAt(0) >= 32) {
13148
- state.customText += char;
13149
- appended = true;
13150
- }
13151
- if (appended) this.renderQuestion(state);
13152
- }
13153
- }
14091
+ state.flow.handleInput(data);
14092
+ this.requestRender();
13154
14093
  return { consume: true };
13155
14094
  }
13156
- /** Resolve the question flow with its answers, or reject on cancel. */
13157
- settleQuestions(state, outcome) {
14095
+ /** Resolve the question flow with its answers, or reject on cancel/abort. */
14096
+ settleQuestions(state, answers) {
13158
14097
  if (this.activeQuestions !== state) return;
13159
14098
  this.activeQuestions = void 0;
13160
14099
  state.handle?.hide();
13161
14100
  if (state.onAbort !== void 0 && state.signal !== void 0) state.signal.removeEventListener("abort", state.onAbort);
13162
14101
  this.overlayHost.setFocus(this.editor);
13163
- if (outcome === "cancelled") {
14102
+ if (answers === void 0) {
13164
14103
  state.reject(/* @__PURE__ */ new Error("question flow cancelled"));
13165
14104
  return;
13166
14105
  }
13167
- const answers = state.questions.map((question) => {
13168
- const selected = [...state.selected.get(question.id) ?? []];
13169
- const custom = (question.options ?? []).length === 0 && question.multiSelect !== true ? state.custom.get(question.id) : void 0;
13170
- return custom === void 0 ? {
13171
- id: question.id,
13172
- selected
13173
- } : {
13174
- id: question.id,
13175
- selected,
13176
- custom
13177
- };
13178
- });
13179
14106
  state.resolve(answers);
13180
14107
  }
13181
14108
  };
@@ -13455,15 +14382,30 @@ function metaOf(cwd, presetId) {
13455
14382
  /**
13456
14383
  * Register the TUI-owned slash commands on the commands service. The
13457
14384
  * completion list is refreshed after every registration so TUI-owned
13458
- * commands appear in the editor's tab list.
14385
+ * commands appear in the editor's tab list. Registration is sessionless:
14386
+ * the commands service's global layer needs no agent, so the whole surface
14387
+ * is available before the first session exists (deferred start).
13459
14388
  * @param runner - the live runner surface.
14389
+ * @returns a hook that rebuilds the per-skill slash commands (a no-op until
14390
+ * the first session exists, since the skill catalog scope is the agent).
13460
14391
  */
13461
14392
  function registerTuiCommands(runner) {
13462
14393
  const { ctx, app } = runner;
13463
14394
  const cwd = runner.cwd;
13464
14395
  const signal = runner.signal;
13465
14396
  const commands = ctx.get("commands");
13466
- if (commands === void 0) return;
14397
+ if (commands === void 0) throw new Error("commands service unavailable");
14398
+ /**
14399
+ * Resolve the live agent for a session-backed command, creating the first
14400
+ * session lazily when the deferred start has not run yet. Throws when the
14401
+ * creation failed — the executor surfaces the error to the user.
14402
+ */
14403
+ const requireAgent = async () => {
14404
+ await runner.ensureSession();
14405
+ const agent = runner.liveAgent;
14406
+ if (agent === void 0) throw new Error("session could not be created");
14407
+ return agent;
14408
+ };
13467
14409
  const refreshCompletions = () => {
13468
14410
  app.setCommandCompletions(commands.list(runner.liveAgent).map((command) => ({
13469
14411
  name: command.name,
@@ -13478,7 +14420,8 @@ function registerTuiCommands(runner) {
13478
14420
  handler: () => {
13479
14421
  const liveAgent = runner.liveAgent;
13480
14422
  app.stop();
13481
- runner.sessions.flush(liveAgent.session).then(() => runner.exit(0));
14423
+ if (liveAgent === void 0) runner.exit(0);
14424
+ else runner.sessions.flush(liveAgent.session).then(() => runner.exit(0));
13482
14425
  return { kind: "success" };
13483
14426
  }
13484
14427
  });
@@ -13490,14 +14433,27 @@ function registerTuiCommands(runner) {
13490
14433
  const tuiSettings = runner.tuiSettings;
13491
14434
  const theme = tuiSettings?.get().theme ?? "auto";
13492
14435
  const themeValue = theme.startsWith("custom:") ? theme.slice(7) : theme;
14436
+ const settings = ctx.get("settings");
14437
+ const permissionNames = [...ctx.get("permissionPresets")?.names ?? []];
14438
+ let defaultPermission;
14439
+ if (settings !== void 0) try {
14440
+ defaultPermission = settings.get(settingsNamespace("permission"))?.defaultPreset;
14441
+ } catch {}
13493
14442
  app.openSettings([
13494
- {
14443
+ ...liveAgent === void 0 ? [] : [{
13495
14444
  id: "approval",
13496
- label: "Approval policy",
14445
+ label: "Approval policy (this session)",
13497
14446
  description: "How tool approvals are handled in this session",
13498
14447
  currentValue: effectiveApprovalPolicy(liveAgent.session.events) ?? "ask",
13499
14448
  values: ["ask", "never"]
13500
- },
14449
+ }],
14450
+ ...permissionNames.length > 0 ? [{
14451
+ id: "default-permission",
14452
+ label: "Default permission",
14453
+ description: "Preset new sessions start with (persisted; Shift+Tab cycles this session)",
14454
+ currentValue: defaultPermission ?? permissionNames[0] ?? "",
14455
+ values: permissionNames
14456
+ }] : [],
13501
14457
  {
13502
14458
  id: "theme",
13503
14459
  label: "Theme",
@@ -13543,24 +14499,26 @@ function registerTuiCommands(runner) {
13543
14499
  label: color.border("─".repeat(34)),
13544
14500
  currentValue: ""
13545
14501
  },
13546
- {
13547
- id: "session",
13548
- label: color.textDim("Session"),
13549
- description: color.textDim(liveAgent.session.id),
13550
- currentValue: color.textDim(displaySessionId(liveAgent.session.id))
13551
- },
13552
- {
13553
- id: "model",
13554
- label: color.textDim("Model"),
13555
- description: color.textDim("Provider and model routing this session"),
13556
- currentValue: color.textDim(`${liveAgent.options.provider}/${liveAgent.options.model}`)
13557
- },
13558
- {
13559
- id: "preset",
13560
- label: color.textDim("Agent preset"),
13561
- description: color.textDim("Composition this session runs on (see /preset)"),
13562
- currentValue: color.textDim(runner.currentPreset() ?? "none")
13563
- },
14502
+ ...liveAgent === void 0 ? [] : [
14503
+ {
14504
+ id: "session",
14505
+ label: color.textDim("Session"),
14506
+ description: color.textDim(liveAgent.session.id),
14507
+ currentValue: color.textDim(displaySessionId(liveAgent.session.id))
14508
+ },
14509
+ {
14510
+ id: "model",
14511
+ label: color.textDim("Model"),
14512
+ description: color.textDim("Provider and model routing this session"),
14513
+ currentValue: color.textDim(`${liveAgent.options.provider}/${liveAgent.options.model}`)
14514
+ },
14515
+ {
14516
+ id: "preset",
14517
+ label: color.textDim("Agent preset"),
14518
+ description: color.textDim("Composition this session runs on (see /preset)"),
14519
+ currentValue: color.textDim(runner.currentPreset() ?? "none")
14520
+ }
14521
+ ],
13564
14522
  {
13565
14523
  id: "cwd",
13566
14524
  label: color.textDim("Working directory"),
@@ -13569,7 +14527,17 @@ function registerTuiCommands(runner) {
13569
14527
  }
13570
14528
  ], (id, value) => {
13571
14529
  if (id === "approval") {
13572
- if (value === "ask" || value === "never") ctx.get("approval")?.setPolicy(liveAgent, value);
14530
+ if ((value === "ask" || value === "never") && liveAgent !== void 0) {
14531
+ ctx.get("approval")?.setPolicy(liveAgent, value);
14532
+ runner.refreshStatus();
14533
+ }
14534
+ } else if (id === "default-permission") {
14535
+ const settings = ctx.get("settings");
14536
+ if (settings !== void 0 && permissionNames.includes(value)) settings.mutate(settingsNamespace("permission"), [{
14537
+ op: "set",
14538
+ path: ["defaultPreset"],
14539
+ value
14540
+ }]);
13573
14541
  } else if (id === "theme") {
13574
14542
  if (value === "auto" || value === "dark" || value === "light" || customThemeNames().includes(value)) {
13575
14543
  if (value === "auto") app.autoDetectTheme();
@@ -13578,7 +14546,7 @@ function registerTuiCommands(runner) {
13578
14546
  const palette = loadCustomTheme(value);
13579
14547
  if (palette !== void 0) app.applyPalette(palette);
13580
14548
  else {
13581
- app.notify(`theme ${value} not found`);
14549
+ app.notify(`theme ${value} not found`, "error");
13582
14550
  return;
13583
14551
  }
13584
14552
  }
@@ -13606,7 +14574,7 @@ function registerTuiCommands(runner) {
13606
14574
  }
13607
14575
  });
13608
14576
  const openSessionPicker = async (invocation, header) => {
13609
- const liveAgent = runner.liveAgent;
14577
+ const currentId = runner.liveAgent?.session.id;
13610
14578
  const persistence = ctx.get("sessionPersistence");
13611
14579
  if (persistence === void 0) return {
13612
14580
  kind: "error",
@@ -13615,17 +14583,17 @@ function registerTuiCommands(runner) {
13615
14583
  const query = ctx.get("sessionQuery");
13616
14584
  let rows;
13617
14585
  if (query !== void 0) rows = (await query.listSessions()).map((record) => headerToPickerRow(record.header, record.live));
13618
- else rows = (await persistence.list()).map((header) => headerToPickerRow(header, header.id === liveAgent.session.id));
14586
+ else rows = (await persistence.list()).map((header) => headerToPickerRow(header, header.id === currentId));
13619
14587
  rows.sort((a, b) => b.createdAt - a.createdAt);
13620
14588
  if (rows.length === 0) return {
13621
14589
  kind: "error",
13622
14590
  text: "no persisted sessions"
13623
14591
  };
13624
14592
  const shown = rows.slice(0, 200);
13625
- const picker = app.openPicker(shown.map((row) => sessionPickerItem(row, liveAgent.session.id)), (id) => {
13626
- if (id === liveAgent.session.id) return;
14593
+ const picker = app.openPicker(shown.map((row) => sessionPickerItem(row, currentId ?? "")), (id) => {
14594
+ if (id === currentId) return;
13627
14595
  runner.switchSession(id).then((error) => {
13628
- if (error !== void 0) app.notify(error);
14596
+ if (error !== void 0) app.notify(error, "error");
13629
14597
  });
13630
14598
  }, () => {}, {
13631
14599
  enableSearch: true,
@@ -13641,7 +14609,7 @@ function registerTuiCommands(runner) {
13641
14609
  picker.setItems(shown.map((row) => sessionPickerItem({
13642
14610
  ...row,
13643
14611
  title: titles.get(row.id)
13644
- }, liveAgent.session.id)));
14612
+ }, runner.liveAgent?.session.id ?? "")));
13645
14613
  }).catch(() => {});
13646
14614
  return { kind: "success" };
13647
14615
  };
@@ -13658,22 +14626,22 @@ function registerTuiCommands(runner) {
13658
14626
  handler: async (invocation) => {
13659
14627
  const raw = invocation.rawInput.trim();
13660
14628
  if (raw !== "") {
13661
- const liveAgent = runner.liveAgent;
14629
+ const currentId = runner.liveAgent?.session.id;
13662
14630
  const persistence = ctx.get("sessionPersistence");
13663
14631
  if (persistence !== void 0) {
13664
14632
  const query = ctx.get("sessionQuery");
13665
14633
  let rows;
13666
14634
  if (query !== void 0) rows = (await query.listSessions()).map((record) => headerToPickerRow(record.header, record.live));
13667
- else rows = (await persistence.list()).map((header) => headerToPickerRow(header, header.id === liveAgent.session.id));
14635
+ else rows = (await persistence.list()).map((header) => headerToPickerRow(header, header.id === currentId));
13668
14636
  rows.sort((a, b) => b.createdAt - a.createdAt);
13669
14637
  const match = findSessionMatch(rows, raw);
13670
14638
  if (match !== void 0) {
13671
- if (match.id === liveAgent.session.id) return {
14639
+ if (match.id === currentId) return {
13672
14640
  kind: "error",
13673
14641
  text: "already on this session"
13674
14642
  };
13675
14643
  runner.switchSession(match.id).then((error) => {
13676
- if (error !== void 0) app.notify(error);
14644
+ if (error !== void 0) app.notify(error, "error");
13677
14645
  });
13678
14646
  return { kind: "success" };
13679
14647
  }
@@ -13682,28 +14650,26 @@ function registerTuiCommands(runner) {
13682
14650
  return openSessionPicker(invocation, "resume");
13683
14651
  }
13684
14652
  });
13685
- const skillService = () => {
13686
- const liveAgent = runner.liveAgent;
13687
- return ctx.get("agentPresets")?.serviceFor(liveAgent, "skills") ?? ctx.get("skills");
14653
+ const skillService = (agent) => {
14654
+ return ctx.get("agentPresets")?.serviceFor(agent, "skills") ?? ctx.get("skills");
13688
14655
  };
13689
14656
  /** The workspace the live session runs in; fallback to the TUI's cwd. */
13690
- const sessionCwd = () => runner.liveAgent.session.header.cwd ?? cwd;
13691
- const loadSkill = async (name) => {
13692
- const liveAgent = runner.liveAgent;
13693
- const skills = skillService();
14657
+ const sessionCwd = (agent) => agent.session.header.cwd ?? cwd;
14658
+ const loadSkill = async (agent, name) => {
14659
+ const skills = skillService(agent);
13694
14660
  if (skills === void 0) return {
13695
14661
  kind: "error",
13696
14662
  text: "skill service unavailable"
13697
14663
  };
13698
14664
  const skill = await skills.get(name, {
13699
- cwd: sessionCwd(),
13700
- scope: liveAgent
14665
+ cwd: sessionCwd(agent),
14666
+ scope: agent
13701
14667
  });
13702
14668
  if (skill === void 0) return {
13703
14669
  kind: "error",
13704
14670
  text: "unknown skill \"" + name + "\""
13705
14671
  };
13706
- liveAgent.inject(createUserMessage({
14672
+ agent.inject(createUserMessage({
13707
14673
  content: [{
13708
14674
  type: "text",
13709
14675
  text: "Skill loaded by the user: **" + skill.name + "**\n\n" + (skill.content ?? skill.description)
@@ -13723,11 +14689,12 @@ function registerTuiCommands(runner) {
13723
14689
  for (const dispose of skillDisposers.values()) dispose();
13724
14690
  skillDisposers.clear();
13725
14691
  const liveAgent = runner.liveAgent;
13726
- const skills = skillService();
14692
+ if (liveAgent === void 0) return 0;
14693
+ const skills = skillService(liveAgent);
13727
14694
  if (skills === void 0) return 0;
13728
14695
  const taken = new Set(commands.list(liveAgent).map((command) => command.name));
13729
14696
  const catalog = await skills.list({
13730
- cwd: sessionCwd(),
14697
+ cwd: sessionCwd(liveAgent),
13731
14698
  scope: liveAgent
13732
14699
  });
13733
14700
  for (const skill of catalog) {
@@ -13736,7 +14703,10 @@ function registerTuiCommands(runner) {
13736
14703
  const dispose = commands.register({
13737
14704
  name: skill.name,
13738
14705
  description: "[skill] " + skill.description,
13739
- handler: async () => loadSkill(skill.name)
14706
+ handler: async () => {
14707
+ const agent = await requireAgent();
14708
+ return loadSkill(agent, skill.name);
14709
+ }
13740
14710
  });
13741
14711
  skillDisposers.set(skill.name, dispose);
13742
14712
  } catch {}
@@ -13750,16 +14720,16 @@ function registerTuiCommands(runner) {
13750
14720
  description: "Load a skill into the session context",
13751
14721
  input: { hint: "<name>" },
13752
14722
  handler: async (invocation) => {
13753
- const liveAgent = runner.liveAgent;
13754
- const skills = skillService();
14723
+ const liveAgent = await requireAgent();
14724
+ const skills = skillService(liveAgent);
13755
14725
  if (skills === void 0) return {
13756
14726
  kind: "error",
13757
14727
  text: "skill service unavailable"
13758
14728
  };
13759
14729
  const name = invocation.rawInput.trim();
13760
- if (name !== "") return loadSkill(name);
14730
+ if (name !== "") return loadSkill(liveAgent, name);
13761
14731
  const catalog = await skills.list({
13762
- cwd: sessionCwd(),
14732
+ cwd: sessionCwd(liveAgent),
13763
14733
  scope: liveAgent
13764
14734
  });
13765
14735
  if (catalog.length === 0) return {
@@ -13773,7 +14743,7 @@ function registerTuiCommands(runner) {
13773
14743
  currentValue: "",
13774
14744
  values: ["✓"]
13775
14745
  })), (id) => {
13776
- loadSkill(id).then((result) => {
14746
+ loadSkill(liveAgent, id).then((result) => {
13777
14747
  if (result.kind === "error") app.notify(result.text);
13778
14748
  });
13779
14749
  }, () => {});
@@ -13800,7 +14770,7 @@ function registerTuiCommands(runner) {
13800
14770
  app.setFooterPreset(doc.footer === "compact" ? "compact" : "full");
13801
14771
  app.setFullscreen(doc.fullscreen === "on");
13802
14772
  }
13803
- app.notify(`reloaded — ${skillCount} skills \u00b7 settings reapplied`);
14773
+ app.notify(`reloaded — ${skillCount} skills \u00b7 settings reapplied`, "info");
13804
14774
  return { kind: "success" };
13805
14775
  }
13806
14776
  });
@@ -13849,24 +14819,117 @@ function registerTuiCommands(runner) {
13849
14819
  sessionId: SessionId(`session-${randomUUID()}`),
13850
14820
  meta: metaOf(cwd, presetId),
13851
14821
  agentOptions: {
13852
- provider: liveAgent.options.provider,
13853
- model: liveAgent.options.model
14822
+ provider: liveAgent?.options.provider ?? runner.selected.current?.provider,
14823
+ model: liveAgent?.options.model ?? runner.selected.current?.model
13854
14824
  },
13855
14825
  setup: composition.setup
13856
14826
  });
13857
14827
  const error = await runner.swapTo(next);
13858
- if (error !== void 0) app.notify(error);
14828
+ if (error !== void 0) app.notify(error, "error");
13859
14829
  return {
13860
14830
  kind: "success",
13861
14831
  text: "started a fresh session"
13862
14832
  };
13863
14833
  }
13864
14834
  });
14835
+ commands.register({
14836
+ name: "queue",
14837
+ description: "Manage queued input: edit, delete, steer, or insert",
14838
+ handler: async () => {
14839
+ const liveAgent = await requireAgent();
14840
+ const inbox = liveAgent.inbox;
14841
+ const queued = [...inbox.nextTurn.map((message, index) => ({
14842
+ message,
14843
+ slot: `next ${index + 1}`,
14844
+ list: "next-turn"
14845
+ })), ...inbox.nextStep.map((message, index) => ({
14846
+ message,
14847
+ slot: `steer ${index + 1}`,
14848
+ list: "next-step"
14849
+ }))];
14850
+ if (queued.length === 0) return {
14851
+ kind: "success",
14852
+ text: "no queued input"
14853
+ };
14854
+ app.openSettings(queued.map(({ message, slot }) => ({
14855
+ id: message.id,
14856
+ label: `[${slot}] ${textOf(message.content).replace(/\s+/g, " ").trim().slice(0, 40)}`,
14857
+ description: message.id,
14858
+ currentValue: "",
14859
+ submenu: (value, done) => new SettingsList([
14860
+ {
14861
+ id: "edit",
14862
+ label: "Edit",
14863
+ description: "Rewrite this queued message",
14864
+ currentValue: "",
14865
+ values: ["✓"]
14866
+ },
14867
+ {
14868
+ id: "delete",
14869
+ label: "Delete",
14870
+ description: "Remove it from the queue",
14871
+ currentValue: "",
14872
+ values: ["✓"]
14873
+ },
14874
+ {
14875
+ id: "steer",
14876
+ label: "Steer",
14877
+ description: "Send it now: into the running turn, or start one when idle",
14878
+ currentValue: "",
14879
+ values: ["✓"]
14880
+ },
14881
+ {
14882
+ id: "insert",
14883
+ label: "Insert before",
14884
+ description: "Queue a new message ahead of this one",
14885
+ currentValue: "",
14886
+ values: ["✓"]
14887
+ }
14888
+ ], 6, settingsListTheme, (action) => done(action), () => done(), {})
14889
+ })), (id, action) => {
14890
+ const target = queued.find((item) => item.message.id === id);
14891
+ if (target === void 0) return;
14892
+ const message = target.message;
14893
+ if (action === "delete") {
14894
+ inbox.remove(message.id);
14895
+ app.notify("queued message deleted", "info");
14896
+ } else if (action === "steer") {
14897
+ inbox.remove(message.id);
14898
+ if (liveAgent.status === "running") liveAgent.steer(message);
14899
+ else liveAgent.followup(message);
14900
+ app.notify("steering queued message", "info");
14901
+ } else if (action === "edit" || action === "insert") app.askQuestions([{
14902
+ id: "q",
14903
+ question: action === "edit" ? "Edit queued message:" : "New message to insert:"
14904
+ }]).then((answers) => {
14905
+ const text = answers[0]?.custom?.trim() ?? "";
14906
+ if (text === "") return;
14907
+ const next = createUserMessage({
14908
+ content: [{
14909
+ type: "text",
14910
+ text
14911
+ }],
14912
+ source: { kind: "user" }
14913
+ });
14914
+ if (action === "edit") {
14915
+ inbox.replace(message.id, next);
14916
+ app.notify("queued message updated", "info");
14917
+ } else {
14918
+ const position = (target.list === "next-turn" ? inbox.nextTurn : inbox.nextStep).findIndex((item) => item.id === message.id);
14919
+ if (position < 0) return;
14920
+ inbox.splice(target.list, position, 0, [next]);
14921
+ app.notify("message inserted before the selected one", "info");
14922
+ }
14923
+ }).catch(() => {});
14924
+ }, () => {});
14925
+ return { kind: "success" };
14926
+ }
14927
+ });
13865
14928
  commands.register({
13866
14929
  name: "tasks",
13867
14930
  description: "List background jobs for this session",
13868
- handler: () => {
13869
- const liveAgent = runner.liveAgent;
14931
+ handler: async () => {
14932
+ const liveAgent = await requireAgent();
13870
14933
  const jobs = ctx.get("jobs");
13871
14934
  if (jobs === void 0) return {
13872
14935
  kind: "error",
@@ -13886,12 +14949,32 @@ function registerTuiCommands(runner) {
13886
14949
  return { kind: "success" };
13887
14950
  }
13888
14951
  });
14952
+ commands.register({
14953
+ name: "yolo",
14954
+ description: "Switch to danger-full-access (alias of /permission danger-full-access)",
14955
+ handler: async () => {
14956
+ const liveAgent = await requireAgent();
14957
+ const commands = ctx.get("commands");
14958
+ if (commands === void 0) return {
14959
+ kind: "error",
14960
+ text: "commands service unavailable"
14961
+ };
14962
+ if (await commands.execute(liveAgent, "/permission danger-full-access", signal) === void 0) return {
14963
+ kind: "error",
14964
+ text: "/permission unavailable (permission presets not composed)"
14965
+ };
14966
+ return {
14967
+ kind: "success",
14968
+ text: "danger-full-access — approvals off"
14969
+ };
14970
+ }
14971
+ });
13889
14972
  commands.register({
13890
14973
  name: "preset",
13891
14974
  description: "Show or switch the session agent preset",
13892
14975
  input: { hint: "[status|<id>|default [<id>]]" },
13893
14976
  handler: async (invocation) => {
13894
- const liveAgent = runner.liveAgent;
14977
+ const liveAgent = await requireAgent();
13895
14978
  const presets = ctx.get("agentPresets");
13896
14979
  if (presets === void 0) return {
13897
14980
  kind: "error",
@@ -13967,7 +15050,7 @@ function registerTuiCommands(runner) {
13967
15050
  name: "subagents",
13968
15051
  description: "List child agents; view a transcript or interrupt one",
13969
15052
  handler: async () => {
13970
- const liveAgent = runner.liveAgent;
15053
+ const liveAgent = await requireAgent();
13971
15054
  const subagents = ctx.get("subagents");
13972
15055
  if (subagents === void 0) return {
13973
15056
  kind: "error",
@@ -14006,7 +15089,7 @@ function registerTuiCommands(runner) {
14006
15089
  kind: "user",
14007
15090
  parentSessionId: liveAgent.session.id
14008
15091
  });
14009
- app.notify(`interrupting ${labelOf(child)}`);
15092
+ app.notify(`interrupting ${labelOf(child)}`, "info");
14010
15093
  }
14011
15094
  }, () => {});
14012
15095
  return { kind: "success" };
@@ -14017,7 +15100,7 @@ function registerTuiCommands(runner) {
14017
15100
  description: "Search persisted sessions for text and switch to a hit",
14018
15101
  input: { hint: "<query>" },
14019
15102
  handler: async (invocation) => {
14020
- const liveAgent = runner.liveAgent;
15103
+ const currentId = runner.liveAgent?.session.id;
14021
15104
  const persistence = ctx.get("sessionPersistence");
14022
15105
  if (persistence === void 0) return {
14023
15106
  kind: "error",
@@ -14060,9 +15143,9 @@ function registerTuiCommands(runner) {
14060
15143
  label: hit.id.length > 26 ? `${hit.id.slice(0, 26)}…` : hit.id,
14061
15144
  description: `${Math.max(0, Math.floor((now - hit.createdAt) / 6e4))}m ago · …${hit.snippet}…`
14062
15145
  })), (id) => {
14063
- if (id === liveAgent.session.id) return;
15146
+ if (id === currentId) return;
14064
15147
  runner.switchSession(id).then((error) => {
14065
- if (error !== void 0) app.notify(error);
15148
+ if (error !== void 0) app.notify(error, "error");
14066
15149
  });
14067
15150
  }, () => {});
14068
15151
  return { kind: "success" };
@@ -14072,8 +15155,8 @@ function registerTuiCommands(runner) {
14072
15155
  name: "title",
14073
15156
  description: "Set or show the session title",
14074
15157
  input: { hint: "<title>" },
14075
- handler: (invocation) => {
14076
- const liveAgent = runner.liveAgent;
15158
+ handler: async (invocation) => {
15159
+ const liveAgent = await requireAgent();
14077
15160
  const titles = ctx.get("sessionTitle");
14078
15161
  if (titles === void 0) return {
14079
15162
  kind: "error",
@@ -14097,8 +15180,8 @@ function registerTuiCommands(runner) {
14097
15180
  commands.register({
14098
15181
  name: "copy",
14099
15182
  description: "Copy the last assistant message (OSC 52 clipboard)",
14100
- handler: () => {
14101
- const last = runner.liveAgent.session.events.findLast((event) => event.type === "assistant/message");
15183
+ handler: async () => {
15184
+ const last = (await requireAgent()).session.events.findLast((event) => event.type === "assistant/message");
14102
15185
  if (last === void 0) return {
14103
15186
  kind: "error",
14104
15187
  text: "no assistant message yet"
@@ -14124,7 +15207,7 @@ function registerTuiCommands(runner) {
14124
15207
  description: "Export this session log (JSONL by default, `md` for a readable transcript)",
14125
15208
  input: { hint: "[md|<path>]" },
14126
15209
  handler: async (invocation) => {
14127
- const liveAgent = runner.liveAgent;
15210
+ const liveAgent = await requireAgent();
14128
15211
  const persistence = ctx.get("sessionPersistence");
14129
15212
  if (persistence === void 0) return {
14130
15213
  kind: "error",
@@ -14165,7 +15248,7 @@ function registerTuiCommands(runner) {
14165
15248
  description: "Fork this session at the last completed turn",
14166
15249
  handler: async () => {
14167
15250
  const liveAgent = runner.liveAgent;
14168
- const seed = forkSeed(liveAgent.session.events);
15251
+ const seed = liveAgent === void 0 ? void 0 : forkSeed(liveAgent.session.events);
14169
15252
  if (seed === void 0) return {
14170
15253
  kind: "error",
14171
15254
  text: "no completed turn to fork from"
@@ -14187,7 +15270,7 @@ function registerTuiCommands(runner) {
14187
15270
  seed
14188
15271
  });
14189
15272
  const error = await runner.swapTo(next);
14190
- if (error !== void 0) app.notify(error);
15273
+ if (error !== void 0) app.notify(error, "error");
14191
15274
  return {
14192
15275
  kind: "success",
14193
15276
  text: `forked as ${next.agent.session.id}`
@@ -14197,8 +15280,8 @@ function registerTuiCommands(runner) {
14197
15280
  commands.register({
14198
15281
  name: "status",
14199
15282
  description: "Show session stats and identity",
14200
- handler: () => {
14201
- const liveAgent = runner.liveAgent;
15283
+ handler: async () => {
15284
+ const liveAgent = await requireAgent();
14202
15285
  const stats = computeStats(liveAgent.session.events);
14203
15286
  let contextTokens;
14204
15287
  const meter = ctx.get("tokenMeter");
@@ -14373,6 +15456,225 @@ function registerTuiCommands(runner) {
14373
15456
  }
14374
15457
  });
14375
15458
  refreshCompletions();
15459
+ return { refreshSkills: () => registerSkillCommands() };
15460
+ }
15461
+ //#endregion
15462
+ //#region src/diag.ts
15463
+ /**
15464
+ * Structured process diagnostics for the TUI.
15465
+ *
15466
+ * The TUI cannot rely on `ctx.logger` for human-visible troubleshooting:
15467
+ * no dsh server-side package registers a logger exporter, so those lines
15468
+ * only fill cordis's in-memory buffer. This module owns a dedicated
15469
+ * diagnostics channel instead — one line per event, written to stderr
15470
+ * (warn/error by default, matching the legacy `[tui]` fd-2 lines) and to a
15471
+ * log file (info by default) under `$DSH_HOME/logs/`.
15472
+ *
15473
+ * Configuration (env):
15474
+ * - `DSH_PI_TUI_LOG` — log file path, or `off` to disable the file sink.
15475
+ * - `DSH_PI_TUI_LOG_LEVEL` — one of debug/info/warn/error (default `info`).
15476
+ *
15477
+ * Writes are best-effort and never throw; a failed file sink is disabled
15478
+ * silently so diagnostics can never take the TUI down.
15479
+ * @module @xmoon76/dsh-pi-tui/diag
15480
+ */
15481
+ const LEVEL_RANK = {
15482
+ debug: 0,
15483
+ info: 1,
15484
+ warn: 2,
15485
+ error: 3
15486
+ };
15487
+ /** `2026-08-15T10:00:00.000+08:00` — local wall time with the zone offset. */
15488
+ function formatDiagTime(date) {
15489
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
15490
+ const offsetMinutes = -date.getTimezoneOffset();
15491
+ const offset = `${offsetMinutes >= 0 ? "+" : "-"}${pad(Math.floor(Math.abs(offsetMinutes) / 60))}:${pad(Math.abs(offsetMinutes) % 60)}`;
15492
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}`;
15493
+ }
15494
+ /** Render a single field value for the `k=v` line format. */
15495
+ function formatField(value) {
15496
+ if (typeof value === "string") return value;
15497
+ if (typeof value === "number" || typeof value === "boolean" || value === null || value === void 0) return String(value);
15498
+ try {
15499
+ return JSON.stringify(value);
15500
+ } catch {
15501
+ return String(value);
15502
+ }
15503
+ }
15504
+ /** The dsh home directory (`$DSH_HOME`, defaulting to `~/.dsh`). */
15505
+ function dshHome(env) {
15506
+ const explicit = env.DSH_HOME;
15507
+ if (explicit !== void 0 && explicit !== "") return explicit;
15508
+ return join(homedir(), ".dsh");
15509
+ }
15510
+ /** Build the default diagnostics log path: `$DSH_HOME/logs/pi-tui-<pid>.log`. */
15511
+ function defaultDiagLogPath(env) {
15512
+ const configured = env.DSH_PI_TUI_LOG;
15513
+ if (configured === "off" || configured === "") return void 0;
15514
+ if (configured !== void 0) return configured;
15515
+ return join(dshHome(env), "logs", `pi-tui-${process.pid}.log`);
15516
+ }
15517
+ /** Resolve the effective file level from `DSH_PI_TUI_LOG_LEVEL`. */
15518
+ function diagLevelFromEnv(env) {
15519
+ const configured = env.DSH_PI_TUI_LOG_LEVEL;
15520
+ if (configured === "debug" || configured === "info" || configured === "warn" || configured === "error") return configured;
15521
+ return "info";
15522
+ }
15523
+ /** Create the diagnostics channel from the process environment. */
15524
+ function diagFromEnv(env) {
15525
+ return createDiag({
15526
+ fileLevel: diagLevelFromEnv(env),
15527
+ filePath: defaultDiagLogPath(env)
15528
+ });
15529
+ }
15530
+ /** Create one diagnostics channel. Writes are best-effort, never throw. */
15531
+ function createDiag(options = {}) {
15532
+ const fileLevel = options.fileLevel ?? "info";
15533
+ const stderrLevel = options.stderrLevel ?? "warn";
15534
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
15535
+ const sinks = [...options.sinks ?? []];
15536
+ let fileHandle;
15537
+ let fileDisabled = false;
15538
+ if (options.filePath !== void 0) try {
15539
+ const dir = options.filePath.slice(0, Math.max(options.filePath.lastIndexOf("/"), 0));
15540
+ if (dir !== "") mkdirSync(dir, { recursive: true });
15541
+ fileHandle = openSync(options.filePath, "a");
15542
+ } catch {
15543
+ fileDisabled = true;
15544
+ }
15545
+ const emit = (level, message, fields) => {
15546
+ const stamp = formatDiagTime(now());
15547
+ const suffix = fields === void 0 ? "" : ` ${Object.entries(fields).map(([key, value]) => `${key}=${formatField(value)}`).join(" ")}`;
15548
+ const line = `[tui] ${stamp} ${level.toUpperCase()} ${message}${suffix}\n`;
15549
+ for (const sink of sinks) try {
15550
+ sink.write(line);
15551
+ } catch {}
15552
+ if (stderrLevel !== "off" && LEVEL_RANK[level] >= LEVEL_RANK[stderrLevel]) try {
15553
+ writeSync(2, line);
15554
+ } catch {}
15555
+ if (fileHandle !== void 0 && !fileDisabled && LEVEL_RANK[level] >= LEVEL_RANK[fileLevel]) try {
15556
+ writeSync(fileHandle, line);
15557
+ } catch {
15558
+ fileDisabled = true;
15559
+ try {
15560
+ writeSync(2, `[tui] ${formatDiagTime(now())} WARN diag file sink disabled\n`);
15561
+ } catch {}
15562
+ }
15563
+ };
15564
+ return {
15565
+ debug: (message, fields) => emit("debug", message, fields),
15566
+ info: (message, fields) => emit("info", message, fields),
15567
+ warn: (message, fields) => emit("warn", message, fields),
15568
+ error: (message, fields) => emit("error", message, fields),
15569
+ dispose: () => {
15570
+ if (fileHandle !== void 0) {
15571
+ try {
15572
+ closeSync(fileHandle);
15573
+ } catch {}
15574
+ fileHandle = void 0;
15575
+ }
15576
+ }
15577
+ };
15578
+ }
15579
+ //#endregion
15580
+ //#region src/guard.ts
15581
+ /** Fresh guard state: forces one real read on the first check. */
15582
+ function freshGuardState() {
15583
+ return {
15584
+ revision: "",
15585
+ diverged: false,
15586
+ fileEvents: 0,
15587
+ error: void 0
15588
+ };
15589
+ }
15590
+ function statRevision(stat) {
15591
+ return `${stat.size}:${stat.mtimeMs}`;
15592
+ }
15593
+ /**
15594
+ * Check whether an external process has written the session since the last
15595
+ * consistent observation. Mutates `state` (revision + divergence latch).
15596
+ * Never throws.
15597
+ */
15598
+ async function checkDivergence(persistence, session, stat, state) {
15599
+ if (persistence === void 0) return {
15600
+ kind: "unavailable",
15601
+ revision: state.revision,
15602
+ reason: "no-persistence"
15603
+ };
15604
+ const artifact = persistence.locate({
15605
+ id: session.id,
15606
+ cwd: session.header?.cwd
15607
+ });
15608
+ if (artifact === void 0) return {
15609
+ kind: "unavailable",
15610
+ revision: state.revision,
15611
+ reason: "no-artifact"
15612
+ };
15613
+ let current;
15614
+ try {
15615
+ current = stat(artifact.path);
15616
+ } catch (error) {
15617
+ if (error.code === "ENOENT") return {
15618
+ kind: "ok",
15619
+ revision: state.revision
15620
+ };
15621
+ state.diverged = true;
15622
+ return {
15623
+ kind: "unreadable",
15624
+ revision: state.revision,
15625
+ error: error instanceof Error ? error.message : String(error)
15626
+ };
15627
+ }
15628
+ const revision = statRevision(current);
15629
+ if (revision === state.revision) {
15630
+ if (!state.diverged) return {
15631
+ kind: "ok",
15632
+ revision
15633
+ };
15634
+ return state.error !== void 0 ? {
15635
+ kind: "unreadable",
15636
+ revision,
15637
+ error: state.error
15638
+ } : {
15639
+ kind: "diverged",
15640
+ revision,
15641
+ fileEvents: state.fileEvents,
15642
+ memoryEvents: session.events.length
15643
+ };
15644
+ }
15645
+ let fileEvents;
15646
+ try {
15647
+ fileEvents = (await persistence.readFrom(session.id, 0)).events.length;
15648
+ } catch (error) {
15649
+ state.revision = revision;
15650
+ state.diverged = true;
15651
+ state.fileEvents = -1;
15652
+ state.error = error instanceof Error ? error.message : String(error);
15653
+ return {
15654
+ kind: "unreadable",
15655
+ revision,
15656
+ error: state.error
15657
+ };
15658
+ }
15659
+ const memoryEvents = session.events.length;
15660
+ state.revision = revision;
15661
+ state.fileEvents = fileEvents;
15662
+ state.error = void 0;
15663
+ if (fileEvents > memoryEvents) {
15664
+ state.diverged = true;
15665
+ return {
15666
+ kind: "diverged",
15667
+ revision,
15668
+ fileEvents,
15669
+ memoryEvents
15670
+ };
15671
+ }
15672
+ state.diverged = false;
15673
+ return {
15674
+ kind: "ok",
15675
+ revision,
15676
+ fileEvents
15677
+ };
14376
15678
  }
14377
15679
  //#endregion
14378
15680
  //#region src/index.ts
@@ -14402,6 +15704,27 @@ const WINDOW_TURNS = 15;
14402
15704
  /** Coalesced repaint interval for streaming events, in ms. */
14403
15705
  const REPAINT_FLUSH_MS = 50;
14404
15706
  /**
15707
+ * Slash commands that need no session: before the first user message
15708
+ * (deferred start) they run locally without creating one. Everything else
15709
+ * dispatches through `commands.execute`, which creates the session lazily
15710
+ * (the command line IS the first user input). Commands in this set must
15711
+ * tolerate `liveAgent === undefined` in their handlers.
15712
+ */
15713
+ const SESSIONLESS_COMMANDS = /* @__PURE__ */ new Set([
15714
+ "exit",
15715
+ "settings",
15716
+ "help",
15717
+ "login",
15718
+ "logout",
15719
+ "model",
15720
+ "reload",
15721
+ "sessions",
15722
+ "resume",
15723
+ "search",
15724
+ "new",
15725
+ "fork"
15726
+ ]);
15727
+ /**
14405
15728
  * The installed dsh version (e.g. `0.1.0-rc.6`), resolved from the launcher's
14406
15729
  * real path: `process.argv[1]` is the `dsh` bin, whose realpath walks up to
14407
15730
  * the `@deepseek-ai/dsh/package.json` that owns it. The version the welcome
@@ -14465,18 +15788,6 @@ function gitBranch(cwd) {
14465
15788
  function shortCwd(cwd) {
14466
15789
  return cwd.split("/").filter(Boolean).slice(-2).join("/") || cwd;
14467
15790
  }
14468
- /**
14469
- * Write raw bytes to a file descriptor, bypassing the cordis-wrapped
14470
- * `process.stderr` (whose `writeSync` is missing). Startup diagnostics go to
14471
- * fd 2 so they never corrupt the TUI frame on stdout.
14472
- * @param fd - the file descriptor (2 for stderr).
14473
- * @param text - the bytes to write.
14474
- */
14475
- function writeFd2(fd, text) {
14476
- try {
14477
- writeFileSync(fd, text);
14478
- } catch {}
14479
- }
14480
15791
  /** Shell commands the approval dialog flags as dangerous (kimi-inspired). */
14481
15792
  const DANGER_PATTERNS = [
14482
15793
  /\bmkfs(\.\w+)?\b/,
@@ -14488,6 +15799,9 @@ const DANGER_PATTERNS = [
14488
15799
  />+\s*\/dev\/sd/,
14489
15800
  /\bcurl\b[^\n|]*\|\s*(ba)?sh\b/
14490
15801
  ];
15802
+ /** Divergence-guard notices (user-facing, English like the rest of the TUI). */
15803
+ const GUARD_BLOCKED_NOTIFY = "This session may be open in another dsh process (TUI/web); send blocked. Press Enter again to force (may corrupt the session log)";
15804
+ const GUARD_FORCED_NOTIFY = "Forced send — the session may be written by another process; the log may be damaged";
14491
15805
  /**
14492
15806
  * Whether a shell command matches a destructive pattern. `rm` is treated
14493
15807
  * specially: any spelling of recursive + force flags (`rm -rf`, `rm -r -f`,
@@ -14502,6 +15816,36 @@ function dangerCommand(command) {
14502
15816
  return DANGER_PATTERNS.some((pattern) => pattern.test(command));
14503
15817
  }
14504
15818
  /**
15819
+ * The dsh profile this process was launched with (the `--profile` flag),
15820
+ * so the exit-time resume hint names the profile the TUI actually runs
15821
+ * under. Falls back to `pi-tui` when the flag is absent (the TUI bundle
15822
+ * cannot load without a profile, so this is defensive only).
15823
+ * @param argv - the process argument vector.
15824
+ * @param fallback - the default profile.
15825
+ */
15826
+ function runningProfile(argv = process.argv, fallback = "pi-tui") {
15827
+ for (let i = argv.length - 1; i >= 0; i--) {
15828
+ const arg = argv[i];
15829
+ if (arg.startsWith("--profile=")) return arg.slice(10);
15830
+ if (arg === "--profile" && i + 1 < argv.length && argv[i + 1] !== void 0 && argv[i + 1] !== "") return argv[i + 1];
15831
+ }
15832
+ return fallback;
15833
+ }
15834
+ /**
15835
+ * The interactive-quit resume hint (pi parity): `dsh --profile <p>
15836
+ * --session <id>`, printed after the terminal restores so the user can
15837
+ * re-enter the session later. Returns undefined when there is no session
15838
+ * to resume (deferred start never created one).
15839
+ * @param profile - the running profile ({@link runningProfile}).
15840
+ * @param sessionId - the live session id.
15841
+ * @returns the resume command line, or undefined without a session.
15842
+ */
15843
+ function resumeCommand(profile, sessionId) {
15844
+ const id = sessionId.trim();
15845
+ if (id === "") return void 0;
15846
+ return `dsh --profile ${profile} --session ${id}`;
15847
+ }
15848
+ /**
14505
15849
  * The active goal badge text from the session log, or undefined. The latest
14506
15850
  * `goal/change` wins; a clear or completed goal hides the badge.
14507
15851
  * @param events - the session log.
@@ -14630,6 +15974,7 @@ function apply(ctx, config) {
14630
15974
  if (exit === void 0) throw new Error("tui-runner: the launcher must provide ctx.appExit before the tree mounts");
14631
15975
  const startup = ctx.get(TUI_STARTUP_SERVICE);
14632
15976
  if (startup === void 0) return;
15977
+ const diag = diagFromEnv(process.env);
14633
15978
  const sessionId = config.sessionId !== void 0 && config.sessionId !== "" ? config.sessionId : startup.sessionId;
14634
15979
  (async () => {
14635
15980
  await ctx.get("loader")?.await();
@@ -14649,6 +15994,14 @@ function apply(ctx, config) {
14649
15994
  const compose = (presetId) => composeAgent(ctx, selected, presetId);
14650
15995
  const withPresetMeta = (composition) => composition.agentPreset === void 0 ? {} : { agentPreset: composition.agentPreset };
14651
15996
  const launchPreset = startup.presetId ?? (process.env.DSH_PI_TUI_PRESET?.trim() || void 0);
15997
+ diag.info("boot", {
15998
+ pid: process.pid,
15999
+ dsh: dshVersion() ?? "unknown",
16000
+ bundle: packageVersion(),
16001
+ cwd: process.cwd(),
16002
+ session: sessionId ?? "(deferred)",
16003
+ preset: launchPreset ?? "default"
16004
+ });
14652
16005
  /** Resolve the launch composition, falling back to the default on an unknown id. */
14653
16006
  const launchComposition = async () => {
14654
16007
  try {
@@ -14656,6 +16009,10 @@ function apply(ctx, config) {
14656
16009
  } catch (error) {
14657
16010
  const message = error instanceof Error ? error.message : String(error);
14658
16011
  ctx.logger.warn(`tui-runner: launch preset unavailable: ${message}`);
16012
+ diag.warn("preset unavailable", {
16013
+ preset: launchPreset ?? "default",
16014
+ error: message
16015
+ });
14659
16016
  return {
14660
16017
  composition: await compose(),
14661
16018
  failure: `preset "${launchPreset}" unavailable; started with the default`
@@ -14672,14 +16029,36 @@ function apply(ctx, config) {
14672
16029
  agentOptions,
14673
16030
  setup: composition.setup
14674
16031
  });
16032
+ diag.info("resume ok", {
16033
+ session: sessionId,
16034
+ seq: handle.agent.session.events.length,
16035
+ preset: recorded ?? "default"
16036
+ });
14675
16037
  if (launchPreset !== void 0 && launchPreset !== recorded) try {
14676
- if ((await recomposeBlank(ctx, handle.agent, launchPreset)).kind === "locked") ctx.logger.warn(`tui-runner: session ${sessionId} has started; its agent preset ${recorded} is fixed, ignoring --preset ${launchPreset}`);
16038
+ if ((await recomposeBlank(ctx, handle.agent, launchPreset)).kind === "locked") {
16039
+ const message = `session ${sessionId} has started; its agent preset ${recorded} is fixed, ignoring --preset ${launchPreset}`;
16040
+ ctx.logger.warn(`tui-runner: ${message}`);
16041
+ diag.warn("preset ignored on resume", {
16042
+ session: sessionId,
16043
+ preset: launchPreset
16044
+ });
16045
+ }
14677
16046
  } catch (error) {
14678
- ctx.logger.warn(`tui-runner: --preset ${launchPreset} not applied on resume: ${error instanceof Error ? error.message : String(error)}`);
16047
+ const message = `--preset ${launchPreset} not applied on resume: ${error instanceof Error ? error.message : String(error)}`;
16048
+ ctx.logger.warn(`tui-runner: ${message}`);
16049
+ diag.warn("preset not applied on resume", {
16050
+ session: sessionId,
16051
+ preset: launchPreset,
16052
+ error: message
16053
+ });
14679
16054
  }
14680
16055
  } catch (error) {
14681
- ctx.logger.warn(`tui-runner: resume ${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
14682
- writeFd2(2, `[tui] resume ${sessionId} failed: ${error instanceof Error ? error.message : String(error)}\n`);
16056
+ const message = error instanceof Error ? error.message : String(error);
16057
+ ctx.logger.warn(`tui-runner: resume ${sessionId} failed: ${message}`);
16058
+ diag.error("resume failed", {
16059
+ session: sessionId,
16060
+ error: message
16061
+ });
14683
16062
  resumeFailure = `session ${sessionId} could not be resumed; started a fresh session`;
14684
16063
  const launched = await launchComposition();
14685
16064
  if (launched.failure !== void 0) resumeFailure = launched.failure;
@@ -14693,52 +16072,86 @@ function apply(ctx, config) {
14693
16072
  setup: launched.composition.setup
14694
16073
  });
14695
16074
  }
14696
- else {
14697
- const launched = await launchComposition();
14698
- if (launched.failure !== void 0) resumeFailure = launched.failure;
14699
- try {
14700
- handle = await agents.create({
14701
- sessionId: SessionId(`session-${randomUUID()}`),
14702
- meta: {
14703
- cwd: process.cwd(),
14704
- ...withPresetMeta(launched.composition)
14705
- },
14706
- agentOptions,
14707
- setup: launched.composition.setup
14708
- });
14709
- } catch (error) {
14710
- ctx.logger.warn(`tui-runner: failed to start with preset "${launchPreset ?? "default"}": ${error instanceof Error ? error.message : String(error)}`);
14711
- resumeFailure = `failed to start with preset "${launchPreset ?? "default"}": ${error instanceof Error ? error.message : String(error)}`;
14712
- const fallback = await compose();
14713
- handle = await agents.create({
14714
- sessionId: SessionId(`session-${randomUUID()}`),
14715
- meta: {
14716
- cwd: process.cwd(),
14717
- ...withPresetMeta(fallback)
14718
- },
14719
- agentOptions,
14720
- setup: fallback.setup
14721
- });
14722
- }
14723
- }
14724
16075
  let liveHandle = handle;
14725
- let liveAgent = handle.agent;
14726
- await liveAgent.whenIdle();
16076
+ let liveAgent = handle?.agent;
16077
+ if (liveAgent !== void 0) await liveAgent.whenIdle();
16078
+ let guardState = freshGuardState();
16079
+ let guardForced = false;
16080
+ /**
16081
+ * Run the divergence guard before a session-writing submission. Returns
16082
+ * 'ok' to proceed, 'blocked' to refuse (the caller restores the draft),
16083
+ * 'forced' when the user overrode a still-bad state, and 'unavailable'
16084
+ * when the deployment cannot guard (proceed).
16085
+ */
16086
+ const guardSend = async () => {
16087
+ if (liveAgent === void 0) return "ok";
16088
+ const session = {
16089
+ id: liveAgent.session.id,
16090
+ header: liveAgent.session.header,
16091
+ events: liveAgent.session.events
16092
+ };
16093
+ const outcome = await checkDivergence(ctx.get("sessionPersistence"), session, (path) => statSync(path), guardState);
16094
+ switch (outcome.kind) {
16095
+ case "ok":
16096
+ guardForced = false;
16097
+ diag.debug("guard ok", {
16098
+ session: session.id,
16099
+ fileEvents: outcome.fileEvents ?? 0,
16100
+ memoryEvents: session.events.length
16101
+ });
16102
+ return "ok";
16103
+ case "diverged":
16104
+ diag.warn("guard diverged", {
16105
+ session: session.id,
16106
+ fileEvents: outcome.fileEvents,
16107
+ memoryEvents: outcome.memoryEvents
16108
+ });
16109
+ if (guardForced) {
16110
+ guardForced = false;
16111
+ return "forced";
16112
+ }
16113
+ return "blocked";
16114
+ case "unreadable":
16115
+ diag.error("guard unreadable", {
16116
+ session: session.id,
16117
+ error: outcome.error
16118
+ });
16119
+ if (guardForced) {
16120
+ guardForced = false;
16121
+ return "forced";
16122
+ }
16123
+ return "blocked";
16124
+ case "unavailable":
16125
+ guardForced = false;
16126
+ return "ok";
16127
+ }
16128
+ };
14727
16129
  /** The preset the live agent runs on, when the deployment composes one. */
14728
- const currentPreset = () => ctx.get("agentPresets")?.composedPreset(liveAgent.ctx) ?? resolveSessionPreset(liveAgent.session);
16130
+ const currentPreset = () => {
16131
+ if (liveAgent === void 0) return void 0;
16132
+ return ctx.get("agentPresets")?.composedPreset(liveAgent.ctx) ?? resolveSessionPreset(liveAgent.session);
16133
+ };
14729
16134
  ctx.effect(function* () {
14730
16135
  yield () => {
14731
16136
  app?.stop();
16137
+ diag.dispose();
14732
16138
  };
14733
16139
  });
14734
16140
  let folder = new TranscriptFolder();
14735
- folder.apply(liveAgent.session.events);
14736
16141
  let statsFolder = new StatsFolder();
14737
- statsFolder.apply(liveAgent.session.events);
14738
- let goalText = foldGoal(liveAgent.session.events);
16142
+ let goalText;
16143
+ if (liveAgent !== void 0) {
16144
+ folder.apply(liveAgent.session.events);
16145
+ statsFolder.apply(liveAgent.session.events);
16146
+ goalText = foldGoal(liveAgent.session.events);
16147
+ }
14739
16148
  /** Repaint the welcome card from the live agent's current facts. Re-read
14740
16149
  * on every call so a still-blank session's preset switch shows up. */
14741
16150
  const updateWelcomeCard = () => {
16151
+ if (liveAgent === void 0) {
16152
+ app.setWelcomeIdle(true);
16153
+ return;
16154
+ }
14742
16155
  app.setWelcomeCard({
14743
16156
  cwd,
14744
16157
  sessionId: liveAgent.session.id,
@@ -14749,28 +16162,31 @@ function apply(ctx, config) {
14749
16162
  };
14750
16163
  /** Swap the live agent to a new handle, repainting for its session. */
14751
16164
  const swapTo = async (next) => {
16165
+ const from = liveAgent?.session.id;
14752
16166
  try {
14753
- await sessions.flush(liveAgent.session);
14754
- await liveHandle.dispose();
16167
+ if (liveAgent !== void 0) {
16168
+ await sessions.flush(liveAgent.session);
16169
+ await liveHandle?.dispose();
16170
+ }
14755
16171
  liveHandle = next;
14756
16172
  liveAgent = next.agent;
14757
16173
  await liveAgent.whenIdle();
14758
16174
  } catch (error) {
14759
- process.stderr.write(`[tui] swap failed: ${error instanceof Error ? error.message : String(error)}\n`);
14760
- return `swap failed: ${error instanceof Error ? error.message : String(error)}`;
16175
+ const message = error instanceof Error ? error.message : String(error);
16176
+ diag.error("swap failed", {
16177
+ from,
16178
+ error: message
16179
+ });
16180
+ return `swap failed: ${message}`;
14761
16181
  }
14762
- folder = new TranscriptFolder();
14763
- folder.apply(liveAgent.session.events);
14764
- statsFolder = new StatsFolder();
14765
- statsFolder.apply(liveAgent.session.events);
14766
- goalText = foldGoal(liveAgent.session.events);
14767
- app.setWorking(workingFromLog(liveAgent.session.events));
14768
- app.setSessionTitle(foldSessionTitle(liveAgent.session.events)?.title);
14769
- app.clearLocalMessages();
14770
- repaint(app, folder);
14771
- refreshStatus();
14772
- setTerminalTitle(`dsh-pi-tui · ${shortCwd(cwd)} · ${liveAgent.session.id}`);
14773
- updateWelcomeCard();
16182
+ guardState = freshGuardState();
16183
+ guardForced = false;
16184
+ await initLiveSession(next.agent);
16185
+ diag.info("switch ok", {
16186
+ from: from ?? "(none)",
16187
+ to: next.agent.session.id,
16188
+ seq: next.agent.session.events.length
16189
+ });
14774
16190
  };
14775
16191
  /** Hand the TUI over to another persisted session. Never throws: every
14776
16192
  * failure (unknown session, broken log, preset mount) returns an error
@@ -14781,31 +16197,38 @@ function apply(ctx, config) {
14781
16197
  const next = await agents.resume({
14782
16198
  resumeSessionId: SessionId(sessionId),
14783
16199
  agentOptions: {
14784
- provider: liveAgent.options.provider,
14785
- model: liveAgent.options.model
16200
+ provider: liveAgent?.options.provider ?? selection.provider,
16201
+ model: liveAgent?.options.model ?? selection.model
14786
16202
  },
14787
16203
  setup: composition.setup
14788
16204
  });
14789
16205
  return swapTo(next);
14790
16206
  } catch (error) {
14791
- ctx.logger.warn(`tui-runner: switch to ${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
14792
- return `switch failed: ${error instanceof Error ? error.message : String(error)}`;
16207
+ const message = error instanceof Error ? error.message : String(error);
16208
+ ctx.logger.warn(`tui-runner: switch to ${sessionId} failed: ${message}`);
16209
+ diag.error("switch failed", {
16210
+ session: sessionId,
16211
+ error: message
16212
+ });
16213
+ return `switch failed: ${message}`;
14793
16214
  }
14794
16215
  };
14795
16216
  const cwd = process.cwd();
14796
16217
  /** The footer model label: the live selection (with effort) when one exists. */
14797
16218
  const modelLabel = () => {
14798
16219
  const selection = selected.current;
14799
- if (selection === void 0) return `${liveAgent.options.provider}/${liveAgent.options.model}`;
14800
- return selection.reasoningEffort === void 0 ? `${selection.provider}/${selection.model}` : `${selection.provider}/${selection.model} @${selection.reasoningEffort}`;
16220
+ if (selection !== void 0) return selection.reasoningEffort === void 0 ? `${selection.provider}/${selection.model}` : `${selection.provider}/${selection.model} @${selection.reasoningEffort}`;
16221
+ if (liveAgent === void 0) return "no model";
16222
+ return `${liveAgent.options.provider}/${liveAgent.options.model}`;
14801
16223
  };
14802
16224
  const refreshStatus = () => {
14803
16225
  const stats = statsFolder.snapshot();
14804
16226
  let contextTokens;
14805
16227
  const meter = ctx.get("tokenMeter");
14806
- if (meter !== void 0) try {
16228
+ if (meter !== void 0 && liveAgent !== void 0) try {
14807
16229
  contextTokens = meter.measure(liveAgent.session).totalTokens;
14808
16230
  } catch {}
16231
+ const permission = ctx.get("permissionPresets");
14809
16232
  app.setStatus({
14810
16233
  model: modelLabel(),
14811
16234
  cwd: shortCwd(cwd),
@@ -14814,6 +16237,7 @@ function apply(ctx, config) {
14814
16237
  turns: stats.turns,
14815
16238
  steps: stats.steps,
14816
16239
  statsLine: formatStats(stats),
16240
+ ...permission === void 0 || liveAgent === void 0 ? {} : { permission: permission.current(liveAgent.session.events) },
14817
16241
  ...contextTokens !== void 0 ? {
14818
16242
  contextTokens,
14819
16243
  contextWindow: stats.contextWindow
@@ -14822,7 +16246,10 @@ function apply(ctx, config) {
14822
16246
  };
14823
16247
  let app;
14824
16248
  const tools = ctx.get("tools");
14825
- const present = toolPresenterFrom((name) => tools?.get(name, liveAgent.ctx));
16249
+ const present = toolPresenterFrom((name) => {
16250
+ if (liveAgent === void 0) return void 0;
16251
+ return tools?.get(name, liveAgent);
16252
+ });
14826
16253
  const signal = new AbortController().signal;
14827
16254
  let localShellController;
14828
16255
  /** Run a `!` command locally; the output renders as a local card. */
@@ -14831,6 +16258,7 @@ function apply(ctx, config) {
14831
16258
  const command = text.replace(/^!+/, "").trim();
14832
16259
  if (command === "") return;
14833
16260
  if (includeInContext) {
16261
+ if (liveAgent === void 0) return;
14834
16262
  liveAgent.followup(createUserMessage({
14835
16263
  content: [{
14836
16264
  type: "text",
@@ -14966,37 +16394,45 @@ function apply(ctx, config) {
14966
16394
  folder: childFolder
14967
16395
  };
14968
16396
  repaint(app, childFolder);
14969
- app.notify(`viewing subagent ${label ?? childId} — Esc returns`);
16397
+ app.notify(`viewing subagent ${label ?? childId} — Esc returns`, "info");
14970
16398
  };
14971
16399
  /** Leave the subagent viewer (single Esc). Returns whether it exited. */
14972
16400
  const exitView = () => {
14973
16401
  if (viewing === void 0) return false;
14974
16402
  viewing = void 0;
14975
16403
  app.clearLocalMessages();
16404
+ app.clearNotify();
14976
16405
  repaint(app, folder);
14977
16406
  refreshStatus();
14978
16407
  return true;
14979
16408
  };
14980
- app = startProcessTui({
14981
- onSubmit: (text) => {
14982
- const history = app.getInputHistory();
14983
- if (history.length > 0) tuiSettings?.replace({
14984
- ...tuiSettings.get(),
14985
- history: {
14986
- ...tuiSettings.get().history,
14987
- [cwd]: history
14988
- }
14989
- });
14990
- if (text.startsWith("!")) {
14991
- runLocalShell(text);
16409
+ /** Error sink for a failed session creation: restore the draft and
16410
+ * surface the reason instead of silently dropping the submission. */
16411
+ const failSubmission = (draft) => (error) => {
16412
+ const message = error instanceof Error ? error.message : String(error);
16413
+ ctx.logger.error(`tui-runner: session creation failed: ${message}`);
16414
+ diag.error("session creation failed", { error: message });
16415
+ app.setEditorText(draft);
16416
+ app.notify(`could not start a session: ${message}`, "error");
16417
+ };
16418
+ /** The session-backed dispatch: create the session lazily (the first
16419
+ * user input is the deferred trigger), guard against cross-process
16420
+ * divergence, then execute a registered slash command or follow up. */
16421
+ const dispatchViaSession = (text) => {
16422
+ ensureSession().then(async () => {
16423
+ const verdict = await guardSend();
16424
+ if (verdict === "blocked") {
16425
+ app.setEditorText(text);
16426
+ app.notify(GUARD_BLOCKED_NOTIFY, "error");
14992
16427
  return;
14993
16428
  }
16429
+ if (verdict === "forced") app.notify(GUARD_FORCED_NOTIFY, "error");
14994
16430
  const commands = ctx.get("commands");
14995
16431
  if (commands !== void 0) {
14996
16432
  const parsed = parseCommand(text);
14997
- const toggled = parsed?.name === "plan" && parsed.rawInput.trim() === "" && foldPlanMode(liveAgent.session.events) ? "/plan off" : text;
16433
+ const toggled = parsed?.name === "plan" && parsed.rawInput.trim() === "" && foldPlanMode(liveAgent?.session.events ?? []) ? "/plan off" : text;
14998
16434
  commands.execute(liveAgent, toggled, signal).then((execution) => {
14999
- if (execution === void 0) liveAgent.followup(createUserMessage({
16435
+ if (execution === void 0 && liveAgent !== void 0) liveAgent.followup(createUserMessage({
15000
16436
  content: [{
15001
16437
  type: "text",
15002
16438
  text
@@ -15004,40 +16440,142 @@ function apply(ctx, config) {
15004
16440
  source: { kind: "user" }
15005
16441
  }));
15006
16442
  }).catch((error) => {
15007
- ctx.logger.error(`tui-runner: command execution failed: ${error instanceof Error ? error.message : String(error)}`);
15008
- app.notify(error instanceof Error ? error.message : String(error));
16443
+ const message = error instanceof Error ? error.message : String(error);
16444
+ ctx.logger.error(`tui-runner: command execution failed: ${message}`);
16445
+ diag.error("command execution failed", { error: message });
16446
+ app.notify(message, "error");
15009
16447
  });
15010
16448
  return;
15011
16449
  }
15012
- liveAgent.followup(createUserMessage({
16450
+ liveAgent?.followup(createUserMessage({
15013
16451
  content: [{
15014
16452
  type: "text",
15015
16453
  text
15016
16454
  }],
15017
16455
  source: { kind: "user" }
15018
16456
  }));
16457
+ }).catch(failSubmission(text));
16458
+ };
16459
+ /**
16460
+ * Run a sessionless slash command locally — no session, no log, no
16461
+ * persistence. The handler comes from the commands service's global
16462
+ * layer (in-process lookup with no agent is safe: it reads the global
16463
+ * layer only). A sessionless command that failed to register falls back
16464
+ * to the session dispatch, which reports unknown commands as messages.
16465
+ */
16466
+ const runLocalCommand = (parsed, text) => {
16467
+ const commands = ctx.get("commands");
16468
+ const definition = commands?.find(void 0, parsed.name);
16469
+ if (commands === void 0 || definition === void 0) {
16470
+ dispatchViaSession(text);
16471
+ return;
16472
+ }
16473
+ const invocation = {
16474
+ commandId: `cmd-local-${randomUUID()}`,
16475
+ agent: void 0,
16476
+ rawInput: parsed.rawInput,
16477
+ signal
16478
+ };
16479
+ Promise.resolve(definition.handler(invocation)).then((result) => {
16480
+ if (result !== void 0 && result.kind === "error") app.notify(result.text);
16481
+ }).catch((error) => {
16482
+ const message = error instanceof Error ? error.message : String(error);
16483
+ ctx.logger.error(`tui-runner: local command failed: ${message}`);
16484
+ diag.error("local command failed", { error: message });
16485
+ app.notify(message, "error");
16486
+ });
16487
+ };
16488
+ app = startProcessTui({
16489
+ onSubmit: (text) => {
16490
+ const history = app.getInputHistory();
16491
+ if (history.length > 0) tuiSettings?.replace({
16492
+ ...tuiSettings.get(),
16493
+ history: {
16494
+ ...tuiSettings.get().history,
16495
+ [cwd]: history
16496
+ }
16497
+ });
16498
+ if (text.startsWith("!")) {
16499
+ if (text.startsWith("!!")) ensureSession().then(() => runLocalShell(text)).catch(failSubmission(text));
16500
+ else runLocalShell(text);
16501
+ return;
16502
+ }
16503
+ const parsed = parseCommand(text);
16504
+ if (parsed !== void 0 && liveAgent === void 0 && SESSIONLESS_COMMANDS.has(parsed.name)) {
16505
+ runLocalCommand(parsed, text);
16506
+ return;
16507
+ }
16508
+ dispatchViaSession(text);
15019
16509
  },
15020
16510
  onExit: () => {
15021
16511
  (async () => {
15022
- await sessions.flush(liveAgent.session);
16512
+ if (liveAgent !== void 0) {
16513
+ const started = Date.now();
16514
+ try {
16515
+ await sessions.flush(liveAgent.session);
16516
+ diag.info("flush ok", {
16517
+ session: liveAgent.session.id,
16518
+ events: liveAgent.session.events.length,
16519
+ tookMs: Date.now() - started
16520
+ });
16521
+ } catch (error) {
16522
+ diag.error("flush failed", {
16523
+ session: liveAgent.session.id,
16524
+ error: error instanceof Error ? error.message : String(error)
16525
+ });
16526
+ }
16527
+ }
16528
+ diag.info("exit", { code: 0 });
16529
+ diag.dispose();
15023
16530
  app.stop();
16531
+ const resume = resumeCommand(runningProfile(), liveAgent?.session.id ?? "");
16532
+ if (resume !== void 0) process.stdout.write(`\n${color.textDim("To resume this session:")} ${resume}\n`);
15024
16533
  exit(0);
15025
16534
  })();
15026
16535
  },
15027
16536
  onCancel: () => {
15028
16537
  localShellController?.abort();
15029
- liveAgent.cancel({ kind: "user" });
16538
+ liveAgent?.cancel({ kind: "user" });
15030
16539
  },
15031
16540
  onSteer: (text) => {
15032
- const message = createUserMessage({
15033
- content: [{
15034
- type: "text",
15035
- text
15036
- }],
15037
- source: { kind: "user" }
15038
- });
15039
- if (liveAgent.status === "running") liveAgent.steer(message);
15040
- else liveAgent.followup(message);
16541
+ if (liveAgent !== void 0) {
16542
+ if ([...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].length === 0 && text.trim() === "") return;
16543
+ } else if (text.trim() === "") return;
16544
+ ensureSession().then(async () => {
16545
+ if (liveAgent === void 0) return;
16546
+ const verdict = await guardSend();
16547
+ if (verdict === "blocked") {
16548
+ app.setEditorText(text);
16549
+ app.notify(GUARD_BLOCKED_NOTIFY, "error");
16550
+ return;
16551
+ }
16552
+ const forced = verdict === "forced";
16553
+ if (forced) app.notify(GUARD_FORCED_NOTIFY, "error");
16554
+ const queued = [...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep];
16555
+ if (queued.length > 0) {
16556
+ const draft = text.trim();
16557
+ const messages = [...queued, ...draft === "" ? [] : [createUserMessage({
16558
+ content: [{
16559
+ type: "text",
16560
+ text: draft
16561
+ }],
16562
+ source: { kind: "user" }
16563
+ })]];
16564
+ liveAgent.inbox.clear();
16565
+ for (const message of messages) liveAgent.steer(message);
16566
+ if (!forced) app.notify(messages.length === 1 ? "steering 1 message" : `steering ${messages.length} messages`, "info");
16567
+ } else {
16568
+ const message = createUserMessage({
16569
+ content: [{
16570
+ type: "text",
16571
+ text
16572
+ }],
16573
+ source: { kind: "user" }
16574
+ });
16575
+ if (liveAgent.status === "running") liveAgent.steer(message);
16576
+ else liveAgent.followup(message);
16577
+ }
16578
+ }).catch(failSubmission(text));
15041
16579
  },
15042
16580
  openExternalEditor: async (draft) => {
15043
16581
  const editor = process.env.VISUAL ?? process.env.EDITOR ?? "vi";
@@ -15084,15 +16622,34 @@ function apply(ctx, config) {
15084
16622
  searchCurrent = -1;
15085
16623
  repaint(app, activeFolder());
15086
16624
  },
15087
- onSingleEscape: () => exitView()
16625
+ onSingleEscape: () => exitView(),
16626
+ onCyclePermission: () => {
16627
+ if (liveAgent === void 0) return;
16628
+ const permission = ctx.get("permissionPresets");
16629
+ if (permission === void 0) return;
16630
+ const names = permission.names;
16631
+ if (names.length === 0) return;
16632
+ const current = permission.current(liveAgent.session.events);
16633
+ const next = names[(names.indexOf(current) + 1) % names.length] ?? names[0];
16634
+ if (next === void 0 || next === current) return;
16635
+ permission.set(liveAgent.session, next);
16636
+ app.notify(next === "danger-full-access" ? `⚠ ${next} — no approvals` : `permission: ${next}`, next === "danger-full-access" ? "error" : "info");
16637
+ refreshStatus();
16638
+ },
16639
+ onDequeue: () => {
16640
+ if (liveAgent === void 0) return;
16641
+ const queued = [...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep];
16642
+ if (queued.length === 0) return;
16643
+ liveAgent.inbox.clear();
16644
+ const queuedText = queued.map((message) => textOf(message.content)).join("\n\n");
16645
+ const current = app.getDraft();
16646
+ app.setDraft([queuedText, current].filter((part) => part.trim() !== "").join("\n\n"));
16647
+ refreshQueue();
16648
+ }
15088
16649
  }, {
15089
16650
  present,
15090
16651
  workspaceRoot: cwd
15091
16652
  });
15092
- paintNow();
15093
- setTerminalTitle(`dsh-pi-tui · ${shortCwd(cwd)} · ${liveAgent.session.id}`);
15094
- updateWelcomeCard();
15095
- if (resumeFailure !== void 0) app.notify(resumeFailure);
15096
16653
  const tuiSettings = ctx.get("settings")?.register(settingsNamespace("dsh-pi-tui"), z.object({
15097
16654
  theme: z.string(),
15098
16655
  footer: z.string(),
@@ -15119,12 +16676,125 @@ function apply(ctx, config) {
15119
16676
  if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
15120
16677
  const storedHistory = tuiSettings?.get().history[cwd];
15121
16678
  if (storedHistory !== void 0 && storedHistory.length > 0) app.seedInputHistory(storedHistory);
15122
- if (ctx.get("commands") !== void 0) registerTuiCommands({
16679
+ refreshStatus();
16680
+ const jobs = ctx.get("jobs");
16681
+ if (jobs !== void 0) {
16682
+ const refreshTasks = () => {
16683
+ let tasks = [];
16684
+ try {
16685
+ tasks = jobs.list(liveAgent).filter((job) => job.status === "running" || job.status === "stopping").map((job) => ({
16686
+ label: job.label,
16687
+ status: job.status
16688
+ }));
16689
+ } catch {}
16690
+ app.setTasks(tasks);
16691
+ };
16692
+ jobs.onJobsChanged(() => refreshTasks());
16693
+ refreshTasks();
16694
+ }
16695
+ const refreshQueue = () => {
16696
+ if (liveAgent === void 0) {
16697
+ app.setQueueItems([]);
16698
+ return;
16699
+ }
16700
+ const queue = [...liveAgent.inbox.nextTurn.map((message) => ({
16701
+ id: message.id,
16702
+ text: textOf(message.content),
16703
+ mode: "followup"
16704
+ })), ...liveAgent.inbox.nextStep.map((message) => ({
16705
+ id: message.id,
16706
+ text: textOf(message.content),
16707
+ mode: "steer"
16708
+ }))];
16709
+ app.setQueueItems(queue);
16710
+ };
16711
+ refreshQueue();
16712
+ /** Rebuild every live-session surface after resume, create, or swap. */
16713
+ const initLiveSession = async (agent) => {
16714
+ folder = new TranscriptFolder();
16715
+ folder.apply(agent.session.events);
16716
+ statsFolder = new StatsFolder();
16717
+ statsFolder.apply(agent.session.events);
16718
+ goalText = foldGoal(agent.session.events);
16719
+ app.setWorking(workingFromLog(agent.session.events));
16720
+ app.setSessionTitle(foldSessionTitle(agent.session.events)?.title);
16721
+ app.clearLocalMessages();
16722
+ app.clearNotify();
16723
+ repaint(app, folder);
16724
+ refreshStatus();
16725
+ refreshQueue();
16726
+ setTerminalTitle(`dsh-pi-tui · ${shortCwd(cwd)} · ${agent.session.id}`);
16727
+ updateWelcomeCard();
16728
+ registerCommands();
16729
+ refreshSkillCommands();
16730
+ };
16731
+ /**
16732
+ * Create the first session lazily — the FIRST user message triggers it
16733
+ * (deferred session creation). Opening the TUI with no --session carries
16734
+ * zero session side-effects: no agent, no log, no persistence.
16735
+ */
16736
+ let creating;
16737
+ const ensureSession = async () => {
16738
+ if (liveAgent !== void 0) return;
16739
+ if (creating !== void 0) return creating;
16740
+ creating = (async () => {
16741
+ const launched = await launchComposition();
16742
+ if (launched.failure !== void 0) resumeFailure = launched.failure;
16743
+ try {
16744
+ const created = await agents.create({
16745
+ sessionId: SessionId(`session-${randomUUID()}`),
16746
+ meta: {
16747
+ cwd: process.cwd(),
16748
+ ...withPresetMeta(launched.composition)
16749
+ },
16750
+ agentOptions,
16751
+ setup: launched.composition.setup
16752
+ });
16753
+ liveHandle = created;
16754
+ liveAgent = created.agent;
16755
+ await liveAgent.whenIdle();
16756
+ await initLiveSession(liveAgent);
16757
+ } catch (error) {
16758
+ const message = error instanceof Error ? error.message : String(error);
16759
+ ctx.logger.warn(`tui-runner: failed to start with preset "${launchPreset ?? "default"}": ${message}`);
16760
+ diag.warn("preset mount failed", {
16761
+ preset: launchPreset ?? "default",
16762
+ error: message
16763
+ });
16764
+ resumeFailure = `failed to start with preset "${launchPreset ?? "default"}": ${message}`;
16765
+ const fallback = await compose();
16766
+ const created = await agents.create({
16767
+ sessionId: SessionId(`session-${randomUUID()}`),
16768
+ meta: {
16769
+ cwd: process.cwd(),
16770
+ ...withPresetMeta(fallback)
16771
+ },
16772
+ agentOptions,
16773
+ setup: fallback.setup
16774
+ });
16775
+ liveHandle = created;
16776
+ liveAgent = created.agent;
16777
+ await liveAgent.whenIdle();
16778
+ await initLiveSession(liveAgent);
16779
+ }
16780
+ if (resumeFailure !== void 0) {
16781
+ app.notify(resumeFailure, "error");
16782
+ resumeFailure = void 0;
16783
+ }
16784
+ })().finally(() => {
16785
+ creating = void 0;
16786
+ });
16787
+ return creating;
16788
+ };
16789
+ let commandsRegistered = false;
16790
+ let refreshSkills;
16791
+ const runner = {
15123
16792
  ctx,
15124
16793
  app,
15125
16794
  get liveAgent() {
15126
16795
  return liveAgent;
15127
16796
  },
16797
+ ensureSession,
15128
16798
  get selected() {
15129
16799
  return selected;
15130
16800
  },
@@ -15144,9 +16814,43 @@ function apply(ctx, config) {
15144
16814
  updateWelcomeCard,
15145
16815
  enterView,
15146
16816
  exit
15147
- });
15148
- refreshStatus();
16817
+ };
16818
+ const registerCommands = () => {
16819
+ if (commandsRegistered) return;
16820
+ if (ctx.get("commands") === void 0) return;
16821
+ commandsRegistered = true;
16822
+ try {
16823
+ refreshSkills = registerTuiCommands(runner).refreshSkills;
16824
+ } catch (error) {
16825
+ commandsRegistered = false;
16826
+ const message = error instanceof Error ? error.message : String(error);
16827
+ ctx.logger.error(`tui-runner: command registration failed: ${message}`);
16828
+ diag.error("command registration failed", { error: message });
16829
+ app.notify(`command registration failed: ${message}`, "error");
16830
+ }
16831
+ };
16832
+ /** Rebuild the agent-scoped per-skill commands after a session exists. */
16833
+ const refreshSkillCommands = () => {
16834
+ if (refreshSkills === void 0) return;
16835
+ refreshSkills().catch((error) => {
16836
+ const message = error instanceof Error ? error.message : String(error);
16837
+ ctx.logger.warn(`tui-runner: skill command refresh failed: ${message}`);
16838
+ diag.warn("skill command refresh failed", { error: message });
16839
+ });
16840
+ };
16841
+ if (liveAgent !== void 0) await initLiveSession(liveAgent);
16842
+ else {
16843
+ app.setWelcomeIdle(true);
16844
+ refreshStatus();
16845
+ setTerminalTitle(`dsh-pi-tui · ${shortCwd(cwd)}`);
16846
+ }
16847
+ registerCommands();
16848
+ if (resumeFailure !== void 0) {
16849
+ app.notify(resumeFailure, "error");
16850
+ resumeFailure = void 0;
16851
+ }
15149
16852
  ctx.on("session/event", (session, event) => {
16853
+ if (liveAgent === void 0) return;
15150
16854
  if (viewing !== void 0) {
15151
16855
  if (session.id !== viewing.id) return;
15152
16856
  viewing.folder.apply([event]);
@@ -15160,10 +16864,13 @@ function apply(ctx, config) {
15160
16864
  folder.apply([event]);
15161
16865
  statsFolder.apply([event]);
15162
16866
  if (event.type === "goal/change") goalText = foldGoal([event]);
15163
- schedulePaint();
16867
+ const isKnob = event.type === "permission/preset" || event.type === "approval/policy" || event.type === "sandbox/mode";
16868
+ if (!isKnob) schedulePaint();
15164
16869
  if (event.type === "todo/write") app.setTodoSummary(event.data.todos);
15165
16870
  if (event.type === "plan/mode") app.setPlanMode(event.data.active);
15166
16871
  if (event.type === "session/title") app.setSessionTitle(foldSessionTitle([event])?.title);
16872
+ if (isKnob) refreshStatus();
16873
+ if (event.type === "agent/inbox/spliced") queueMicrotask(refreshQueue);
15167
16874
  if (event.type === "turn/start") app.setWorking(true);
15168
16875
  else if (event.type === "turn/end") {
15169
16876
  app.setWorking(false);
@@ -15172,14 +16879,16 @@ function apply(ctx, config) {
15172
16879
  sessions.flush(liveAgent.session);
15173
16880
  } else if (event.type === "step/start") refreshStatus();
15174
16881
  });
15175
- app.setPlanMode(foldPlanMode(liveAgent.session.events));
15176
- app.setWorking(workingFromLog(liveAgent.session.events));
15177
- app.setSessionTitle(foldSessionTitle(liveAgent.session.events)?.title);
15178
- for (let index = liveAgent.session.events.length - 1; index >= 0; index -= 1) {
15179
- const event = liveAgent.session.events[index];
15180
- if (event.type === "todo/write") {
15181
- app.setTodoSummary(event.data.todos);
15182
- break;
16882
+ if (liveAgent !== void 0) {
16883
+ app.setPlanMode(foldPlanMode(liveAgent.session.events));
16884
+ app.setWorking(workingFromLog(liveAgent.session.events));
16885
+ app.setSessionTitle(foldSessionTitle(liveAgent.session.events)?.title);
16886
+ for (let index = liveAgent.session.events.length - 1; index >= 0; index -= 1) {
16887
+ const event = liveAgent.session.events[index];
16888
+ if (event.type === "todo/write") {
16889
+ app.setTodoSummary(event.data.todos);
16890
+ break;
16891
+ }
15183
16892
  }
15184
16893
  }
15185
16894
  ctx.on("approval/request", (req, next) => {
@@ -15199,8 +16908,10 @@ function apply(ctx, config) {
15199
16908
  id: question.id,
15200
16909
  question: question.question,
15201
16910
  ...question.header !== void 0 ? { header: question.header } : {},
16911
+ ...question.detail !== void 0 ? { detail: question.detail } : {},
15202
16912
  ...question.options !== void 0 ? { options: question.options } : {},
15203
- ...question.multiSelect !== void 0 ? { multiSelect: question.multiSelect } : {}
16913
+ ...question.multiSelect !== void 0 ? { multiSelect: question.multiSelect } : {},
16914
+ ...question.intent !== void 0 ? { intent: question.intent } : {}
15204
16915
  })), request.signal)).map((answer) => ({
15205
16916
  id: answer.id,
15206
16917
  selected: answer.selected,
@@ -15208,9 +16919,12 @@ function apply(ctx, config) {
15208
16919
  })) };
15209
16920
  } });
15210
16921
  })().catch((error) => {
15211
- ctx.logger.error(`tui-runner: ${error instanceof Error ? error.message : String(error)}`);
16922
+ const message = error instanceof Error ? error.message : String(error);
16923
+ ctx.logger.error(`tui-runner: ${message}`);
16924
+ diag.error("fatal", { error: message });
16925
+ diag.dispose();
15212
16926
  exit(1);
15213
16927
  });
15214
16928
  }
15215
16929
  //#endregion
15216
- export { Config, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset };
16930
+ export { Config, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resumeCommand, runningProfile };