@xmoon76/dsh-pi-tui 0.2.1 → 0.2.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.
@@ -85,8 +85,121 @@ const TUI_TOOL_TITLES = {
85
85
  "workflow-member": "Workflow Agent",
86
86
  error: "Error",
87
87
  interrupted: "Interrupted",
88
- ask_user_question: "Question"
88
+ ask_user_question: "Question",
89
+ get_goal: "Read Goal",
90
+ create_goal: "Create Goal",
91
+ update_goal: "Update Goal"
89
92
  };
93
+ /** The goal tool family (all three share the `{"goal":…}` result shape). */
94
+ const GOAL_TOOL_NAMES = /* @__PURE__ */ new Set([
95
+ "get_goal",
96
+ "create_goal",
97
+ "update_goal"
98
+ ]);
99
+ /**
100
+ * Tools whose folded result preview must never leak the raw result JSON
101
+ * (web parity: the web's folded row shows only the args summary, never the
102
+ * result). The TUI keeps its result-preview row but shows a parsed summary
103
+ * instead — and nothing at all when the result cannot be parsed.
104
+ *
105
+ * `ralph`'s render already leads with a friendly line ("Ralph worker
106
+ * reported completion after N rounds.") before the JSON report, so its
107
+ * summary IS that first line. The agent-team family is deliberately NOT in
108
+ * the set: it is experimental (not in the production bundle) and two of its
109
+ * tools (`send_message`, `interrupt_agent`) share names with subagent tools
110
+ * whose render text is friendly — a name-keyed set cannot tell them apart.
111
+ */
112
+ const FOLDED_JSON_RESULT_TOOLS = /* @__PURE__ */ new Set([
113
+ "ralph",
114
+ "schedule_create",
115
+ "schedule_list",
116
+ "schedule_delete",
117
+ "cordis_inspect_list",
118
+ "cordis_inspect_query",
119
+ "cordis_inspect_self"
120
+ ]);
121
+ /** Parse a result text as JSON, tolerating a friendly prefix line before
122
+ * the JSON document (e.g. ralph's "…\nFinal report:\n{…}"). */
123
+ function parseJsonValue(text) {
124
+ try {
125
+ return JSON.parse(text);
126
+ } catch {}
127
+ const brace = text.indexOf("{");
128
+ const bracket = text.indexOf("[");
129
+ const start = bracket !== -1 && (brace === -1 || bracket < brace) ? bracket : brace;
130
+ if (start > 0) try {
131
+ return JSON.parse(text.slice(start));
132
+ } catch {
133
+ return;
134
+ }
135
+ }
136
+ /** A non-empty string field, or undefined. */
137
+ function stringField(value) {
138
+ return typeof value === "string" && value !== "" ? value : void 0;
139
+ }
140
+ /**
141
+ * The folded preview summary for one of {@link FOLDED_JSON_RESULT_TOOLS}:
142
+ * a short human phrase derived from the parsed result JSON. Returns
143
+ * undefined when nothing useful can be derived — the caller then shows NO
144
+ * folded result preview (web parity), never the raw JSON.
145
+ * @param name - the tool name.
146
+ * @param text - the settled result text.
147
+ */
148
+ function foldedResultSummaryFor(name, text) {
149
+ if (name === "ralph") {
150
+ const line = firstLine(text);
151
+ if (line !== "" && !line.startsWith("{")) return line;
152
+ const parsed = parseJsonValue(text);
153
+ if (typeof parsed === "object" && parsed !== null) {
154
+ const record = parsed;
155
+ const status = stringField(record.status);
156
+ const rounds = typeof record.roundsStarted === "number" ? String(record.roundsStarted) : void 0;
157
+ const parts = [status, rounds === void 0 ? void 0 : `${rounds} rounds`].filter((part) => part !== void 0);
158
+ return parts.length === 0 ? void 0 : parts.join(" · ");
159
+ }
160
+ return;
161
+ }
162
+ const parsed = parseJsonValue(text);
163
+ if (name === "schedule_list") {
164
+ if (Array.isArray(parsed)) return parsed.length === 0 ? "no scheduled jobs" : `${parsed.length} scheduled`;
165
+ if (typeof parsed === "object" && parsed !== null) return stringField(parsed.code);
166
+ return;
167
+ }
168
+ if (name === "schedule_create") {
169
+ if (typeof parsed !== "object" || parsed === null) return void 0;
170
+ const record = parsed;
171
+ const parts = [stringField(record.kind), stringField(record.state)].filter((part) => part !== void 0);
172
+ return parts.length === 0 ? void 0 : parts.join(" · ");
173
+ }
174
+ if (name === "schedule_delete") {
175
+ if (typeof parsed !== "object" || parsed === null) return void 0;
176
+ const record = parsed;
177
+ if (record.deleted === true) return "deleted";
178
+ if (record.deleted === false) return "not found";
179
+ return stringField(record.code);
180
+ }
181
+ if (name === "cordis_inspect_list") {
182
+ if (typeof parsed !== "object" || parsed === null) return void 0;
183
+ const providers = parsed.providers;
184
+ if (Array.isArray(providers)) return providers.length === 0 ? "no providers" : `${providers.length} providers`;
185
+ return;
186
+ }
187
+ if (name === "cordis_inspect_query") {
188
+ if (typeof parsed !== "object" || parsed === null) return void 0;
189
+ const record = parsed;
190
+ const parts = [
191
+ stringField(record.platform),
192
+ stringField(record.provider),
193
+ stringField(record.method)
194
+ ].filter((part) => part !== void 0);
195
+ return parts.length === 0 ? void 0 : parts.join(" · ");
196
+ }
197
+ if (name === "cordis_inspect_self") {
198
+ if (typeof parsed !== "object" || parsed === null) return void 0;
199
+ const mode = stringField(parsed.mode);
200
+ return mode === void 0 ? void 0 : `mode ${mode}`;
201
+ }
202
+ }
90
203
  /** Summary key preference per variant (args-derived). */
