@xmoon76/dsh-pi-tui 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,8 @@ import { fileURLToPath } from "node:url";
31
31
  import { EventEmitter } from "events";
32
32
  import { Service } from "@deepseek-ai/cordis";
33
33
  import { isUserInvocable, renderSkillContent } from "@deepseek-ai/dsh-skill";
34
+ import { AuthorizationDeclinedError } from "@deepseek-ai/dsh-authorization";
35
+ import { credentialKeyId, credentialKeyScope } from "@deepseek-ai/dsh-credentials";
34
36
  //#region src/present.ts
35
37
  /** Figma row titles per variant (design literals, not translatable copy). */
36
38
  const VARIANT_TITLES = {
@@ -85,8 +87,121 @@ const TUI_TOOL_TITLES = {
85
87
  "workflow-member": "Workflow Agent",
86
88
  error: "Error",
87
89
  interrupted: "Interrupted",
88
- ask_user_question: "Question"
90
+ ask_user_question: "Question",
91
+ get_goal: "Read Goal",
92
+ create_goal: "Create Goal",
93
+ update_goal: "Update Goal"
89
94
  };
95
+ /** The goal tool family (all three share the `{"goal":…}` result shape). */
96
+ const GOAL_TOOL_NAMES = /* @__PURE__ */ new Set([
97
+ "get_goal",
98
+ "create_goal",
99
+ "update_goal"
100
+ ]);
101
+ /**
102
+ * Tools whose folded result preview must never leak the raw result JSON
103
+ * (web parity: the web's folded row shows only the args summary, never the
104
+ * result). The TUI keeps its result-preview row but shows a parsed summary
105
+ * instead — and nothing at all when the result cannot be parsed.
106
+ *
107
+ * `ralph`'s render already leads with a friendly line ("Ralph worker
108
+ * reported completion after N rounds.") before the JSON report, so its
109
+ * summary IS that first line. The agent-team family is deliberately NOT in
110
+ * the set: it is experimental (not in the production bundle) and two of its
111
+ * tools (`send_message`, `interrupt_agent`) share names with subagent tools
112
+ * whose render text is friendly — a name-keyed set cannot tell them apart.
113
+ */
114
+ const FOLDED_JSON_RESULT_TOOLS = /* @__PURE__ */ new Set([
115
+ "ralph",
116
+ "schedule_create",
117
+ "schedule_list",
118
+ "schedule_delete",
119
+ "cordis_inspect_list",
120
+ "cordis_inspect_query",
121
+ "cordis_inspect_self"
122
+ ]);
123
+ /** Parse a result text as JSON, tolerating a friendly prefix line before
124
+ * the JSON document (e.g. ralph's "…\nFinal report:\n{…}"). */
125
+ function parseJsonValue(text) {
126
+ try {
127
+ return JSON.parse(text);
128
+ } catch {}
129
+ const brace = text.indexOf("{");
130
+ const bracket = text.indexOf("[");
131
+ const start = bracket !== -1 && (brace === -1 || bracket < brace) ? bracket : brace;
132
+ if (start > 0) try {
133
+ return JSON.parse(text.slice(start));
134
+ } catch {
135
+ return;
136
+ }
137
+ }
138
+ /** A non-empty string field, or undefined. */
139
+ function stringField(value) {
140
+ return typeof value === "string" && value !== "" ? value : void 0;
141
+ }
142
+ /**
143
+ * The folded preview summary for one of {@link FOLDED_JSON_RESULT_TOOLS}:
144
+ * a short human phrase derived from the parsed result JSON. Returns
145
+ * undefined when nothing useful can be derived — the caller then shows NO
146
+ * folded result preview (web parity), never the raw JSON.
147
+ * @param name - the tool name.
148
+ * @param text - the settled result text.
149
+ */
150
+ function foldedResultSummaryFor(name, text) {
151
+ if (name === "ralph") {
152
+ const line = firstLine(text);
153
+ if (line !== "" && !line.startsWith("{")) return line;
154
+ const parsed = parseJsonValue(text);
155
+ if (typeof parsed === "object" && parsed !== null) {
156
+ const record = parsed;
157
+ const status = stringField(record.status);
158
+ const rounds = typeof record.roundsStarted === "number" ? String(record.roundsStarted) : void 0;
159
+ const parts = [status, rounds === void 0 ? void 0 : `${rounds} rounds`].filter((part) => part !== void 0);
160
+ return parts.length === 0 ? void 0 : parts.join(" · ");
161
+ }
162
+ return;
163
+ }
164
+ const parsed = parseJsonValue(text);
165
+ if (name === "schedule_list") {
166
+ if (Array.isArray(parsed)) return parsed.length === 0 ? "no scheduled jobs" : `${parsed.length} scheduled`;
167
+ if (typeof parsed === "object" && parsed !== null) return stringField(parsed.code);
168
+ return;
169
+ }
170
+ if (name === "schedule_create") {
171
+ if (typeof parsed !== "object" || parsed === null) return void 0;
172
+ const record = parsed;
173
+ const parts = [stringField(record.kind), stringField(record.state)].filter((part) => part !== void 0);
174
+ return parts.length === 0 ? void 0 : parts.join(" · ");
175
+ }
176
+ if (name === "schedule_delete") {
177
+ if (typeof parsed !== "object" || parsed === null) return void 0;
178
+ const record = parsed;
179
+ if (record.deleted === true) return "deleted";
180
+ if (record.deleted === false) return "not found";
181
+ return stringField(record.code);
182
+ }
183
+ if (name === "cordis_inspect_list") {
184
+ if (typeof parsed !== "object" || parsed === null) return void 0;
185
+ const providers = parsed.providers;
186
+ if (Array.isArray(providers)) return providers.length === 0 ? "no providers" : `${providers.length} providers`;
187
+ return;
188
+ }
189
+ if (name === "cordis_inspect_query") {
190
+ if (typeof parsed !== "object" || parsed === null) return void 0;
191
+ const record = parsed;
192
+ const parts = [
193
+ stringField(record.platform),
194
+ stringField(record.provider),
195
+ stringField(record.method)
196
+ ].filter((part) => part !== void 0);
197
+ return parts.length === 0 ? void 0 : parts.join(" · ");
198
+ }
199
+ if (name === "cordis_inspect_self") {
200
+ if (typeof parsed !== "object" || parsed === null) return void 0;
201
+ const mode = stringField(parsed.mode);
202
+ return mode === void 0 ? void 0 : `mode ${mode}`;
203
+ }
204
+ }
90
205
  /** Summary key preference per variant (args-derived). */
91
206
  const SUMMARY_KEYS = {
92
207
  bash: ["description", "command"],
@@ -172,6 +287,15 @@ function summarizeToolArgs(name, argsRaw) {
172
287
  if (typeof question !== "string" || question === "") return void 0;
173
288
  return firstLine(question);
174
289
  }
290
+ if (name === "create_goal") {
291
+ const objective = args.objective;
292
+ return typeof objective === "string" && objective !== "" ? firstLine(objective) : void 0;
293
+ }
294
+ if (name === "update_goal") {
295
+ const action = args.action;
296
+ return typeof action === "string" && action !== "" ? action : void 0;
297
+ }
298
+ if (name === "get_goal") return "";
175
299
  if (name !== "todo_write") return void 0;
176
300
  const todos = args.todos;
177
301
  if (!Array.isArray(todos)) return void 0;
@@ -421,6 +545,36 @@ function resultTextLines(blocks, error) {
421
545
  return lines;
422
546
  }
423
547
  /**
548
+ * Parse and normalize the tool's `{"answers":[…]}` render text. A
549
+ * malformed entry (non-object) invalidates the WHOLE text — the Web's
550
+ * `answeredSummary` checks `answers.every(isAnswer)` the same way, and a
551
+ * half-valid set would make the count and the rendered rows disagree.
552
+ * Non-string `selected` members are dropped; missing id/custom normalize
553
+ * to empty.
554
+ * @param text - the settled result text.
555
+ * @returns the normalized entries, or undefined when unparseable/malformed.
556
+ */
557
+ function parseAnswerEntries(text) {
558
+ let parsed;
559
+ try {
560
+ parsed = JSON.parse(text);
561
+ } catch {
562
+ return;
563
+ }
564
+ if (typeof parsed !== "object" || parsed === null) return void 0;
565
+ const answers = parsed.answers;
566
+ if (!Array.isArray(answers)) return void 0;
567
+ if (!answers.every((entry) => typeof entry === "object" && entry !== null)) return void 0;
568
+ return answers.map((entry) => {
569
+ const candidate = entry;
570
+ return {
571
+ id: typeof candidate.id === "string" ? candidate.id : "",
572
+ selected: Array.isArray(candidate.selected) ? candidate.selected.filter((item) => typeof item === "string") : [],
573
+ custom: typeof candidate.custom === "string" ? candidate.custom : ""
574
+ };
575
+ });
576
+ }
577
+ /**
424
578
  * The answered-count summary for a settled `ask_user_question` result
425
579
  * (Web AskQuestionRow parity): parses the tool's `{"answers":[…]}` render
426
580
  * text and counts the entries that actually carry an answer — a non-empty
@@ -432,6 +586,43 @@ function resultTextLines(blocks, error) {
432
586
  * @returns the answered-count summary, or undefined when unparseable.
433
587
  */
434
588
  function askAnswersSummary(text) {
589
+ const entries = parseAnswerEntries(text);
590
+ if (entries === void 0) return void 0;
591
+ return `${entries.filter((entry) => entry.selected.length > 0 || entry.custom !== "").length}/${entries.length} answered`;
592
+ }
593
+ /**
594
+ * The per-question display lines for a settled `ask_user_question` result
595
+ * (the expanded card's body, complementing {@link askAnswersSummary}'s
596
+ * count). Parses the tool's `{"answers":[…]}` render text and renders one
597
+ * line per entry: `● <id> → <selected, custom>` for an answered question,
598
+ * `○ <id> — skipped` when neither is present. Shares the normalization with
599
+ * {@link askAnswersSummary}, so the rendered rows and the count always
600
+ * agree; an unparseable/malformed text returns undefined.
601
+ * @param text - the settled result text.
602
+ * @returns the display lines, or undefined when unparseable/malformed.
603
+ */
604
+ function askAnswersLines(text) {
605
+ const entries = parseAnswerEntries(text);
606
+ if (entries === void 0) return void 0;
607
+ return entries.map((entry) => {
608
+ if (entry.selected.length > 0) return {
609
+ text: `● ${entry.id} → ${entry.selected.join(", ")}`,
610
+ skipped: false
611
+ };
612
+ if (entry.custom !== "") return {
613
+ text: `● ${entry.id} → ${entry.custom}`,
614
+ skipped: false
615
+ };
616
+ return {
617
+ text: `○ ${entry.id} — skipped`,
618
+ skipped: true
619
+ };
620
+ });
621
+ }
622
+ /** Parse a settled goal-tool result text, or undefined when it is not the
623
+ * expected `{"goal":…}` JSON (the caller then falls back to the generic
624
+ * presentation). */
625
+ function parseGoalResult(text) {
435
626
  let parsed;
436
627
  try {
437
628
  parsed = JSON.parse(text);
@@ -439,14 +630,72 @@ function askAnswersSummary(text) {
439
630
  return;
440
631
  }
441
632
  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`;
633
+ const value = parsed;
634
+ if (value.goal === null) return { goal: null };
635
+ if (typeof value.goal !== "object" || value.goal === null) return void 0;
636
+ const goal = value.goal;
637
+ const blocked = typeof goal.blockedReason === "object" && goal.blockedReason !== null ? goal.blockedReason : void 0;
638
+ return {
639
+ goal: {
640
+ ...typeof goal.id === "string" ? { id: goal.id } : {},
641
+ ...typeof goal.revision === "number" ? { revision: goal.revision } : {},
642
+ ...typeof goal.objective === "string" ? { objective: goal.objective } : {},
643
+ ...typeof goal.phase === "string" ? { phase: goal.phase } : {},
644
+ ...typeof goal.roundsStarted === "number" ? { roundsStarted: goal.roundsStarted } : {},
645
+ ...typeof goal.maxGoalRounds === "number" ? { maxGoalRounds: goal.maxGoalRounds } : {},
646
+ ...blocked !== void 0 ? { blockedReason: {
647
+ ...typeof blocked.code === "string" ? { code: blocked.code } : {},
648
+ ...typeof blocked.message === "string" ? { message: blocked.message } : {}
649
+ } } : {}
650
+ },
651
+ ...typeof value.activation === "string" ? { activation: value.activation } : {}
652
+ };
653
+ }
654
+ /**
655
+ * The one-line folded summary for a settled goal-tool result: `phase … ·
656
+ * revision N · N/M rounds`, or `no goal set` for `{"goal":null}`.
657
+ * Returns undefined when the text is not the expected result JSON (the
658
+ * caller then falls back to the generic preview instead of inventing one).
659
+ * @param text - the settled result text.
660
+ */
661
+ function goalResultSummary(text) {
662
+ const value = parseGoalResult(text);
663
+ if (value === void 0) return void 0;
664
+ const goal = value.goal;
665
+ if (goal === null) return "no goal set";
666
+ const parts = [];
667
+ if (goal.phase !== void 0) parts.push(`phase ${goal.phase}`);
668
+ if (goal.revision !== void 0) parts.push(`revision ${goal.revision}`);
669
+ if (goal.roundsStarted !== void 0) parts.push(goal.maxGoalRounds === void 0 ? `${goal.roundsStarted} rounds` : `${goal.roundsStarted}/${goal.maxGoalRounds} rounds`);
670
+ return parts.length === 0 ? "goal" : parts.join(" · ");
671
+ }
672
+ /**
673
+ * The expanded-card display lines for a settled goal-tool result: one field
674
+ * per line (`● objective: …`, `● phase: … · revision N`, `● rounds: N/M`,
675
+ * `● activation: …`, `● blocked: code — message`), or a single `no goal
676
+ * set` line for `{"goal":null}`. Returns undefined when the text is not the
677
+ * expected result JSON (the caller falls back to the generic presentation).
678
+ * @param text - the settled result text.
679
+ */
680
+ function goalResultLines(text) {
681
+ const value = parseGoalResult(text);
682
+ if (value === void 0) return void 0;
683
+ const goal = value.goal;
684
+ if (goal === null) return ["no goal set"];
685
+ const lines = [];
686
+ if (goal.objective !== void 0 && goal.objective !== "") lines.push(`● objective: ${goal.objective}`);
687
+ const identity = [];
688
+ if (typeof goal.phase === "string" && goal.phase !== "") identity.push(goal.phase);
689
+ if (goal.revision !== void 0) identity.push(`revision ${goal.revision}`);
690
+ if (identity.length > 0) lines.push(`● ${identity.join(" · ")}`);
691
+ if (goal.roundsStarted !== void 0) lines.push(goal.maxGoalRounds === void 0 ? `● rounds: ${goal.roundsStarted}` : `● rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`);
692
+ const blocked = goal.blockedReason;
693
+ if (blocked !== void 0 && (blocked.code !== void 0 || blocked.message !== void 0)) {
694
+ const detail = [blocked.code, blocked.message].filter((part) => typeof part === "string" && part !== "").join(" — ");
695
+ lines.push(`● blocked: ${detail}`);
696
+ }
697
+ if (value.activation !== void 0) lines.push(`● activation: ${value.activation}`);
698
+ return lines;
450
699
  }
451
700
  /**
452
701
  * The folded-card call preview for tools whose args carry a one-line
@@ -519,6 +768,41 @@ function toolPresenterFrom(get) {
519
768
  }
520
769
  };
521
770
  }
771
+ /**
772
+ * Subagent-family tool names whose call args may carry an explicit
773
+ * model/provider override. Matched by REGISTERED name; unknown names are
774
+ * ignored so third-party subagent-like tools keep their default rendering.
775
+ */
776
+ const SUBAGENT_TOOL_NAMES = /* @__PURE__ */ new Set([
777
+ "subagent",
778
+ "subagent_route",
779
+ "subagent_router",
780
+ "subagent_fork"
781
+ ]);
782
+ /**
783
+ * Extract the model · provider display line for a subagent-family tool call.
784
+ *
785
+ * COMPATIBILITY CONTRACT: the line renders ONLY when the call args actually
786
+ * carry a model/provider — the top-level `model`/`provider` shape
787
+ * (subagent_route / subagent_router) or the `agentOptions.model/provider`
788
+ * shape. Every other call returns `undefined` and the card renders exactly
789
+ * as before; unknown tool names and unparseable args are ignored. The
790
+ * official `subagent` tool's model lives in deployment config (never in
791
+ * the call args), so it naturally renders nothing.
792
+ * @param name - the tool's registered name.
793
+ * @param argsRaw - the raw tool-call arguments JSON ('' or malformed → none).
794
+ * @returns `model · provider` (either part alone when only one is present),
795
+ * or undefined when the call carries no model/provider.
796
+ */
797
+ function subagentModelDisplay(name, argsRaw) {
798
+ if (!SUBAGENT_TOOL_NAMES.has(name)) return void 0;
799
+ const parsed = parseArgs(argsRaw);
800
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
801
+ const args = parsed;
802
+ const opts = typeof args.agentOptions === "object" && args.agentOptions !== null ? args.agentOptions : void 0;
803
+ 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);
804
+ return parts.length === 0 ? void 0 : parts.join(" · ");
805
+ }
522
806
  //#endregion
523
807
  //#region src/context.ts
524
808
  /**
@@ -13154,6 +13438,7 @@ var TaskBrowserPanel = class {
13154
13438
  searchEnabled;
13155
13439
  onSelect;
13156
13440
  onCancel;
13441
+ onAction;
13157
13442
  /** Live tick: bumped every second while a running row is visible. */
13158
13443
  now = Date.now();
13159
13444
  tickTimer;
@@ -13168,6 +13453,7 @@ var TaskBrowserPanel = class {
13168
13453
  this.options = options;
13169
13454
  this.onSelect = onSelect;
13170
13455
  this.onCancel = onCancel;
13456
+ this.onAction = options.onAction;
13171
13457
  this.requestRender = requestRender;
13172
13458
  this.searchEnabled = options.enableSearch ?? false;
13173
13459
  this.searchInput.onEscape = () => this.onCancel();
@@ -13230,6 +13516,18 @@ var TaskBrowserPanel = class {
13230
13516
  const isNavUp = matchesKey(data, "up") || !this.searchEnabled && data === "k";
13231
13517
  const isNavDown = matchesKey(data, "down") || !this.searchEnabled && data === "j";
13232
13518
  const isPage = matchesKey(data, "pageUp") || matchesKey(data, "pageDown");
13519
+ if (data === "i" && this.onAction !== void 0 && !this.searchEnabled) {
13520
+ const item = this.filtered[this.selected];
13521
+ if (item !== void 0) this.onAction(item.value, "interrupt");
13522
+ return;
13523
+ }
13524
+ if (data === "i" && this.onAction !== void 0 && this.searchEnabled && (this.searchInput.getValue() ?? "") === "") {
13525
+ const item = this.filtered[this.selected];
13526
+ if (item !== void 0 && item.value.startsWith("agent:")) {
13527
+ this.onAction(item.value, "interrupt");
13528
+ return;
13529
+ }
13530
+ }
13233
13531
  if (!isNavUp && !isNavDown && !isPage && !matchesKey(data, "enter") && !matchesKey(data, "escape") && this.searchEnabled) {
13234
13532
  this.searchInput.handleInput(data);
13235
13533
  this.applyFilter(this.searchInput.getValue() ?? "");
@@ -13270,7 +13568,7 @@ var TaskBrowserPanel = class {
13270
13568
  if (query === "") this.filtered = this.items;
13271
13569
  else {
13272
13570
  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));
13571
+ this.filtered = this.items.filter((item) => `${item.value}\n${item.label}\n${item.status}\n${item.detail ?? ""}\n${item.group ?? ""}`.toLowerCase().includes(needle));
13274
13572
  }
13275
13573
  this.selected = 0;
13276
13574
  this.scroll = 0;
@@ -13346,7 +13644,7 @@ var TaskBrowserPanel = class {
13346
13644
  return out;
13347
13645
  }
13348
13646
  hint() {
13349
- return `${this.searchEnabled ? "type to filter · " : ""}↑↓ navigate · enter open · esc close`;
13647
+ return `${this.searchEnabled ? "type to filter · " : ""}${this.filtered.some((item) => item.value.startsWith("agent:")) ? "i interrupt · " : ""}↑↓ navigate · enter open · esc close`;
13350
13648
  }
13351
13649
  };
13352
13650
  //#endregion
@@ -13718,6 +14016,11 @@ var QuestionFlow = class {
13718
14016
  if (row.key === OTHER_ROW && this.editingOther) {
13719
14017
  const inputLine = this.otherInput.render(Math.max(1, width - visibleWidth(prefix)))[0] ?? "";
13720
14018
  const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
14019
+ if (question.masked === true && this.otherInput.getValue() !== "") {
14020
+ lines.push(prefix + color.textDim("•".repeat(this.otherInput.getValue().length)));
14021
+ hits.push(OTHER_ROW);
14022
+ continue;
14023
+ }
13721
14024
  lines.push(prefix + (this.otherInput.getValue() === "" ? color.textDim(row.label) : stripped));
13722
14025
  hits.push(OTHER_ROW);
13723
14026
  continue;
@@ -14030,7 +14333,7 @@ var QuestionFlow = class {
14030
14333
  const question = this.questions[qi];
14031
14334
  const draft = this.drafts[qi];
14032
14335
  if (question === void 0 || draft === void 0) continue;
14033
- const value = draft.skipped ? "(skipped)" : draft.custom !== "" && question.multiSelect !== true ? draft.custom : [...draft.selected].join(", ") + (draft.custom !== "" ? ` + ${draft.custom}` : "");
14336
+ const value = draft.skipped ? "(skipped)" : draft.custom !== "" && question.multiSelect !== true ? question.masked === true ? "•".repeat(draft.custom.length) : draft.custom : [...draft.selected].join(", ") + (draft.custom !== "" ? ` + ${draft.custom}` : "");
14034
14337
  reviewBudget = appendWrappedBudgeted(lines, `${color.textDim(`Q${qi + 1}`)} `, " ", question.question, safeWidth, reviewBudget);
14035
14338
  if (reviewBudget > 0) reviewBudget = appendWrappedBudgeted(lines, " ", " ", value === "" ? color.textDim("(no answer)") : value, safeWidth, reviewBudget);
14036
14339
  }
@@ -14091,7 +14394,8 @@ var QuestionFlow = class {
14091
14394
  if (optionless) {
14092
14395
  const inputLine = this.otherInput.render(Math.max(1, safeWidth - 2))[0] ?? "";
14093
14396
  const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
14094
- lines.push(this.otherInput.getValue() === "" ? color.textDim(" Type your answer…") : ` ${stripped}`);
14397
+ if (question.masked === true && this.otherInput.getValue() !== "") lines.push(` ${color.textDim("•".repeat(this.otherInput.getValue().length))}`);
14398
+ else lines.push(this.otherInput.getValue() === "" ? color.textDim(" Type your answer…") : ` ${stripped}`);
14095
14399
  }
14096
14400
  if (draft.skipped) lines.push(color.textDim("(skipped)"));
14097
14401
  lines.push("");
@@ -14120,6 +14424,305 @@ var QuestionFlow = class {
14120
14424
  }
14121
14425
  };
14122
14426
  //#endregion
14427
+ //#region src/shell-words.ts
14428
+ /**
14429
+ * A small, reliable POSIX-ish shell word parser for external-command
14430
+ * strings like `$VISUAL`/`$EDITOR` (`code --wait`, `vim -f`, `sensible-editor
14431
+ * --nofork`) and `!` shell lines. It handles the quoting rules that matter
14432
+ * in practice — single quotes (literal), double quotes (backslash escapes,
14433
+ * everything else verbatim — no expansion), and backslash escapes outside
14434
+ * quotes — WITHOUT executing a shell.
14435
+ *
14436
+ * This deliberately replaces naive `command.split(/\s+/)` splitting: an
14437
+ * editor path with spaces or a quoted argument would be mangled into
14438
+ * phantom arguments.
14439
+ * @module @xmoon76/dsh-pi-tui/shell-words
14440
+ */
14441
+ /**
14442
+ * Split a command line into words, POSIX-ish. Never throws: unbalanced
14443
+ * quotes are treated as a trailing literal word (matching how shells
14444
+ * consume the rest of the line). Backslashes before non-special characters
14445
+ * are dropped outside quotes; inside double quotes a backslash only escapes
14446
+ * `"`, `\`, and `$` and is kept verbatim before anything else.
14447
+ * @param line - the raw command line.
14448
+ * @returns the parsed words.
14449
+ */
14450
+ function parseShellWords(line) {
14451
+ const words = [];
14452
+ let current = "";
14453
+ let inWord = false;
14454
+ let quote;
14455
+ for (let index = 0; index < line.length; index += 1) {
14456
+ const char = line[index];
14457
+ if (quote === void 0 && char === "\\") {
14458
+ const next = line[index + 1];
14459
+ if (next !== void 0) {
14460
+ current += next;
14461
+ index += 1;
14462
+ } else current += "\\";
14463
+ inWord = true;
14464
+ continue;
14465
+ }
14466
+ if (quote === void 0 && (char === "'" || char === "\"")) {
14467
+ quote = char;
14468
+ inWord = true;
14469
+ continue;
14470
+ }
14471
+ if (quote === "\"" && char === "\\") {
14472
+ const next = line[index + 1];
14473
+ if (next === "\"" || next === "\\" || next === "$") {
14474
+ current += next;
14475
+ index += 1;
14476
+ } else current += "\\";
14477
+ inWord = true;
14478
+ continue;
14479
+ }
14480
+ if (quote === "'" && char === "'") {
14481
+ quote = void 0;
14482
+ continue;
14483
+ }
14484
+ if (quote === "\"" && char === "\"") {
14485
+ quote = void 0;
14486
+ continue;
14487
+ }
14488
+ if (quote === void 0 && (char === " " || char === " " || char === "\n")) {
14489
+ if (inWord) {
14490
+ words.push(current);
14491
+ current = "";
14492
+ inWord = false;
14493
+ }
14494
+ continue;
14495
+ }
14496
+ current += char;
14497
+ inWord = true;
14498
+ }
14499
+ if (inWord) words.push(current);
14500
+ return words;
14501
+ }
14502
+ //#endregion
14503
+ //#region src/shell-completion.ts
14504
+ /**
14505
+ * Bash completion for `!`/`!!` shell lines: a real-shell `compgen` bridge
14506
+ * (docs/input-and-card-polish.md §1). Command names come from
14507
+ * `compgen -A command` (cached per cwd+PATH for 30s), `$VAR` names from
14508
+ * `compgen -A variable`, and subcommands from a small per-command table
14509
+ * (`git --list-cmds`). Path completion stays with the fork's fd-backed
14510
+ * provider — the bridge never competes with it.
14511
+ *
14512
+ * Decisions that matter (design doc):
14513
+ * - `bash -lc`, never `-ic`: an interactive shell sources `.bashrc` on
14514
+ * every keystroke (slow, and runs user startup code). Alias completion
14515
+ * is therefore not provided — accepted limitation.
14516
+ * - One spawn per request, hard-capped at COMPGEN_TIMEOUT_MS and wired to
14517
+ * the editor's AbortSignal; the caller commits latest-only. A slow or
14518
+ * missing `bash` degrades to no suggestions, never an error.
14519
+ * - The completion prefix crosses to bash through an environment variable
14520
+ * (COMPGEN_WORD), never string interpolation — no shell injection from
14521
+ * the user's own draft.
14522
+ * @module @xmoon76/dsh-pi-tui/shell-completion
14523
+ */
14524
+ /**
14525
+ * Hard cap on one compgen spawn (ms). GitHub Actions runners are markedly
14526
+ * slower at a `bash -lc` cold start (measured 260-310ms in CI vs ~60ms
14527
+ * locally — a login shell sources /etc/profile, which is heavy on the
14528
+ * runner image), so a single tight cap made the suite flaky there (three
14529
+ * CI failures, every one at the 300ms boundary). The cap is therefore
14530
+ * raised under GitHub Actions only; local runs keep the tight cap.
14531
+ */
14532
+ const COMPGEN_TIMEOUT_MS = process.env.GITHUB_ACTIONS === "true" ? 1500 : 300;
14533
+ /** Command-name cache TTL (ms): command sets change rarely. */
14534
+ const COMMAND_CACHE_TTL_MS = 3e4;
14535
+ /** Suggestion cap per request (the fork's own lists are capped too). */
14536
+ const MAX_SUGGESTIONS = 50;
14537
+ /**
14538
+ * Parse the editor line at the cursor for shell completion. Returns a
14539
+ * context only when the line is a `!`/`!!` line AND the cursor completes a
14540
+ * shell-bridge word (command name, subcommand, `$VAR`); a path position
14541
+ * returns undefined so the caller falls through to the fork's fd
14542
+ * completion — paths are not the bridge's job.
14543
+ * @param line - the full editor line.
14544
+ * @param col - the cursor column.
14545
+ */
14546
+ function shellCompletionContext(line, col) {
14547
+ if (!line.startsWith("!")) return void 0;
14548
+ const prefixLength = line.startsWith("!!") ? 2 : 1;
14549
+ const before = line.slice(prefixLength, col);
14550
+ const lastSpace = Math.max(before.lastIndexOf(" "), before.lastIndexOf(" "));
14551
+ const currentWord = before.slice(lastSpace + 1);
14552
+ const priorWords = parseShellWords(lastSpace === -1 ? "" : before.slice(0, lastSpace));
14553
+ if (priorWords.length === 0) {
14554
+ if (currentWord.startsWith(".") || currentWord.startsWith("/") || currentWord.startsWith("~")) return void 0;
14555
+ return {
14556
+ kind: "command",
14557
+ prefix: currentWord,
14558
+ priorWords
14559
+ };
14560
+ }
14561
+ if (currentWord.startsWith("$")) return {
14562
+ kind: "variable",
14563
+ prefix: currentWord,
14564
+ priorWords
14565
+ };
14566
+ if (priorWords.length === 1 && SUBCOMMAND_TABLE[priorWords[0]] !== void 0) return {
14567
+ kind: "subcommand",
14568
+ prefix: currentWord,
14569
+ priorWords
14570
+ };
14571
+ }
14572
+ /**
14573
+ * Per-command subcommand completion. `lister` emits the live list (one
14574
+ * name per line, no headers) when the installed command supports it;
14575
+ * `fallback` covers older versions (e.g. git before 2.18 has no
14576
+ * `--list-cmds`) and is used when the lister produces nothing. Extend the
14577
+ * table with one entry per command.
14578
+ */
14579
+ const SUBCOMMAND_TABLE = { git: {
14580
+ lister: "git --list-cmds 2>/dev/null",
14581
+ fallback: [
14582
+ "add",
14583
+ "am",
14584
+ "archive",
14585
+ "bisect",
14586
+ "branch",
14587
+ "bundle",
14588
+ "checkout",
14589
+ "cherry-pick",
14590
+ "clean",
14591
+ "clone",
14592
+ "commit",
14593
+ "config",
14594
+ "diff",
14595
+ "fetch",
14596
+ "init",
14597
+ "log",
14598
+ "merge",
14599
+ "mv",
14600
+ "pull",
14601
+ "push",
14602
+ "rebase",
14603
+ "reset",
14604
+ "restore",
14605
+ "revert",
14606
+ "rm",
14607
+ "show",
14608
+ "stash",
14609
+ "status",
14610
+ "switch",
14611
+ "tag"
14612
+ ]
14613
+ } };
14614
+ let compgenRunner = (cwd, expression, prefix, signal) => runCompgenSpawn(cwd, expression, prefix, signal);
14615
+ function runCompgenSpawn(cwd, expression, prefix, signal) {
14616
+ return new Promise((resolve) => {
14617
+ const settle = (ok, lines) => {
14618
+ if (settled) return;
14619
+ settled = true;
14620
+ clearTimeout(timer);
14621
+ signal.removeEventListener("abort", onAbort);
14622
+ resolve({
14623
+ ok,
14624
+ lines
14625
+ });
14626
+ };
14627
+ let settled = false;
14628
+ const onAbort = () => {
14629
+ child.kill();
14630
+ settle(false, []);
14631
+ };
14632
+ const child = spawn("bash", ["-lc", expression], {
14633
+ cwd,
14634
+ stdio: [
14635
+ "ignore",
14636
+ "pipe",
14637
+ "pipe"
14638
+ ],
14639
+ env: {
14640
+ ...process.env,
14641
+ COMPGEN_WORD: prefix
14642
+ }
14643
+ });
14644
+ const timer = setTimeout(() => {
14645
+ child.kill("SIGKILL");
14646
+ settle(false, []);
14647
+ }, COMPGEN_TIMEOUT_MS);
14648
+ signal.addEventListener("abort", onAbort, { once: true });
14649
+ let out = "";
14650
+ child.stdout.on("data", (chunk) => {
14651
+ out += chunk.toString("utf8");
14652
+ });
14653
+ child.stderr.on("data", () => {});
14654
+ child.on("error", () => settle(false, []));
14655
+ child.on("close", (code) => {
14656
+ settle(code === 0, out.split("\n").filter((line) => line !== ""));
14657
+ });
14658
+ });
14659
+ }
14660
+ /** Command-name cache, keyed by cwd + PATH (30s TTL). Only SUCCESSFUL runs
14661
+ * are cached — a failed run (timeout/abort/spawn error) must not suppress
14662
+ * completion for the whole TTL, so the next request retries the spawn. */
14663
+ const commandCache = /* @__PURE__ */ new Map();
14664
+ /** The cached `compgen -A command` list for one cwd+PATH, refreshed on
14665
+ * expiry or after a failed run. */
14666
+ async function cachedCommands(cwd, signal) {
14667
+ const key = `${cwd}\0${process.env.PATH ?? ""}`;
14668
+ const entry = commandCache.get(key);
14669
+ if (entry !== void 0 && entry.expires > Date.now()) return entry.commands;
14670
+ const run = await compgenRunner(cwd, "compgen -A command", "", signal);
14671
+ if (!run.ok) return [];
14672
+ commandCache.set(key, {
14673
+ expires: Date.now() + COMMAND_CACHE_TTL_MS,
14674
+ commands: run.lines
14675
+ });
14676
+ return run.lines;
14677
+ }
14678
+ /** Filter one candidate list to the prefix and cap it ('' prefix keeps all). */
14679
+ function matchesFor(prefix, candidates) {
14680
+ const matched = prefix === "" ? candidates : candidates.filter((name) => name.startsWith(prefix));
14681
+ if (matched.length === 0) return null;
14682
+ return {
14683
+ items: matched.slice(0, MAX_SUGGESTIONS).map((name) => ({
14684
+ value: name,
14685
+ label: name
14686
+ })),
14687
+ prefix
14688
+ };
14689
+ }
14690
+ /**
14691
+ * Suggest shell completions for a parsed `!` line context. Returns null
14692
+ * when nothing matches or the shell is unavailable — the editor then shows
14693
+ * no suggestions (the caller falls through to the fork's own provider).
14694
+ * @param context - the parsed cursor context.
14695
+ * @param cwd - the session workspace (compgen runs there).
14696
+ * @param options - the editor's request options (signal; force: an empty
14697
+ * command prefix lists the cached commands on explicit Tab, while a
14698
+ * natural trigger with an empty prefix stays quiet).
14699
+ */
14700
+ async function suggestShellCompletion(context, cwd, options) {
14701
+ if (options.signal.aborted) return null;
14702
+ if (context.kind === "command") {
14703
+ if (context.prefix === "" && options.force !== true) return null;
14704
+ const commands = await cachedCommands(cwd, options.signal);
14705
+ return matchesFor(context.prefix, commands);
14706
+ }
14707
+ if (context.kind === "variable") {
14708
+ const run = await compgenRunner(cwd, "compgen -A variable -- \"$COMPGEN_WORD\"", context.prefix.slice(1), options.signal);
14709
+ if (!run.ok) return null;
14710
+ const items = run.lines.slice(0, MAX_SUGGESTIONS).map((name) => ({
14711
+ value: `$${name}`,
14712
+ label: `$${name}`
14713
+ }));
14714
+ return items.length === 0 ? null : {
14715
+ items,
14716
+ prefix: context.prefix
14717
+ };
14718
+ }
14719
+ const command = context.priorWords[0];
14720
+ const entry = SUBCOMMAND_TABLE[command];
14721
+ const run = await compgenRunner(cwd, entry.lister, "", options.signal);
14722
+ const candidates = run.ok && run.lines.length > 0 ? run.lines.map((line) => line.trim()).filter((line) => line !== "") : entry.fallback;
14723
+ return matchesFor(context.prefix, candidates);
14724
+ }
14725
+ //#endregion
14123
14726
  //#region src/mentions.ts
14124
14727
  /**
14125
14728
  * @-file mention completion for the editor: a consumer-side wrapper over the
@@ -14270,13 +14873,19 @@ var MentionProvider = class {
14270
14873
  this.inner = new CombinedAutocompleteProvider([...slashCommands], workDir, fdPath);
14271
14874
  }
14272
14875
  async getSuggestions(lines, cursorLine, cursorCol, options) {
14273
- const atPrefix = extractAtPrefix((lines[cursorLine] ?? "").slice(0, cursorCol));
14876
+ const currentLine = lines[cursorLine] ?? "";
14877
+ const atPrefix = extractAtPrefix(currentLine.slice(0, cursorCol));
14274
14878
  if (atPrefix !== null) {
14275
14879
  if (this.fdPath !== null) try {
14276
14880
  return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
14277
14881
  } catch {}
14278
14882
  return fsMentionSuggestions(this.workDir, atPrefix, options.signal);
14279
14883
  }
14884
+ const shellContext = shellCompletionContext(currentLine, cursorCol);
14885
+ if (shellContext !== void 0) {
14886
+ const suggestions = await suggestShellCompletion(shellContext, this.workDir, options);
14887
+ if (suggestions !== null) return suggestions;
14888
+ }
14280
14889
  try {
14281
14890
  return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
14282
14891
  } catch {
@@ -14284,6 +14893,19 @@ var MentionProvider = class {
14284
14893
  }
14285
14894
  }
14286
14895
  applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
14896
+ const currentLine = lines[cursorLine] ?? "";
14897
+ if (shellCompletionContext(currentLine, cursorCol) !== void 0) {
14898
+ const before = currentLine.slice(0, cursorCol - prefix.length);
14899
+ const after = currentLine.slice(cursorCol);
14900
+ const newLine = `${before}${item.value} ${after}`;
14901
+ const newLines = [...lines];
14902
+ newLines[cursorLine] = newLine;
14903
+ return {
14904
+ lines: newLines,
14905
+ cursorLine,
14906
+ cursorCol: before.length + item.value.length + 1
14907
+ };
14908
+ }
14287
14909
  return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
14288
14910
  }
14289
14911
  shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
@@ -14820,9 +15442,9 @@ function describeKey(key) {
14820
15442
  * This inventory is the SINGLE authoritative list, kept in sync with the
14821
15443
  * host's `matchesKey` lifecycle checks in tui-app.ts (Ctrl+C/D exit,
14822
15444
  * 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
15445
+ * expand, Ctrl+T todo panel, Ctrl+G external editor, Ctrl+Enter queue,
15446
+ * Enter submit, Esc cancel, Shift+Tab permission cycle, Alt+Up dequeue,
15447
+ * Alt+T thinking toggle). Every
14826
15448
  * reserved binding here must match a host `matchesKey(data, ...)` call;
14827
15449
  * when a new host lifecycle key lands, extend THIS list in the same
14828
15450
  * commit.
@@ -14884,13 +15506,6 @@ const RESERVED_HOST_KEYS = [
14884
15506
  shift: false,
14885
15507
  super: false
14886
15508
  },
14887
- {
14888
- key: "j",
14889
- ctrl: true,
14890
- alt: false,
14891
- shift: false,
14892
- super: false
14893
- },
14894
15509
  {
14895
15510
  key: "enter",
14896
15511
  ctrl: true,
@@ -16799,6 +17414,7 @@ var QuestionFrame = class extends Frame {
16799
17414
  render(width) {
16800
17415
  const rows = Math.max(1, this.heightOf());
16801
17416
  this.lastTermRows = rows;
17417
+ this.lastTermColumns = width;
16802
17418
  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
17419
  this.flow.setMaxRows(frameRows - 2);
16804
17420
  const out = super.render(width);
@@ -16813,12 +17429,18 @@ var QuestionFrame = class extends Frame {
16813
17429
  get termRows() {
16814
17430
  return this.lastTermRows;
16815
17431
  }
17432
+ /** The terminal width the last render used (a resize changes wrapping and
17433
+ * the flow's hit map, so clicks must wait for a fresh render too). */
17434
+ get termColumns() {
17435
+ return this.lastTermColumns;
17436
+ }
16816
17437
  get focused() {
16817
17438
  return this.flow.focused;
16818
17439
  }
16819
17440
  set focused(value) {
16820
17441
  this.flow.focused = value;
16821
17442
  }
17443
+ lastTermColumns = 0;
16822
17444
  };
16823
17445
  /** The session head card: identity facts, wrapped to the available width so
16824
17446
  * nothing is truncated, framed with a box whose width matches the editor's
@@ -17136,6 +17758,9 @@ var TuiApp = class TuiApp {
17136
17758
  todoItems = [];
17137
17759
  /** Ctrl+T: whether the todo panel between transcript and editor is shown. */
17138
17760
  todoPanelVisible = false;
17761
+ /** Fullscreen click on the todo panel (or the panel's own expand verb):
17762
+ * whether the panel shows the FULL list or the compact five rows. */
17763
+ todoExpanded = false;
17139
17764
  /** The todo panel Text; empty when hidden. */
17140
17765
  todoPanel;
17141
17766
  /**
@@ -17298,8 +17923,11 @@ var TuiApp = class TuiApp {
17298
17923
  workingMessageOverride;
17299
17924
  /** Timestamp of the last Ctrl+C press, for the empty-editor exit chord. */
17300
17925
  lastCtrlCAt;
17301
- /** The empty-editor double-Ctrl+C exit window in ms (pi handleCtrlC). */
17302
- static CTRL_C_EXIT_WINDOW_MS = 500;
17926
+ /** The empty-editor double-Ctrl+C exit window in ms. A 500ms window
17927
+ * silently misses a human-paced double press (0.6–1s apart), which
17928
+ * read as "the chord doesn't work"; 1.5s covers a natural double
17929
+ * press, and the armed state is announced by the hint. */
17930
+ static CTRL_C_EXIT_WINDOW_MS = 1500;
17303
17931
  /** Session workspace root for path relativization (Web relativizeToCwd). */
17304
17932
  workspaceRoot;
17305
17933
  /** The tool presentation bridge, wired by the runner to the live registry. */
@@ -17772,6 +18400,7 @@ var TuiApp = class TuiApp {
17772
18400
  this.clearNotify();
17773
18401
  this.seatEditor().setText("");
17774
18402
  this.editorSeatHolder.notifyChanged();
18403
+ this.requestRender();
17775
18404
  this.events.onQueueSubmit(text);
17776
18405
  return { consume: true };
17777
18406
  }
@@ -17810,10 +18439,11 @@ var TuiApp = class TuiApp {
17810
18439
  const draft = this.seatEditor().getText();
17811
18440
  this.seatEditor().setText("");
17812
18441
  this.editorSeatHolder.notifyChanged();
18442
+ this.requestRender();
17813
18443
  this.events.onSteer?.(draft);
17814
18444
  return { consume: true };
17815
18445
  }
17816
- if (matchesKey(data, "down") || matchesKey(data, "ctrl+j")) {
18446
+ if (matchesKey(data, "down")) {
17817
18447
  if (this.tasksActive && !this.activeScreen.hasOverlayEntries && this.seatEditor().getText().trim() === "") {
17818
18448
  this.events.onOpenTasks?.();
17819
18449
  return { consume: true };
@@ -17831,13 +18461,17 @@ var TuiApp = class TuiApp {
17831
18461
  this.seatEditor().setText("");
17832
18462
  this.editorSeatHolder.notifyChanged();
17833
18463
  this.lastCtrlCAt = Date.now();
18464
+ this.requestRender();
17834
18465
  return { consume: true };
17835
18466
  }
17836
18467
  const now = Date.now();
17837
18468
  if (this.lastCtrlCAt !== void 0 && now - this.lastCtrlCAt < TuiApp.CTRL_C_EXIT_WINDOW_MS) {
17838
18469
  this.lastCtrlCAt = void 0;
17839
18470
  this.events.onExit();
17840
- } else this.lastCtrlCAt = now;
18471
+ } else {
18472
+ this.lastCtrlCAt = now;
18473
+ this.notify("Press Ctrl+C again to exit", "info");
18474
+ }
17841
18475
  return { consume: true };
17842
18476
  }
17843
18477
  if (matchesKey(data, "ctrl+d")) {
@@ -18388,7 +19022,7 @@ var TuiApp = class TuiApp {
18388
19022
  handleFullscreenClick(x, y) {
18389
19023
  const question = this.activeQuestions;
18390
19024
  if (question?.frame !== void 0) {
18391
- if (this.terminal.rows !== question.frame.termRows) return;
19025
+ if (this.terminal.rows !== question.frame.termRows || this.terminal.columns !== question.frame.termColumns) return;
18392
19026
  const width = this.terminal.columns;
18393
19027
  const height = this.terminal.rows;
18394
19028
  const footerHeight = this.footer.render(width).length;
@@ -18400,10 +19034,28 @@ var TuiApp = class TuiApp {
18400
19034
  question.flow.clickRow(y - seatTop - 1);
18401
19035
  this.requestRender();
18402
19036
  }
18403
- return;
18404
19037
  }
19038
+ return;
18405
19039
  }
18406
19040
  const width = this.terminal.columns;
19041
+ const height = this.terminal.rows;
19042
+ const footerHeight = this.footer.render(width).length;
19043
+ const editorHeight = this.editorSeat.render(width).length;
19044
+ const workingHeight = this.working.render(width).length;
19045
+ const queueHeight = this.queuePane.render(width).length;
19046
+ const goalHeight = this.goalLine.render(width).length;
19047
+ const todoHeight = this.todoPanel.render(width).length;
19048
+ const todoBottom = Math.max(0, Math.min(height, height - footerHeight - editorHeight - workingHeight - queueHeight - goalHeight));
19049
+ const todoTop = Math.max(0, todoBottom - todoHeight);
19050
+ const dockHeight = this.dock.render(width).length;
19051
+ if (dockHeight > 0 && y >= todoTop - dockHeight && y < todoTop) {
19052
+ this.toggleTodoPanel();
19053
+ return;
19054
+ }
19055
+ if (todoTop < todoBottom && y >= todoTop && y < todoBottom) {
19056
+ this.handleTodoPanelClick();
19057
+ return;
19058
+ }
18407
19059
  const rowInScroll = y - this.header.render(width).length;
18408
19060
  if (rowInScroll < 0) return;
18409
19061
  const scrollTop = this.fullscreenScroll?.scrollTop ?? 0;
@@ -19551,7 +20203,18 @@ var TuiApp = class TuiApp {
19551
20203
  const rows = [];
19552
20204
  const callPreview = parseCallPreview(message.name, message.args);
19553
20205
  const foldedCall = header.summary === "" ? foldedCallPreview(message.name, message.args) : "";
19554
- const resultPreview = message.name === "read" ? readFoldedPreview(message.result) : message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
20206
+ let resultPreview;
20207
+ if (message.name === "read") resultPreview = readFoldedPreview(message.result);
20208
+ else if (message.name === "ask_user_question") {
20209
+ const summary = message.error === void 0 ? askAnswersSummary(message.result) : void 0;
20210
+ resultPreview = summary === void 0 ? "" : ` — ${summary}`;
20211
+ } else if (GOAL_TOOL_NAMES.has(message.name)) {
20212
+ const summary = message.error === void 0 ? goalResultSummary(message.result) : void 0;
20213
+ resultPreview = summary === void 0 ? "" : ` — ${summary}`;
20214
+ } else if (FOLDED_JSON_RESULT_TOOLS.has(message.name)) {
20215
+ const summary = foldedResultSummaryFor(message.name, message.result);
20216
+ resultPreview = summary === void 0 || summary === "" ? "" : ` — ${summary}`;
20217
+ } else resultPreview = message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
19555
20218
  const callHead = foldedCall === "" ? "" : foldedCall;
19556
20219
  const headWithPreview = `${head}${callHead}${resultPreview}`;
19557
20220
  if (callPreview?.kind === "bash" && callPreview.command !== "") {
@@ -19723,6 +20386,8 @@ var TuiApp = class TuiApp {
19723
20386
  return;
19724
20387
  }
19725
20388
  if (message.status === "running") {
20389
+ const modelLine = subagentModelDisplay(message.name, message.args);
20390
+ if (modelLine !== void 0) card.addChild(new Text(color.textDim(modelLine), 0, 0));
19726
20391
  const callView = this.present?.call(message.name, message.args);
19727
20392
  if (callView !== void 0) {
19728
20393
  if (callView.card === "diff" && callView.diffs.length > 0) {
@@ -19752,10 +20417,25 @@ var TuiApp = class TuiApp {
19752
20417
  const summary = message.status === "ok" ? askAnswersSummary(message.result) : void 0;
19753
20418
  if (summary !== void 0) {
19754
20419
  card.addChild(new Text(color.textDim(summary), 0, 0));
20420
+ const answerLines = askAnswersLines(message.result);
20421
+ 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));
20422
+ return;
20423
+ }
20424
+ }
20425
+ if (GOAL_TOOL_NAMES.has(message.name)) {
20426
+ if (message.error !== void 0) {
20427
+ card.addChild(new Text(color.textDim(`${message.error.name}: ${message.error.code}`), 0, 0));
20428
+ return;
20429
+ }
20430
+ const goalLines = message.status === "ok" ? goalResultLines(message.result) : void 0;
20431
+ if (goalLines !== void 0) {
20432
+ for (const line of goalLines) card.addChild(new Text(color.textDim(line), 0, 0));
19755
20433
  return;
19756
20434
  }
19757
20435
  }
19758
20436
  if (message.result === "" && (message.resultBlocks?.length ?? 0) === 0) return;
20437
+ const settledModelLine = subagentModelDisplay(message.name, message.args);
20438
+ if (settledModelLine !== void 0) card.addChild(new Text(color.textDim(settledModelLine), 0, 0));
19759
20439
  const resultView = this.present?.result(message.name, message.args, {
19760
20440
  content: message.resultBlocks ?? [],
19761
20441
  isError: message.status === "error",
@@ -19957,19 +20637,42 @@ var TuiApp = class TuiApp {
19957
20637
  /** Toggle the todo panel between the transcript and the editor. */
19958
20638
  toggleTodoPanel() {
19959
20639
  this.todoPanelVisible = !this.todoPanelVisible;
20640
+ if (!this.todoPanelVisible) this.todoExpanded = false;
19960
20641
  this.renderTodoPanel();
19961
20642
  this.renderDock();
19962
20643
  this.requestRender();
19963
20644
  return this.todoPanelVisible;
19964
20645
  }
20646
+ /** Toggle the todo panel between the compact five rows and the full list
20647
+ * (fullscreen click on the panel's area). */
20648
+ toggleTodoExpanded() {
20649
+ if (!this.todoPanelVisible) return false;
20650
+ this.todoExpanded = !this.todoExpanded;
20651
+ this.renderTodoPanel();
20652
+ this.requestRender();
20653
+ return this.todoExpanded;
20654
+ }
20655
+ /** The fullscreen click loop over the todo panel's own rows: compact →
20656
+ * full list → back to the summary row (the panel closes). The mouse thus
20657
+ * opens AND closes the panel without Ctrl+T; the dock summary row itself
20658
+ * opens it (handleFullscreenClick's dock region). */
20659
+ handleTodoPanelClick() {
20660
+ if (this.todoExpanded) this.toggleTodoPanel();
20661
+ else this.toggleTodoExpanded();
20662
+ }
19965
20663
  /** Whether the todo panel is currently shown. */
19966
20664
  isTodoPanelVisible() {
19967
20665
  return this.todoPanelVisible;
19968
20666
  }
20667
+ /** Whether the todo panel is showing the full list (headless-test hook). */
20668
+ isTodoPanelExpanded() {
20669
+ return this.todoExpanded;
20670
+ }
19969
20671
  /**
19970
20672
  * 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).
20673
+ * one cell) plus up to five rows by default (in_progress first, then
20674
+ * pending, then completed (strikethrough)); the full list when expanded
20675
+ * (fullscreen click on the panel toggles).
19973
20676
  */
19974
20677
  renderTodoPanel() {
19975
20678
  if (!this.todoPanelVisible) {
@@ -19981,15 +20684,16 @@ var TuiApp = class TuiApp {
19981
20684
  ...this.todoItems.filter((todo) => todo.status === "in_progress"),
19982
20685
  ...this.todoItems.filter((todo) => todo.status === "pending"),
19983
20686
  ...this.todoItems.filter((todo) => todo.status === "completed")
19984
- ].slice(0, 5);
20687
+ ];
20688
+ const shown = this.todoExpanded ? ordered : ordered.slice(0, 5);
19985
20689
  const width = Math.max(1, this.terminal.columns);
19986
20690
  const border = color.border(` ${"─".repeat(Math.max(0, width - 2))} `);
19987
20691
  const title = color.textStrong(" Todo");
19988
- if (ordered.length === 0) {
20692
+ if (shown.length === 0) {
19989
20693
  this.todoPanel.setText([border, title].join("\n"));
19990
20694
  return;
19991
20695
  }
19992
- const lines = ordered.map((todo) => {
20696
+ const lines = shown.map((todo) => {
19993
20697
  const body = todo.status === "completed" ? `\x1b[9m${todo.content}\x1b[29m` : todo.content;
19994
20698
  return `${mark(todo)} ${body}`;
19995
20699
  });
@@ -20136,6 +20840,8 @@ var TuiApp = class TuiApp {
20136
20840
  this.renderQueuePane();
20137
20841
  this.syncExtensionState();
20138
20842
  }
20843
+ /** Notice rows shown in full before the `+N more` fold (user rows are never folded). */
20844
+ static MAX_NOTICE_ROWS = 5;
20139
20845
  /** Rebuild the queue pane text from the current inbox rows. */
20140
20846
  renderQueuePane() {
20141
20847
  const items = this.queueItems;
@@ -20146,12 +20852,20 @@ var TuiApp = class TuiApp {
20146
20852
  }
20147
20853
  const width = Math.max(1, this.terminal.columns);
20148
20854
  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";
20855
+ const userRows = items.filter((item) => item.notice !== true);
20856
+ const noticeRows = items.filter((item) => item.notice === true);
20857
+ const shownNotices = noticeRows.slice(0, TuiApp.MAX_NOTICE_ROWS);
20858
+ for (const item of userRows) {
20859
+ const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth("❯ ")), "…");
20860
+ lines.push(`${color.accent("❯")} ${truncated}`);
20861
+ }
20862
+ for (const item of shownNotices) {
20863
+ const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth("⏳ ")), "…");
20864
+ lines.push(`${color.textDim("⏳")} ${color.textDim(truncated)}`);
20865
+ }
20866
+ const folded = noticeRows.length - shownNotices.length;
20867
+ if (folded > 0) lines.push(color.textDim(truncateToWidth(` +${folded} more notices pending · they deliver as the next turn runs`, Math.max(1, width - 2), "…")));
20868
+ const hint = userRows.length > 0 ? "ctrl+s to steer all · alt+↑ to edit all" : "notices deliver after the current task · /tasks to view";
20155
20869
  lines.push(color.textDim(truncateToWidth(` ${hint}`, Math.max(1, width - 2), "…")));
20156
20870
  this.queuePane.setText(lines.join("\n"));
20157
20871
  this.requestRender();
@@ -20536,7 +21250,8 @@ var TuiApp = class TuiApp {
20536
21250
  header: options.header,
20537
21251
  noMatchText: options.noMatchText,
20538
21252
  enableSearch: options.enableSearch,
20539
- initialQuery: options.initialQuery
21253
+ initialQuery: options.initialQuery,
21254
+ onAction: options.onAction
20540
21255
  }, (value) => {
20541
21256
  close();
20542
21257
  onSelect(value);
@@ -20875,7 +21590,8 @@ var TuiApp = class TuiApp {
20875
21590
  ...question.detail !== void 0 ? { detail: question.detail } : {},
20876
21591
  ...question.options !== void 0 ? { options: question.options } : {},
20877
21592
  ...question.multiSelect !== void 0 ? { multiSelect: question.multiSelect } : {},
20878
- ...question.intent !== void 0 ? { intent: question.intent } : {}
21593
+ ...question.intent !== void 0 ? { intent: question.intent } : {},
21594
+ ...question.masked !== void 0 ? { masked: question.masked } : {}
20879
21595
  })), (answers) => this.settleQuestions(state, answers), () => this.settleQuestions(state, void 0)),
20880
21596
  suspendedOverlays: /* @__PURE__ */ new Set(),
20881
21597
  resolve,
@@ -24475,6 +25191,7 @@ function providerOptionsFor(entries, readSection) {
24475
25191
  route: "deepseek-official",
24476
25192
  configured: true,
24477
25193
  declared: false,
25194
+ namesCredential: true,
24478
25195
  group: "configured",
24479
25196
  settingsNs: "",
24480
25197
  settingsPath: []
@@ -24486,13 +25203,15 @@ function providerOptionsFor(entries, readSection) {
24486
25203
  const profile = entry.settingsPath.length === 0 ? section : getPath(section, entry.settingsPath);
24487
25204
  const configured = profile !== void 0;
24488
25205
  const namedRef = configured && typeof profile === "object" && profile !== null && typeof profile.apiKeyEnv === "string" ? profile.apiKeyEnv : void 0;
24489
- const ref = namedRef !== void 0 && namedRef !== "" ? namedRef : deriveKeyRef(entry.provider);
25206
+ const namesCredential = namedRef !== void 0 && namedRef !== "";
25207
+ const ref = namesCredential ? namedRef : deriveKeyRef(entry.provider);
24490
25208
  push({
24491
25209
  label: entry.displayName,
24492
25210
  ref,
24493
25211
  route: entry.provider,
24494
25212
  configured,
24495
25213
  declared: entry.declared === true,
25214
+ namesCredential,
24496
25215
  group: configured ? "configured" : entry.declared === true ? "custom" : "available",
24497
25216
  settingsNs: entry.settingsNs,
24498
25217
  settingsPath: [...entry.settingsPath]
@@ -24537,7 +25256,185 @@ function resolveCredentialArg(arg, options) {
24537
25256
  if (route !== void 0) return route.ref;
24538
25257
  if (/^[a-zA-Z][a-zA-Z0-9_]*$/.test(trimmed)) return trimmed.toUpperCase();
24539
25258
  }
24540
- //#endregion
25259
+ /** Map the seam's entries to /login targets, deriving the route from the
25260
+ * key's scope (llm-pi-ai flows address the provider route). */
25261
+ function authorizationTargets(entries) {
25262
+ return entries.map((entry) => ({
25263
+ kind: "authorization",
25264
+ route: credentialKeyScope(entry.key) === "llm-pi-ai" ? credentialKeyId(entry.key) : void 0,
25265
+ key: entry.key,
25266
+ label: entry.label,
25267
+ methods: entry.methods,
25268
+ inFlight: entry.inFlight
25269
+ }));
25270
+ }
25271
+ /** The flow target for a provider route, when one is registered. */
25272
+ function flowForRoute(targets, route) {
25273
+ return targets.find((target) => target.route === route);
25274
+ }
25275
+ /**
25276
+ * Merge the reference targets and the authorization flows into the /login
25277
+ * picker set, applying the rc.1 dedup rule per route:
25278
+ *
25279
+ * - explicit `apiKeyEnv` profile → the reference target wins (the flow is
25280
+ * NOT offered as a second default entry for the same route — requests
25281
+ * resolve the reference first and a "successful" flow login would leave
25282
+ * the reference unset, failing the next request);
25283
+ * - keyless route with a flow → the authorization target wins (no derived
25284
+ * `<ROUTE>_API_KEY` entry is offered as a default);
25285
+ * - keyless route without a flow → the derived reference fallback.
25286
+ *
25287
+ * Flows whose key maps no option route (foreign scopes, or catalog routes
25288
+ * the llm directory does not expose) are appended as standalone targets.
25289
+ */
25290
+ function mergeLoginTargets(options, targets) {
25291
+ const flowByRoute = /* @__PURE__ */ new Map();
25292
+ const standalone = [];
25293
+ for (const target of targets) if (target.route !== void 0) flowByRoute.set(target.route, target);
25294
+ else standalone.push(target);
25295
+ const merged = [];
25296
+ const covered = /* @__PURE__ */ new Set();
25297
+ for (const option of options) {
25298
+ const flow = flowByRoute.get(option.route);
25299
+ merged.push(option.namesCredential || flow === void 0 ? {
25300
+ kind: "reference",
25301
+ route: option.route,
25302
+ label: option.label,
25303
+ ref: option.ref,
25304
+ configured: option.configured,
25305
+ declared: option.declared,
25306
+ namesCredential: option.namesCredential
25307
+ } : flow);
25308
+ covered.add(option.route);
25309
+ }
25310
+ for (const target of targets) if (target.route === void 0 || !covered.has(target.route)) merged.push(target);
25311
+ return merged;
25312
+ }
25313
+ /**
25314
+ * The one user-facing notice body: the message first, then the page to open
25315
+ * and the code to enter, each on its own line. Never truncated — the
25316
+ * output panel wraps, so the URL stays copyable. Notices never carry
25317
+ * secrets.
25318
+ */
25319
+ function formatAuthorizationNotice(notice) {
25320
+ const lines = [notice.message];
25321
+ if (notice.url !== void 0 && notice.url !== "") lines.push("", "Open this page to continue:", notice.url);
25322
+ if (notice.code !== void 0 && notice.code !== "") lines.push("", `Code: ${notice.code}`);
25323
+ return lines.join("\n");
25324
+ }
25325
+ /**
25326
+ * Build the interaction half of one authorization attempt. One instance per
25327
+ * attempt: the notice panel is reused across progress notices, and
25328
+ * `close()` hides it when the attempt settles (the caller's finally).
25329
+ *
25330
+ * Prompt mapping:
25331
+ * - `text` and `secret` go through the question flow's free-text row;
25332
+ * `secret` is rendered masked (the real value never leaves the input's
25333
+ * memory and is never logged, put in history, or shown anywhere else);
25334
+ * - `select` goes through the picker and returns the chosen option's `id`,
25335
+ * never its label;
25336
+ * - the user closing the question is a decline → `AuthorizationDeclinedError`;
25337
+ * - the prompt's OWN signal withdrawing it is NOT a decline: the rejection
25338
+ * is passed through as-is so the flow can tell a refusal from a race.
25339
+ */
25340
+ function createAuthorizationInteraction(app) {
25341
+ let noticeHandle;
25342
+ let noticeBody = "";
25343
+ const showNotice = (notice) => {
25344
+ noticeBody = formatAuthorizationNotice(notice);
25345
+ if (noticeHandle === void 0) noticeHandle = app.openOutputViewer({
25346
+ title: "Sign in",
25347
+ initial: noticeBody,
25348
+ refresh: () => noticeBody,
25349
+ onClose: () => {
25350
+ noticeHandle = void 0;
25351
+ }
25352
+ });
25353
+ };
25354
+ const close = () => {
25355
+ noticeHandle?.();
25356
+ noticeHandle = void 0;
25357
+ };
25358
+ return {
25359
+ interaction: {
25360
+ notify: (notice) => {
25361
+ showNotice(notice);
25362
+ },
25363
+ prompt: async (prompt) => {
25364
+ if (prompt.kind === "select") {
25365
+ const signal = prompt.signal;
25366
+ return await new Promise((resolve, reject) => {
25367
+ if (signal?.aborted === true) {
25368
+ reject(cancellationError("authorization prompt withdrawn"));
25369
+ return;
25370
+ }
25371
+ let settled = false;
25372
+ let handle;
25373
+ const cleanup = () => {
25374
+ if (signal !== void 0) signal.removeEventListener("abort", onAbort);
25375
+ };
25376
+ const onAbort = () => {
25377
+ if (settled) return;
25378
+ settled = true;
25379
+ cleanup();
25380
+ handle?.close?.();
25381
+ reject(cancellationError("authorization prompt withdrawn"));
25382
+ };
25383
+ if (signal !== void 0) signal.addEventListener("abort", onAbort, { once: true });
25384
+ handle = app.openPicker(prompt.options.map((option) => ({
25385
+ value: option.id,
25386
+ label: option.label,
25387
+ ...option.description !== void 0 && option.description !== "" ? { description: option.description } : {}
25388
+ })), (value) => {
25389
+ if (settled) return;
25390
+ settled = true;
25391
+ cleanup();
25392
+ resolve(value);
25393
+ }, () => {
25394
+ if (settled) return;
25395
+ settled = true;
25396
+ cleanup();
25397
+ reject(new AuthorizationDeclinedError());
25398
+ }, {
25399
+ header: prompt.message,
25400
+ enableSearch: true
25401
+ });
25402
+ if (settled) handle?.close?.();
25403
+ });
25404
+ }
25405
+ try {
25406
+ const text = (await app.askQuestions([{
25407
+ id: "answer",
25408
+ question: prompt.message,
25409
+ ...prompt.placeholder !== void 0 && prompt.placeholder !== "" ? { placeholder: prompt.placeholder } : {},
25410
+ ...prompt.kind === "secret" ? { masked: true } : {}
25411
+ }], prompt.signal))[0]?.custom ?? "";
25412
+ if (text === "") throw new AuthorizationDeclinedError();
25413
+ return text;
25414
+ } catch (error) {
25415
+ if (prompt.signal?.aborted === true) throw error;
25416
+ throw new AuthorizationDeclinedError();
25417
+ }
25418
+ }
25419
+ },
25420
+ close
25421
+ };
25422
+ }
25423
+ /** The user-facing text for one authorization attempt failure, mapping the
25424
+ * seam's stable error taxonomy to copy (plan §15). `unknownErrorText` is
25425
+ * the safe fallback for provider/network errors (already passed through
25426
+ * safeErrorMessage by the caller). */
25427
+ function authorizationFailureText(error, unknownErrorText) {
25428
+ switch (error?.code) {
25429
+ case "NO_FLOW": return "login method is no longer available";
25430
+ case "UNKNOWN_METHOD": return "selected login method is no longer available";
25431
+ case "ALREADY_IN_FLIGHT": return "sign-in already in progress";
25432
+ case "NOT_COMMITTED": return "sign-in finished without storing a credential";
25433
+ case "DECLINED": return "login cancelled";
25434
+ default: return `sign-in failed: ${unknownErrorText}`;
25435
+ }
25436
+ }
25437
+ //#endregion
24541
25438
  //#region src/skill-catalog.ts
24542
25439
  /**
24543
25440
  * The standing-scope skill catalog adapter: the SINGLE narrow seam between
@@ -24807,7 +25704,7 @@ function sortCommands(commands) {
24807
25704
  //#region src/commands.ts
24808
25705
  /**
24809
25706
  * The TUI-owned slash commands (/exit /settings /sessions /skill /model
24810
- * /new /tasks /preset /subagents /search /title /rename /copy /export
25707
+ * /new /tasks /preset /search /title /copy /export
24811
25708
  * /fork /status /login /logout /help), extracted from the runner's
24812
25709
  * monolithic apply() so the registration surface is testable and the runner
24813
25710
  * closure shrinks. Every command reads the live runner state through the
@@ -24879,6 +25776,9 @@ function presetDisplayText(preset) {
24879
25776
  }
24880
25777
  /** The sentinel picker value for the "add a brand-new provider" action row. */
24881
25778
  const ADD_PROVIDER_VALUE = "\0add-provider";
25779
+ /** The sentinel prefix for an authorization-target picker row (a credential
25780
+ * key can never start with NUL, so route values cannot collide). */
25781
+ const AUTH_VALUE_PREFIX = "\0auth:";
24882
25782
  /**
24883
25783
  * M11: the /status extension-health rows (plan §16 — /status extension
24884
25784
  * health). Reads the extension registries' live snapshots: contribution
@@ -24970,6 +25870,7 @@ function readProviderOptions(ctx) {
24970
25870
  route: "deepseek-official",
24971
25871
  configured: true,
24972
25872
  declared: false,
25873
+ namesCredential: true,
24973
25874
  group: "configured",
24974
25875
  settingsNs: "",
24975
25876
  settingsPath: []
@@ -24978,26 +25879,36 @@ function readProviderOptions(ctx) {
24978
25879
  route: option.label,
24979
25880
  configured: true,
24980
25881
  declared: false,
25882
+ namesCredential: true,
24981
25883
  group: "configured",
24982
25884
  settingsNs: "llm-pi-ai",
24983
25885
  settingsPath: ["providers", option.label]
24984
25886
  });
24985
25887
  }
24986
- /** Build the picker rows from the merged options: grouped by configured /
24987
- * available / custom via the SelectList group extension (the fork renders a
24988
- * non-interactive header row per group automatically), with the Add New
24989
- * Platform action row pinned last. */
24990
- function providerPickerRows(options) {
25888
+ /** Build the merged /login picker rows: reference targets keep their
25889
+ * configured/available/custom groups (the fork renders a non-interactive
25890
+ * header row per group), authorization targets get their own group, and the
25891
+ * Add New Platform action row is pinned last. Authorization row values
25892
+ * carry the AUTH_VALUE_PREFIX so they can never collide with a route. */
25893
+ function mergedPickerRows(merged) {
24991
25894
  const rows = [];
24992
25895
  const groupLabels = {
24993
25896
  configured: "configured",
24994
25897
  available: "available · catalog",
24995
- custom: "custom"
25898
+ custom: "custom",
25899
+ authorization: "sign in with provider"
24996
25900
  };
24997
- for (const option of options) rows.push({
24998
- value: option.route,
24999
- label: `${option.label} (${option.ref})`,
25000
- group: groupLabels[option.group]
25901
+ for (const target of merged) if (target.kind === "reference") {
25902
+ const group = target.configured ? "configured" : target.declared ? "custom" : "available";
25903
+ rows.push({
25904
+ value: target.route,
25905
+ label: `${target.label} (${target.ref})`,
25906
+ group: groupLabels[group]
25907
+ });
25908
+ } else rows.push({
25909
+ value: AUTH_VALUE_PREFIX + target.key,
25910
+ label: target.inFlight ? `${target.label} — sign-in in progress` : `${target.label} — sign in`,
25911
+ group: groupLabels.authorization
25001
25912
  });
25002
25913
  rows.push({
25003
25914
  value: ADD_PROVIDER_VALUE,
@@ -25005,6 +25916,161 @@ function providerPickerRows(options) {
25005
25916
  });
25006
25917
  return rows;
25007
25918
  }
25919
+ /** Recover an authorization target from a picked row value (the marker
25920
+ * prefix guarantees no collision with reference route values). */
25921
+ function targetFromPickerValue(merged, value) {
25922
+ if (!value.startsWith(AUTH_VALUE_PREFIX)) return void 0;
25923
+ const key = value.slice(6);
25924
+ for (const target of merged) if (target.kind === "authorization" && target.key === key) return target;
25925
+ }
25926
+ /** One-line summary of every merged target, for the unknown-target error. */
25927
+ function mergedTargetsSummary(merged) {
25928
+ return merged.map((target) => target.kind === "reference" ? `${target.label} (${target.ref})` : `${target.label} (provider sign-in)`).join(", ");
25929
+ }
25930
+ /**
25931
+ * Run one authorization attempt on the seam and report it. Method picking
25932
+ * (single method → direct; multiple → a picker), notice rendering and
25933
+ * prompts all live behind the interaction built here; the seam's stable
25934
+ * error taxonomy maps to user-facing copy (§15). On success, a catalog
25935
+ * route that is not configured yet gets a minimal keyless profile so the
25936
+ * runtime keeps reading the credential record (§12.1 — never an apiKeyEnv,
25937
+ * which would switch the request path back to a reference that is not set).
25938
+ */
25939
+ async function runAuthorizationLogin(ctx, app, runner, target, options) {
25940
+ const authorization = ctx.get("authorization");
25941
+ if (authorization === void 0) return {
25942
+ kind: "error",
25943
+ text: "authorization service unavailable"
25944
+ };
25945
+ if (target.inFlight) return {
25946
+ kind: "error",
25947
+ text: `sign-in already in progress for ${target.label}`
25948
+ };
25949
+ let method = target.methods[0]?.id;
25950
+ if (method === void 0) return {
25951
+ kind: "error",
25952
+ text: `no sign-in method available for ${target.label}`
25953
+ };
25954
+ if (target.methods.length > 1) {
25955
+ const picked = await new Promise((resolve) => {
25956
+ app.openPicker(target.methods.map((candidate) => ({
25957
+ value: candidate.id,
25958
+ label: candidate.label
25959
+ })), (value) => resolve(value), () => resolve(void 0), {
25960
+ header: `Sign in method · ${target.label}`,
25961
+ enableSearch: false
25962
+ });
25963
+ });
25964
+ if (picked === void 0) return {
25965
+ kind: "error",
25966
+ text: "login cancelled"
25967
+ };
25968
+ method = picked;
25969
+ }
25970
+ const { interaction, close } = createAuthorizationInteraction(app);
25971
+ let outcome;
25972
+ try {
25973
+ outcome = await authorization.begin({
25974
+ key: target.key,
25975
+ method,
25976
+ interaction,
25977
+ signal: runner.signal
25978
+ });
25979
+ } catch (error) {
25980
+ if (runner.signal.aborted) return {
25981
+ kind: "error",
25982
+ text: "login cancelled"
25983
+ };
25984
+ if (error?.code === "NOT_COMMITTED") runner.diag.error("authorization", {
25985
+ key: target.key,
25986
+ error: safeErrorMessage(error)
25987
+ });
25988
+ return {
25989
+ kind: "error",
25990
+ text: authorizationFailureText(error, safeErrorMessage(error))
25991
+ };
25992
+ } finally {
25993
+ close();
25994
+ }
25995
+ if (outcome.status === "cancelled") return {
25996
+ kind: "error",
25997
+ text: "login cancelled"
25998
+ };
25999
+ const profileNote = await provisionKeylessProfile(ctx, runner, target, options);
26000
+ return {
26001
+ kind: "success",
26002
+ text: `signed in to ${target.label}${profileNote}`
26003
+ };
26004
+ }
26005
+ /**
26006
+ * After a successful authorization, write a MINIMAL keyless profile for a
26007
+ * catalog route that is not configured yet (§12.1). The record alone does
26008
+ * not make the route selectable — llm-pi-ai registers a route only when
26009
+ * its settings section names it — and the profile must NOT carry
26010
+ * apiKeyEnv, or the request path would switch back to a reference that was
26011
+ * never set. Hand-declared/custom routes are left to the add wizard
26012
+ * (§12.2). Any failure degrades silently (the sign-in itself succeeded).
26013
+ * @returns a user-facing note, or '' when nothing was provisioned.
26014
+ */
26015
+ async function provisionKeylessProfile(ctx, runner, target, options) {
26016
+ if (target.route === void 0) return "";
26017
+ const option = options.find((candidate) => candidate.route === target.route);
26018
+ if (option === void 0 || option.configured || option.declared || option.settingsNs === "") return "";
26019
+ const settings = ctx.get("settings");
26020
+ if (settings === void 0) return "";
26021
+ try {
26022
+ await settings.mutate(option.settingsNs, [{
26023
+ op: "set",
26024
+ path: [...option.settingsPath],
26025
+ value: {}
26026
+ }]);
26027
+ return " — provider profile recorded";
26028
+ } catch (error) {
26029
+ runner.diag.warn("authorization", {
26030
+ key: target.key,
26031
+ note: "profile write failed",
26032
+ error: safeErrorMessage(error)
26033
+ });
26034
+ return "";
26035
+ }
26036
+ }
26037
+ /** Value prefixes for the /logout picker rows (no collision with a ref). */
26038
+ const LOGOUT_REF_VALUE = "\0ref:";
26039
+ const LOGOUT_RECORD_VALUE = "\0record:";
26040
+ /** Build the /logout picker rows: every stored credential record plus every
26041
+ * configured reference (presence only — a secret's value never leaves the
26042
+ * credentials service). Records are deduplicated by key and labelled with
26043
+ * the authorization flow's user-facing name when one owns the key (a
26044
+ * record row must say what signing out actually clears). */
26045
+ async function logoutPickerRows(credentials, options, targets) {
26046
+ const rows = [];
26047
+ const seenRefs = /* @__PURE__ */ new Set();
26048
+ for (const option of options) {
26049
+ if (seenRefs.has(option.ref)) continue;
26050
+ seenRefs.add(option.ref);
26051
+ try {
26052
+ if ((await credentials.describe(option.ref)).configured) rows.push({
26053
+ value: LOGOUT_REF_VALUE + option.ref,
26054
+ label: `${option.label} (${option.ref})`,
26055
+ group: "API keys"
26056
+ });
26057
+ } catch {}
26058
+ }
26059
+ const records = await credentials.listRecords();
26060
+ const seenKeys = /* @__PURE__ */ new Set();
26061
+ for (const record of records) {
26062
+ if (seenKeys.has(record.key)) continue;
26063
+ seenKeys.add(record.key);
26064
+ const owner = targets.find((target) => target.key === record.key);
26065
+ const label = owner !== void 0 ? `${owner.label} — stored credential${record.kind === void 0 ? "" : ` (${record.kind})`}` : `${record.key}${record.kind === void 0 ? "" : ` (${record.kind})`}`;
26066
+ rows.push({
26067
+ value: LOGOUT_RECORD_VALUE + record.key,
26068
+ label,
26069
+ group: "stored credentials"
26070
+ });
26071
+ }
26072
+ return rows;
26073
+ }
25008
26074
  /** Run the add-provider wizard: collect route/api/baseURL/displayName/key
25009
26075
  * through question flows, probe the endpoint for its models (falling back to
25010
26076
  * hand entry), review, then persist the profile + credential.
@@ -25252,18 +26318,43 @@ function registerTuiCommands(runner, initial) {
25252
26318
  }
25253
26319
  };
25254
26320
  refreshCompletions();
26321
+ /**
26322
+ * Register one TUI command plus its aliases (kimi parity: an alias is
26323
+ * another NAME of the same logical command). Every alias registers with
26324
+ * the host commands service — the shared handler by default, or its own
26325
+ * handler when the alias keeps a fast path (e.g. /resume's direct-resume
26326
+ * lookup) — so host dispatch, the completion catalog (aliases are
26327
+ * searchable: typing `resume` completes `/resume`) and the busy-Enter
26328
+ * gate all see it, while the command surface lists one logical command
26329
+ * and the docs mark the alias.
26330
+ * @param spec - the primary command; `aliases` register with the shared
26331
+ * handler unless `aliasHandlers` overrides one.
26332
+ */
26333
+ const registerTuiCommand = (spec) => {
26334
+ commands.register({
26335
+ name: spec.name,
26336
+ description: spec.description,
26337
+ ...spec.input === void 0 ? {} : { input: spec.input },
26338
+ handler: spec.handler
26339
+ });
26340
+ for (const alias of spec.aliases ?? []) {
26341
+ const handler = spec.aliasHandlers?.[alias] ?? spec.handler;
26342
+ commands.register({
26343
+ name: alias,
26344
+ description: `${spec.description} (alias of /${spec.name})`,
26345
+ ...spec.input === void 0 ? {} : { input: spec.input },
26346
+ handler
26347
+ });
26348
+ }
26349
+ };
25255
26350
  const exitHandler = () => {
25256
26351
  runner.requestExit();
25257
26352
  return { kind: "success" };
25258
26353
  };
25259
- commands.register({
26354
+ registerTuiCommand({
25260
26355
  name: "exit",
25261
26356
  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",
26357
+ aliases: ["quit"],
25267
26358
  handler: exitHandler
25268
26359
  });
25269
26360
  commands.register({
@@ -25337,6 +26428,13 @@ function registerTuiCommands(runner, initial) {
25337
26428
  currentValue: tuiSettings?.get().busyEnter ?? "queue",
25338
26429
  values: ["queue", "steer"]
25339
26430
  },
26431
+ {
26432
+ id: "local-shell-sandbox",
26433
+ label: "Local shell sandbox",
26434
+ description: "! / !! commands run outside the dsh sandbox (bypass, default) or under the sandbox policy",
26435
+ currentValue: tuiSettings?.get().localShellSandbox ?? "bypass",
26436
+ values: ["bypass", "sandbox"]
26437
+ },
25340
26438
  {
25341
26439
  id: "fullscreen",
25342
26440
  label: "Fullscreen",
@@ -25464,6 +26562,14 @@ function registerTuiCommands(runner, initial) {
25464
26562
  busyEnter: value
25465
26563
  }), { notify: true });
25466
26564
  }
26565
+ } else if (id === "local-shell-sandbox") {
26566
+ if (value === "bypass" || value === "sandbox") {
26567
+ const settings = tuiSettings;
26568
+ if (settings !== void 0) detach("settings local shell sandbox write", () => settings.replace({
26569
+ ...settings.get(),
26570
+ localShellSandbox: value
26571
+ }), { notify: true });
26572
+ }
25467
26573
  } else if (id === "fullscreen") {
25468
26574
  if (value === "off" || value === "on") app.setFullscreen(value === "on");
25469
26575
  }
@@ -25538,17 +26644,13 @@ function registerTuiCommands(runner, initial) {
25538
26644
  });
25539
26645
  return { kind: "success" };
25540
26646
  };
25541
- commands.register({
26647
+ registerTuiCommand({
25542
26648
  name: "sessions",
25543
26649
  description: "List, search, and switch persisted sessions",
25544
26650
  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) => {
26651
+ handler: (invocation) => openSessionPicker(invocation, "sessions"),
26652
+ aliases: ["resume"],
26653
+ aliasHandlers: { resume: async (invocation) => {
25552
26654
  const raw = invocation.rawInput.trim();
25553
26655
  if (raw !== "") {
25554
26656
  const currentId = runner.liveAgent?.session.id;
@@ -25571,7 +26673,7 @@ function registerTuiCommands(runner, initial) {
25571
26673
  }
25572
26674
  }
25573
26675
  return openSessionPicker(invocation, "resume");
25574
- }
26676
+ } }
25575
26677
  });
25576
26678
  /** The workspace the live session runs in; fallback to the TUI's cwd. */
25577
26679
  const sessionCwd = (agent) => agent.session.header.cwd ?? cwd;
@@ -25957,129 +27059,13 @@ function registerTuiCommands(runner, initial) {
25957
27059
  };
25958
27060
  }
25959
27061
  });
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({
27062
+ registerTuiCommand({
26058
27063
  name: "tasks",
26059
- description: "List background jobs for this session",
27064
+ description: "Browse background jobs and subagents for this session (search filters rows)",
27065
+ aliases: ["subagents"],
26060
27066
  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
- });
27067
+ await requireAgent();
27068
+ runner.openTasksBrowser();
26083
27069
  return { kind: "success" };
26084
27070
  }
26085
27071
  });
@@ -26271,56 +27257,6 @@ function registerTuiCommands(runner, initial) {
26271
27257
  return { kind: "success" };
26272
27258
  }
26273
27259
  });
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
27260
  commands.register({
26325
27261
  name: "search",
26326
27262
  description: "Search persisted sessions for text and switch to a hit",
@@ -26412,16 +27348,11 @@ function registerTuiCommands(runner, initial) {
26412
27348
  };
26413
27349
  }
26414
27350
  };
26415
- commands.register({
27351
+ registerTuiCommand({
26416
27352
  name: "title",
26417
27353
  description: "Set the session title; without an argument, regenerate it from the conversation (overwrites the current title)",
26418
27354
  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>" },
27355
+ aliases: ["rename"],
26425
27356
  handler: titleHandler
26426
27357
  });
26427
27358
  commands.register({
@@ -26561,7 +27492,7 @@ function registerTuiCommands(runner, initial) {
26561
27492
  });
26562
27493
  commands.register({
26563
27494
  name: "login",
26564
- description: "Set an API key — deepseek official or an llm-pi-ai provider route",
27495
+ description: "Sign in — deepseek official or an llm-pi-ai provider route (API key or provider-native flow)",
26565
27496
  input: { hint: "[<route|env-var>]" },
26566
27497
  handler: async (invocation) => {
26567
27498
  const credentials = ctx.get("credentials");
@@ -26569,39 +27500,51 @@ function registerTuiCommands(runner, initial) {
26569
27500
  kind: "error",
26570
27501
  text: "credentials service unavailable"
26571
27502
  };
27503
+ const authorization = ctx.get("authorization");
26572
27504
  const options = readProviderOptions(ctx);
27505
+ const targets = authorizationTargets(authorization?.list() ?? []);
27506
+ const merged = mergeLoginTargets(options, targets);
26573
27507
  const arg = invocation.rawInput.trim();
26574
27508
  let route;
26575
27509
  let ref;
27510
+ let target;
26576
27511
  if (arg !== "") {
26577
27512
  const known = resolveCredentialArg(arg, options);
26578
27513
  if (known !== void 0) {
26579
27514
  const option = options.find((candidate) => candidate.ref === known);
26580
27515
  if (option !== void 0) {
26581
- route = option.route;
26582
- ref = option.ref;
27516
+ const flow = option.route !== "deepseek-official" ? flowForRoute(targets, option.route) : void 0;
27517
+ if (flow !== void 0 && option.namesCredential === false) target = flow;
27518
+ else {
27519
+ route = option.route;
27520
+ ref = option.ref;
27521
+ }
26583
27522
  } else ref = known;
26584
27523
  } else if (ROUTE_PATTERN.test(arg)) {
26585
- const outcome = await askAddProvider(ctx, app, runner.signal, arg);
26586
- if (outcome.kind === "cancelled") return {
26587
- kind: "error",
26588
- text: "add provider cancelled"
26589
- };
26590
- if (outcome.kind === "error") return {
26591
- kind: "error",
26592
- text: outcome.text
26593
- };
26594
- return {
26595
- kind: "success",
26596
- text: outcome.text
26597
- };
27524
+ const flow = flowForRoute(targets, arg);
27525
+ if (flow !== void 0) target = flow;
27526
+ else {
27527
+ const outcome = await askAddProvider(ctx, app, runner.signal, arg);
27528
+ if (outcome.kind === "cancelled") return {
27529
+ kind: "error",
27530
+ text: "add provider cancelled"
27531
+ };
27532
+ if (outcome.kind === "error") return {
27533
+ kind: "error",
27534
+ text: outcome.text
27535
+ };
27536
+ return {
27537
+ kind: "success",
27538
+ text: outcome.text
27539
+ };
27540
+ }
26598
27541
  } else return {
26599
27542
  kind: "error",
26600
- text: `unknown credential target "${arg}" — ${options.map((option) => `${option.label} (${option.ref})`).join(", ")}`
27543
+ text: `unknown credential target "${arg}" — ${mergedTargetsSummary(merged)}`
26601
27544
  };
26602
- } else if (options.length > 1) {
27545
+ } else if (merged.length > 1) {
26603
27546
  const picked = await new Promise((resolve) => {
26604
- app.openPicker(providerPickerRows(options), (value) => resolve(value), () => resolve(void 0), {
27547
+ app.openPicker(mergedPickerRows(merged), (value) => resolve(value), () => resolve(void 0), {
26605
27548
  enableSearch: true,
26606
27549
  header: "login · providers",
26607
27550
  noMatchText: " no matching providers",
@@ -26629,8 +27572,22 @@ function registerTuiCommands(runner, initial) {
26629
27572
  text: outcome.text
26630
27573
  };
26631
27574
  }
26632
- route = picked;
26633
- } else route = options[0]?.route;
27575
+ const authTarget = targetFromPickerValue(merged, picked);
27576
+ if (authTarget !== void 0) target = authTarget;
27577
+ else route = picked;
27578
+ } else {
27579
+ const only = merged[0];
27580
+ if (only === void 0) return {
27581
+ kind: "error",
27582
+ text: "no credential targets available"
27583
+ };
27584
+ if (only.kind === "authorization") target = only;
27585
+ else {
27586
+ route = only.route;
27587
+ ref = only.ref;
27588
+ }
27589
+ }
27590
+ if (target !== void 0) return runAuthorizationLogin(ctx, app, runner, target, options);
26634
27591
  if (route === void 0 && ref === void 0) return {
26635
27592
  kind: "error",
26636
27593
  text: "no credential targets available"
@@ -26641,7 +27598,8 @@ function registerTuiCommands(runner, initial) {
26641
27598
  try {
26642
27599
  const key = (await app.askQuestions([{
26643
27600
  id: "key",
26644
- question: `Paste the API key for ${label}:`
27601
+ question: `Paste the API key for ${label}:`,
27602
+ masked: true
26645
27603
  }]))[0]?.custom ?? "";
26646
27604
  if (key === "") return {
26647
27605
  kind: "error",
@@ -26662,7 +27620,7 @@ function registerTuiCommands(runner, initial) {
26662
27620
  });
26663
27621
  commands.register({
26664
27622
  name: "logout",
26665
- description: "Clear a stored API key — deepseek official or an llm-pi-ai provider route",
27623
+ description: "Clear a stored credential — deepseek official or an llm-pi-ai provider route (API key or stored record)",
26666
27624
  input: { hint: "[<route|env-var>]" },
26667
27625
  handler: async (invocation) => {
26668
27626
  const credentials = ctx.get("credentials");
@@ -26670,24 +27628,72 @@ function registerTuiCommands(runner, initial) {
26670
27628
  kind: "error",
26671
27629
  text: "credentials service unavailable"
26672
27630
  };
27631
+ const authorization = ctx.get("authorization");
26673
27632
  const options = readProviderOptions(ctx);
27633
+ const targets = authorizationTargets(authorization?.list() ?? []);
26674
27634
  const arg = invocation.rawInput.trim();
26675
27635
  if (arg !== "") {
26676
27636
  const resolved = resolveCredentialArg(arg, options);
26677
- if (resolved === void 0) return {
27637
+ if (resolved !== void 0) {
27638
+ const option = options.find((candidate) => candidate.ref === resolved);
27639
+ const flow = option !== void 0 && option.route !== "deepseek-official" && !option.namesCredential ? flowForRoute(targets, option.route) : void 0;
27640
+ if (flow !== void 0) {
27641
+ await credentials.deleteRecord(flow.key);
27642
+ return {
27643
+ kind: "success",
27644
+ text: `${flow.label} signed out locally — stored credential cleared`
27645
+ };
27646
+ }
27647
+ await credentials.unset(resolved);
27648
+ return {
27649
+ kind: "success",
27650
+ text: `${resolved} cleared`
27651
+ };
27652
+ }
27653
+ const flow = flowForRoute(targets, arg.toLowerCase());
27654
+ if (flow !== void 0) {
27655
+ await credentials.deleteRecord(flow.key);
27656
+ return {
27657
+ kind: "success",
27658
+ text: `${flow.label} signed out locally — stored credential cleared`
27659
+ };
27660
+ }
27661
+ return {
26678
27662
  kind: "error",
26679
- text: `unknown credential target "${arg}" — ${options.map((option) => `${option.label} (${option.ref})`).join(", ")}`
27663
+ text: `unknown credential target "${arg}" — ${mergedTargetsSummary(mergeLoginTargets(options, targets))}`
26680
27664
  };
26681
- await credentials.unset(resolved);
27665
+ }
27666
+ const rows = await logoutPickerRows(credentials, options, targets);
27667
+ if (rows.length === 0) return {
27668
+ kind: "error",
27669
+ text: "nothing to sign out"
27670
+ };
27671
+ const picked = await new Promise((resolve) => {
27672
+ app.openPicker(rows, (value) => resolve(value), () => resolve(void 0), {
27673
+ enableSearch: true,
27674
+ header: "logout · credentials",
27675
+ noMatchText: " nothing to sign out",
27676
+ width: 76,
27677
+ maxHeight: 26,
27678
+ showHint: true
27679
+ });
27680
+ });
27681
+ if (picked === void 0) return {
27682
+ kind: "error",
27683
+ text: "logout cancelled"
27684
+ };
27685
+ if (picked.startsWith(LOGOUT_RECORD_VALUE)) {
27686
+ await credentials.deleteRecord(picked.slice(8));
26682
27687
  return {
26683
27688
  kind: "success",
26684
- text: `${resolved} cleared`
27689
+ text: `${picked.slice(8)} signed out locally — stored credential cleared`
26685
27690
  };
26686
27691
  }
26687
- await credentials.unset(options[0].ref);
27692
+ const targetRef = picked.slice(5);
27693
+ await credentials.unset(targetRef);
26688
27694
  return {
26689
27695
  kind: "success",
26690
- text: `${options[0].ref} cleared`
27696
+ text: `${targetRef} cleared`
26691
27697
  };
26692
27698
  }
26693
27699
  });
@@ -26918,6 +27924,33 @@ function appendHistoryLine(file, text, lastContent) {
26918
27924
  return true;
26919
27925
  }
26920
27926
  //#endregion
27927
+ //#region src/dsh-version.ts
27928
+ /**
27929
+ * The installed dsh (DeepSeek Harness CLI) version, resolved from the
27930
+ * launcher's real path: `process.argv[1]` is the `dsh` bin, whose realpath
27931
+ * walks up to the `@deepseek-ai/dsh/package.json` that owns it. The header
27932
+ * and the welcome card show the harness the TUI runs on, not this bundle's
27933
+ * own patch level. Undefined when the launcher path is unreadable.
27934
+ * @module @xmoon76/dsh-pi-tui/dsh-version
27935
+ */
27936
+ /** @returns the installed dsh version string, or undefined. */
27937
+ function dshVersion() {
27938
+ const bin = process.argv[1];
27939
+ if (bin === void 0) return void 0;
27940
+ try {
27941
+ let dir = dirname(realpathSync(bin));
27942
+ for (let depth = 0; depth < 8; depth += 1) {
27943
+ try {
27944
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
27945
+ if (pkg.name === "@deepseek-ai/dsh" && typeof pkg.version === "string") return pkg.version;
27946
+ } catch {}
27947
+ const parent = dirname(dir);
27948
+ if (parent === dir) return void 0;
27949
+ dir = parent;
27950
+ }
27951
+ } catch {}
27952
+ }
27953
+ //#endregion
26921
27954
  //#region src/exit.ts
26922
27955
  /**
26923
27956
  * The TUI exit contract: flush the live session with a HARD timeout, record
@@ -27460,6 +28493,17 @@ function shellCommandOf(text) {
27460
28493
  return text.replace(/^!+/, "").trim();
27461
28494
  }
27462
28495
  /**
28496
+ * The local-shell sandbox preference for user-typed `!`/`!!` commands:
28497
+ * 'sandbox' routes them through the dsh shell capability's policy,
28498
+ * anything else (including an absent settings document) is 'bypass' — the
28499
+ * pi/kimi default that runs the user's own commands outside the sandbox
28500
+ * (the sandbox guards the model's autonomous commands, not the user's).
28501
+ * @param settings - the TUI settings document's fields, when present.
28502
+ */
28503
+ function localShellSandboxPreferenceOf(settings) {
28504
+ return settings?.localShellSandbox === "sandbox" ? "sandbox" : "bypass";
28505
+ }
28506
+ /**
27463
28507
  * The model-facing submission text: the command echoed `$`-style (kimi
27464
28508
  * ShellExecution parity) followed by the settled card result (output +
27465
28509
  * `[exit N]` / truncation lines).
@@ -27678,82 +28722,6 @@ function formatBytes(bytes) {
27678
28722
  return `${(bytes / 1048576).toFixed(1)} MiB`;
27679
28723
  }
27680
28724
  //#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
28725
  //#region src/skill-catalog-refresh.ts
27758
28726
  /** Whether two targets are the same owner (no transition needed). The
27759
28727
  * preset identity is the caller-provided presetId compared EXACTLY —
@@ -28241,6 +29209,19 @@ const inject = [
28241
29209
  TUI_STARTUP_SERVICE
28242
29210
  ];
28243
29211
  const Config = z.object({ sessionId: z.string() });
29212
+ /**
29213
+ * Wire the credential surface refresh to the dsh 0.1.1-rc.1 credential
29214
+ * events. The rc.1 release split the credential update event into the
29215
+ * reference half and the durable-record half; both change what the footer
29216
+ * model row and the welcome card show (a /login /logout, an external
29217
+ * .credentials.yaml edit, or an authorization flow committing a record), so
29218
+ * they share one best-effort callback. The callback must not read secrets
29219
+ * or mutate providers — it only re-reads describe-level state.
29220
+ */
29221
+ function registerCredentialSurfaceRefresh(ctx, refresh) {
29222
+ ctx.on("credentials/reference-updated", refresh);
29223
+ ctx.on("credentials/record-updated", refresh);
29224
+ }
28244
29225
  /** Display window in turns; older turns collapse into a summary entry. */
28245
29226
  const WINDOW_TURNS = 15;
28246
29227
  /** Coalesced repaint interval for streaming events, in ms. */
@@ -28294,7 +29275,6 @@ const LOCAL_COMMANDS = /* @__PURE__ */ new Set([
28294
29275
  "model",
28295
29276
  "new",
28296
29277
  "preset",
28297
- "queue",
28298
29278
  "quit",
28299
29279
  "reload",
28300
29280
  "rename",
@@ -28431,15 +29411,30 @@ function subagentJobTranscriptId(snapshot) {
28431
29411
  /** Viewer body for a subagent job with no uniquely matched child. */
28432
29412
  function subagentJobViewHint(status, detail) {
28433
29413
  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})`}`}`,
29414
+ `status: ${status}${status === "running" || status === "stopping" ? " — running in the background; its transcript updates live in /tasks" : ` — this subagent finished${detail === void 0 ? "" : ` (${detail})`}`}`,
28435
29415
  "",
28436
29416
  "The job record does not carry the child session id, so this job cannot",
28437
29417
  "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",
29418
+ "run would be indistinguishable). Open /tasks and pick the child by",
28439
29419
  "its label to read the transcript."
28440
29420
  ].join("\n");
28441
29421
  }
28442
29422
  /**
29423
+ * Whether an inbox message is USER-ORIGIN input — the queue pane's steerable
29424
+ * `❯` rows. Everything else is injected context, not the user's own queued
29425
+ * input, and must never read as one: plugin notices (background-job
29426
+ * completions), `subagent-report` relays (a child's active report, e.g.
29427
+ * "Background subagent X reported:"), injected skill/agent instructions,
29428
+ * goal messages. The web makes the same cut (`placement: source.kind ===
29429
+ * 'user' ? 'steering' : 'context'`), and this deployment's queue pane has
29430
+ * the same rule: only user-origin rows are steerable. A sourceless row
29431
+ * (undefined) is treated as user input — plain rows never carry a source.
29432
+ * @param source - the message source projection, or undefined for a plain row.
29433
+ */
29434
+ function isUserQueueInput(source) {
29435
+ return source === void 0 || source.kind === "user";
29436
+ }
29437
+ /**
28443
29438
  * Whether a plain submitted draft is the quit word: exactly `exit` (trimmed,
28444
29439
  * lowercase). The runner intercepts this BEFORE any session creation or
28445
29440
  * submission (shell muscle memory); anything else — `exit!`, `Exit`, or a
@@ -28511,7 +29506,7 @@ function foldQueueRows(messages, mode, notified) {
28511
29506
  id: message.id,
28512
29507
  text: textOf(message.content),
28513
29508
  mode,
28514
- notice: source?.form === "notice"
29509
+ notice: !isUserQueueInput(source)
28515
29510
  });
28516
29511
  }
28517
29512
  return {
@@ -28520,33 +29515,10 @@ function foldQueueRows(messages, mode, notified) {
28520
29515
  };
28521
29516
  }
28522
29517
  /**
28523
- * The installed dsh version (e.g. `0.1.0-rc.6`), resolved from the launcher's
28524
- * real path: `process.argv[1]` is the `dsh` bin, whose realpath walks up to
28525
- * the `@deepseek-ai/dsh/package.json` that owns it. The version the welcome
28526
- * card shows is the harness the TUI runs on, not this bundle's own patch
28527
- * level. Undefined when the launcher path is unreadable.
28528
- * @returns the installed dsh version string, or undefined.
28529
- */
28530
- function dshVersion() {
28531
- const bin = process.argv[1];
28532
- if (bin === void 0) return void 0;
28533
- try {
28534
- let dir = dirname(realpathSync(bin));
28535
- for (let depth = 0; depth < 8; depth += 1) {
28536
- try {
28537
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
28538
- if (pkg.name === "@deepseek-ai/dsh" && typeof pkg.version === "string") return pkg.version;
28539
- } catch {}
28540
- const parent = dirname(dir);
28541
- if (parent === dir) return void 0;
28542
- dir = parent;
28543
- }
28544
- } catch {}
28545
- }
28546
- /**
28547
29518
  * The bundle's own version, read from package.json at runtime so the welcome
28548
29519
  * card never drifts from the shipped version. The DISPLAYED version prefers
28549
- * the installed dsh version (`dshVersion`), falling back to this one.
29520
+ * the installed dsh version (`dshVersion` — shared with the header badge
29521
+ * via src/dsh-version.ts), falling back to this one.
28550
29522
  * @returns the version string, or a fallback when the file is unreadable.
28551
29523
  */
28552
29524
  function packageVersion() {
@@ -29292,9 +30264,11 @@ function apply(ctx, config) {
29292
30264
  /**
29293
30265
  * The LIVE session's workspace: each session carries its own header cwd
29294
30266
  * (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).
30267
+ * directory). The footer/welcome/completions AND `!`/`!!` shell runs
30268
+ * follow THIS cwd, so a session switch moves the whole surface with the
30269
+ * session (pi parity: `executeBash` runs in the session cwd) and a
30270
+ * shell command executes where the completions suggest files; `cwd`
30271
+ * (the process cwd) stays for launch-relative concerns (/export paths).
29298
30272
  */
29299
30273
  const sessionCwd = () => liveAgent?.session.header.cwd ?? cwd;
29300
30274
  /** The footer model label: the live selection (with effort) when one exists. */
@@ -29466,11 +30440,13 @@ function apply(ctx, config) {
29466
30440
  });
29467
30441
  if (includeInContext && !localSignal.aborted) submitResult(result);
29468
30442
  };
29469
- const shell = ctx.get("shell");
30443
+ const sandboxPreference = localShellSandboxPreferenceOf(tuiSettings?.get());
30444
+ const shell = sandboxPreference === "sandbox" ? ctx.get("shell") : void 0;
30445
+ if (shell === void 0 && sandboxPreference === "sandbox") app.notify("local shell sandbox unavailable in this composition — running unsandboxed", "error");
29470
30446
  if (shell !== void 0) {
29471
30447
  const spec = shell.resolve({
29472
30448
  command,
29473
- workdir: cwd,
30449
+ workdir: sessionCwd(),
29474
30450
  signal: localSignal
29475
30451
  });
29476
30452
  runOwned("local shell", () => shell.run(spec), {
@@ -29500,7 +30476,7 @@ function apply(ctx, config) {
29500
30476
  return;
29501
30477
  }
29502
30478
  const child = spawn(command, {
29503
- cwd,
30479
+ cwd: sessionCwd(),
29504
30480
  stdio: [
29505
30481
  "ignore",
29506
30482
  "pipe",
@@ -30063,8 +31039,7 @@ function apply(ctx, config) {
30063
31039
  },
30064
31040
  onDequeue: () => {
30065
31041
  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));
31042
+ const queued = [...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].filter((message) => isUserQueueInput(message.source));
30068
31043
  if (queued.length === 0) return;
30069
31044
  for (const message of queued) liveAgent.inbox.remove(message.id);
30070
31045
  const queuedText = queued.map((message) => textOf(message.content)).join("\n\n");
@@ -30072,58 +31047,7 @@ function apply(ctx, config) {
30072
31047
  app.setDraft([queuedText, current].filter((part) => part.trim() !== "").join("\n\n"));
30073
31048
  refreshQueue();
30074
31049
  },
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
- }
31050
+ onOpenTasks: () => openTasksBrowser()
30127
31051
  }, {
30128
31052
  present,
30129
31053
  workspaceRoot: cwd,
@@ -30142,6 +31066,73 @@ function apply(ctx, config) {
30142
31066
  unstableInputsRevision: () => extensionService?._unstableInputsRevision() ?? 0,
30143
31067
  unstableFailSafeRelease: () => extensionService?._unstableEmergencyRelease()
30144
31068
  });
31069
+ const openTasksBrowser = () => {
31070
+ if (liveAgent === void 0) return;
31071
+ let jobSnapshots = [];
31072
+ if (jobs !== void 0) try {
31073
+ jobSnapshots = jobs.list(liveAgent);
31074
+ } catch {}
31075
+ let rows = buildTaskRows(jobSnapshots, []);
31076
+ const selectRow = (value) => {
31077
+ const row = rows.find((candidate) => candidate.value === value);
31078
+ if (row === void 0) return;
31079
+ if (row.kind === "subagent") {
31080
+ runOwned("subagent view from tasks", () => enterView(row.childId, row.label), {
31081
+ diag,
31082
+ sessionId: () => liveAgent?.session.id,
31083
+ onError: (error) => app.notify(`could not open the subagent view: ${safeErrorMessage(error)}`, "error")
31084
+ });
31085
+ return;
31086
+ }
31087
+ openJobView(row.jobId);
31088
+ };
31089
+ const actionRow = (value, action) => {
31090
+ const row = rows.find((candidate) => candidate.value === value);
31091
+ if (row === void 0 || row.kind !== "subagent") return;
31092
+ const service = ctx.get("subagents");
31093
+ if (service === void 0 || liveAgent === void 0) {
31094
+ app.notify("subagent service unavailable", "error");
31095
+ return;
31096
+ }
31097
+ service.interrupt(row.childId, {
31098
+ kind: "user",
31099
+ parentSessionId: liveAgent.session.id
31100
+ });
31101
+ app.notify(`interrupting ${row.label}`, "info");
31102
+ };
31103
+ const taskPanelItems = (target) => target.map((row) => row.kind === "job" ? {
31104
+ value: row.value,
31105
+ label: taskRowLabel(row),
31106
+ status: row.status,
31107
+ detail: row.detail,
31108
+ startedAt: row.startedAt,
31109
+ group: rowGroup(row)
31110
+ } : {
31111
+ value: row.value,
31112
+ label: taskRowLabel(row),
31113
+ status: row.activity,
31114
+ detail: row.hasChildren ? "has children" : void 0,
31115
+ group: rowGroup(row)
31116
+ });
31117
+ const handle = app.openTaskBrowser(taskPanelItems(rows), selectRow, () => {}, {
31118
+ header: "tasks · subagents",
31119
+ enableSearch: true,
31120
+ onAction: actionRow
31121
+ });
31122
+ if (subagents !== void 0) {
31123
+ const sessionId = liveAgent.session.id;
31124
+ const generation = sessionGeneration;
31125
+ runOwned("task browser children", () => subagents.listChildren(sessionId), {
31126
+ diag,
31127
+ sessionId: () => liveAgent?.session.id,
31128
+ onResult: (entries) => {
31129
+ if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
31130
+ rows = buildTaskRows(jobSnapshots, entries);
31131
+ handle.setItems(taskPanelItems(rows));
31132
+ }
31133
+ });
31134
+ }
31135
+ };
30145
31136
  if (extensionHost !== void 0 && extensionService !== void 0) {
30146
31137
  app.setRendererErrorSink(({ id, error, slot }) => {
30147
31138
  const message = safeErrorMessage(error).replace(/\s+/g, " ").slice(0, 200);
@@ -30186,12 +31177,14 @@ function apply(ctx, config) {
30186
31177
  theme: z.string(),
30187
31178
  footer: z.string(),
30188
31179
  fullscreen: z.string(),
30189
- busyEnter: z.string()
31180
+ busyEnter: z.string(),
31181
+ localShellSandbox: z.string()
30190
31182
  }), { base: {
30191
31183
  theme: "auto",
30192
31184
  footer: "full",
30193
31185
  fullscreen: "on",
30194
- busyEnter: "queue"
31186
+ busyEnter: "queue",
31187
+ localShellSandbox: "bypass"
30195
31188
  } });
30196
31189
  if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
30197
31190
  const storedTheme = tuiSettings?.get().theme;
@@ -30305,7 +31298,7 @@ function apply(ctx, config) {
30305
31298
  * (never the output — the job's single read cursor belongs to the agent's
30306
31299
  * job_output; consuming it from the UI would leave the model an
30307
31300
  * incomplete result and could swallow the completion notice); a subagent
30308
- * job shows the status viewer with a /subagents hint. The job record
31301
+ * job shows the status viewer with a /tasks hint. The job record
30309
31302
  * carries no child session id, so label/order/time heuristics cannot
30310
31303
  * distinguish a background child from a same-label foreground one-shot;
30311
31304
  * the task browser therefore never opens a transcript by guess.
@@ -30338,7 +31331,7 @@ function apply(ctx, config) {
30338
31331
  };
30339
31332
  /**
30340
31333
  * Status-only viewer for one job (never touches the read cursor). The
30341
- * subagent variant appends the /subagents hint because a transcript
31334
+ * subagent variant appends the /tasks hint because a transcript
30342
31335
  * cannot always be matched; the bash variant is pure status.
30343
31336
  */
30344
31337
  const openJobStatusViewer = (jobId, title, snapshot) => {
@@ -30608,6 +31601,7 @@ function apply(ctx, config) {
30608
31601
  refreshStatus,
30609
31602
  updateWelcomeCard,
30610
31603
  openJobView,
31604
+ openTasksBrowser,
30611
31605
  enterView,
30612
31606
  requestExit,
30613
31607
  exit
@@ -30777,10 +31771,11 @@ function apply(ctx, config) {
30777
31771
  updateWelcomeCard();
30778
31772
  }
30779
31773
  });
30780
- ctx.on("credentials/updated", () => {
31774
+ const refreshCredentialSurface = () => {
30781
31775
  refreshStatus();
30782
31776
  updateWelcomeCard();
30783
- });
31777
+ };
31778
+ registerCredentialSurfaceRefresh(ctx, refreshCredentialSurface);
30784
31779
  if (liveAgent !== void 0) {
30785
31780
  app.setPlanMode(foldPlanMode(liveAgent.session.events));
30786
31781
  app.setWorking(workingFromLog(liveAgent.session.events));
@@ -30848,4 +31843,4 @@ function apply(ctx, config) {
30848
31843
  });
30849
31844
  }
30850
31845
  //#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 };
31846
+ export { dshVersion as A, resumeCommand as C, subagentJobTranscriptId as D, shouldSteerOnEnter as E, PI_TUI_EXTENSIONS_SERVICE as F, API_VERSION as I, describeKey as L, apply$1 as M, inject$1 as N, subagentJobViewHint as O, name$1 as P, resolveInitialCatalog as S, shouldConsumeAdvertisedMiss 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, forkSeed as j, subagentNoticeIsFailure 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, runningProfile as w, registerCredentialSurfaceRefresh as x, recomposeBlank as y };