91
204
  const SUMMARY_KEYS = {
92
205
  bash: ["description", "command"],
@@ -172,6 +285,15 @@ function summarizeToolArgs(name, argsRaw) {
172
285
  if (typeof question !== "string" || question === "") return void 0;
173
286
  return firstLine(question);
174
287
  }
288
+ if (name === "create_goal") {
289
+ const objective = args.objective;
290
+ return typeof objective === "string" && objective !== "" ? firstLine(objective) : void 0;
291
+ }
292
+ if (name === "update_goal") {
293
+ const action = args.action;
294
+ return typeof action === "string" && action !== "" ? action : void 0;
295
+ }
296
+ if (name === "get_goal") return "";
175
297
  if (name !== "todo_write") return void 0;
176
298
  const todos = args.todos;
177
299
  if (!Array.isArray(todos)) return void 0;
@@ -421,6 +543,36 @@ function resultTextLines(blocks, error) {
421
543
  return lines;
422
544
  }
423
545
  /**
546
+ * Parse and normalize the tool's `{"answers":[…]}` render text. A
547
+ * malformed entry (non-object) invalidates the WHOLE text — the Web's
548
+ * `answeredSummary` checks `answers.every(isAnswer)` the same way, and a
549
+ * half-valid set would make the count and the rendered rows disagree.
550
+ * Non-string `selected` members are dropped; missing id/custom normalize
551
+ * to empty.
552
+ * @param text - the settled result text.
553
+ * @returns the normalized entries, or undefined when unparseable/malformed.
554
+ */
555
+ function parseAnswerEntries(text) {
556
+ let parsed;
557
+ try {
558
+ parsed = JSON.parse(text);
559
+ } catch {
560
+ return;
561
+ }
562
+ if (typeof parsed !== "object" || parsed === null) return void 0;
563
+ const answers = parsed.answers;
564
+ if (!Array.isArray(answers)) return void 0;
565
+ if (!answers.every((entry) => typeof entry === "object" && entry !== null)) return void 0;
566
+ return answers.map((entry) => {
567
+ const candidate = entry;
568
+ return {
569
+ id: typeof candidate.id === "string" ? candidate.id : "",
570
+ selected: Array.isArray(candidate.selected) ? candidate.selected.filter((item) => typeof item === "string") : [],
571
+ custom: typeof candidate.custom === "string" ? candidate.custom : ""
572
+ };
573
+ });
574
+ }
575
+ /**
424
576
  * The answered-count summary for a settled `ask_user_question` result
425
577
  * (Web AskQuestionRow parity): parses the tool's `{"answers":[…]}` render
426
578
  * text and counts the entries that actually carry an answer — a non-empty
@@ -432,6 +584,43 @@ function resultTextLines(blocks, error) {
432
584
  * @returns the answered-count summary, or undefined when unparseable.
433
585
  */
434
586
  function askAnswersSummary(text) {
587
+ const entries = parseAnswerEntries(text);
588
+ if (entries === void 0) return void 0;
589
+ return `${entries.filter((entry) => entry.selected.length > 0 || entry.custom !== "").length}/${entries.length} answered`;
590
+ }
591
+ /**
592
+ * The per-question display lines for a settled `ask_user_question` result
593
+ * (the expanded card's body, complementing {@link askAnswersSummary}'s
594
+ * count). Parses the tool's `{"answers":[…]}` render text and renders one
595
+ * line per entry: `● <id> → <selected, custom>` for an answered question,
596
+ * `○ <id> — skipped` when neither is present. Shares the normalization with
597
+ * {@link askAnswersSummary}, so the rendered rows and the count always
598
+ * agree; an unparseable/malformed text returns undefined.
599
+ * @param text - the settled result text.
600
+ * @returns the display lines, or undefined when unparseable/malformed.
601
+ */
602
+ function askAnswersLines(text) {
603
+ const entries = parseAnswerEntries(text);
604
+ if (entries === void 0) return void 0;
605
+ return entries.map((entry) => {
606
+ if (entry.selected.length > 0) return {
607
+ text: `● ${entry.id} → ${entry.selected.join(", ")}`,
608
+ skipped: false
609
+ };
610
+ if (entry.custom !== "") return {
611
+ text: `● ${entry.id} → ${entry.custom}`,
612
+ skipped: false
613
+ };
614
+ return {
615
+ text: `○ ${entry.id} — skipped`,
616
+ skipped: true
617
+ };
618
+ });
619
+ }
620
+ /** Parse a settled goal-tool result text, or undefined when it is not the
621
+ * expected `{"goal":…}` JSON (the caller then falls back to the generic
622
+ * presentation). */
623
+ function parseGoalResult(text) {
435
624
  let parsed;
436
625
  try {
437
626
  parsed = JSON.parse(text);
@@ -439,14 +628,72 @@ function askAnswersSummary(text) {
439
628
  return;
440
629
  }
441
630
  if (typeof parsed !== "object" || parsed === null) return void 0;
442
- const answers = parsed.answers;
443
- if (!Array.isArray(answers)) return void 0;
444
- const total = answers.length;
445
- return `${answers.filter((entry) => {
446
- if (typeof entry !== "object" || entry === null) return false;
447
- const candidate = entry;
448
- return Array.isArray(candidate.selected) && candidate.selected.length > 0 || typeof candidate.custom === "string" && candidate.custom !== "";
449
- }).length}/${total} answered`;
631
+ const value = parsed;
632
+ if (value.goal === null) return { goal: null };
633
+ if (typeof value.goal !== "object" || value.goal === null) return void 0;
634
+ const goal = value.goal;
635
+ const blocked = typeof goal.blockedReason === "object" && goal.blockedReason !== null ? goal.blockedReason : void 0;
636
+ return {
637
+ goal: {
638
+ ...typeof goal.id === "string" ? { id: goal.id } : {},
639
+ ...typeof goal.revision === "number" ? { revision: goal.revision } : {},
640
+ ...typeof goal.objective === "string" ? { objective: goal.objective } : {},
641
+ ...typeof goal.phase === "string" ? { phase: goal.phase } : {},
642
+ ...typeof goal.roundsStarted === "number" ? { roundsStarted: goal.roundsStarted } : {},
643
+ ...typeof goal.maxGoalRounds === "number" ? { maxGoalRounds: goal.maxGoalRounds } : {},
644
+ ...blocked !== void 0 ? { blockedReason: {
645
+ ...typeof blocked.code === "string" ? { code: blocked.code } : {},
646
+ ...typeof blocked.message === "string" ? { message: blocked.message } : {}
647
+ } } : {}
648
+ },
649
+ ...typeof value.activation === "string" ? { activation: value.activation } : {}
650
+ };
651
+ }
652
+ /**
653
+ * The one-line folded summary for a settled goal-tool result: `phase … ·
654
+ * revision N · N/M rounds`, or `no goal set` for `{"goal":null}`.
655
+ * Returns undefined when the text is not the expected result JSON (the
656
+ * caller then falls back to the generic preview instead of inventing one).
657
+ * @param text - the settled result text.
658
+ */
659
+ function goalResultSummary(text) {
660
+ const value = parseGoalResult(text);
661
+ if (value === void 0) return void 0;
662
+ const goal = value.goal;
663
+ if (goal === null) return "no goal set";
664
+ const parts = [];
665
+ if (goal.phase !== void 0) parts.push(`phase ${goal.phase}`);
666
+ if (goal.revision !== void 0) parts.push(`revision ${goal.revision}`);
667
+ if (goal.roundsStarted !== void 0) parts.push(goal.maxGoalRounds === void 0 ? `${goal.roundsStarted} rounds` : `${goal.roundsStarted}/${goal.maxGoalRounds} rounds`);
668
+ return parts.length === 0 ? "goal" : parts.join(" · ");
669
+ }
670
+ /**
671
+ * The expanded-card display lines for a settled goal-tool result: one field
672
+ * per line (`● objective: …`, `● phase: … · revision N`, `● rounds: N/M`,
673
+ * `● activation: …`, `● blocked: code — message`), or a single `no goal
674
+ * set` line for `{"goal":null}`. Returns undefined when the text is not the
675
+ * expected result JSON (the caller falls back to the generic presentation).
676
+ * @param text - the settled result text.
677
+ */
678
+ function goalResultLines(text) {
679
+ const value = parseGoalResult(text);
680
+ if (value === void 0) return void 0;
681
+ const goal = value.goal;
682
+ if (goal === null) return ["no goal set"];
683
+ const lines = [];
684
+ if (goal.objective !== void 0 && goal.objective !== "") lines.push(`● objective: ${goal.objective}`);
685
+ const identity = [];
686
+ if (typeof goal.phase === "string" && goal.phase !== "") identity.push(goal.phase);
687
+ if (goal.revision !== void 0) identity.push(`revision ${goal.revision}`);
688
+ if (identity.length > 0) lines.push(`● ${identity.join(" · ")}`);
689
+ if (goal.roundsStarted !== void 0) lines.push(goal.maxGoalRounds === void 0 ? `● rounds: ${goal.roundsStarted}` : `● rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`);
690
+ const blocked = goal.blockedReason;
691
+ if (blocked !== void 0 && (blocked.code !== void 0 || blocked.message !== void 0)) {
692
+ const detail = [blocked.code, blocked.message].filter((part) => typeof part === "string" && part !== "").join(" — ");
693
+ lines.push(`● blocked: ${detail}`);
694
+ }
695
+ if (value.activation !== void 0) lines.push(`● activation: ${value.activation}`);
696
+ return lines;
450
697
  }
451
698
  /**
452
699
  * The folded-card call preview for tools whose args carry a one-line
@@ -519,6 +766,41 @@ function toolPresenterFrom(get) {
519
766
  }
520
767
  };
521
768
  }
769
+ /**
770
+ * Subagent-family tool names whose call args may carry an explicit
771
+ * model/provider override. Matched by REGISTERED name; unknown names are
772
+ * ignored so third-party subagent-like tools keep their default rendering.
773
+ */
774
+ const SUBAGENT_TOOL_NAMES = /* @__PURE__ */ new Set([
775
+ "subagent",
776
+ "subagent_route",
777
+ "subagent_router",
778
+ "subagent_fork"
779
+ ]);
780
+ /**
781
+ * Extract the model · provider display line for a subagent-family tool call.
782
+ *
783
+ * COMPATIBILITY CONTRACT: the line renders ONLY when the call args actually
784
+ * carry a model/provider — the top-level `model`/`provider` shape
785
+ * (subagent_route / subagent_router) or the `agentOptions.model/provider`
786
+ * shape. Every other call returns `undefined` and the card renders exactly
787
+ * as before; unknown tool names and unparseable args are ignored. The
788
+ * official `subagent` tool's model lives in deployment config (never in
789
+ * the call args), so it naturally renders nothing.
790
+ * @param name - the tool's registered name.
791
+ * @param argsRaw - the raw tool-call arguments JSON ('' or malformed → none).
792
+ * @returns `model · provider` (either part alone when only one is present),
793
+ * or undefined when the call carries no model/provider.
794
+ */
795
+ function subagentModelDisplay(name, argsRaw) {
796
+ if (!SUBAGENT_TOOL_NAMES.has(name)) return void 0;
797
+ const parsed = parseArgs(argsRaw);
798
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
799
+ const args = parsed;
800
+ const opts = typeof args.agentOptions === "object" && args.agentOptions !== null ? args.agentOptions : void 0;
801
+ const parts = [typeof args.model === "string" && args.model !== "" ? args.model : typeof opts?.model === "string" && opts.model !== "" ? opts.model : void 0, typeof args.provider === "string" && args.provider !== "" ? args.provider : typeof opts?.provider === "string" && opts.provider !== "" ? opts.provider : void 0].filter((part) => part !== void 0);
802
+ return parts.length === 0 ? void 0 : parts.join(" · ");
803
+ }
522
804
  //#endregion
523
805
  //#region src/context.ts
524
806
  /**
@@ -13154,6 +13436,7 @@ var TaskBrowserPanel = class {
13154
13436
  searchEnabled;
13155
13437
  onSelect;
13156
13438
  onCancel;
13439
+ onAction;
13157
13440
  /** Live tick: bumped every second while a running row is visible. */
13158
13441
  now = Date.now();
13159
13442
  tickTimer;
@@ -13168,6 +13451,7 @@ var TaskBrowserPanel = class {
13168
13451
  this.options = options;
13169
13452
  this.onSelect = onSelect;
13170
13453
  this.onCancel = onCancel;
13454
+ this.onAction = options.onAction;
13171
13455
  this.requestRender = requestRender;
13172
13456
  this.searchEnabled = options.enableSearch ?? false;
13173
13457
  this.searchInput.onEscape = () => this.onCancel();
@@ -13230,6 +13514,18 @@ var TaskBrowserPanel = class {
13230
13514
  const isNavUp = matchesKey(data, "up") || !this.searchEnabled && data === "k";
13231
13515
  const isNavDown = matchesKey(data, "down") || !this.searchEnabled && data === "j";
13232
13516
  const isPage = matchesKey(data, "pageUp") || matchesKey(data, "pageDown");
13517
+ if (data === "i" && this.onAction !== void 0 && !this.searchEnabled) {
13518
+ const item = this.filtered[this.selected];
13519
+ if (item !== void 0) this.onAction(item.value, "interrupt");
13520
+ return;
13521
+ }
13522
+ if (data === "i" && this.onAction !== void 0 && this.searchEnabled && (this.searchInput.getValue() ?? "") === "") {
13523
+ const item = this.filtered[this.selected];
13524
+ if (item !== void 0 && item.value.startsWith("agent:")) {
13525
+ this.onAction(item.value, "interrupt");
13526
+ return;
13527
+ }
13528
+ }
13233
13529
  if (!isNavUp && !isNavDown && !isPage && !matchesKey(data, "enter") && !matchesKey(data, "escape") && this.searchEnabled) {
13234
13530
  this.searchInput.handleInput(data);
13235
13531
  this.applyFilter(this.searchInput.getValue() ?? "");
@@ -13270,7 +13566,7 @@ var TaskBrowserPanel = class {
13270
13566
  if (query === "") this.filtered = this.items;
13271
13567
  else {
13272
13568
  const needle = query.toLowerCase();
13273
- this.filtered = this.items.filter((item) => `${item.value}\n${item.label}\n${item.status}\n${item.detail ?? ""}`.toLowerCase().includes(needle));
13569
+ this.filtered = this.items.filter((item) => `${item.value}\n${item.label}\n${item.status}\n${item.detail ?? ""}\n${item.group ?? ""}`.toLowerCase().includes(needle));
13274
13570
  }
13275
13571
  this.selected = 0;
13276
13572
  this.scroll = 0;
@@ -13346,7 +13642,7 @@ var TaskBrowserPanel = class {
13346
13642
  return out;
13347
13643
  }
13348
13644
  hint() {
13349
- return `${this.searchEnabled ? "type to filter · " : ""}↑↓ navigate · enter open · esc close`;
13645
+ return `${this.searchEnabled ? "type to filter · " : ""}${this.filtered.some((item) => item.value.startsWith("agent:")) ? "i interrupt · " : ""}↑↓ navigate · enter open · esc close`;
13350
13646
  }
13351
13647
  };
13352
13648
  //#endregion
@@ -14120,6 +14416,298 @@ var QuestionFlow = class {
14120
14416
  }
14121
14417
  };
14122
14418
  //#endregion
14419
+ //#region src/shell-words.ts
14420
+ /**
14421
+ * A small, reliable POSIX-ish shell word parser for external-command
14422
+ * strings like `$VISUAL`/`$EDITOR` (`code --wait`, `vim -f`, `sensible-editor
14423
+ * --nofork`) and `!` shell lines. It handles the quoting rules that matter
14424
+ * in practice — single quotes (literal), double quotes (backslash escapes,
14425
+ * everything else verbatim — no expansion), and backslash escapes outside
14426
+ * quotes — WITHOUT executing a shell.
14427
+ *
14428
+ * This deliberately replaces naive `command.split(/\s+/)` splitting: an
14429
+ * editor path with spaces or a quoted argument would be mangled into
14430
+ * phantom arguments.
14431
+ * @module @xmoon76/dsh-pi-tui/shell-words
14432
+ */
14433
+ /**
14434
+ * Split a command line into words, POSIX-ish. Never throws: unbalanced
14435
+ * quotes are treated as a trailing literal word (matching how shells
14436
+ * consume the rest of the line). Backslashes before non-special characters
14437
+ * are dropped outside quotes; inside double quotes a backslash only escapes
14438
+ * `"`, `\`, and `$` and is kept verbatim before anything else.
14439
+ * @param line - the raw command line.
14440
+ * @returns the parsed words.
14441
+ */
14442
+ function parseShellWords(line) {
14443
+ const words = [];
14444
+ let current = "";
14445
+ let inWord = false;
14446
+ let quote;
14447
+ for (let index = 0; index < line.length; index += 1) {
14448
+ const char = line[index];
14449
+ if (quote === void 0 && char === "\\") {
14450
+ const next = line[index + 1];
14451
+ if (next !== void 0) {
14452
+ current += next;
14453
+ index += 1;
14454
+ } else current += "\\";
14455
+ inWord = true;
14456
+ continue;
14457
+ }
14458
+ if (quote === void 0 && (char === "'" || char === "\"")) {
14459
+ quote = char;
14460
+ inWord = true;
14461
+ continue;
14462
+ }
14463
+ if (quote === "\"" && char === "\\") {
14464
+ const next = line[index + 1];
14465
+ if (next === "\"" || next === "\\" || next === "$") {
14466
+ current += next;
14467
+ index += 1;
14468
+ } else current += "\\";
14469
+ inWord = true;
14470
+ continue;
14471
+ }
14472
+ if (quote === "'" && char === "'") {
14473
+ quote = void 0;
14474
+ continue;
14475
+ }
14476
+ if (quote === "\"" && char === "\"") {
14477
+ quote = void 0;
14478
+ continue;
14479
+ }
14480
+ if (quote === void 0 && (char === " " || char === " " || char === "\n")) {
14481
+ if (inWord) {
14482
+ words.push(current);
14483
+ current = "";
14484
+ inWord = false;
14485
+ }
14486
+ continue;
14487
+ }
14488
+ current += char;
14489
+ inWord = true;
14490
+ }
14491
+ if (inWord) words.push(current);
14492
+ return words;
14493
+ }
14494
+ //#endregion
14495
+ //#region src/shell-completion.ts
14496
+ /**
14497
+ * Bash completion for `!`/`!!` shell lines: a real-shell `compgen` bridge
14498
+ * (docs/input-and-card-polish.md §1). Command names come from
14499
+ * `compgen -A command` (cached per cwd+PATH for 30s), `$VAR` names from
14500
+ * `compgen -A variable`, and subcommands from a small per-command table
14501
+ * (`git --list-cmds`). Path completion stays with the fork's fd-backed
14502
+ * provider — the bridge never competes with it.
14503
+ *
14504
+ * Decisions that matter (design doc):
14505
+ * - `bash -lc`, never `-ic`: an interactive shell sources `.bashrc` on
14506
+ * every keystroke (slow, and runs user startup code). Alias completion
14507
+ * is therefore not provided — accepted limitation.
14508
+ * - One spawn per request, hard-capped at COMPGEN_TIMEOUT_MS and wired to
14509
+ * the editor's AbortSignal; the caller commits latest-only. A slow or
14510
+ * missing `bash` degrades to no suggestions, never an error.
14511
+ * - The completion prefix crosses to bash through an environment variable
14512
+ * (COMPGEN_WORD), never string interpolation — no shell injection from
14513
+ * the user's own draft.
14514
+ * @module @xmoon76/dsh-pi-tui/shell-completion
14515
+ */
14516
+ /** Hard cap on one compgen spawn (ms). */
14517
+ const COMPGEN_TIMEOUT_MS = 300;
14518
+ /** Command-name cache TTL (ms): command sets change rarely. */
14519
+ const COMMAND_CACHE_TTL_MS = 3e4;
14520
+ /** Suggestion cap per request (the fork's own lists are capped too). */
14521
+ const MAX_SUGGESTIONS = 50;
14522
+ /**
14523
+ * Parse the editor line at the cursor for shell completion. Returns a
14524
+ * context only when the line is a `!`/`!!` line AND the cursor completes a
14525
+ * shell-bridge word (command name, subcommand, `$VAR`); a path position
14526
+ * returns undefined so the caller falls through to the fork's fd
14527
+ * completion — paths are not the bridge's job.
14528
+ * @param line - the full editor line.
14529
+ * @param col - the cursor column.
14530
+ */
14531
+ function shellCompletionContext(line, col) {
14532
+ if (!line.startsWith("!")) return void 0;
14533
+ const prefixLength = line.startsWith("!!") ? 2 : 1;
14534
+ const before = line.slice(prefixLength, col);
14535
+ const lastSpace = Math.max(before.lastIndexOf(" "), before.lastIndexOf(" "));
14536
+ const currentWord = before.slice(lastSpace + 1);
14537
+ const priorWords = parseShellWords(lastSpace === -1 ? "" : before.slice(0, lastSpace));
14538
+ if (priorWords.length === 0) {
14539
+ if (currentWord.startsWith(".") || currentWord.startsWith("/") || currentWord.startsWith("~")) return void 0;
14540
+ return {
14541
+ kind: "command",
14542
+ prefix: currentWord,
14543
+ priorWords
14544
+ };
14545
+ }
14546
+ if (currentWord.startsWith("$")) return {
14547
+ kind: "variable",
14548
+ prefix: currentWord,
14549
+ priorWords
14550
+ };
14551
+ if (priorWords.length === 1 && SUBCOMMAND_TABLE[priorWords[0]] !== void 0) return {
14552
+ kind: "subcommand",
14553
+ prefix: currentWord,
14554
+ priorWords
14555
+ };
14556
+ }
14557
+ /**
14558
+ * Per-command subcommand completion. `lister` emits the live list (one
14559
+ * name per line, no headers) when the installed command supports it;
14560
+ * `fallback` covers older versions (e.g. git before 2.18 has no
14561
+ * `--list-cmds`) and is used when the lister produces nothing. Extend the
14562
+ * table with one entry per command.
14563
+ */
14564
+ const SUBCOMMAND_TABLE = { git: {
14565
+ lister: "git --list-cmds 2>/dev/null",
14566
+ fallback: [
14567
+ "add",
14568
+ "am",
14569
+ "archive",
14570
+ "bisect",
14571
+ "branch",
14572
+ "bundle",
14573
+ "checkout",
14574
+ "cherry-pick",
14575
+ "clean",
14576
+ "clone",
14577
+ "commit",
14578
+ "config",
14579
+ "diff",
14580
+ "fetch",
14581
+ "init",
14582
+ "log",
14583
+ "merge",
14584
+ "mv",
14585
+ "pull",
14586
+ "push",
14587
+ "rebase",
14588
+ "reset",
14589
+ "restore",
14590
+ "revert",
14591
+ "rm",
14592
+ "show",
14593
+ "stash",
14594
+ "status",
14595
+ "switch",
14596
+ "tag"
14597
+ ]
14598
+ } };
14599
+ let compgenRunner = (cwd, expression, prefix, signal) => runCompgenSpawn(cwd, expression, prefix, signal);
14600
+ function runCompgenSpawn(cwd, expression, prefix, signal) {
14601
+ return new Promise((resolve) => {
14602
+ const settle = (ok, lines) => {
14603
+ if (settled) return;
14604
+ settled = true;
14605
+ clearTimeout(timer);
14606
+ signal.removeEventListener("abort", onAbort);
14607
+ resolve({
14608
+ ok,
14609
+ lines
14610
+ });
14611
+ };
14612
+ let settled = false;
14613
+ const onAbort = () => {
14614
+ child.kill();
14615
+ settle(false, []);
14616
+ };
14617
+ const child = spawn("bash", ["-lc", expression], {
14618
+ cwd,
14619
+ stdio: [
14620
+ "ignore",
14621
+ "pipe",
14622
+ "pipe"
14623
+ ],
14624
+ env: {
14625
+ ...process.env,
14626
+ COMPGEN_WORD: prefix
14627
+ }
14628
+ });
14629
+ const timer = setTimeout(() => {
14630
+ child.kill("SIGKILL");
14631
+ settle(false, []);
14632
+ }, COMPGEN_TIMEOUT_MS);
14633
+ signal.addEventListener("abort", onAbort, { once: true });
14634
+ let out = "";
14635
+ child.stdout.on("data", (chunk) => {
14636
+ out += chunk.toString("utf8");
14637
+ });
14638
+ child.stderr.on("data", () => {});
14639
+ child.on("error", () => settle(false, []));
14640
+ child.on("close", (code) => {
14641
+ settle(code === 0, out.split("\n").filter((line) => line !== ""));
14642
+ });
14643
+ });
14644
+ }
14645
+ /** Command-name cache, keyed by cwd + PATH (30s TTL). Only SUCCESSFUL runs
14646
+ * are cached — a failed run (timeout/abort/spawn error) must not suppress
14647
+ * completion for the whole TTL, so the next request retries the spawn. */
14648
+ const commandCache = /* @__PURE__ */ new Map();
14649
+ /** The cached `compgen -A command` list for one cwd+PATH, refreshed on
14650
+ * expiry or after a failed run. */
14651
+ async function cachedCommands(cwd, signal) {
14652
+ const key = `${cwd}\0${process.env.PATH ?? ""}`;
14653
+ const entry = commandCache.get(key);
14654
+ if (entry !== void 0 && entry.expires > Date.now()) return entry.commands;
14655
+ const run = await compgenRunner(cwd, "compgen -A command", "", signal);
14656
+ if (!run.ok) return [];
14657
+ commandCache.set(key, {
14658
+ expires: Date.now() + COMMAND_CACHE_TTL_MS,
14659
+ commands: run.lines
14660
+ });
14661
+ return run.lines;
14662
+ }
14663
+ /** Filter one candidate list to the prefix and cap it ('' prefix keeps all). */
14664
+ function matchesFor(prefix, candidates) {
14665
+ const matched = prefix === "" ? candidates : candidates.filter((name) => name.startsWith(prefix));
14666
+ if (matched.length === 0) return null;
14667
+ return {
14668
+ items: matched.slice(0, MAX_SUGGESTIONS).map((name) => ({
14669
+ value: name,
14670
+ label: name
14671
+ })),
14672
+ prefix
14673
+ };
14674
+ }
14675
+ /**
14676
+ * Suggest shell completions for a parsed `!` line context. Returns null
14677
+ * when nothing matches or the shell is unavailable — the editor then shows
14678
+ * no suggestions (the caller falls through to the fork's own provider).
14679
+ * @param context - the parsed cursor context.
14680
+ * @param cwd - the session workspace (compgen runs there).
14681
+ * @param options - the editor's request options (signal; force: an empty
14682
+ * command prefix lists the cached commands on explicit Tab, while a
14683
+ * natural trigger with an empty prefix stays quiet).
14684
+ */
14685
+ async function suggestShellCompletion(context, cwd, options) {
14686
+ if (options.signal.aborted) return null;
14687
+ if (context.kind === "command") {
14688
+ if (context.prefix === "" && options.force !== true) return null;
14689
+ const commands = await cachedCommands(cwd, options.signal);
14690
+ return matchesFor(context.prefix, commands);
14691
+ }
14692
+ if (context.kind === "variable") {
14693
+ const run = await compgenRunner(cwd, "compgen -A variable -- \"$COMPGEN_WORD\"", context.prefix.slice(1), options.signal);
14694
+ if (!run.ok) return null;
14695
+ const items = run.lines.slice(0, MAX_SUGGESTIONS).map((name) => ({
14696
+ value: `$${name}`,
14697
+ label: `$${name}`
14698
+ }));
14699
+ return items.length === 0 ? null : {
14700
+ items,
14701
+ prefix: context.prefix
14702
+ };
14703
+ }
14704
+ const command = context.priorWords[0];
14705
+ const entry = SUBCOMMAND_TABLE[command];
14706
+ const run = await compgenRunner(cwd, entry.lister, "", options.signal);
14707
+ const candidates = run.ok && run.lines.length > 0 ? run.lines.map((line) => line.trim()).filter((line) => line !== "") : entry.fallback;
14708
+ return matchesFor(context.prefix, candidates);
14709
+ }
14710
+ //#endregion
14123
14711
  //#region src/mentions.ts
14124
14712
  /**
14125
14713
  * @-file mention completion for the editor: a consumer-side wrapper over the
@@ -14270,13 +14858,19 @@ var MentionProvider = class {
14270
14858
  this.inner = new CombinedAutocompleteProvider([...slashCommands], workDir, fdPath);
14271
14859
  }
14272
14860
  async getSuggestions(lines, cursorLine, cursorCol, options) {
14273
- const atPrefix = extractAtPrefix((lines[cursorLine] ?? "").slice(0, cursorCol));
14861
+ const currentLine = lines[cursorLine] ?? "";
14862
+ const atPrefix = extractAtPrefix(currentLine.slice(0, cursorCol));
14274
14863
  if (atPrefix !== null) {
14275
14864
  if (this.fdPath !== null) try {
14276
14865
  return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
14277
14866
  } catch {}
14278
14867
  return fsMentionSuggestions(this.workDir, atPrefix, options.signal);
14279
14868
  }
14869
+ const shellContext = shellCompletionContext(currentLine, cursorCol);
14870
+ if (shellContext !== void 0) {
14871
+ const suggestions = await suggestShellCompletion(shellContext, this.workDir, options);
14872
+ if (suggestions !== null) return suggestions;
14873
+ }
14280
14874
  try {
14281
14875
  return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
14282
14876
  } catch {
@@ -14284,6 +14878,19 @@ var MentionProvider = class {
14284
14878
  }
14285
14879
  }
14286
14880
  applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
14881
+ const currentLine = lines[cursorLine] ?? "";
14882
+ if (shellCompletionContext(currentLine, cursorCol) !== void 0) {
14883
+ const before = currentLine.slice(0, cursorCol - prefix.length);
14884
+ const after = currentLine.slice(cursorCol);
14885
+ const newLine = `${before}${item.value} ${after}`;
14886
+ const newLines = [...lines];
14887
+ newLines[cursorLine] = newLine;
14888
+ return {
14889
+ lines: newLines,
14890
+ cursorLine,
14891
+ cursorCol: before.length + item.value.length + 1
14892
+ };
14893
+ }
14287
14894
  return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
14288
14895
  }
14289
14896
  shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
@@ -14820,9 +15427,9 @@ function describeKey(key) {
14820
15427
  * This inventory is the SINGLE authoritative list, kept in sync with the
14821
15428
  * host's `matchesKey` lifecycle checks in tui-app.ts (Ctrl+C/D exit,
14822
15429
  * Ctrl+S steer-all, Ctrl+F transcript search, Ctrl+Shift+F search, Ctrl+O
14823
- * expand, Ctrl+T todo panel, Ctrl+G external editor, Ctrl+J task
14824
- * browser, Ctrl+Enter queue, Enter submit, Esc cancel, Shift+Tab
14825
- * permission cycle, Alt+Up dequeue, Alt+T thinking toggle). Every
15430
+ * expand, Ctrl+T todo panel, Ctrl+G external editor, Ctrl+Enter queue,
15431
+ * Enter submit, Esc cancel, Shift+Tab permission cycle, Alt+Up dequeue,
15432
+ * Alt+T thinking toggle). Every
14826
15433
  * reserved binding here must match a host `matchesKey(data, ...)` call;
14827
15434
  * when a new host lifecycle key lands, extend THIS list in the same
14828
15435
  * commit.
@@ -14884,13 +15491,6 @@ const RESERVED_HOST_KEYS = [
14884
15491
  shift: false,
14885
15492
  super: false
14886
15493
  },
14887
- {
14888
- key: "j",
14889
- ctrl: true,
14890
- alt: false,
14891
- shift: false,
14892
- super: false
14893
- },
14894
15494
  {
14895
15495
  key: "enter",
14896
15496
  ctrl: true,
@@ -16799,6 +17399,7 @@ var QuestionFrame = class extends Frame {
16799
17399
  render(width) {
16800
17400
  const rows = Math.max(1, this.heightOf());
16801
17401
  this.lastTermRows = rows;
17402
+ this.lastTermColumns = width;
16802
17403
  const frameRows = this.flow.isBodyExpanded() ? Math.max(10, Math.min(40, Math.floor(rows * .8))) : Math.max(10, Math.min(26, Math.floor(rows * .6)));
16803
17404
  this.flow.setMaxRows(frameRows - 2);
16804
17405
  const out = super.render(width);
@@ -16813,12 +17414,18 @@ var QuestionFrame = class extends Frame {
16813
17414
  get termRows() {
16814
17415
  return this.lastTermRows;
16815
17416
  }
17417
+ /** The terminal width the last render used (a resize changes wrapping and
17418
+ * the flow's hit map, so clicks must wait for a fresh render too). */
17419
+ get termColumns() {
17420
+ return this.lastTermColumns;
17421
+ }
16816
17422
  get focused() {
16817
17423
  return this.flow.focused;
16818
17424
  }
16819
17425
  set focused(value) {
16820
17426
  this.flow.focused = value;
16821
17427
  }
17428
+ lastTermColumns = 0;
16822
17429
  };
16823
17430
  /** The session head card: identity facts, wrapped to the available width so
16824
17431
  * nothing is truncated, framed with a box whose width matches the editor's
@@ -17136,6 +17743,9 @@ var TuiApp = class TuiApp {
17136
17743
  todoItems = [];
17137
17744
  /** Ctrl+T: whether the todo panel between transcript and editor is shown. */
17138
17745
  todoPanelVisible = false;
17746
+ /** Fullscreen click on the todo panel (or the panel's own expand verb):
17747
+ * whether the panel shows the FULL list or the compact five rows. */
17748
+ todoExpanded = false;
17139
17749
  /** The todo panel Text; empty when hidden. */
17140
17750
  todoPanel;
17141
17751
  /**
@@ -17298,8 +17908,11 @@ var TuiApp = class TuiApp {
17298
17908
  workingMessageOverride;
17299
17909
  /** Timestamp of the last Ctrl+C press, for the empty-editor exit chord. */
17300
17910
  lastCtrlCAt;
17301
- /** The empty-editor double-Ctrl+C exit window in ms (pi handleCtrlC). */
17302
- static CTRL_C_EXIT_WINDOW_MS = 500;
17911
+ /** The empty-editor double-Ctrl+C exit window in ms. A 500ms window
17912
+ * silently misses a human-paced double press (0.6–1s apart), which
17913
+ * read as "the chord doesn't work"; 1.5s covers a natural double
17914
+ * press, and the armed state is announced by the hint. */
17915
+ static CTRL_C_EXIT_WINDOW_MS = 1500;
17303
17916
  /** Session workspace root for path relativization (Web relativizeToCwd). */
17304
17917
  workspaceRoot;
17305
17918
  /** The tool presentation bridge, wired by the runner to the live registry. */
@@ -17772,6 +18385,7 @@ var TuiApp = class TuiApp {
17772
18385
  this.clearNotify();
17773
18386
  this.seatEditor().setText("");
17774
18387
  this.editorSeatHolder.notifyChanged();
18388
+ this.requestRender();
17775
18389
  this.events.onQueueSubmit(text);
17776
18390
  return { consume: true };
17777
18391
  }
@@ -17810,10 +18424,11 @@ var TuiApp = class TuiApp {
17810
18424
  const draft = this.seatEditor().getText();
17811
18425
  this.seatEditor().setText("");
17812
18426
  this.editorSeatHolder.notifyChanged();
18427
+ this.requestRender();
17813
18428
  this.events.onSteer?.(draft);
17814
18429
  return { consume: true };
17815
18430
  }
17816
- if (matchesKey(data, "down") || matchesKey(data, "ctrl+j")) {
18431
+ if (matchesKey(data, "down")) {
17817
18432
  if (this.tasksActive && !this.activeScreen.hasOverlayEntries && this.seatEditor().getText().trim() === "") {
17818
18433
  this.events.onOpenTasks?.();
17819
18434
  return { consume: true };
@@ -17831,13 +18446,17 @@ var TuiApp = class TuiApp {
17831
18446
  this.seatEditor().setText("");
17832
18447
  this.editorSeatHolder.notifyChanged();
17833
18448
  this.lastCtrlCAt = Date.now();
18449
+ this.requestRender();
17834
18450
  return { consume: true };
17835
18451
  }
17836
18452
  const now = Date.now();
17837
18453
  if (this.lastCtrlCAt !== void 0 && now - this.lastCtrlCAt < TuiApp.CTRL_C_EXIT_WINDOW_MS) {
17838
18454
  this.lastCtrlCAt = void 0;
17839
18455
  this.events.onExit();
17840
- } else this.lastCtrlCAt = now;
18456
+ } else {
18457
+ this.lastCtrlCAt = now;
18458
+ this.notify("Press Ctrl+C again to exit", "info");
18459
+ }
17841
18460
  return { consume: true };
17842
18461
  }
17843
18462
  if (matchesKey(data, "ctrl+d")) {
@@ -18388,7 +19007,7 @@ var TuiApp = class TuiApp {
18388
19007
  handleFullscreenClick(x, y) {
18389
19008
  const question = this.activeQuestions;
18390
19009
  if (question?.frame !== void 0) {
18391
- if (this.terminal.rows !== question.frame.termRows) return;
19010
+ if (this.terminal.rows !== question.frame.termRows || this.terminal.columns !== question.frame.termColumns) return;
18392
19011
  const width = this.terminal.columns;
18393
19012
  const height = this.terminal.rows;
18394
19013
  const footerHeight = this.footer.render(width).length;
@@ -18400,10 +19019,28 @@ var TuiApp = class TuiApp {
18400
19019
  question.flow.clickRow(y - seatTop - 1);
18401
19020
  this.requestRender();
18402
19021
  }
18403
- return;
18404
19022
  }
19023
+ return;
18405
19024
  }
18406
19025
  const width = this.terminal.columns;
19026
+ const height = this.terminal.rows;
19027
+ const footerHeight = this.footer.render(width).length;
19028
+ const editorHeight = this.editorSeat.render(width).length;
19029
+ const workingHeight = this.working.render(width).length;
19030
+ const queueHeight = this.queuePane.render(width).length;
19031
+ const goalHeight = this.goalLine.render(width).length;
19032
+ const todoHeight = this.todoPanel.render(width).length;
19033
+ const todoBottom = Math.max(0, Math.min(height, height - footerHeight - editorHeight - workingHeight - queueHeight - goalHeight));
19034
+ const todoTop = Math.max(0, todoBottom - todoHeight);
19035
+ const dockHeight = this.dock.render(width).length;
19036
+ if (dockHeight > 0 && y >= todoTop - dockHeight && y < todoTop) {
19037
+ this.toggleTodoPanel();
19038
+ return;
19039
+ }
19040
+ if (todoTop < todoBottom && y >= todoTop && y < todoBottom) {
19041
+ this.handleTodoPanelClick();
19042
+ return;
19043
+ }
18407
19044
  const rowInScroll = y - this.header.render(width).length;
18408
19045
  if (rowInScroll < 0) return;
18409
19046
  const scrollTop = this.fullscreenScroll?.scrollTop ?? 0;
@@ -19551,7 +20188,18 @@ var TuiApp = class TuiApp {
19551
20188
  const rows = [];
19552
20189
  const callPreview = parseCallPreview(message.name, message.args);
19553
20190
  const foldedCall = header.summary === "" ? foldedCallPreview(message.name, message.args) : "";
19554
- const resultPreview = message.name === "read" ? readFoldedPreview(message.result) : message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
20191
+ let resultPreview;
20192
+ if (message.name === "read") resultPreview = readFoldedPreview(message.result);
20193
+ else if (message.name === "ask_user_question") {
20194
+ const summary = message.error === void 0 ? askAnswersSummary(message.result) : void 0;
20195
+ resultPreview = summary === void 0 ? "" : ` — ${summary}`;
20196
+ } else if (GOAL_TOOL_NAMES.has(message.name)) {
20197
+ const summary = message.error === void 0 ? goalResultSummary(message.result) : void 0;
20198
+ resultPreview = summary === void 0 ? "" : ` — ${summary}`;
20199
+ } else if (FOLDED_JSON_RESULT_TOOLS.has(message.name)) {
20200
+ const summary = foldedResultSummaryFor(message.name, message.result);
20201
+ resultPreview = summary === void 0 || summary === "" ? "" : ` — ${summary}`;
20202
+ } else resultPreview = message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
19555
20203
  const callHead = foldedCall === "" ? "" : foldedCall;
19556
20204
  const headWithPreview = `${head}${callHead}${resultPreview}`;
19557
20205
  if (callPreview?.kind === "bash" && callPreview.command !== "") {
@@ -19723,6 +20371,8 @@ var TuiApp = class TuiApp {
19723
20371
  return;
19724
20372
  }
19725
20373
  if (message.status === "running") {
20374
+ const modelLine = subagentModelDisplay(message.name, message.args);
20375
+ if (modelLine !== void 0) card.addChild(new Text(color.textDim(modelLine), 0, 0));
19726
20376
  const callView = this.present?.call(message.name, message.args);
19727
20377
  if (callView !== void 0) {
19728
20378
  if (callView.card === "diff" && callView.diffs.length > 0) {
@@ -19752,10 +20402,25 @@ var TuiApp = class TuiApp {
19752
20402
  const summary = message.status === "ok" ? askAnswersSummary(message.result) : void 0;
19753
20403
  if (summary !== void 0) {
19754
20404
  card.addChild(new Text(color.textDim(summary), 0, 0));
20405
+ const answerLines = askAnswersLines(message.result);
20406
+ if (answerLines !== void 0) for (const line of answerLines) card.addChild(new Text(` ${line.skipped ? color.textMuted(line.text) : color.textDim(line.text)}`, 0, 0));
20407
+ return;
20408
+ }
20409
+ }
20410
+ if (GOAL_TOOL_NAMES.has(message.name)) {
20411
+ if (message.error !== void 0) {
20412
+ card.addChild(new Text(color.textDim(`${message.error.name}: ${message.error.code}`), 0, 0));
20413
+ return;
20414
+ }
20415
+ const goalLines = message.status === "ok" ? goalResultLines(message.result) : void 0;
20416
+ if (goalLines !== void 0) {
20417
+ for (const line of goalLines) card.addChild(new Text(color.textDim(line), 0, 0));
19755
20418
  return;
19756
20419
  }
19757
20420
  }
19758
20421
  if (message.result === "" && (message.resultBlocks?.length ?? 0) === 0) return;
20422
+ const settledModelLine = subagentModelDisplay(message.name, message.args);
20423
+ if (settledModelLine !== void 0) card.addChild(new Text(color.textDim(settledModelLine), 0, 0));
19759
20424
  const resultView = this.present?.result(message.name, message.args, {
19760
20425
  content: message.resultBlocks ?? [],
19761
20426
  isError: message.status === "error",
@@ -19957,19 +20622,42 @@ var TuiApp = class TuiApp {
19957
20622
  /** Toggle the todo panel between the transcript and the editor. */
19958
20623
  toggleTodoPanel() {
19959
20624
  this.todoPanelVisible = !this.todoPanelVisible;
20625
+ if (!this.todoPanelVisible) this.todoExpanded = false;
19960
20626
  this.renderTodoPanel();
19961
20627
  this.renderDock();
19962
20628
  this.requestRender();
19963
20629
  return this.todoPanelVisible;
19964
20630
  }
20631
+ /** Toggle the todo panel between the compact five rows and the full list
20632
+ * (fullscreen click on the panel's area). */
20633
+ toggleTodoExpanded() {
20634
+ if (!this.todoPanelVisible) return false;
20635
+ this.todoExpanded = !this.todoExpanded;
20636
+ this.renderTodoPanel();
20637
+ this.requestRender();
20638
+ return this.todoExpanded;
20639
+ }
20640
+ /** The fullscreen click loop over the todo panel's own rows: compact →
20641
+ * full list → back to the summary row (the panel closes). The mouse thus
20642
+ * opens AND closes the panel without Ctrl+T; the dock summary row itself
20643
+ * opens it (handleFullscreenClick's dock region). */
20644
+ handleTodoPanelClick() {
20645
+ if (this.todoExpanded) this.toggleTodoPanel();
20646
+ else this.toggleTodoExpanded();
20647
+ }
19965
20648
  /** Whether the todo panel is currently shown. */
19966
20649
  isTodoPanelVisible() {
19967
20650
  return this.todoPanelVisible;
19968
20651
  }
20652
+ /** Whether the todo panel is showing the full list (headless-test hook). */
20653
+ isTodoPanelExpanded() {
20654
+ return this.todoExpanded;
20655
+ }
19969
20656
  /**
19970
20657
  * Rebuild the todo panel text: a border rule + `Todo` title (both indented
19971
- * one cell) plus up to five rows, in_progress first, then pending, then
19972
- * completed (strikethrough).
20658
+ * one cell) plus up to five rows by default (in_progress first, then
20659
+ * pending, then completed (strikethrough)); the full list when expanded
20660
+ * (fullscreen click on the panel toggles).
19973
20661
  */
19974
20662
  renderTodoPanel() {
19975
20663
  if (!this.todoPanelVisible) {
@@ -19981,15 +20669,16 @@ var TuiApp = class TuiApp {
19981
20669
  ...this.todoItems.filter((todo) => todo.status === "in_progress"),
19982
20670
  ...this.todoItems.filter((todo) => todo.status === "pending"),
19983
20671
  ...this.todoItems.filter((todo) => todo.status === "completed")
19984
- ].slice(0, 5);
20672
+ ];
20673
+ const shown = this.todoExpanded ? ordered : ordered.slice(0, 5);
19985
20674
  const width = Math.max(1, this.terminal.columns);
19986
20675
  const border = color.border(` ${"─".repeat(Math.max(0, width - 2))} `);
19987
20676
  const title = color.textStrong(" Todo");
19988
- if (ordered.length === 0) {
20677
+ if (shown.length === 0) {
19989
20678
  this.todoPanel.setText([border, title].join("\n"));
19990
20679
  return;
19991
20680
  }
19992
- const lines = ordered.map((todo) => {
20681
+ const lines = shown.map((todo) => {
19993
20682
  const body = todo.status === "completed" ? `\x1b[9m${todo.content}\x1b[29m` : todo.content;
19994
20683
  return `${mark(todo)} ${body}`;
19995
20684
  });
@@ -20136,6 +20825,8 @@ var TuiApp = class TuiApp {
20136
20825
  this.renderQueuePane();
20137
20826
  this.syncExtensionState();
20138
20827
  }
20828
+ /** Notice rows shown in full before the `+N more` fold (user rows are never folded). */
20829
+ static MAX_NOTICE_ROWS = 5;
20139
20830
  /** Rebuild the queue pane text from the current inbox rows. */
20140
20831
  renderQueuePane() {
20141
20832
  const items = this.queueItems;
@@ -20146,12 +20837,20 @@ var TuiApp = class TuiApp {
20146
20837
  }
20147
20838
  const width = Math.max(1, this.terminal.columns);
20148
20839
  const lines = [color.border(` ${"─".repeat(Math.max(0, width - 2))} `)];
20149
- for (const item of items) {
20150
- const prefix = item.notice === true ? `${color.textDim("⏳")} ` : `${color.accent("❯")} `;
20151
- const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth(prefix)), "…");
20152
- lines.push(prefix + (item.notice === true ? color.textDim(truncated) : truncated));
20153
- }
20154
- const hint = items.some((item) => item.notice !== true) ? "ctrl+s to steer all · alt+↑ to edit all · /queue for per-item actions" : "job notices deliver after the current task · /tasks to view";
20840
+ const userRows = items.filter((item) => item.notice !== true);
20841
+ const noticeRows = items.filter((item) => item.notice === true);
20842
+ const shownNotices = noticeRows.slice(0, TuiApp.MAX_NOTICE_ROWS);
20843
+ for (const item of userRows) {
20844
+ const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth("❯ ")), "…");
20845
+ lines.push(`${color.accent("❯")} ${truncated}`);
20846
+ }
20847
+ for (const item of shownNotices) {
20848
+ const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth("⏳ ")), "…");
20849
+ lines.push(`${color.textDim("⏳")} ${color.textDim(truncated)}`);
20850
+ }
20851
+ const folded = noticeRows.length - shownNotices.length;
20852
+ if (folded > 0) lines.push(color.textDim(truncateToWidth(` +${folded} more notices pending · they deliver as the next turn runs`, Math.max(1, width - 2), "…")));
20853
+ const hint = userRows.length > 0 ? "ctrl+s to steer all · alt+↑ to edit all" : "notices deliver after the current task · /tasks to view";
20155
20854
  lines.push(color.textDim(truncateToWidth(` ${hint}`, Math.max(1, width - 2), "…")));
20156
20855
  this.queuePane.setText(lines.join("\n"));
20157
20856
  this.requestRender();
@@ -20536,7 +21235,8 @@ var TuiApp = class TuiApp {
20536
21235
  header: options.header,
20537
21236
  noMatchText: options.noMatchText,
20538
21237
  enableSearch: options.enableSearch,
20539
- initialQuery: options.initialQuery
21238
+ initialQuery: options.initialQuery,
21239
+ onAction: options.onAction
20540
21240
  }, (value) => {
20541
21241
  close();
20542
21242
  onSelect(value);
@@ -24807,7 +25507,7 @@ function sortCommands(commands) {
24807
25507
  //#region src/commands.ts
24808
25508
  /**
24809
25509
  * The TUI-owned slash commands (/exit /settings /sessions /skill /model
24810
- * /new /tasks /preset /subagents /search /title /rename /copy /export
25510
+ * /new /tasks /preset /search /title /copy /export
24811
25511
  * /fork /status /login /logout /help), extracted from the runner's
24812
25512
  * monolithic apply() so the registration surface is testable and the runner
24813
25513
  * closure shrinks. Every command reads the live runner state through the
@@ -25252,18 +25952,43 @@ function registerTuiCommands(runner, initial) {
25252
25952
  }
25253
25953
  };
25254
25954
  refreshCompletions();
25955
+ /**
25956
+ * Register one TUI command plus its aliases (kimi parity: an alias is
25957
+ * another NAME of the same logical command). Every alias registers with
25958
+ * the host commands service — the shared handler by default, or its own
25959
+ * handler when the alias keeps a fast path (e.g. /resume's direct-resume
25960
+ * lookup) — so host dispatch, the completion catalog (aliases are
25961
+ * searchable: typing `resume` completes `/resume`) and the busy-Enter
25962
+ * gate all see it, while the command surface lists one logical command
25963
+ * and the docs mark the alias.
25964
+ * @param spec - the primary command; `aliases` register with the shared
25965
+ * handler unless `aliasHandlers` overrides one.
25966
+ */
25967
+ const registerTuiCommand = (spec) => {
25968
+ commands.register({
25969
+ name: spec.name,
25970
+ description: spec.description,
25971
+ ...spec.input === void 0 ? {} : { input: spec.input },
25972
+ handler: spec.handler
25973
+ });
25974
+ for (const alias of spec.aliases ?? []) {
25975
+ const handler = spec.aliasHandlers?.[alias] ?? spec.handler;
25976
+ commands.register({
25977
+ name: alias,
25978
+ description: `${spec.description} (alias of /${spec.name})`,
25979
+ ...spec.input === void 0 ? {} : { input: spec.input },
25980
+ handler
25981
+ });
25982
+ }
25983
+ };
25255
25984
  const exitHandler = () => {
25256
25985
  runner.requestExit();
25257
25986
  return { kind: "success" };
25258
25987
  };
25259
- commands.register({
25988
+ registerTuiCommand({
25260
25989
  name: "exit",
25261
25990
  description: "Quit the terminal UI (flush and exit)",
25262
- handler: exitHandler
25263
- });
25264
- commands.register({
25265
- name: "quit",
25266
- description: "Quit the terminal UI (flush and exit) — alias of /exit",
25991
+ aliases: ["quit"],
25267
25992
  handler: exitHandler
25268
25993
  });
25269
25994
  commands.register({
@@ -25337,6 +26062,13 @@ function registerTuiCommands(runner, initial) {
25337
26062
  currentValue: tuiSettings?.get().busyEnter ?? "queue",
25338
26063
  values: ["queue", "steer"]
25339
26064
  },
26065
+ {
26066
+ id: "local-shell-sandbox",
26067
+ label: "Local shell sandbox",
26068
+ description: "! / !! commands run outside the dsh sandbox (bypass, default) or under the sandbox policy",
26069
+ currentValue: tuiSettings?.get().localShellSandbox ?? "bypass",
26070
+ values: ["bypass", "sandbox"]
26071
+ },
25340
26072
  {
25341
26073
  id: "fullscreen",
25342
26074
  label: "Fullscreen",
@@ -25464,6 +26196,14 @@ function registerTuiCommands(runner, initial) {
25464
26196
  busyEnter: value
25465
26197
  }), { notify: true });
25466
26198
  }
26199
+ } else if (id === "local-shell-sandbox") {
26200
+ if (value === "bypass" || value === "sandbox") {
26201
+ const settings = tuiSettings;
26202
+ if (settings !== void 0) detach("settings local shell sandbox write", () => settings.replace({
26203
+ ...settings.get(),
26204
+ localShellSandbox: value
26205
+ }), { notify: true });
26206
+ }
25467
26207
  } else if (id === "fullscreen") {
25468
26208
  if (value === "off" || value === "on") app.setFullscreen(value === "on");
25469
26209
  }
@@ -25538,17 +26278,13 @@ function registerTuiCommands(runner, initial) {
25538
26278
  });
25539
26279
  return { kind: "success" };
25540
26280
  };
25541
- commands.register({
26281
+ registerTuiCommand({
25542
26282
  name: "sessions",
25543
26283
  description: "List, search, and switch persisted sessions",
25544
26284
  input: { hint: "[query]" },
25545
- handler: (invocation) => openSessionPicker(invocation, "sessions")
25546
- });
25547
- commands.register({
25548
- name: "resume",
25549
- description: "Resume a session by id or pick from the list (alias of /sessions)",
25550
- input: { hint: "[id|query]" },
25551
- handler: async (invocation) => {
26285
+ handler: (invocation) => openSessionPicker(invocation, "sessions"),
26286
+ aliases: ["resume"],
26287
+ aliasHandlers: { resume: async (invocation) => {
25552
26288
  const raw = invocation.rawInput.trim();
25553
26289
  if (raw !== "") {
25554
26290
  const currentId = runner.liveAgent?.session.id;
@@ -25571,7 +26307,7 @@ function registerTuiCommands(runner, initial) {
25571
26307
  }
25572
26308
  }
25573
26309
  return openSessionPicker(invocation, "resume");
25574
- }
26310
+ } }
25575
26311
  });
25576
26312
  /** The workspace the live session runs in; fallback to the TUI's cwd. */
25577
26313
  const sessionCwd = (agent) => agent.session.header.cwd ?? cwd;
@@ -25957,129 +26693,13 @@ function registerTuiCommands(runner, initial) {
25957
26693
  };
25958
26694
  }
25959
26695
  });
25960
- commands.register({
25961
- name: "queue",
25962
- description: "Manage queued input: edit, delete, steer, or insert",
25963
- handler: async () => {
25964
- const liveAgent = await requireAgent();
25965
- const inbox = liveAgent.inbox;
25966
- const queued = [...inbox.nextTurn.map((message, index) => ({
25967
- message,
25968
- slot: `next ${index + 1}`,
25969
- list: "next-turn"
25970
- })), ...inbox.nextStep.map((message, index) => ({
25971
- message,
25972
- slot: `steer ${index + 1}`,
25973
- list: "next-step"
25974
- }))];
25975
- if (queued.length === 0) return {
25976
- kind: "success",
25977
- text: "no queued input"
25978
- };
25979
- app.openSettings(queued.map(({ message, slot }) => ({
25980
- id: message.id,
25981
- label: `[${slot}] ${textOf(message.content).replace(/\s+/g, " ").trim().slice(0, 40)}`,
25982
- description: message.id,
25983
- currentValue: "",
25984
- submenu: (value, done) => new SettingsList([
25985
- {
25986
- id: "edit",
25987
- label: "Edit",
25988
- description: "Rewrite this queued message",
25989
- currentValue: "",
25990
- values: ["✓"]
25991
- },
25992
- {
25993
- id: "delete",
25994
- label: "Delete",
25995
- description: "Remove it from the queue",
25996
- currentValue: "",
25997
- values: ["✓"]
25998
- },
25999
- {
26000
- id: "steer",
26001
- label: "Steer",
26002
- description: "Send it now: into the running turn, or start one when idle",
26003
- currentValue: "",
26004
- values: ["✓"]
26005
- },
26006
- {
26007
- id: "insert",
26008
- label: "Insert before",
26009
- description: "Queue a new message ahead of this one",
26010
- currentValue: "",
26011
- values: ["✓"]
26012
- }
26013
- ], 6, settingsListTheme(), (action) => done(action), () => done(), {})
26014
- })), (id, action) => {
26015
- const target = queued.find((item) => item.message.id === id);
26016
- if (target === void 0) return;
26017
- const message = target.message;
26018
- if (action === "delete") {
26019
- inbox.remove(message.id);
26020
- app.notify("queued message deleted", "info");
26021
- } else if (action === "steer") {
26022
- inbox.remove(message.id);
26023
- if (liveAgent.status === "running") liveAgent.steer(message);
26024
- else liveAgent.followup(message);
26025
- app.notify("steering queued message", "info");
26026
- } else if (action === "edit" || action === "insert") runOwned("queued message edit", () => app.askQuestions([{
26027
- id: "q",
26028
- question: action === "edit" ? "Edit queued message:" : "New message to insert:"
26029
- }]), {
26030
- diag: runner.diag,
26031
- sessionId: () => runner.liveAgent?.session.id,
26032
- onResult: (answers) => {
26033
- const text = answers[0]?.custom?.trim() ?? "";
26034
- if (text === "") return;
26035
- const next = createUserMessage({
26036
- content: [{
26037
- type: "text",
26038
- text
26039
- }],
26040
- source: { kind: "user" }
26041
- });
26042
- if (action === "edit") {
26043
- inbox.replace(message.id, next);
26044
- app.notify("queued message updated", "info");
26045
- } else {
26046
- const position = (target.list === "next-turn" ? inbox.nextTurn : inbox.nextStep).findIndex((item) => item.id === message.id);
26047
- if (position < 0) return;
26048
- inbox.splice(target.list, position, 0, [next]);
26049
- app.notify("message inserted before the selected one", "info");
26050
- }
26051
- }
26052
- });
26053
- }, () => {});
26054
- return { kind: "success" };
26055
- }
26056
- });
26057
- commands.register({
26696
+ registerTuiCommand({
26058
26697
  name: "tasks",
26059
- description: "List background jobs for this session",
26698
+ description: "Browse background jobs and subagents for this session (search filters rows)",
26699
+ aliases: ["subagents"],
26060
26700
  handler: async () => {
26061
- const liveAgent = await requireAgent();
26062
- const jobs = ctx.get("jobs");
26063
- if (jobs === void 0) return {
26064
- kind: "error",
26065
- text: "jobs service unavailable"
26066
- };
26067
- const snapshots = jobs.list(liveAgent);
26068
- if (snapshots.length === 0) return {
26069
- kind: "error",
26070
- text: "no background jobs"
26071
- };
26072
- app.openTaskBrowser(snapshots.map((job) => ({
26073
- value: job.id,
26074
- label: `${job.kind} · ${job.label}`,
26075
- status: job.status,
26076
- detail: job.detail,
26077
- startedAt: job.startedAt,
26078
- group: "jobs"
26079
- })), (jobId) => runner.openJobView(jobId), () => {}, {
26080
- header: "tasks",
26081
- enableSearch: true
26082
- });
26701
+ await requireAgent();
26702
+ runner.openTasksBrowser();
26083
26703
  return { kind: "success" };
26084
26704
  }
26085
26705
  });
@@ -26271,56 +26891,6 @@ function registerTuiCommands(runner, initial) {
26271
26891
  return { kind: "success" };
26272
26892
  }
26273
26893
  });
26274
- commands.register({
26275
- name: "subagents",
26276
- description: "List child agents; view a transcript or interrupt one",
26277
- handler: async () => {
26278
- const liveAgent = await requireAgent();
26279
- const subagents = ctx.get("subagents");
26280
- if (subagents === void 0) return {
26281
- kind: "error",
26282
- text: "subagent service unavailable"
26283
- };
26284
- const children = (await subagents.listChildren(liveAgent.session.id)).filter((child) => child.kind === "child");
26285
- if (children.length === 0) return {
26286
- kind: "success",
26287
- text: "no subagents for this session"
26288
- };
26289
- const labelOf = (child) => child.label ?? child.id;
26290
- const closeSubagents = app.openSettings(children.map((child) => ({
26291
- id: child.id,
26292
- label: labelOf(child),
26293
- description: `${child.mode} · ${child.activity}${child.hasChildren ? " · has children" : ""}`,
26294
- currentValue: "",
26295
- submenu: (value, done) => new SettingsList([{
26296
- id: "view",
26297
- label: "View transcript",
26298
- description: "Watch this session read-only (Esc to return)",
26299
- currentValue: "",
26300
- values: ["✓"]
26301
- }, {
26302
- id: "interrupt",
26303
- label: "Interrupt",
26304
- description: "Cancel the child agent",
26305
- currentValue: "",
26306
- values: ["✓"]
26307
- }], 6, settingsListTheme(), (id) => done(id), () => done(), {})
26308
- })), (childId, action) => {
26309
- const child = children.find((candidate) => candidate.id === childId);
26310
- if (child === void 0) return;
26311
- closeSubagents();
26312
- if (action === "view") detach("subagent view", () => runner.enterView(child.id, labelOf(child)), { notify: true });
26313
- else if (action === "interrupt") {
26314
- subagents.interrupt(child.id, {
26315
- kind: "user",
26316
- parentSessionId: liveAgent.session.id
26317
- });
26318
- app.notify(`interrupting ${labelOf(child)}`, "info");
26319
- }
26320
- }, () => {});
26321
- return { kind: "success" };
26322
- }
26323
- });
26324
26894
  commands.register({
26325
26895
  name: "search",
26326
26896
  description: "Search persisted sessions for text and switch to a hit",
@@ -26412,16 +26982,11 @@ function registerTuiCommands(runner, initial) {
26412
26982
  };
26413
26983
  }
26414
26984
  };
26415
- commands.register({
26985
+ registerTuiCommand({
26416
26986
  name: "title",
26417
26987
  description: "Set the session title; without an argument, regenerate it from the conversation (overwrites the current title)",
26418
26988
  input: { hint: "<title>" },
26419
- handler: titleHandler
26420
- });
26421
- commands.register({
26422
- name: "rename",
26423
- description: "Alias of /title: set the session title, or regenerate it without an argument (overwrites the current title)",
26424
- input: { hint: "<title>" },
26989
+ aliases: ["rename"],
26425
26990
  handler: titleHandler
26426
26991
  });
26427
26992
  commands.register({
@@ -27460,6 +28025,17 @@ function shellCommandOf(text) {
27460
28025
  return text.replace(/^!+/, "").trim();
27461
28026
  }
27462
28027
  /**
28028
+ * The local-shell sandbox preference for user-typed `!`/`!!` commands:
28029
+ * 'sandbox' routes them through the dsh shell capability's policy,
28030
+ * anything else (including an absent settings document) is 'bypass' — the
28031
+ * pi/kimi default that runs the user's own commands outside the sandbox
28032
+ * (the sandbox guards the model's autonomous commands, not the user's).
28033
+ * @param settings - the TUI settings document's fields, when present.
28034
+ */
28035
+ function localShellSandboxPreferenceOf(settings) {
28036
+ return settings?.localShellSandbox === "sandbox" ? "sandbox" : "bypass";
28037
+ }
28038
+ /**
27463
28039
  * The model-facing submission text: the command echoed `$`-style (kimi
27464
28040
  * ShellExecution parity) followed by the settled card result (output +
27465
28041
  * `[exit N]` / truncation lines).
@@ -27678,82 +28254,6 @@ function formatBytes(bytes) {
27678
28254
  return `${(bytes / 1048576).toFixed(1)} MiB`;
27679
28255
  }
27680
28256
  //#endregion
27681
- //#region src/shell-words.ts
27682
- /**
27683
- * A small, reliable POSIX-ish shell word parser for external-command
27684
- * strings like `$VISUAL`/`$EDITOR` (`code --wait`, `vim -f`, `sensible-editor
27685
- * --nofork`) and `!` shell lines. It handles the quoting rules that matter
27686
- * in practice — single quotes (literal), double quotes (backslash escapes,
27687
- * everything else verbatim — no expansion), and backslash escapes outside
27688
- * quotes — WITHOUT executing a shell.
27689
- *
27690
- * This deliberately replaces naive `command.split(/\s+/)` splitting: an
27691
- * editor path with spaces or a quoted argument would be mangled into
27692
- * phantom arguments.
27693
- * @module @xmoon76/dsh-pi-tui/shell-words
27694
- */
27695
- /**
27696
- * Split a command line into words, POSIX-ish. Never throws: unbalanced
27697
- * quotes are treated as a trailing literal word (matching how shells
27698
- * consume the rest of the line). Backslashes before non-special characters
27699
- * are dropped outside quotes; inside double quotes a backslash only escapes
27700
- * `"`, `\`, and `$` and is kept verbatim before anything else.
27701
- * @param line - the raw command line.
27702
- * @returns the parsed words.
27703
- */
27704
- function parseShellWords(line) {
27705
- const words = [];
27706
- let current = "";
27707
- let inWord = false;
27708
- let quote;
27709
- for (let index = 0; index < line.length; index += 1) {
27710
- const char = line[index];
27711
- if (quote === void 0 && char === "\\") {
27712
- const next = line[index + 1];
27713
- if (next !== void 0) {
27714
- current += next;
27715
- index += 1;
27716
- } else current += "\\";
27717
- inWord = true;
27718
- continue;
27719
- }
27720
- if (quote === void 0 && (char === "'" || char === "\"")) {
27721
- quote = char;
27722
- inWord = true;
27723
- continue;
27724
- }
27725
- if (quote === "\"" && char === "\\") {
27726
- const next = line[index + 1];
27727
- if (next === "\"" || next === "\\" || next === "$") {
27728
- current += next;
27729
- index += 1;
27730
- } else current += "\\";
27731
- inWord = true;
27732
- continue;
27733
- }
27734
- if (quote === "'" && char === "'") {
27735
- quote = void 0;
27736
- continue;
27737
- }
27738
- if (quote === "\"" && char === "\"") {
27739
- quote = void 0;
27740
- continue;
27741
- }
27742
- if (quote === void 0 && (char === " " || char === " " || char === "\n")) {
27743
- if (inWord) {
27744
- words.push(current);
27745
- current = "";
27746
- inWord = false;
27747
- }
27748
- continue;
27749
- }
27750
- current += char;
27751
- inWord = true;
27752
- }
27753
- if (inWord) words.push(current);
27754
- return words;
27755
- }
27756
- //#endregion
27757
28257
  //#region src/skill-catalog-refresh.ts
27758
28258
  /** Whether two targets are the same owner (no transition needed). The
27759
28259
  * preset identity is the caller-provided presetId compared EXACTLY —
@@ -28294,7 +28794,6 @@ const LOCAL_COMMANDS = /* @__PURE__ */ new Set([
28294
28794
  "model",
28295
28795
  "new",
28296
28796
  "preset",
28297
- "queue",
28298
28797
  "quit",
28299
28798
  "reload",
28300
28799
  "rename",
@@ -28431,15 +28930,30 @@ function subagentJobTranscriptId(snapshot) {
28431
28930
  /** Viewer body for a subagent job with no uniquely matched child. */
28432
28931
  function subagentJobViewHint(status, detail) {
28433
28932
  return [
28434
- `status: ${status}${status === "running" || status === "stopping" ? " — running in the background; its transcript updates live in /subagents" : ` — this subagent finished${detail === void 0 ? "" : ` (${detail})`}`}`,
28933
+ `status: ${status}${status === "running" || status === "stopping" ? " — running in the background; its transcript updates live in /tasks" : ` — this subagent finished${detail === void 0 ? "" : ` (${detail})`}`}`,
28435
28934
  "",
28436
28935
  "The job record does not carry the child session id, so this job cannot",
28437
28936
  "be matched to its child from the task browser (a same-label foreground",
28438
- "run would be indistinguishable). Open /subagents and pick the child by",
28937
+ "run would be indistinguishable). Open /tasks and pick the child by",
28439
28938
  "its label to read the transcript."
28440
28939
  ].join("\n");
28441
28940
  }
28442
28941
  /**
28942
+ * Whether an inbox message is USER-ORIGIN input — the queue pane's steerable
28943
+ * `❯` rows. Everything else is injected context, not the user's own queued
28944
+ * input, and must never read as one: plugin notices (background-job
28945
+ * completions), `subagent-report` relays (a child's active report, e.g.
28946
+ * "Background subagent X reported:"), injected skill/agent instructions,
28947
+ * goal messages. The web makes the same cut (`placement: source.kind ===
28948
+ * 'user' ? 'steering' : 'context'`), and this deployment's queue pane has
28949
+ * the same rule: only user-origin rows are steerable. A sourceless row
28950
+ * (undefined) is treated as user input — plain rows never carry a source.
28951
+ * @param source - the message source projection, or undefined for a plain row.
28952
+ */
28953
+ function isUserQueueInput(source) {
28954
+ return source === void 0 || source.kind === "user";
28955
+ }
28956
+ /**
28443
28957
  * Whether a plain submitted draft is the quit word: exactly `exit` (trimmed,
28444
28958
  * lowercase). The runner intercepts this BEFORE any session creation or
28445
28959
  * submission (shell muscle memory); anything else — `exit!`, `Exit`, or a
@@ -28511,7 +29025,7 @@ function foldQueueRows(messages, mode, notified) {
28511
29025
  id: message.id,
28512
29026
  text: textOf(message.content),
28513
29027
  mode,
28514
- notice: source?.form === "notice"
29028
+ notice: !isUserQueueInput(source)
28515
29029
  });
28516
29030
  }
28517
29031
  return {
@@ -29292,9 +29806,11 @@ function apply(ctx, config) {
29292
29806
  /**
29293
29807
  * The LIVE session's workspace: each session carries its own header cwd
29294
29808
  * (fixed at creation, e.g. a session birthed by the web in another
29295
- * directory). The footer/welcome/completions follow THIS cwd so a
29296
- * session switch updates the whole surface; `cwd` (the process cwd)
29297
- * stays for launch-relative concerns (`!` shell, /export paths).
29809
+ * directory). The footer/welcome/completions AND `!`/`!!` shell runs
29810
+ * follow THIS cwd, so a session switch moves the whole surface with the
29811
+ * session (pi parity: `executeBash` runs in the session cwd) and a
29812
+ * shell command executes where the completions suggest files; `cwd`
29813
+ * (the process cwd) stays for launch-relative concerns (/export paths).
29298
29814
  */
29299
29815
  const sessionCwd = () => liveAgent?.session.header.cwd ?? cwd;
29300
29816
  /** The footer model label: the live selection (with effort) when one exists. */
@@ -29466,11 +29982,13 @@ function apply(ctx, config) {
29466
29982
  });
29467
29983
  if (includeInContext && !localSignal.aborted) submitResult(result);
29468
29984
  };
29469
- const shell = ctx.get("shell");
29985
+ const sandboxPreference = localShellSandboxPreferenceOf(tuiSettings?.get());
29986
+ const shell = sandboxPreference === "sandbox" ? ctx.get("shell") : void 0;
29987
+ if (shell === void 0 && sandboxPreference === "sandbox") app.notify("local shell sandbox unavailable in this composition — running unsandboxed", "error");
29470
29988
  if (shell !== void 0) {
29471
29989
  const spec = shell.resolve({
29472
29990
  command,
29473
- workdir: cwd,
29991
+ workdir: sessionCwd(),
29474
29992
  signal: localSignal
29475
29993
  });
29476
29994
  runOwned("local shell", () => shell.run(spec), {
@@ -29500,7 +30018,7 @@ function apply(ctx, config) {
29500
30018
  return;
29501
30019
  }
29502
30020
  const child = spawn(command, {
29503
- cwd,
30021
+ cwd: sessionCwd(),
29504
30022
  stdio: [
29505
30023
  "ignore",
29506
30024
  "pipe",
@@ -30063,8 +30581,7 @@ function apply(ctx, config) {
30063
30581
  },
30064
30582
  onDequeue: () => {
30065
30583
  if (liveAgent === void 0) return;
30066
- const isNotice = (message) => message.source?.form === "notice";
30067
- const queued = [...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].filter((message) => !isNotice(message));
30584
+ const queued = [...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].filter((message) => isUserQueueInput(message.source));
30068
30585
  if (queued.length === 0) return;
30069
30586
  for (const message of queued) liveAgent.inbox.remove(message.id);
30070
30587
  const queuedText = queued.map((message) => textOf(message.content)).join("\n\n");
@@ -30072,58 +30589,7 @@ function apply(ctx, config) {
30072
30589
  app.setDraft([queuedText, current].filter((part) => part.trim() !== "").join("\n\n"));
30073
30590
  refreshQueue();
30074
30591
  },
30075
- onOpenTasks: () => {
30076
- if (liveAgent === void 0) return;
30077
- let jobSnapshots = [];
30078
- if (jobs !== void 0) try {
30079
- jobSnapshots = jobs.list(liveAgent);
30080
- } catch {}
30081
- let rows = buildTaskRows(jobSnapshots, []);
30082
- const selectRow = (value) => {
30083
- const row = rows.find((candidate) => candidate.value === value);
30084
- if (row === void 0) return;
30085
- if (row.kind === "subagent") {
30086
- runOwned("subagent view from tasks", () => enterView(row.childId, row.label), {
30087
- diag,
30088
- sessionId: () => liveAgent?.session.id,
30089
- onError: (error) => app.notify(`could not open the subagent view: ${safeErrorMessage(error)}`, "error")
30090
- });
30091
- return;
30092
- }
30093
- openJobView(row.jobId);
30094
- };
30095
- const taskPanelItems = (target) => target.map((row) => row.kind === "job" ? {
30096
- value: row.value,
30097
- label: taskRowLabel(row),
30098
- status: row.status,
30099
- detail: row.detail,
30100
- startedAt: row.startedAt,
30101
- group: rowGroup(row)
30102
- } : {
30103
- value: row.value,
30104
- label: taskRowLabel(row),
30105
- status: row.activity,
30106
- detail: row.hasChildren ? "has children" : void 0,
30107
- group: rowGroup(row)
30108
- });
30109
- const handle = app.openTaskBrowser(taskPanelItems(rows), selectRow, () => {}, {
30110
- header: "tasks · subagents",
30111
- enableSearch: true
30112
- });
30113
- if (subagents !== void 0) {
30114
- const sessionId = liveAgent.session.id;
30115
- const generation = sessionGeneration;
30116
- runOwned("task browser children", () => subagents.listChildren(sessionId), {
30117
- diag,
30118
- sessionId: () => liveAgent?.session.id,
30119
- onResult: (entries) => {
30120
- if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
30121
- rows = buildTaskRows(jobSnapshots, entries);
30122
- handle.setItems(taskPanelItems(rows));
30123
- }
30124
- });
30125
- }
30126
- }
30592
+ onOpenTasks: () => openTasksBrowser()
30127
30593
  }, {
30128
30594
  present,
30129
30595
  workspaceRoot: cwd,
@@ -30142,6 +30608,73 @@ function apply(ctx, config) {
30142
30608
  unstableInputsRevision: () => extensionService?._unstableInputsRevision() ?? 0,
30143
30609
  unstableFailSafeRelease: () => extensionService?._unstableEmergencyRelease()
30144
30610
  });
30611
+ const openTasksBrowser = () => {
30612
+ if (liveAgent === void 0) return;
30613
+ let jobSnapshots = [];
30614
+ if (jobs !== void 0) try {
30615
+ jobSnapshots = jobs.list(liveAgent);
30616
+ } catch {}
30617
+ let rows = buildTaskRows(jobSnapshots, []);
30618
+ const selectRow = (value) => {
30619
+ const row = rows.find((candidate) => candidate.value === value);
30620
+ if (row === void 0) return;
30621
+ if (row.kind === "subagent") {
30622
+ runOwned("subagent view from tasks", () => enterView(row.childId, row.label), {
30623
+ diag,
30624
+ sessionId: () => liveAgent?.session.id,
30625
+ onError: (error) => app.notify(`could not open the subagent view: ${safeErrorMessage(error)}`, "error")
30626
+ });
30627
+ return;
30628
+ }
30629
+ openJobView(row.jobId);
30630
+ };
30631
+ const actionRow = (value, action) => {
30632
+ const row = rows.find((candidate) => candidate.value === value);
30633
+ if (row === void 0 || row.kind !== "subagent") return;
30634
+ const service = ctx.get("subagents");
30635
+ if (service === void 0 || liveAgent === void 0) {
30636
+ app.notify("subagent service unavailable", "error");
30637
+ return;
30638
+ }
30639
+ service.interrupt(row.childId, {
30640
+ kind: "user",
30641
+ parentSessionId: liveAgent.session.id
30642
+ });
30643
+ app.notify(`interrupting ${row.label}`, "info");
30644
+ };
30645
+ const taskPanelItems = (target) => target.map((row) => row.kind === "job" ? {
30646
+ value: row.value,
30647
+ label: taskRowLabel(row),
30648
+ status: row.status,
30649
+ detail: row.detail,
30650
+ startedAt: row.startedAt,
30651
+ group: rowGroup(row)
30652
+ } : {
30653
+ value: row.value,
30654
+ label: taskRowLabel(row),
30655
+ status: row.activity,
30656
+ detail: row.hasChildren ? "has children" : void 0,
30657
+ group: rowGroup(row)
30658
+ });
30659
+ const handle = app.openTaskBrowser(taskPanelItems(rows), selectRow, () => {}, {
30660
+ header: "tasks · subagents",
30661
+ enableSearch: true,
30662
+ onAction: actionRow
30663
+ });
30664
+ if (subagents !== void 0) {
30665
+ const sessionId = liveAgent.session.id;
30666
+ const generation = sessionGeneration;
30667
+ runOwned("task browser children", () => subagents.listChildren(sessionId), {
30668
+ diag,
30669
+ sessionId: () => liveAgent?.session.id,
30670
+ onResult: (entries) => {
30671
+ if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
30672
+ rows = buildTaskRows(jobSnapshots, entries);
30673
+ handle.setItems(taskPanelItems(rows));
30674
+ }
30675
+ });
30676
+ }
30677
+ };
30145
30678
  if (extensionHost !== void 0 && extensionService !== void 0) {
30146
30679
  app.setRendererErrorSink(({ id, error, slot }) => {
30147
30680
  const message = safeErrorMessage(error).replace(/\s+/g, " ").slice(0, 200);
@@ -30186,12 +30719,14 @@ function apply(ctx, config) {
30186
30719
  theme: z.string(),
30187
30720
  footer: z.string(),
30188
30721
  fullscreen: z.string(),
30189
- busyEnter: z.string()
30722
+ busyEnter: z.string(),
30723
+ localShellSandbox: z.string()
30190
30724
  }), { base: {
30191
30725
  theme: "auto",
30192
30726
  footer: "full",
30193
30727
  fullscreen: "on",
30194
- busyEnter: "queue"
30728
+ busyEnter: "queue",
30729
+ localShellSandbox: "bypass"
30195
30730
  } });
30196
30731
  if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
30197
30732
  const storedTheme = tuiSettings?.get().theme;
@@ -30305,7 +30840,7 @@ function apply(ctx, config) {
30305
30840
  * (never the output — the job's single read cursor belongs to the agent's
30306
30841
  * job_output; consuming it from the UI would leave the model an
30307
30842
  * incomplete result and could swallow the completion notice); a subagent
30308
- * job shows the status viewer with a /subagents hint. The job record
30843
+ * job shows the status viewer with a /tasks hint. The job record
30309
30844
  * carries no child session id, so label/order/time heuristics cannot
30310
30845
  * distinguish a background child from a same-label foreground one-shot;
30311
30846
  * the task browser therefore never opens a transcript by guess.
@@ -30338,7 +30873,7 @@ function apply(ctx, config) {
30338
30873
  };
30339
30874
  /**
30340
30875
  * Status-only viewer for one job (never touches the read cursor). The
30341
- * subagent variant appends the /subagents hint because a transcript
30876
+ * subagent variant appends the /tasks hint because a transcript
30342
30877
  * cannot always be matched; the bash variant is pure status.
30343
30878
  */
30344
30879
  const openJobStatusViewer = (jobId, title, snapshot) => {
@@ -30608,6 +31143,7 @@ function apply(ctx, config) {
30608
31143
  refreshStatus,
30609
31144
  updateWelcomeCard,
30610
31145
  openJobView,
31146
+ openTasksBrowser,
30611
31147
  enterView,
30612
31148
  requestExit,
30613
31149
  exit
@@ -30848,4 +31384,4 @@ function apply(ctx, config) {
30848
31384
  });
30849
31385
  }
30850
31386
  //#endregion
30851
- export { inject$1 as A, shouldConsumeAdvertisedMiss as C, subagentNoticeIsFailure as D, subagentJobViewHint as E, PI_TUI_EXTENSIONS_SERVICE as M, API_VERSION as N, forkSeed as O, describeKey as P, runningProfile as S, subagentJobTranscriptId as T, name as _, SessionLockRefusedError as a, resolveInitialCatalog as b, compactingFromLog as c, foldCompactionEvent as d, foldQueueRows as f, matchPendingSubagentCall as g, isSubagentSettlementNotice as h, SESSIONLESS_COMMANDS as i, name$1 as j, apply$1 as k, composeAgent as l, isPlainExitPrompt as m, HOST_COMMAND_CATALOG as n, apply as o, inject as p, LOCAL_COMMANDS as r, busyAfterTurnBoundary as s, Config as t, dangerCommand as u, recomposeBlank as v, shouldSteerOnEnter as w, resumeCommand as x, recordedPreset as y };
31387
+ export { apply$1 as A, runningProfile as C, subagentJobViewHint as D, subagentJobTranscriptId as E, describeKey as F, name$1 as M, PI_TUI_EXTENSIONS_SERVICE as N, subagentNoticeIsFailure as O, API_VERSION as P, resumeCommand as S, shouldSteerOnEnter as T, matchPendingSubagentCall as _, SessionLockRefusedError as a, recordedPreset as b, compactingFromLog as c, foldCompactionEvent as d, foldQueueRows as f, isUserQueueInput as g, isSubagentSettlementNotice as h, SESSIONLESS_COMMANDS as i, inject$1 as j, forkSeed as k, composeAgent as l, isPlainExitPrompt as m, HOST_COMMAND_CATALOG as n, apply as o, inject as p, LOCAL_COMMANDS as r, busyAfterTurnBoundary as s, Config as t, dangerCommand as u, name as v, shouldConsumeAdvertisedMiss as w, resolveInitialCatalog as x, recomposeBlank as y };