@xmoon76/dsh-pi-tui 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -29,6 +29,7 @@ import { eastAsianWidth } from "get-east-asian-width";
29
29
  import { performance } from "node:perf_hooks";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { EventEmitter } from "events";
32
+ import { isUserInvocable } from "@deepseek-ai/dsh-skill";
32
33
  //#region src/present.ts
33
34
  /** Figma row titles per variant (design literals, not translatable copy). */
34
35
  const VARIANT_TITLES = {
@@ -12607,33 +12608,56 @@ var TranscriptSearchComponent = class {
12607
12608
  //#endregion
12608
12609
  //#region src/question.ts
12609
12610
  /**
12610
- * The user-questions dialog flow (ask_user_question v2): a focusable overlay
12611
- * component that walks one question at a time, collects drafts per question,
12612
- * and submits the whole batch at the end — the Web QuestionComposer
12613
- * semantics (per-question paging, batch submit, skip) with pi/kimi keyboard
12614
- * ergonomics (↑↓ highlight, digits, real Input for free text, review page).
12611
+ * The user-questions dialog flow (ask_user_question v2): the component that
12612
+ * sits in the EDITOR SEAT (kimi's mountEditorReplacement pattern) while the
12613
+ * agent asks — one question at a time, drafts per question, whole batch
12614
+ * submitted at the end — the Web QuestionComposer semantics (per-question
12615
+ * paging, batch submit, skip) with pi/kimi keyboard ergonomics (↑↓
12616
+ * highlight, digits, real Input for free text, review page).
12615
12617
  *
12616
- * Pure component: the app layer owns the promise/abort plumbing and routes
12617
- * input here while the flow is active.
12618
+ * Pure component: the app layer owns the promise/abort plumbing, the seat
12619
+ * swap, and routes input here while the flow is active.
12618
12620
  * @module @xmoon76/dsh-pi-tui/question
12619
12621
  */
12620
12622
  /** The "type your own answer" row shown below options (pi isOther parity). */
12621
12623
  const OTHER_ROW = "\0other";
12622
12624
  /**
12623
- * Total physical-row budget of the question flow itself. The Frame wrapper
12624
- * adds its two border rows, and the overlay clips past maxHeight 26 instead
12625
- * of scrolling — so EVERYTHING (tabs, question, detail, options with their
12626
- * descriptions, scroll marker, skipped note, hint) must fit in 24 rows.
12625
+ * Default total physical-row budget of the question flow itself. The
12626
+ * QuestionFrame in tui-app.ts re-derives the budget from the terminal height
12627
+ * on every render (60% cap COLLAPSED, 8..24 content rows; up to 38 when
12628
+ * expanded) and pushes it through {@link QuestionFlow.setMaxRows}; 24 is the
12629
+ * fallback for direct renders.
12630
+ * The Frame wrapper adds its two border rows, and NOTHING clips the flow's
12631
+ * output in the editor-seat layout — so EVERYTHING (tabs, question, detail,
12632
+ * options with their descriptions, scroll marker, skipped note, hint) must
12633
+ * fit in the budget.
12627
12634
  */
12628
- const DIALOG_BUDGET = 24;
12635
+ const DEFAULT_BUDGET = 24;
12629
12636
  /**
12630
- * Total physical-row budget for the question body (question text + detail),
12631
- * shared so a single unbroken long line cannot push the options and hints
12632
- * out of the dialog's maxHeight (kimi MAX_BODY_LINES scale).
12637
+ * Smallest supported content-row budget (a 16-row terminal caps the frame at
12638
+ * 10 rows). Below this the required rows (tabs, the question's first row,
12639
+ * the current option, the hint) cannot all coexist.
12633
12640
  */
12634
- const MAX_BODY_LINES = 12;
12635
- /** Max visible option rows before the list windows around the cursor. */
12636
- const MAX_VISIBLE_OPTIONS = 7;
12641
+ const MIN_SUPPORTED_BUDGET = 8;
12642
+ /**
12643
+ * Largest supported content-row budget: the EXPANDED frame cap (80% of the
12644
+ * terminal, at most 40 frame rows) minus the two frame borders. An explicit
12645
+ * body expand ('e' / marker click) grows the panel beyond the default 60%
12646
+ * cap — the user asked for the room; the budget math is linear and holds
12647
+ * for every budget >= MIN_SUPPORTED_BUDGET.
12648
+ */
12649
+ const MAX_BUDGET = 38;
12650
+ /**
12651
+ * Cap for the WRAPPED page scrollport content (question + detail + options
12652
+ * with their descriptions). Beyond it the content ends in a `... more
12653
+ * content hidden` note — wrapping an unbounded 100KB detail every frame
12654
+ * would be wasteful. 256 rows keeps realistic questions AND all their
12655
+ * answerable options reachable (7 options × ~19 wrapped description rows
12656
+ * at 50 columns fits); the cap is a hard wall, so a page whose content
12657
+ * alone exceeds 256 rows ends in the note — that page is unreachable in
12658
+ * practice, and the note makes the cut non-silent.
12659
+ */
12660
+ const MAX_CONTENT_ROWS = 256;
12637
12661
  /**
12638
12662
  * Budgeted push of `content` to `lines`, wrapping it to `width`: pushes at
12639
12663
  * most `budget` PHYSICAL (wrapped) rows, appends a `... N more` marker when
@@ -12658,14 +12682,15 @@ function appendWrappedBudgeted(lines, firstPrefix, continuationPrefix, content,
12658
12682
  return Math.max(0, budget - used);
12659
12683
  }
12660
12684
  /**
12661
- * Push the CURRENT option's label: its first physical row is ALWAYS emitted
12662
- * (the `→` pointer must stay on screen), extra rows fill `budget`, and a
12663
- * `... N more` marker reports a label that was itself cut. Unlike
12664
- * {@link appendWrappedBudgeted}, a 1-row budget shows the label content, not
12665
- * just the cut marker — the anchored current option may never vanish.
12685
+ * Required-first push: the FIRST physical row always carries `content` —
12686
+ * never just a cut marker — while extra rows fill `budget` and a `... N
12687
+ * more` marker reports content that was itself cut, only when a row remains
12688
+ * after the content. Used for the question body and the highlighted option
12689
+ * label, whose first rows may never vanish (a 1-row budget shows the
12690
+ * content, not the marker).
12666
12691
  * @returns the remaining budget (≥ 0).
12667
12692
  */
12668
- function appendCurrentLabel(lines, firstPrefix, continuationPrefix, content, width, budget) {
12693
+ function appendContentFirst(lines, firstPrefix, continuationPrefix, content, width, budget) {
12669
12694
  if (budget <= 0) return 0;
12670
12695
  const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
12671
12696
  const wrapped = wrapTextWithAnsi(content, Math.max(1, width - prefixWidth));
@@ -12723,7 +12748,47 @@ var QuestionFlow = class {
12723
12748
  otherInput = new Input();
12724
12749
  /** Review-page action highlight (0 = Submit, 1 = Cancel). */
12725
12750
  submitIdx = 0;
12726
- focused = false;
12751
+ /**
12752
+ * Current content-row budget (8..38). The editor-seat QuestionFrame in
12753
+ * tui-app.ts re-derives it from the terminal height on every render and
12754
+ * pushes it through {@link setMaxRows}; nothing clips the flow's output
12755
+ * in that layout, so every render must fit the budget exactly.
12756
+ */
12757
+ budget = DEFAULT_BUDGET;
12758
+ _focused = false;
12759
+ /**
12760
+ * Scroll offset (content rows) into the UNIFIED page scrollport (question,
12761
+ * detail, then every option with its description, then the free-text row).
12762
+ * PageUp/PageDown move it; ↑↓/digits/click move the cursor and the render
12763
+ * follows it into view (pendingCursorScroll).
12764
+ */
12765
+ bodyScroll = 0;
12766
+ /**
12767
+ * Expanded mode ('e' / marker click): the FRAME grows toward 80% (the
12768
+ * QuestionFrame reads isBodyExpanded each render), so the scrollport is
12769
+ * taller and more of the page — including option descriptions — is visible
12770
+ * at once. Everything stays reachable by scrolling either way.
12771
+ */
12772
+ bodyExpanded = false;
12773
+ /** Cursor moved since the last render: the next render must scroll the
12774
+ * cursor's rows into view. */
12775
+ pendingCursorScroll = false;
12776
+ /** Hit map from the last render: content row -> option row key. Built each
12777
+ * render; drives fullscreen click-to-select. */
12778
+ hitMap = /* @__PURE__ */ new Map();
12779
+ /** Content row of the scroll marker in the last render (-1 = none);
12780
+ * clicking it toggles the expanded panel. */
12781
+ lastMarkerRow = -1;
12782
+ /** Scrollport height from the last render (scroll page math). */
12783
+ lastRegionHeight = 0;
12784
+ /** Wrapped page content length from the last render. */
12785
+ lastContentRows = 0;
12786
+ /** Content rows visible in the region from the last render (region minus
12787
+ * the marker row when overflowing). */
12788
+ lastVisibleRows = 0;
12789
+ /** Whether the page content overflows the scrollport on the last render —
12790
+ * 'e' (and the scroll verbs) are available when true. */
12791
+ lastExpandable = false;
12727
12792
  constructor(questions, onDone, onCancel) {
12728
12793
  this.questions = questions;
12729
12794
  this.onDone = onDone;
@@ -12737,6 +12802,162 @@ var QuestionFlow = class {
12737
12802
  this.otherInput.onEscape = () => this.exitOther();
12738
12803
  this.syncEditMode();
12739
12804
  }
12805
+ /**
12806
+ * Focus mirror for the editor-seat layout: the app focuses the wrapping
12807
+ * QuestionFrame, which forwards here. The free-text Input renders its
12808
+ * hardware cursor only while focused AND editing, so the mirror must sync
12809
+ * it — a plain field would leave the cursor permanently hidden.
12810
+ */
12811
+ get focused() {
12812
+ return this._focused;
12813
+ }
12814
+ set focused(value) {
12815
+ this._focused = value;
12816
+ if (this.editingOther) this.otherInput.focused = value;
12817
+ }
12818
+ /**
12819
+ * Replace the content-row budget (the Frame wrapper adds its two border
12820
+ * rows on top). Called by QuestionFrame on every render immediately
12821
+ * before the flow renders, so no invalidation is needed; values outside
12822
+ * [MIN_SUPPORTED_BUDGET, MAX_BUDGET] are clamped.
12823
+ */
12824
+ setMaxRows(rows) {
12825
+ this.budget = Math.max(MIN_SUPPORTED_BUDGET, Math.min(MAX_BUDGET, Math.floor(rows)));
12826
+ }
12827
+ /** Whether the panel is expanded (the frame grows toward 80% of the
12828
+ * terminal — QuestionFrame reads this every render). */
12829
+ isBodyExpanded() {
12830
+ return this.bodyExpanded;
12831
+ }
12832
+ /**
12833
+ * Reset the view (scroll + expanded + pending cursor follow) — called on
12834
+ * every tab change so each question starts at its top, collapsed.
12835
+ */
12836
+ resetBodyView() {
12837
+ this.bodyScroll = 0;
12838
+ this.bodyExpanded = false;
12839
+ this.pendingCursorScroll = false;
12840
+ }
12841
+ /**
12842
+ * Page the unified scrollport. One page = the visible content rows. No-op
12843
+ * when the content fits the region.
12844
+ */
12845
+ scrollBody(direction) {
12846
+ const visible = this.lastVisibleRows;
12847
+ if (visible <= 0 || this.lastContentRows <= visible) return;
12848
+ const maxScroll = this.lastContentRows - visible;
12849
+ this.bodyScroll = Math.max(0, Math.min(this.bodyScroll + direction * visible, maxScroll));
12850
+ }
12851
+ /** Toggle the expanded panel ('e' or a click on the scroll marker).
12852
+ * Expanding is only meaningful when the page content overflows the
12853
+ * scrollport (everything fits -> a taller panel shows nothing new). The
12854
+ * SCROLL POSITION is kept: the taller region reveals MORE rows where the
12855
+ * user is looking (e.g. the option descriptions they scrolled to), instead
12856
+ * of jumping back to the top — the render clamps the offset to the new
12857
+ * maxScroll. */
12858
+ toggleExpanded() {
12859
+ if (!this.bodyExpanded && !this.lastExpandable) return;
12860
+ this.bodyExpanded = !this.bodyExpanded;
12861
+ }
12862
+ /**
12863
+ * Primary-click routing (fullscreen): an option row selects it (single-
12864
+ * select advances, multi-select toggles, the "Type something." row enters
12865
+ * free-text), and the scroll marker toggles the expanded panel. The hit
12866
+ * map reflects the LAST rendered frame, which is what the user sees.
12867
+ */
12868
+ clickRow(row) {
12869
+ if (row < 0 || this.tab >= this.questions.length) return;
12870
+ const key = this.hitMap.get(row);
12871
+ if (key === OTHER_ROW) {
12872
+ if (this.editingOther) return;
12873
+ const index = this.rows().findIndex((candidate) => candidate.key === OTHER_ROW);
12874
+ if (index >= 0) {
12875
+ this.cursor = index;
12876
+ this.pendingCursorScroll = true;
12877
+ this.enterOther();
12878
+ }
12879
+ return;
12880
+ }
12881
+ if (key !== void 0) {
12882
+ this.cursor = Number(key);
12883
+ this.pendingCursorScroll = true;
12884
+ this.confirm();
12885
+ return;
12886
+ }
12887
+ if (row === this.lastMarkerRow) this.toggleExpanded();
12888
+ }
12889
+ /**
12890
+ * Build the UNIFIED page scrollport content: the question + detail first
12891
+ * (required-first — row 0 is always the question's first physical row),
12892
+ * a separator, then every option row (label + description), then the
12893
+ * free-text row. Each option row maps to its key for click hit-testing.
12894
+ * Capped at MAX_CONTENT_ROWS with a trailing `... more content hidden`
12895
+ * note, so wrapping an unbounded detail/description stays cheap.
12896
+ */
12897
+ buildPageContent(question, rows, draft, multi, width) {
12898
+ const lines = [];
12899
+ const hits = [];
12900
+ let left = MAX_CONTENT_ROWS;
12901
+ left = appendContentFirst(lines, `${color.primary("?")} `, " ", color.textStrong(question.question), width, left);
12902
+ hits.push(...new Array(lines.length).fill(void 0));
12903
+ if (question.detail !== void 0 && question.detail !== "") for (const line of question.detail.split("\n")) {
12904
+ if (left <= 1) {
12905
+ appendWrappedBudgeted(lines, " ", " ", color.textDim("... more content hidden"), width, left);
12906
+ break;
12907
+ }
12908
+ left = appendWrappedBudgeted(lines, " ", " ", color.textDim(line), width, left);
12909
+ }
12910
+ hits.push(...new Array(lines.length - hits.length).fill(void 0));
12911
+ lines.push("");
12912
+ hits.push(void 0);
12913
+ for (const row of rows) {
12914
+ const isCursor = rows[this.cursor] === row;
12915
+ const selected = row.key === OTHER_ROW ? draft.custom !== "" : draft.selected.has(question.options?.[Number(row.key)]?.label ?? "");
12916
+ const marker = multi || row.key === OTHER_ROW ? selected ? color.success("[✓]") : color.textDim("[ ]") : selected ? color.success(`[${Number(row.key) + 1}]`) : color.textDim(`[${Number(row.key) + 1}]`);
12917
+ const prefix = `${isCursor ? color.primary("→") : " "} ${marker} `;
12918
+ const indent = " ".repeat(visibleWidth(prefix));
12919
+ const badge = row.recommended ? ` ${color.primary("[recommended]")}` : "";
12920
+ const label = isCursor ? color.textStrong(row.label) : row.label;
12921
+ if (row.key === OTHER_ROW && this.editingOther) {
12922
+ const inputLine = this.otherInput.render(Math.max(1, width - visibleWidth(prefix)))[0] ?? "";
12923
+ const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
12924
+ lines.push(prefix + (this.otherInput.getValue() === "" ? color.textDim(row.label) : stripped));
12925
+ hits.push(OTHER_ROW);
12926
+ continue;
12927
+ }
12928
+ const labelWidth = Math.max(1, width - visibleWidth(prefix));
12929
+ for (const wrapped of wrapTextWithAnsi(`${label}${badge}`, labelWidth)) if (!this.pushPageRow(lines, hits, prefix + wrapped, row.key)) return {
12930
+ lines,
12931
+ hits
12932
+ };
12933
+ if (row.description !== void 0 && row.description !== "" && !(row.key === OTHER_ROW && this.editingOther)) {
12934
+ const descWidth = Math.max(1, width - visibleWidth(indent));
12935
+ for (const wrapped of wrapTextWithAnsi(color.textDim(row.description), descWidth)) if (!this.pushPageRow(lines, hits, indent + wrapped, row.key)) return {
12936
+ lines,
12937
+ hits
12938
+ };
12939
+ }
12940
+ }
12941
+ return {
12942
+ lines,
12943
+ hits
12944
+ };
12945
+ }
12946
+ /** Push one page-content row, capping at MAX_CONTENT_ROWS with a single
12947
+ * `... more content hidden` note (never a second one after the detail's).
12948
+ * @returns false when the cap was hit (callers stop building). */
12949
+ pushPageRow(lines, hits, line, key) {
12950
+ if (lines.length >= MAX_CONTENT_ROWS) {
12951
+ if (lines[lines.length - 1]?.includes("more content hidden") !== true) {
12952
+ lines.push(color.textDim("... more content hidden"));
12953
+ hits.push(void 0);
12954
+ }
12955
+ return false;
12956
+ }
12957
+ lines.push(line);
12958
+ hits.push(key);
12959
+ return true;
12960
+ }
12740
12961
  /** The rows of the current question (options plus the free-text row). */
12741
12962
  rows() {
12742
12963
  const question = this.questions[this.tab];
@@ -12805,6 +13026,7 @@ var QuestionFlow = class {
12805
13026
  }
12806
13027
  /** Optionless questions edit text directly; options start in list mode. */
12807
13028
  syncEditMode() {
13029
+ this.resetBodyView();
12808
13030
  const question = this.questions[this.tab];
12809
13031
  const optionless = question !== void 0 && (question.options?.length ?? 0) === 0;
12810
13032
  this.editingOther = optionless;
@@ -12879,6 +13101,7 @@ var QuestionFlow = class {
12879
13101
  if (this.editingOther) {
12880
13102
  if (data === "\r" || data === "\n") this.commitOther(this.otherInput.getValue());
12881
13103
  else if (data === "\x1B") this.exitOther();
13104
+ else if (data === "\x1B[5~" || data === "\x1B[6~") this.scrollBody(data === "\x1B[5~" ? -1 : 1);
12882
13105
  else if (data === "\x1B[D") {
12883
13106
  if (this.tab > 0) {
12884
13107
  this.tab -= 1;
@@ -12921,16 +13144,27 @@ var QuestionFlow = class {
12921
13144
  const row = rows[Number(digit[0]) - 1];
12922
13145
  if (row !== void 0) {
12923
13146
  this.cursor = rows.indexOf(row);
13147
+ this.pendingCursorScroll = true;
12924
13148
  this.confirm();
12925
13149
  }
12926
13150
  return;
12927
13151
  }
12928
13152
  if (data === "\x1B[A" || data === "k") {
12929
13153
  if (rows.length > 0) this.cursor = (this.cursor - 1 + rows.length) % rows.length;
13154
+ this.pendingCursorScroll = true;
12930
13155
  return;
12931
13156
  }
12932
13157
  if (data === "\x1B[B" || data === "j") {
12933
13158
  if (rows.length > 0) this.cursor = (this.cursor + 1) % rows.length;
13159
+ this.pendingCursorScroll = true;
13160
+ return;
13161
+ }
13162
+ if (data === "\x1B[5~" || data === "\x1B[6~") {
13163
+ this.scrollBody(data === "\x1B[5~" ? -1 : 1);
13164
+ return;
13165
+ }
13166
+ if (data === "e") {
13167
+ this.toggleExpanded();
12934
13168
  return;
12935
13169
  }
12936
13170
  if (data === "\r" || data === "\n") {
@@ -12951,6 +13185,7 @@ var QuestionFlow = class {
12951
13185
  this.tab += 1;
12952
13186
  this.cursor = 0;
12953
13187
  this.submitIdx = 0;
13188
+ this.syncEditMode();
12954
13189
  } else {
12955
13190
  this.tab = this.questions.length;
12956
13191
  this.cursor = 0;
@@ -12971,6 +13206,8 @@ var QuestionFlow = class {
12971
13206
  render(width) {
12972
13207
  const safeWidth = Math.max(1, width);
12973
13208
  const lines = [];
13209
+ this.hitMap.clear();
13210
+ this.lastMarkerRow = -1;
12974
13211
  const tabs = this.questions.map((_, index) => {
12975
13212
  const draft = this.drafts[index];
12976
13213
  const answered = draft !== void 0 && (draft.selected.size > 0 || draft.custom !== "" || draft.skipped);
@@ -12983,7 +13220,7 @@ var QuestionFlow = class {
12983
13220
  lines.push(tabs.join(" "));
12984
13221
  lines.push("");
12985
13222
  if (this.tab >= this.questions.length) {
12986
- let reviewBudget = 19;
13223
+ let reviewBudget = this.budget - lines.length - 2;
12987
13224
  reviewBudget = appendWrappedBudgeted(lines, "", "", color.textStrong("Review your answer before submit"), safeWidth, reviewBudget);
12988
13225
  if (reviewBudget > 0) {
12989
13226
  lines.push("");
@@ -13010,98 +13247,75 @@ var QuestionFlow = class {
13010
13247
  const question = this.questions[this.tab];
13011
13248
  const draft = this.draft();
13012
13249
  if (question === void 0 || draft === void 0) return lines;
13013
- if (question.header !== void 0 && question.header !== "") lines.push(color.textDim(question.header));
13014
- let bodyBudget = MAX_BODY_LINES;
13015
- bodyBudget = appendWrappedBudgeted(lines, `${color.primary("?")} `, " ", color.textStrong(question.question), safeWidth, bodyBudget);
13016
- if (question.detail !== void 0 && question.detail !== "") for (const line of question.detail.split("\n")) {
13017
- if (bodyBudget <= 1) {
13018
- appendWrappedBudgeted(lines, " ", " ", color.textDim("... more content hidden"), safeWidth, bodyBudget);
13019
- break;
13020
- }
13021
- bodyBudget = appendWrappedBudgeted(lines, " ", " ", color.textDim(line), safeWidth, bodyBudget);
13022
- }
13023
13250
  const rows = this.rows();
13024
13251
  const multi = question.multiSelect === true;
13025
- this.editingOther && rows.some((row) => row.key === OTHER_ROW);
13026
- if (rows.length > 0 && !(rows.length === 1 && rows[0]?.key === OTHER_ROW)) {
13027
- lines.push("");
13028
- const showingRow = rows.length > MAX_VISIBLE_OPTIONS ? 1 : 0;
13029
- const skippedRow = draft.skipped ? 1 : 0;
13030
- let optionBudget = DIALOG_BUDGET - lines.length - 2 - showingRow - skippedRow;
13031
- const maxWindow = Math.max(1, Math.min(MAX_VISIBLE_OPTIONS, optionBudget));
13032
- const visibleCount = Math.min(rows.length, maxWindow);
13033
- const half = Math.floor(maxWindow / 2);
13034
- const maxStart = Math.max(0, rows.length - visibleCount);
13035
- const start = Math.max(0, Math.min(this.cursor - half, maxStart));
13036
- const end = Math.min(rows.length, start + visibleCount);
13037
- if (optionBudget > 0) {
13038
- const cursorRow = rows[this.cursor];
13039
- let reserved = 1;
13040
- if (cursorRow !== void 0 && cursorRow.key !== OTHER_ROW) {
13041
- const prefix = `→ ${multi || cursorRow.key === OTHER_ROW ? draft.selected.has(question.options?.[Number(cursorRow.key)]?.label ?? "") ? "[✓]" : "[ ]" : draft.selected.has(question.options?.[Number(cursorRow.key)]?.label ?? "") ? `[${Number(cursorRow.key) + 1}]` : `[${Number(cursorRow.key) + 1}]`} `;
13042
- const badge = cursorRow.recommended ? ` ${color.primary("[recommended]")}` : "";
13043
- reserved = Math.min(Math.max(1, wrapTextWithAnsi(`${color.textStrong(cursorRow.label)}${badge}`, Math.max(1, safeWidth - visibleWidth(prefix))).length), maxWindow);
13044
- }
13045
- let left = optionBudget;
13046
- let cursorRendered = false;
13047
- for (let index = start; index < end; index++) {
13048
- const row = rows[index];
13049
- if (row === void 0) continue;
13050
- const isCursor = index === this.cursor;
13051
- if (!isCursor && (cursorRendered ? left <= 0 : left <= reserved)) continue;
13052
- if (row.key === OTHER_ROW && this.editingOther) {
13053
- const prefix = `${color.primary("→")} ${color.textDim(`[${index + 1}]`)} `;
13054
- const inputLine = this.otherInput.render(Math.max(1, safeWidth - visibleWidth(prefix)))[0] ?? "";
13055
- const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
13056
- left -= 1;
13057
- lines.push(prefix + stripped);
13058
- if (isCursor) cursorRendered = true;
13059
- continue;
13060
- }
13061
- const selected = row.key === OTHER_ROW ? draft.custom !== "" : draft.selected.has(question.options?.[Number(row.key)]?.label ?? "");
13062
- const marker = multi || row.key === OTHER_ROW ? selected ? color.success("[✓]") : color.textDim("[ ]") : selected ? color.success(`[${Number(row.key) + 1}]`) : color.textDim(`[${Number(row.key) + 1}]`);
13063
- const prefix = `${isCursor ? color.primary("→") : " "} ${marker} `;
13064
- const badge = row.recommended ? ` ${color.primary("[recommended]")}` : "";
13065
- const label = isCursor ? color.textStrong(row.label) : row.label;
13066
- const indent = " ".repeat(visibleWidth(prefix));
13067
- const available = isCursor || cursorRendered ? left : left - reserved;
13068
- const beforeLabel = lines.length;
13069
- if (isCursor) appendCurrentLabel(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
13070
- else appendWrappedBudgeted(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
13071
- left -= lines.length - beforeLabel;
13072
- if (row.description !== void 0 && row.description !== "" && left > 0) {
13073
- const descCap = isCursor || cursorRendered ? left : left - reserved;
13074
- const beforeDescription = lines.length;
13075
- appendWrappedBudgeted(lines, indent, indent, color.textDim(row.description), safeWidth, descCap);
13076
- left -= lines.length - beforeDescription;
13077
- }
13078
- if (isCursor) cursorRendered = true;
13252
+ const skippedRow = draft.skipped ? 1 : 0;
13253
+ const optionless = rows.length === 0 || rows.length === 1 && rows[0]?.key === OTHER_ROW;
13254
+ const tail = optionless ? 3 + skippedRow : 2 + skippedRow;
13255
+ const header = question.header;
13256
+ if (header !== void 0 && header !== "" && this.budget - lines.length - tail >= 2) lines.push(color.textDim(header));
13257
+ const content = this.buildPageContent(question, rows, draft, multi, safeWidth);
13258
+ const regionHeight = Math.max(1, this.budget - lines.length - tail);
13259
+ const overflow = content.lines.length > regionHeight;
13260
+ const visible = overflow && regionHeight >= 2 ? regionHeight - 1 : regionHeight;
13261
+ this.lastRegionHeight = regionHeight;
13262
+ this.lastContentRows = content.lines.length;
13263
+ this.lastVisibleRows = visible;
13264
+ this.lastExpandable = overflow && regionHeight >= 2;
13265
+ const maxScroll = Math.max(0, content.lines.length - visible);
13266
+ this.bodyScroll = Math.min(this.bodyScroll, maxScroll);
13267
+ if (this.pendingCursorScroll) {
13268
+ this.pendingCursorScroll = false;
13269
+ const cursorKey = rows[this.cursor]?.key;
13270
+ if (cursorKey !== void 0) {
13271
+ const first = content.hits.indexOf(cursorKey);
13272
+ if (first >= 0) {
13273
+ if (first < this.bodyScroll) this.bodyScroll = first;
13274
+ else if (first >= this.bodyScroll + visible) this.bodyScroll = first - visible + 1;
13275
+ this.bodyScroll = Math.max(0, Math.min(this.bodyScroll, maxScroll));
13079
13276
  }
13080
- if (showingRow === 1 && left > 0) left = appendWrappedBudgeted(lines, " ", " ", `showing ${start + 1}-${end} of ${rows.length}`, safeWidth, left);
13081
13277
  }
13082
- } else if (this.editingOther || (question.options?.length ?? 0) === 0) {
13278
+ }
13279
+ for (let index = this.bodyScroll; index < this.bodyScroll + visible && index < content.lines.length; index++) {
13280
+ const hit = content.hits[index];
13281
+ if (hit !== void 0) this.hitMap.set(lines.length, hit);
13282
+ lines.push(content.lines[index]);
13283
+ }
13284
+ if (overflow && regionHeight >= 2) {
13285
+ const above = this.bodyScroll;
13286
+ const below = content.lines.length - (this.bodyScroll + visible);
13287
+ const marker = above > 0 && below > 0 ? `↑ ${above} up · ↓ ${below} more lines` : above > 0 ? `↑ ${above} up` : `↓ ${below} more lines`;
13288
+ this.lastMarkerRow = lines.length;
13289
+ lines.push(color.textDim(marker));
13290
+ } else this.lastMarkerRow = -1;
13291
+ if (optionless) {
13083
13292
  const inputLine = this.otherInput.render(Math.max(1, safeWidth - 2))[0] ?? "";
13084
13293
  const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
13085
- lines.push(` ${stripped}`);
13294
+ lines.push(this.otherInput.getValue() === "" ? color.textDim(" Type your answer…") : ` ${stripped}`);
13086
13295
  }
13087
13296
  if (draft.skipped) lines.push(color.textDim("(skipped)"));
13088
13297
  lines.push("");
13089
13298
  const optionCount = Math.min(rows.length, 9);
13299
+ const scrollable = this.lastExpandable;
13090
13300
  const hintParts = [
13091
13301
  "↑↓ select",
13092
13302
  optionCount > 0 ? `1-${optionCount} choose` : "",
13093
13303
  multi ? "↵ toggle" : "↵ confirm",
13304
+ scrollable ? "pgup/pgdn scroll" : "",
13305
+ !this.editingOther ? this.bodyExpanded ? "e collapse" : scrollable ? "e expand" : "" : "",
13094
13306
  this.questions.length > 1 ? "←→ switch" : "",
13095
13307
  "s skip",
13096
13308
  "esc cancel"
13097
13309
  ].filter((part) => part !== "");
13310
+ const cancel = "esc cancel";
13311
+ const verbs = hintParts.filter((part) => part !== cancel);
13098
13312
  let hint = "";
13099
- for (const part of hintParts) {
13313
+ for (const part of verbs) {
13100
13314
  const next = hint === "" ? part : `${hint} · ${part}`;
13101
- if (visibleWidth(next) > safeWidth) break;
13315
+ if (visibleWidth(`${next} · ${cancel}`) > safeWidth) break;
13102
13316
  hint = next;
13103
13317
  }
13104
- lines.push(color.textDim(hint === "" ? "esc cancel" : hint));
13318
+ lines.push(color.textDim(hint === "" ? cancel : `${hint} · ${cancel}`));
13105
13319
  return lines;
13106
13320
  }
13107
13321
  };
@@ -13610,13 +13824,16 @@ function preview(text, lines) {
13610
13824
  const CONTEXT_BAR_WIDTH = 12;
13611
13825
  /**
13612
13826
  * Rounded-frame wrapper for overlay content: `╭─╮` border in the border
13613
- * token, one cell of padding, width sized to the content. Keyboard input
13614
- * forwards to the wrapped component.
13827
+ * token, one cell of padding, width sized to the content. With `fillWidth`
13828
+ * the frame keeps the overlay's full width instead of hugging the widest
13829
+ * content row. Keyboard input forwards to the wrapped component.
13615
13830
  */
13616
13831
  var Frame = class {
13617
13832
  child;
13618
- constructor(child) {
13833
+ fillWidth;
13834
+ constructor(child, fillWidth = false) {
13619
13835
  this.child = child;
13836
+ this.fillWidth = fillWidth;
13620
13837
  }
13621
13838
  invalidate() {
13622
13839
  this.child.invalidate?.();
@@ -13630,7 +13847,7 @@ var Frame = class {
13630
13847
  render(width) {
13631
13848
  const inner = Math.max(1, Math.floor(width) - 4);
13632
13849
  const lines = this.child.render(inner).map((line) => truncateToWidth(line, inner, "…"));
13633
- const contentWidth = Math.min(inner, Math.max(1, ...lines.map((line) => visibleWidth(line))));
13850
+ const contentWidth = this.fillWidth ? inner : Math.min(inner, Math.max(1, ...lines.map((line) => visibleWidth(line))));
13634
13851
  const frameWidth = contentWidth + 4;
13635
13852
  const b = color.border;
13636
13853
  const out = [b(`╭${"─".repeat(frameWidth - 2)}╮`)];
@@ -13643,6 +13860,51 @@ var Frame = class {
13643
13860
  return out;
13644
13861
  }
13645
13862
  };
13863
+ /**
13864
+ * Frame for the question flow in the EDITOR SEAT: it re-derives the flow's
13865
+ * row budget from the terminal height on EVERY render (60% cap, 8..24
13866
+ * content rows — the flow's render output IS its height in the seat layout,
13867
+ * nothing clips it), so an active resize or a queued flow presented later
13868
+ * always budgets against the current terminal. It also forwards focus to the
13869
+ * flow so its free-text Input keeps the hardware cursor (a plain Frame would
13870
+ * swallow the focus flag).
13871
+ */
13872
+ var QuestionFrame = class extends Frame {
13873
+ flow;
13874
+ heightOf;
13875
+ /** Rendered height of the last frame (fullscreen click hit-testing). */
13876
+ lastRows = 0;
13877
+ /** Terminal height the last render used (click staleness guard). */
13878
+ lastTermRows = 0;
13879
+ constructor(flow, heightOf) {
13880
+ super(flow, true);
13881
+ this.flow = flow;
13882
+ this.heightOf = heightOf;
13883
+ }
13884
+ render(width) {
13885
+ const rows = Math.max(1, this.heightOf());
13886
+ this.lastTermRows = rows;
13887
+ 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)));
13888
+ this.flow.setMaxRows(frameRows - 2);
13889
+ const out = super.render(width);
13890
+ this.lastRows = out.length;
13891
+ return out;
13892
+ }
13893
+ /** The frame's height from the last render (0 before the first one). */
13894
+ get rows() {
13895
+ return this.lastRows;
13896
+ }
13897
+ /** The terminal height the last render used. */
13898
+ get termRows() {
13899
+ return this.lastTermRows;
13900
+ }
13901
+ get focused() {
13902
+ return this.flow.focused;
13903
+ }
13904
+ set focused(value) {
13905
+ this.flow.focused = value;
13906
+ }
13907
+ };
13646
13908
  /** The session head card: identity facts, wrapped to the available width so
13647
13909
  * nothing is truncated, framed with a box whose width matches the editor's
13648
13910
  * border below it (a fixed-width rule looked misaligned next to the frame). */
@@ -13712,6 +13974,134 @@ var Spacer = class {
13712
13974
  return [""];
13713
13975
  }
13714
13976
  };
13977
+ /**
13978
+ * Bullet + continuation-indent wrapper that keeps its child LIVE, so a
13979
+ * terminal resize re-renders the child at the new width instead of
13980
+ * re-wrapping a frozen render (the 5a76526 regression: assistant/user
13981
+ * messages were flattened to a static Text at build time, so markdown
13982
+ * tables could never reflow and border lines wrapped as plain text on
13983
+ * narrow windows). The bullet leads the FIRST line; wrapped continuation
13984
+ * lines indent under it (kimi prefix+indent parity).
13985
+ *
13986
+ * The prefixed output keeps a REFERENCE-STABLE cache: when the child
13987
+ * returns the same array instance (its own text+width cache hit) at the
13988
+ * same width, the wrapper returns the same prefixed array — so the fork's
13989
+ * per-frame processed-line reuse (fork AGENTS.md divergence 5) keeps
13990
+ * hitting on steady frames instead of re-normalizing every line.
13991
+ */
13992
+ var BulletedComponent = class {
13993
+ child;
13994
+ prefix;
13995
+ prefixWidth;
13996
+ indent;
13997
+ lastChild;
13998
+ lastWidth = -1;
13999
+ cached;
14000
+ constructor(child, prefix) {
14001
+ this.child = child;
14002
+ this.prefix = prefix;
14003
+ this.prefixWidth = visibleWidth(prefix);
14004
+ this.indent = " ".repeat(this.prefixWidth);
14005
+ }
14006
+ invalidate() {
14007
+ this.child.invalidate?.();
14008
+ }
14009
+ dispose() {
14010
+ this.child.dispose?.();
14011
+ }
14012
+ render(width) {
14013
+ const inner = Math.max(1, width - this.prefixWidth);
14014
+ const child = this.child.render(inner);
14015
+ if (child === this.lastChild && width === this.lastWidth && this.cached !== void 0) return this.cached;
14016
+ this.lastChild = child;
14017
+ this.lastWidth = width;
14018
+ this.cached = child.map((line, index) => (index === 0 ? this.prefix : this.indent) + line);
14019
+ return this.cached;
14020
+ }
14021
+ };
14022
+ /**
14023
+ * Longest prefix of `text` whose WRAPPED height fits `budget` rows at
14024
+ * `width`, with an ellipsis marking a cut — the approval dialog's height
14025
+ * budget must count wrapped rows, not raw lines, because a single long
14026
+ * line can wrap across many display rows. Wrapped height is monotonic in
14027
+ * the prefix length, so a binary search bounds the wrap calls. The
14028
+ * ellipsis reserves its own row when truncating (a full last row would
14029
+ * otherwise push it onto a new row and overflow the budget).
14030
+ * @param text - the candidate text ('' yields '').
14031
+ * @param width - the wrap width.
14032
+ * @param budget - the row budget; 0 or negative yields '…' for non-empty.
14033
+ * @returns the fitted text and whether it was truncated.
14034
+ */
14035
+ function capWrappedToHeight(text, width, budget) {
14036
+ if (text === "") return {
14037
+ text: "",
14038
+ truncated: false
14039
+ };
14040
+ if (budget <= 0) return {
14041
+ text: "",
14042
+ truncated: true
14043
+ };
14044
+ const fits = (candidate, rows) => wrapTextWithAnsi(candidate, width).length <= rows;
14045
+ if (fits(text, budget)) return {
14046
+ text,
14047
+ truncated: false
14048
+ };
14049
+ if (budget === 1) return {
14050
+ text: truncateToWidth(text, width, "…"),
14051
+ truncated: true
14052
+ };
14053
+ const target = budget - 1;
14054
+ let low = 0;
14055
+ let high = text.length;
14056
+ while (low < high) {
14057
+ const mid = Math.ceil((low + high) / 2);
14058
+ if (fits(text.slice(0, mid), target)) low = mid;
14059
+ else high = mid - 1;
14060
+ }
14061
+ return {
14062
+ text: `${text.slice(0, low)}…`,
14063
+ truncated: true
14064
+ };
14065
+ }
14066
+ /**
14067
+ * Fit `text` into `budget` wrapped rows for the approval dialog, ending with
14068
+ * a dimmed `... N more` marker row when the content is cut. The marker rides
14069
+ * INSIDE the budget (content rows cap at budget−1, the marker itself is
14070
+ * width-cropped so it can never wrap), so a section can never silently
14071
+ * overflow the dialog's maxHeight — same marker semantics as the
14072
+ * question flow's `appendWrappedBudgeted`. A single-row budget keeps
14073
+ * the old width-cropped ellipsis (a bare marker row would waste the row).
14074
+ * @returns the display text (rows joined with '\n') and the hidden row count.
14075
+ */
14076
+ function capWrappedToMarker(text, width, budget) {
14077
+ if (text === "" || budget <= 0) return {
14078
+ text: "",
14079
+ hidden: 0
14080
+ };
14081
+ const total = wrapTextWithAnsi(text, width).length;
14082
+ if (total <= budget) return {
14083
+ text,
14084
+ hidden: 0
14085
+ };
14086
+ if (budget === 1) return {
14087
+ text: truncateToWidth(text, width, "…"),
14088
+ hidden: total - 1
14089
+ };
14090
+ const target = budget - 1;
14091
+ let low = 0;
14092
+ let high = text.length;
14093
+ while (low < high) {
14094
+ const mid = Math.ceil((low + high) / 2);
14095
+ if (wrapTextWithAnsi(text.slice(0, mid), width).length <= target) low = mid;
14096
+ else high = mid - 1;
14097
+ }
14098
+ const hidden = total - target;
14099
+ const marker = truncateToWidth(color.textDim(`... ${hidden} more line${hidden > 1 ? "s" : ""}`), width, "…");
14100
+ return {
14101
+ text: `${text.slice(0, low)}\n${marker}`,
14102
+ hidden
14103
+ };
14104
+ }
13715
14105
  /** The live job-output viewer body: a title line + refreshable text panel. */
13716
14106
  var OutputViewerPanel = class {
13717
14107
  title;
@@ -13751,6 +14141,13 @@ var TuiApp = class TuiApp {
13751
14141
  terminal;
13752
14142
  tui;
13753
14143
  editor;
14144
+ /**
14145
+ * The editor's SEAT (kimi's editorContainer): holds the editor normally,
14146
+ * the QuestionFrame while a question flow is active — so the dialog
14147
+ * renders in the editor's row position (full width, above the footer)
14148
+ * instead of a centered modal that covered the transcript.
14149
+ */
14150
+ editorSeat;
13754
14151
  header;
13755
14152
  messagesView;
13756
14153
  footer;
@@ -13789,6 +14186,8 @@ var TuiApp = class TuiApp {
13789
14186
  dock;
13790
14187
  /** Active background tasks for the dock/footer lines (label + status). */
13791
14188
  dockTasks = [];
14189
+ /** Live child subagents (continuable or running one-shot) for the dock/footer lines (never jobs records). */
14190
+ dockAgents = [];
13792
14191
  /**
13793
14192
  * The queued-input pane below the todo panel (kimi QueuePane parity):
13794
14193
  * a border rule plus one `❯ text` row per pending message and a dim hint.
@@ -13810,6 +14209,19 @@ var TuiApp = class TuiApp {
13810
14209
  }
13811
14210
  /** Fullscreen (alt-screen) instance; absent in regular mode. */
13812
14211
  fullscreen;
14212
+ /** One shared in-flight autodetect; concurrent callers coalesce onto it
14213
+ * (overlapping OSC 11 queries would mis-pair replies by FIFO order). */
14214
+ autoDetectInFlight;
14215
+ /** The LATEST shouldApply guard of the in-flight autodetect; consulted at
14216
+ * settle time so a late result can never override a newer explicit
14217
+ * choice; reset when the flight settles. */
14218
+ autoDetectGuard;
14219
+ /** Whether the terminal's live scheme reports are being tracked. */
14220
+ followingTerminalTheme = false;
14221
+ /** Terminal-scheme listeners, fanned out from every screen's reports. */
14222
+ terminalSchemeListeners = /* @__PURE__ */ new Set();
14223
+ /** Per-screen scheme-report registrations, rebuilt on screen switches. */
14224
+ schemeDisposers = [];
13813
14225
  /** The mounted transcript-search overlay, while one is open. */
13814
14226
  searchOverlay;
13815
14227
  /** The search input component, while one is open (for match counts). */
@@ -13874,6 +14286,13 @@ var TuiApp = class TuiApp {
13874
14286
  externalEditorInFlight = false;
13875
14287
  /** The live session's auto-generated title, shown in the header when set. */
13876
14288
  sessionTitleText = "";
14289
+ /** The read-only subagent viewer: while set, the editor bar shows a
14290
+ * placeholder, the editor border switches to the accent color, and the
14291
+ * header carries a persistent badge — the transient notify line is not
14292
+ * the only signal. */
14293
+ viewerMode;
14294
+ /** The real draft preserved while the viewer covers the editor bar. */
14295
+ draftBeforeViewer;
13877
14296
  constructor(terminal, events, options = {}) {
13878
14297
  if (events.openExternalEditor !== void 0 && events.runOwned === void 0) throw new Error("openExternalEditor requires runOwned (the owned-task entry — AGENTS.md)");
13879
14298
  this.terminal = terminal;
@@ -13899,13 +14318,15 @@ var TuiApp = class TuiApp {
13899
14318
  this.queuePane = new Text("", 0, 0);
13900
14319
  this.working = new WorkingIndicator(() => this.requestRender(), options.workingIntervalMs === void 0 ? {} : { intervalMs: options.workingIntervalMs });
13901
14320
  this.footer = new Text("", 0, 0);
14321
+ this.editorSeat = new Container();
14322
+ this.editorSeat.addChild(this.editor);
13902
14323
  this.tui.addChild(this.header);
13903
14324
  this.tui.addChild(this.messagesView);
13904
14325
  this.tui.addChild(this.dock);
13905
14326
  this.tui.addChild(this.todoPanel);
13906
14327
  this.tui.addChild(this.queuePane);
13907
14328
  this.tui.addChild(this.working);
13908
- this.tui.addChild(this.editor);
14329
+ this.tui.addChild(this.editorSeat);
13909
14330
  this.tui.addChild(this.footer);
13910
14331
  this.tui.setFocus(this.editor);
13911
14332
  this.tui.addInputListener((data) => this.routeInput(data));
@@ -13941,6 +14362,8 @@ var TuiApp = class TuiApp {
13941
14362
  this.clearNotify();
13942
14363
  this.working.dispose();
13943
14364
  this.cancelQuestionFlows();
14365
+ for (const dispose of this.schemeDisposers) dispose();
14366
+ this.schemeDisposers = [];
13944
14367
  this.tui.stop();
13945
14368
  this.fullscreen?.stop();
13946
14369
  this.fullscreen = void 0;
@@ -13950,6 +14373,7 @@ var TuiApp = class TuiApp {
13950
14373
  if (isKeyRelease(data) || isKeyRepeat(data)) return void 0;
13951
14374
  if (this.activeQuestions !== void 0) return this.handleQuestionKey(data);
13952
14375
  if (this.activeApproval !== void 0) return this.handleApprovalKey(data);
14376
+ if (this.viewerMode !== void 0 && !this.overlayHost.hasOverlayEntries && !matchesKey(data, "escape") && !matchesKey(data, "ctrl+o")) return { consume: true };
13953
14377
  if (this.searchOverlay !== void 0) {
13954
14378
  if (matchesKey(data, "escape")) {
13955
14379
  this.closeTranscriptSearch();
@@ -13983,6 +14407,17 @@ var TuiApp = class TuiApp {
13983
14407
  this.events.onDequeue?.();
13984
14408
  return { consume: true };
13985
14409
  }
14410
+ if (matchesKey(data, "ctrl+enter")) {
14411
+ if (this.overlayHost.hasOverlayEntries) return void 0;
14412
+ if (this.events.onQueueSubmit === void 0) return void 0;
14413
+ const text = this.editor.getText();
14414
+ if (text.trim() === "") return void 0;
14415
+ this.rememberInput(text);
14416
+ this.clearNotify();
14417
+ this.editor.setText("");
14418
+ this.events.onQueueSubmit(text);
14419
+ return { consume: true };
14420
+ }
13986
14421
  if (matchesKey(data, "escape")) {
13987
14422
  if (this.overlayHost.hasOverlayEntries) return void 0;
13988
14423
  if (this.events.onSingleEscape?.() === true) return { consume: true };
@@ -14047,6 +14482,14 @@ var TuiApp = class TuiApp {
14047
14482
  * compositor interleaves stacked boxes line by line (two different-width
14048
14483
  * dialogs would garble into one frame), and modal hiding is done here so
14049
14484
  * the vendored fork stays pristine.
14485
+ *
14486
+ * While a question flow owns the seat (a logical capturing modal), a NEW
14487
+ * overlay joins the question's suspension set instead of appearing on top:
14488
+ * it is hidden, the question's directly suspended handles become ITS
14489
+ * dependents (kept hidden, restored only when it closes), and the overlay
14490
+ * itself becomes the question's frontmost suspended handle — so reverse
14491
+ * modal order survives the question. Question input keeps first priority
14492
+ * either way (routeInput).
14050
14493
  * @param component - the overlay content.
14051
14494
  * @param options - overlay sizing/positioning.
14052
14495
  * @returns the handle; hide() also forgets the handle.
@@ -14054,6 +14497,27 @@ var TuiApp = class TuiApp {
14054
14497
  showOverlayOnHost(component, options) {
14055
14498
  const handle = this.overlayHost.showOverlay(component, options);
14056
14499
  this.overlayHandles.add(handle);
14500
+ const question = this.activeQuestions;
14501
+ if (question !== void 0) {
14502
+ for (const other of this.overlayHandles) if (other !== handle && !other.isHidden()) {
14503
+ other.setHidden(true);
14504
+ question.suspendedOverlays.add(other);
14505
+ }
14506
+ if (options?.nonCapturing !== true) {
14507
+ const dependents = /* @__PURE__ */ new Set();
14508
+ for (const other of question.suspendedOverlays) dependents.add(other);
14509
+ if (dependents.size > 0) {
14510
+ for (const other of dependents) question.suspendedOverlays.delete(other);
14511
+ this.overlayDependents.set(handle, dependents);
14512
+ }
14513
+ }
14514
+ handle.setHidden(true);
14515
+ question.suspendedOverlays.add(handle);
14516
+ return {
14517
+ ...handle,
14518
+ hide: () => this.closeOverlayHandle(handle)
14519
+ };
14520
+ }
14057
14521
  if (options?.nonCapturing !== true) {
14058
14522
  const hidden = /* @__PURE__ */ new Set();
14059
14523
  for (const other of this.overlayHandles) if (other !== handle && !other.isHidden()) {
@@ -14064,15 +14528,33 @@ var TuiApp = class TuiApp {
14064
14528
  }
14065
14529
  return {
14066
14530
  ...handle,
14067
- hide: () => {
14068
- this.overlayDependents.get(handle)?.forEach((other) => other.setHidden(false));
14069
- this.overlayDependents.delete(handle);
14070
- this.overlayHandles.delete(handle);
14071
- handle.hide();
14072
- }
14531
+ hide: () => this.closeOverlayHandle(handle)
14073
14532
  };
14074
14533
  }
14075
14534
  /**
14535
+ * Question-aware close for one tracked overlay handle (the wrapper's
14536
+ * hide). Without an active question this matches the historical behavior:
14537
+ * the handle's dependents are unhidden, the graph is cleaned, and the
14538
+ * overlay is removed. While a question owns the seat, the handle leaves
14539
+ * the question's suspension set, every dependency set drops it (no parent
14540
+ * retains a dead child), and its still-mounted dependents remain hidden
14541
+ * and become DIRECTLY owned by the question — they must not flash back
14542
+ * while the question is still up.
14543
+ */
14544
+ closeOverlayHandle(handle) {
14545
+ const question = this.activeQuestions;
14546
+ if (question !== void 0) question.suspendedOverlays.delete(handle);
14547
+ for (const dependents of this.overlayDependents.values()) dependents.delete(handle);
14548
+ const owned = this.overlayDependents.get(handle);
14549
+ if (owned !== void 0) {
14550
+ this.overlayDependents.delete(handle);
14551
+ if (question !== void 0) for (const dependent of owned) question.suspendedOverlays.add(dependent);
14552
+ else for (const dependent of owned) dependent.setHidden(false);
14553
+ }
14554
+ this.overlayHandles.delete(handle);
14555
+ handle.hide();
14556
+ }
14557
+ /**
14076
14558
  * Launch the external editor with the current draft. The TUI stops first
14077
14559
  * (raw mode released) and restarts after the editor returns; a fullscreen
14078
14560
  * mode is not restored (the editor session ends in regular mode).
@@ -14189,6 +14671,20 @@ var TuiApp = class TuiApp {
14189
14671
  this.rebuildMessages();
14190
14672
  }
14191
14673
  /**
14674
+ * Drop SETTLED local cards (completed `!`/`!!` shell runs); running cards
14675
+ * survive so a live stream is never dismissed by a concurrent submit.
14676
+ * Local cards are a live view, not a record: context runs clear on submit
14677
+ * success (the transcript's user row takes over), local-only runs on the
14678
+ * next user submission (dispatchUserInput / steerNow).
14679
+ */
14680
+ clearSettledLocalMessages() {
14681
+ const running = this.localMessages.filter((message) => message.kind === "tool" && message.status === "running");
14682
+ if (running.length === this.localMessages.length) return;
14683
+ this.localMessages.length = 0;
14684
+ this.localMessages.push(...running);
14685
+ this.rebuildMessages();
14686
+ }
14687
+ /**
14192
14688
  * Toggle between regular (terminal scrollback) and fullscreen (alt screen).
14193
14689
  * Overlays live on the active screen, so the switch hides every mounted
14194
14690
  * overlay; a pending approval prompt is re-rendered on the new screen.
@@ -14211,6 +14707,8 @@ var TuiApp = class TuiApp {
14211
14707
  pending?.handle?.hide();
14212
14708
  for (const handle of this.overlayHandles) handle.hide();
14213
14709
  this.overlayHandles.clear();
14710
+ this.overlayDependents.clear();
14711
+ if (this.activeQuestions !== void 0) this.activeQuestions.suspendedOverlays.clear();
14214
14712
  if (enabled) {
14215
14713
  const alt = new TuiAltScreen(this.terminal, void 0, void 0, { onCellClick: (x, y) => this.handleFullscreenClick(x, y) });
14216
14714
  this.fullscreenScroll = new ScrollView(this.messagesView, {
@@ -14244,7 +14742,7 @@ var TuiApp = class TuiApp {
14244
14742
  shrink: 0
14245
14743
  },
14246
14744
  {
14247
- component: this.editor,
14745
+ component: this.editorSeat,
14248
14746
  shrink: 0
14249
14747
  },
14250
14748
  {
@@ -14258,6 +14756,7 @@ var TuiApp = class TuiApp {
14258
14756
  alt.start();
14259
14757
  alt.setFocus(this.editor);
14260
14758
  this.fullscreen = alt;
14759
+ this.refreshSchemeRegistrations();
14261
14760
  } else {
14262
14761
  this.fullscreen?.stop();
14263
14762
  this.fullscreen = void 0;
@@ -14265,9 +14764,12 @@ var TuiApp = class TuiApp {
14265
14764
  this.tui.start();
14266
14765
  this.tui.requestRender(true);
14267
14766
  this.tui.setFocus(this.editor);
14767
+ this.refreshSchemeRegistrations();
14268
14768
  }
14269
14769
  this.events.onFullscreenChange?.(enabled);
14270
14770
  if (pending !== void 0) this.renderApprovalDialog(pending);
14771
+ const question = this.activeQuestions;
14772
+ if (question?.frame !== void 0) (this.fullscreen ?? this.tui).setFocus(question.frame);
14271
14773
  }
14272
14774
  /**
14273
14775
  * Open the transcript-search overlay (Ctrl+Shift+F) and focus its input.
@@ -14394,6 +14896,23 @@ var TuiApp = class TuiApp {
14394
14896
  * The global Ctrl+O fold still wins, so keyboard behavior is unchanged.
14395
14897
  */
14396
14898
  handleFullscreenClick(x, y) {
14899
+ const question = this.activeQuestions;
14900
+ if (question?.frame !== void 0) {
14901
+ if (this.terminal.rows !== question.frame.termRows) return;
14902
+ const width = this.terminal.columns;
14903
+ const height = this.terminal.rows;
14904
+ const footerHeight = this.footer.render(width).length;
14905
+ const seatHeight = question.frame.rows;
14906
+ const seatBottom = height - footerHeight;
14907
+ const seatTop = seatBottom - seatHeight;
14908
+ if (y >= seatTop && y < seatBottom) {
14909
+ if (x >= 2 && x <= width - 3) {
14910
+ question.flow.clickRow(y - seatTop - 1);
14911
+ this.requestRender();
14912
+ }
14913
+ return;
14914
+ }
14915
+ }
14397
14916
  const width = this.terminal.columns;
14398
14917
  const rowInScroll = y - this.header.render(width).length;
14399
14918
  if (rowInScroll < 0) return;
@@ -14467,11 +14986,46 @@ var TuiApp = class TuiApp {
14467
14986
  /**
14468
14987
  * Replace the editor draft. The runner restores a submission that the
14469
14988
  * divergence guard blocked, so the user's text survives for a retry.
14989
+ * While the subagent viewer covers the editor, the write goes to the
14990
+ * preserved draft (the placeholder bar stays up; the draft is restored
14991
+ * on exit).
14470
14992
  */
14471
14993
  setEditorText(text) {
14994
+ if (this.viewerMode !== void 0) {
14995
+ this.draftBeforeViewer = text;
14996
+ return;
14997
+ }
14472
14998
  this.editor.setText(text);
14473
14999
  }
14474
15000
  /**
15001
+ * Enter or leave the read-only subagent viewer. While viewing, the
15002
+ * editor bar is covered by a placeholder (the real draft is preserved
15003
+ * and restored on exit), the editor border switches to the accent color,
15004
+ * and the header shows a persistent `[viewing subagent]` badge — so the
15005
+ * mode reads at a glance instead of relying on the transient notify.
15006
+ * @param mode - the child identity + label; `undefined` leaves the viewer.
15007
+ */
15008
+ setViewerMode(mode) {
15009
+ if (mode === void 0) {
15010
+ if (this.viewerMode === void 0) return;
15011
+ this.viewerMode = void 0;
15012
+ this.editor.borderColor = this.editorBorder;
15013
+ this.editor.setText(this.draftBeforeViewer ?? "");
15014
+ this.draftBeforeViewer = void 0;
15015
+ this.editor.invalidate();
15016
+ this.renderHeader();
15017
+ this.requestRender();
15018
+ return;
15019
+ }
15020
+ if (this.viewerMode === void 0) this.draftBeforeViewer = this.editor.getText();
15021
+ this.viewerMode = mode;
15022
+ this.editor.borderColor = color.accent;
15023
+ this.editor.setText(`viewing subagent: ${mode.label} — read-only · Esc returns`);
15024
+ this.editor.invalidate();
15025
+ this.renderHeader();
15026
+ this.requestRender();
15027
+ }
15028
+ /**
14475
15029
  * Set the session head rendered above the transcript: the session identity,
14476
15030
  * model, version, preset, and cwd, wrapped to the terminal width with a
14477
15031
  * full-width rule beneath. Replaces any previous head.
@@ -14568,16 +15122,8 @@ var TuiApp = class TuiApp {
14568
15122
  }
14569
15123
  /** Render one transcript message as a pi-tui component. */
14570
15124
  renderMessage(message, boundary) {
14571
- if (message.kind === "user") {
14572
- const prefix = `${color.roleUser("❯")} `;
14573
- const indent = " ".repeat(visibleWidth(prefix));
14574
- return new Text(new Text(message.text, 0, 0).render(Math.max(1, this.terminal.columns - visibleWidth(prefix))).map((line, index) => (index === 0 ? prefix : indent) + line).join("\n"), 0, 0);
14575
- }
14576
- if (message.kind === "assistant") {
14577
- const prefix = `${color.primary("🐋")} `;
14578
- const indent = " ".repeat(visibleWidth(prefix));
14579
- return new Text(new Markdown(message.text, 0, 0, markdownTheme).render(Math.max(1, this.terminal.columns - visibleWidth(prefix))).map((line, index) => (index === 0 ? prefix : indent) + line).join("\n"), 0, 0);
14580
- }
15125
+ if (message.kind === "user") return new BulletedComponent(new Text(message.text, 0, 0), `${color.roleUser("❯")} `);
15126
+ if (message.kind === "assistant") return new BulletedComponent(new Markdown(message.text, 0, 0, markdownTheme), `${color.primary("🐋")} `);
14581
15127
  if (message.kind === "thinking") {
14582
15128
  if (message.turn >= boundary || this.expandedOverride.get(message) === true) return new Text(color.textDimItalic(`🐳 ${message.text}`), 0, 0);
14583
15129
  const lines = message.text.split("\n").filter((line) => line !== "");
@@ -14649,6 +15195,34 @@ var TuiApp = class TuiApp {
14649
15195
  return card;
14650
15196
  }
14651
15197
  /**
15198
+ * The expanded card's terminal-command row: `$ cmd` (bash) or `PS> cmd`
15199
+ * (pwsh) in the shell prompt token, dimmed (kimi ShellExecution parity —
15200
+ * the folded card shows the command; the expanded body must not drop
15201
+ * it). Multi-line commands render every line under the prompt. No-op for
15202
+ * an empty command.
15203
+ * @param card - the card container to fill.
15204
+ * @param command - the command text.
15205
+ * @param prompt - the shell prompt token (`$ ` or `PS> `).
15206
+ */
15207
+ addTerminalCommandRow(card, command, prompt) {
15208
+ if (command === "") return;
15209
+ const styledPrompt = color.shellMode(prompt);
15210
+ const lines = command.split("\n");
15211
+ for (const [index, line] of lines.entries()) {
15212
+ const prefix = index === 0 ? styledPrompt : " ".repeat(visibleWidth(styledPrompt));
15213
+ card.addChild(new Text(`${prefix}${color.textDim(line)}`, 0, 0));
15214
+ }
15215
+ }
15216
+ /** The bash/pwsh command from the raw args ('' when not a terminal call). */
15217
+ terminalCommand(name, argsRaw) {
15218
+ const call = parseCallPreview(name, argsRaw);
15219
+ return call?.kind === "bash" ? call.command : "";
15220
+ }
15221
+ /** The shell prompt token for a terminal tool (pwsh renders `PS> `). */
15222
+ shellPrompt(name) {
15223
+ return name === "pwsh" ? "PS> " : "$ ";
15224
+ }
15225
+ /**
14652
15226
  * Render one expanded tool card's body. When the runner wired a presenter,
14653
15227
  * the body follows the tool's own render intent (presentResult): a read
14654
15228
  * card shows numbered lines plus the relativized path and total line
@@ -14689,12 +15263,18 @@ var TuiApp = class TuiApp {
14689
15263
  this.renderDiffBody(card, callView.diffs, explicitlyExpanded);
14690
15264
  return;
14691
15265
  }
15266
+ if (callView.card === "terminal") {
15267
+ this.addTerminalCommandRow(card, callView.title, this.shellPrompt(message.name));
15268
+ return;
15269
+ }
14692
15270
  if (callView.card === "generic" && callView.rawInput !== void 0) {
15271
+ this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
14693
15272
  const raw = typeof callView.rawInput === "string" ? callView.rawInput : JSON.stringify(callView.rawInput, null, 2);
14694
15273
  card.addChild(new Text(color.textDim(raw), 0, 0));
14695
15274
  return;
14696
15275
  }
14697
15276
  }
15277
+ this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
14698
15278
  return;
14699
15279
  }
14700
15280
  if (message.result === "" && (message.resultBlocks?.length ?? 0) === 0) return;
@@ -14722,6 +15302,7 @@ var TuiApp = class TuiApp {
14722
15302
  }
14723
15303
  return;
14724
15304
  case "terminal": {
15305
+ this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
14725
15306
  if (resultView.output !== void 0 && resultView.output !== "") for (const line of resultView.output.split("\n")) card.addChild(new Text(color.textDim(line), 0, 0));
14726
15307
  const exit = resultView.exitCode !== void 0 ? `[exit ${resultView.exitCode}]` : resultView.signal !== void 0 ? `[signal ${resultView.signal}]` : "";
14727
15308
  if (exit !== "") card.addChild(new Text(color.textDim(exit), 0, 0));
@@ -14755,7 +15336,10 @@ var TuiApp = class TuiApp {
14755
15336
  }
14756
15337
  }
14757
15338
  if (isDiffResult(message.name, message.result)) for (const line of renderDiffLines(message.result)) card.addChild(new Text(line, 0, 0));
14758
- else card.addChild(new Text(color.textDim(message.result), 0, 0));
15339
+ else {
15340
+ this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
15341
+ card.addChild(new Text(color.textDim(message.result), 0, 0));
15342
+ }
14759
15343
  }
14760
15344
  /**
14761
15345
  * Fill a tool card with a diff body: LCS-aligned clustered lines, capped
@@ -14846,8 +15430,9 @@ var TuiApp = class TuiApp {
14846
15430
  * switch only has to re-run this. */
14847
15431
  renderHeader() {
14848
15432
  const badge = this.planMode ? ` ${color.warning("[plan]")}` : "";
14849
- const title = this.sessionTitleText === "" ? "" : ` · ${color.textMuted(this.sessionTitleText)}`;
14850
- this.header.setText(`🐋 dsh-pi-tui${title}${this.todoText}${badge}`);
15433
+ const viewerBadge = this.viewerMode === void 0 ? "" : ` ${color.accent("[viewing subagent]")}`;
15434
+ const title = this.viewerMode !== void 0 ? ` · ${color.textMuted(this.viewerMode.label)}` : this.sessionTitleText === "" ? "" : ` · ${color.textMuted(this.sessionTitleText)}`;
15435
+ this.header.setText(`🐋 dsh-pi-tui${title}${this.todoText}${badge}${viewerBadge}`);
14851
15436
  this.requestRender();
14852
15437
  }
14853
15438
  /**
@@ -14871,7 +15456,20 @@ var TuiApp = class TuiApp {
14871
15456
  */
14872
15457
  setTasks(tasks) {
14873
15458
  this.dockTasks = tasks;
14874
- this.tasksActive = tasks.length > 0;
15459
+ this.tasksActive = tasks.length > 0 || this.dockAgents.length > 0;
15460
+ this.renderDock();
15461
+ this.renderFooter();
15462
+ }
15463
+ /**
15464
+ * Replace the live child-subagent list for the dock lines and the
15465
+ * footer badge. Continuable children and foreground one-shot children
15466
+ * never register jobs records (AGENTS.md), so they arm the ↓/Ctrl+J
15467
+ * trigger through this channel.
15468
+ * @param agents - live children (id + label + activity), empty to hide.
15469
+ */
15470
+ setAgents(agents) {
15471
+ this.dockAgents = agents;
15472
+ this.tasksActive = this.dockTasks.length > 0 || agents.length > 0;
14875
15473
  this.renderDock();
14876
15474
  this.renderFooter();
14877
15475
  }
@@ -14911,15 +15509,35 @@ var TuiApp = class TuiApp {
14911
15509
  }
14912
15510
  /**
14913
15511
  * Replace the editor draft wholesale (the Alt+↑ dequeue path pulls every
14914
- * queued message back into the editor for editing).
15512
+ * queued message back into the editor for editing). While the subagent
15513
+ * viewer covers the editor, the write goes to the preserved draft.
14915
15514
  */
14916
15515
  setDraft(text) {
15516
+ if (this.viewerMode !== void 0) {
15517
+ this.draftBeforeViewer = text;
15518
+ return;
15519
+ }
14917
15520
  this.editor.setText(text);
14918
15521
  this.requestRender();
14919
15522
  }
14920
- /** The editor's current draft text (the Alt+↑ dequeue merge reads it). */
15523
+ /** The editor's current draft text (the Alt+↑ dequeue merge reads it);
15524
+ * while the viewer covers the editor, the preserved draft is returned. */
14921
15525
  getDraft() {
14922
- return this.editor.getText();
15526
+ return this.viewerMode !== void 0 && this.draftBeforeViewer !== void 0 ? this.draftBeforeViewer : this.editor.getText();
15527
+ }
15528
+ /**
15529
+ * Headless-test hook: current overlay tracking-graph sizes. The graph
15530
+ * (overlayHandles / overlayDependents / the active question's suspension)
15531
+ * is behaviorally invisible — stale entries only leak memory — so the
15532
+ * headless suite asserts its sizes directly (e.g. the fullscreen teardown
15533
+ * must leave it empty instead of retaining dead handles).
15534
+ */
15535
+ overlayGraphState() {
15536
+ return {
15537
+ handles: this.overlayHandles.size,
15538
+ dependents: this.overlayDependents.size,
15539
+ suspended: this.activeQuestions?.suspendedOverlays.size ?? 0
15540
+ };
14923
15541
  }
14924
15542
  /**
14925
15543
  * Rebuild the persistent dock strip above the todo panel: todo summary and
@@ -14952,6 +15570,14 @@ var TuiApp = class TuiApp {
14952
15570
  }
14953
15571
  if (this.dockTasks.length > 3) lines.push(color.textDim(` … ${this.dockTasks.length - 3} more`));
14954
15572
  }
15573
+ if (this.dockAgents.length > 0) {
15574
+ const shown = this.dockAgents.slice(0, 3);
15575
+ for (const agent of shown) {
15576
+ const label = agent.label.length > 40 ? `${agent.label.slice(0, 40)}…` : agent.label;
15577
+ lines.push(color.textDim(`🤖 ${agent.id} · ${label}`));
15578
+ }
15579
+ if (this.dockAgents.length > 3) lines.push(color.textDim(` … ${this.dockAgents.length - 3} more`));
15580
+ }
14955
15581
  this.dock.setText(lines.join("\n"));
14956
15582
  this.requestRender();
14957
15583
  }
@@ -14969,12 +15595,17 @@ var TuiApp = class TuiApp {
14969
15595
  /** Rebuild the two footer lines from the current status and plan badge. */
14970
15596
  renderFooter() {
14971
15597
  const context = this.status.contextTokens !== void 0 && this.status.contextWindow !== void 0 && this.status.contextWindow > 0 ? contextBar(this.status.contextTokens, this.status.contextWindow) : "";
15598
+ const permissionBadge = this.status.permission === "danger-full-access" ? color.warning("[yolo]") : this.status.permission === "read-only" ? color.textMuted("[read-only]") : this.status.permission === "workspace-write" ? color.text("[workspace-write]") : this.status.permission === "custom" ? color.warning("[custom]") : "";
15599
+ const badgeParts = [];
15600
+ if (this.dockTasks.length > 0) badgeParts.push(`${this.dockTasks.length} task${this.dockTasks.length === 1 ? "" : "s"} running`);
15601
+ if (this.dockAgents.length > 0) badgeParts.push(`${this.dockAgents.length} agent${this.dockAgents.length === 1 ? "" : "s"}`);
15602
+ const taskBadge = badgeParts.length === 0 ? "" : color.primary(`[${badgeParts.join(" · ")}${this.editor.getText().trim() === "" ? " · ↓ view" : ""}]`);
14972
15603
  const line1 = [
14973
- this.status.permission === "danger-full-access" ? color.warning("[yolo]") : this.status.permission === "read-only" ? color.textMuted("[read-only]") : this.status.permission === "workspace-write" ? color.text("[workspace-write]") : this.status.permission === "custom" ? color.warning("[custom]") : "",
15604
+ permissionBadge,
14974
15605
  this.planMode ? color.warning("[plan]") : "",
14975
15606
  this.status.goal === void 0 || this.status.goal === "" ? "" : color.primary(this.status.goal),
14976
15607
  this.status.model === "" ? "" : `[${this.status.model}]`,
14977
- this.tasksActive ? color.primary(`[${this.dockTasks.length} task${this.dockTasks.length === 1 ? "" : "s"} running${this.editor.getText().trim() === "" ? " · ↓ view" : ""}]`) : "",
15608
+ taskBadge,
14978
15609
  this.status.cwd,
14979
15610
  this.status.branch === "" ? "" : this.status.branch,
14980
15611
  context,
@@ -15121,25 +15752,85 @@ var TuiApp = class TuiApp {
15121
15752
  * FORCE_COLOR=0 / CI) stay dark without querying; a terminal that never
15122
15753
  * answers falls back to COLORFGBG (VT100/xterm convention); a terminal
15123
15754
  * with neither leaves the current theme untouched.
15755
+ *
15756
+ * The query targets the screen that OWNS the terminal input handler (the
15757
+ * alt screen in fullscreen mode): a query on the stopped main screen
15758
+ * would have its reply swallowed by the alt screen's OSC 11 consumer and
15759
+ * time out, silently turning `auto` into a no-op.
15760
+ *
15761
+ * Concurrent calls COALESCE onto one shared in-flight query — overlapping
15762
+ * OSC 11 queries have no sequence ids, so replies would mis-pair by FIFO
15763
+ * order. The LATEST {@link AutoDetectOptions.shouldApply} guard is
15764
+ * consulted at settle time, so a late result can never override a newer
15765
+ * explicit theme choice.
15766
+ * @param options.shouldApply - returns false to drop the settled result
15767
+ * instead of applying it (default: always apply).
15124
15768
  */
15125
- async autoDetectTheme() {
15769
+ async autoDetectTheme(options) {
15126
15770
  if (themeOptOut()) return;
15127
- const rgb = await this.tui.queryTerminalBackgroundColor({ timeoutMs: 800 });
15771
+ if (options?.shouldApply !== void 0) this.autoDetectGuard = options.shouldApply;
15772
+ if (this.autoDetectInFlight === void 0) this.autoDetectInFlight = this.runAutoDetect().finally(() => {
15773
+ this.autoDetectInFlight = void 0;
15774
+ this.autoDetectGuard = void 0;
15775
+ });
15776
+ return this.autoDetectInFlight;
15777
+ }
15778
+ async runAutoDetect() {
15779
+ const rgb = await this.overlayHost.queryTerminalBackgroundColor({ timeoutMs: 800 });
15780
+ const guard = this.autoDetectGuard;
15781
+ const apply = () => guard === void 0 || guard();
15128
15782
  if (rgb !== void 0) {
15129
- this.applyTheme(detectThemeFromBackground(rgb));
15783
+ if (apply()) this.applyTheme(detectThemeFromBackground(rgb));
15130
15784
  return;
15131
15785
  }
15132
15786
  const fromEnv = detectThemeFromColorFgBg();
15133
- if (fromEnv !== void 0) this.applyTheme(fromEnv);
15787
+ if (fromEnv !== void 0 && apply()) this.applyTheme(fromEnv);
15788
+ }
15789
+ /**
15790
+ * Follow the terminal's live colour-scheme reports while `following` is
15791
+ * true. Enabling sends one DSR 996 query — xterm-class terminals only
15792
+ * START reporting after being asked — and reported schemes fan out to the
15793
+ * {@link onTerminalThemeChange} listeners (which guard whether to apply,
15794
+ * typically against the persisted `auto` preference). Disabling stops
15795
+ * tracking. Idempotent.
15796
+ * @param following - whether to track and apply live scheme reports.
15797
+ */
15798
+ trackTerminalTheme(following) {
15799
+ if (following === this.followingTerminalTheme) return;
15800
+ this.followingTerminalTheme = following;
15801
+ if (!following) return;
15802
+ this.overlayHost.queryTerminalColorScheme({ timeoutMs: 800 }).then((scheme) => {
15803
+ if (scheme === void 0 || !this.followingTerminalTheme) return;
15804
+ for (const listener of [...this.terminalSchemeListeners]) listener(scheme);
15805
+ }).catch(() => {});
15134
15806
  }
15135
15807
  /**
15136
15808
  * Register a live terminal-theme listener (colour-scheme reports). The
15137
- * runner uses it to follow the terminal when the preference is `auto`.
15809
+ * listener is registered on EVERY screen: reports arrive only at the
15810
+ * screen that owns the terminal input handler (the alt screen in
15811
+ * fullscreen mode), so the registrations never double-fire. The caller
15812
+ * guards whether to apply (typically: only while the persisted preference
15813
+ * is `auto`).
15138
15814
  * @param listener - receives the detected palette family.
15139
15815
  * @returns a disposer.
15140
15816
  */
15141
15817
  onTerminalThemeChange(listener) {
15142
- return this.tui.onTerminalColorSchemeChange((scheme) => listener(scheme));
15818
+ this.terminalSchemeListeners.add(listener);
15819
+ this.refreshSchemeRegistrations();
15820
+ return () => {
15821
+ this.terminalSchemeListeners.delete(listener);
15822
+ this.refreshSchemeRegistrations();
15823
+ };
15824
+ }
15825
+ /** (Re)register the scheme-report fan-out on every screen. */
15826
+ refreshSchemeRegistrations() {
15827
+ for (const dispose of this.schemeDisposers) dispose();
15828
+ this.schemeDisposers = [];
15829
+ const screens = [this.tui];
15830
+ if (this.fullscreen !== void 0) screens.push(this.fullscreen);
15831
+ for (const screen of screens) this.schemeDisposers.push(screen.onTerminalColorSchemeChange((scheme) => {
15832
+ for (const listener of [...this.terminalSchemeListeners]) listener(scheme);
15833
+ }));
15143
15834
  }
15144
15835
  /**
15145
15836
  * Queue an approval prompt and resolve when the user decides. Requests
@@ -15181,19 +15872,31 @@ var TuiApp = class TuiApp {
15181
15872
  }
15182
15873
  /** Build and mount the approval dialog for one prompt on the active screen. */
15183
15874
  renderApprovalDialog(pending) {
15875
+ const width = this.terminal.columns;
15876
+ const maxHeight = Math.max(8, Math.min(16, this.terminal.rows - 2));
15877
+ const contentWidth = Math.max(1, width - 8);
15878
+ const titleShown = truncateToWidth(`Approve ${pending.request.toolName}?`, contentWidth, "…");
15879
+ const dangerShown = pending.request.danger === true ? truncateToWidth("⚠ DANGEROUS COMMAND — confirm carefully", contentWidth, "…") : "";
15880
+ const hintShown = capWrappedToHeight("[y] allow once [n] reject [esc/ctrl+c] cancel", contentWidth, Math.max(0, maxHeight - (1 + (dangerShown === "" ? 0 : 1) + 1 + 2 + 2))).text;
15881
+ const hintWrapped = hintShown === "" ? 0 : wrapTextWithAnsi(hintShown, contentWidth).length;
15882
+ const chrome = 1 + (dangerShown === "" ? 0 : 1) + 1 + hintWrapped + 2 + 2;
15883
+ const reasonBudget = Math.max(0, maxHeight - chrome);
15884
+ const reasonShown = capWrappedToMarker(pending.request.reason ?? "", contentWidth, reasonBudget).text;
15885
+ const reasonWrapped = reasonShown === "" ? 0 : wrapTextWithAnsi(reasonShown, contentWidth).length;
15886
+ const previewBudget = Math.max(0, maxHeight - chrome - reasonWrapped);
15184
15887
  const dialog = new Box(1, 1);
15185
- dialog.addChild(new Text(`Approve ${pending.request.toolName}?`));
15186
- if (pending.request.danger === true) dialog.addChild(new Text(color.error("⚠ DANGEROUS COMMAND — confirm carefully")));
15187
- if (pending.request.arguments !== void 0 && pending.request.arguments !== "") {
15188
- const preview = pending.request.arguments.split("\n").slice(0, 6).join("\n");
15189
- dialog.addChild(new Text(color.textDim(preview.length > 240 ? `${preview.slice(0, 240)}…` : preview)));
15190
- }
15191
- if (pending.request.reason !== void 0 && pending.request.reason !== "") dialog.addChild(new Text(pending.request.reason));
15192
- dialog.addChild(new Text(""));
15193
- dialog.addChild(new Text("[y] allow once [n] reject [esc/ctrl+c] cancel"));
15888
+ dialog.addChild(new Text(titleShown, 1, 0));
15889
+ if (dangerShown !== "") dialog.addChild(new Text(color.error(dangerShown), 1, 0));
15890
+ if (pending.request.arguments !== void 0 && pending.request.arguments !== "" && previewBudget > 0) {
15891
+ const previewShown = capWrappedToMarker(pending.request.arguments.split("\n").slice(0, 6).join("\n"), contentWidth, previewBudget).text;
15892
+ if (previewShown !== "") dialog.addChild(new Text(color.textDim(previewShown), 1, 0));
15893
+ }
15894
+ if (reasonShown !== "") dialog.addChild(new Text(reasonShown, 1, 0));
15895
+ dialog.addChild(new Text(" ", 1, 0));
15896
+ dialog.addChild(new Text(hintShown, 1, 0));
15194
15897
  pending.handle = this.showOverlayOnHost(new Frame(dialog), {
15195
- width: 60,
15196
- maxHeight: 16
15898
+ width,
15899
+ maxHeight
15197
15900
  });
15198
15901
  }
15199
15902
  /** Route a key while a prompt is showing; every key is consumed. */
@@ -15252,6 +15955,7 @@ var TuiApp = class TuiApp {
15252
15955
  ...question.multiSelect !== void 0 ? { multiSelect: question.multiSelect } : {},
15253
15956
  ...question.intent !== void 0 ? { intent: question.intent } : {}
15254
15957
  })), (answers) => this.settleQuestions(state, answers), () => this.settleQuestions(state, void 0)),
15958
+ suspendedOverlays: /* @__PURE__ */ new Set(),
15255
15959
  resolve,
15256
15960
  reject,
15257
15961
  signal
@@ -15269,13 +15973,20 @@ var TuiApp = class TuiApp {
15269
15973
  else this.questionQueue.push(state);
15270
15974
  });
15271
15975
  }
15272
- /** Mount one flow's overlay and make it the active one. */
15976
+ /** Mount one flow into the editor seat and make it the active one. */
15273
15977
  presentQuestion(state) {
15274
15978
  this.activeQuestions = state;
15275
- state.handle = this.showOverlayOnHost(new Frame(state.flow), {
15276
- width: 76,
15277
- maxHeight: 26
15278
- });
15979
+ for (const handle of this.overlayHandles) if (!handle.isHidden()) {
15980
+ handle.setHidden(true);
15981
+ state.suspendedOverlays.add(handle);
15982
+ }
15983
+ const frame = new QuestionFrame(state.flow, () => this.terminal.rows);
15984
+ state.frame = frame;
15985
+ this.editorSeat.clear();
15986
+ this.editorSeat.addChild(frame);
15987
+ const screen = this.fullscreen ?? this.tui;
15988
+ screen.setFocus(frame);
15989
+ screen.requestRender();
15279
15990
  }
15280
15991
  /** Abort one flow (its signal fired). The ACTIVE flow settles rejected
15281
15992
  * and the queue advances; a QUEUED flow is removed from the queue and
@@ -15293,12 +16004,6 @@ var TuiApp = class TuiApp {
15293
16004
  if (index !== -1) this.questionQueue.splice(index, 1);
15294
16005
  state.reject(cancellationError("question flow cancelled"));
15295
16006
  }
15296
- /** Show the next queued flow, if any (called after the active one settles). */
15297
- nextQuestion() {
15298
- const next = this.questionQueue.shift();
15299
- if (next === void 0) return;
15300
- this.presentQuestion(next);
15301
- }
15302
16007
  /** Cancel every pending flow: each promise rejects exactly once. */
15303
16008
  cancelQuestionFlows() {
15304
16009
  for (const state of [...this.questionQueue]) this.abortQuestion(state);
@@ -15316,13 +16021,36 @@ var TuiApp = class TuiApp {
15316
16021
  settleQuestions(state, answers) {
15317
16022
  if (this.activeQuestions !== state || state.settled === true) return;
15318
16023
  state.settled = true;
15319
- this.activeQuestions = void 0;
15320
- state.handle?.hide();
15321
16024
  if (state.onAbort !== void 0 && state.signal !== void 0) state.signal.removeEventListener("abort", state.onAbort);
15322
- this.overlayHost.setFocus(this.editor);
16025
+ const next = this.questionQueue.shift();
16026
+ if (next !== void 0) {
16027
+ next.suspendedOverlays = state.suspendedOverlays;
16028
+ state.suspendedOverlays = /* @__PURE__ */ new Set();
16029
+ const frame = new QuestionFrame(next.flow, () => this.terminal.rows);
16030
+ next.frame = frame;
16031
+ this.editorSeat.clear();
16032
+ this.editorSeat.addChild(frame);
16033
+ this.activeQuestions = next;
16034
+ const screen = this.fullscreen ?? this.tui;
16035
+ screen.setFocus(frame);
16036
+ screen.requestRender();
16037
+ this.settle(state, answers);
16038
+ return;
16039
+ }
16040
+ this.editorSeat.clear();
16041
+ this.editorSeat.addChild(this.editor);
16042
+ this.activeQuestions = void 0;
16043
+ const screen = this.fullscreen ?? this.tui;
16044
+ screen.setFocus(this.editor);
16045
+ for (const handle of state.suspendedOverlays) if (this.overlayHandles.has(handle)) handle.setHidden(false);
16046
+ state.suspendedOverlays.clear();
16047
+ screen.requestRender();
16048
+ this.settle(state, answers);
16049
+ }
16050
+ /** Resolve or reject the settled promise (exactly once, by construction). */
16051
+ settle(state, answers) {
15323
16052
  if (answers === void 0) state.reject(cancellationError("question flow cancelled"));
15324
16053
  else state.resolve(answers);
15325
- this.nextQuestion();
15326
16054
  }
15327
16055
  };
15328
16056
  const dim = color.textDim;
@@ -15336,6 +16064,101 @@ function startProcessTui(events, options = {}) {
15336
16064
  return app;
15337
16065
  }
15338
16066
  //#endregion
16067
+ //#region src/tasks-browser.ts
16068
+ /**
16069
+ * Task-browser row model — the merged view over the two background surfaces.
16070
+ *
16071
+ * Source A — the jobs registry (`ctx.jobs`): bash jobs and one-shot
16072
+ * subagent jobs. A job row is STATUS-ONLY:
16073
+ * - a bash job's output has a single read cursor owned by the model's
16074
+ * `job_output`; consuming it from the UI would swallow the main
16075
+ * session's result;
16076
+ * - a one-shot subagent job record carries no child session id, so it can
16077
+ * never be matched to its child transcript — label/order/time are never
16078
+ * identity (see subagentJobTranscriptId).
16079
+ *
16080
+ * Source B — the subagent registry (`ctx.subagents.listChildren`):
16081
+ * live child subagents. Continuable children are separate durable
16082
+ * conversations that deliver no result to the parent, so Enter may open
16083
+ * the read-only transcript viewer directly. A RUNNING one-shot child
16084
+ * (the parent's pending foreground tool call, which registers no job
16085
+ * record) is merged the same way, so the ↓ trigger stays armed while a
16086
+ * foreground delegation is in flight. A finished one-shot child is not
16087
+ * merged — its work is over and `/subagents` remains its surface.
16088
+ *
16089
+ * Deliberate overlap: a running BACKGROUND one-shot has BOTH a job record
16090
+ * and a child record with no cross-reference, so it may appear twice — the
16091
+ * job row (status-only) and the child row (transcript viewable). Dedup is
16092
+ * impossible without the missing childSessionId on the job record; the
16093
+ * viewable child row is strictly more useful, so the overlap is accepted.
16094
+ *
16095
+ * Pure and injectable so row typing, ordering, and descriptions are
16096
+ * unit-testable without any dsh service.
16097
+ * @module @xmoon76/dsh-pi-tui/tasks-browser
16098
+ */
16099
+ /** Picker-value prefix for a subagent row. */
16100
+ const AGENT_ROW_PREFIX = "agent:";
16101
+ /** Picker-value prefix for a job row. */
16102
+ const JOB_ROW_PREFIX = "job:";
16103
+ /** Picker group label for continuable subagents. */
16104
+ const SUBAGENT_GROUP = "subagents";
16105
+ /** Picker group label for jobs. */
16106
+ const JOB_GROUP = "jobs";
16107
+ const isActiveJob = (status) => status === "running" || status === "stopping";
16108
+ /**
16109
+ * Ordering shared with kimi's tasks browser: active rows first, terminal
16110
+ * rows last. Jobs keep their registry order within each class (active by
16111
+ * startedAt, terminal newest-finish first); subagents keep the registry's
16112
+ * createdAt order (running before inactive).
16113
+ */
16114
+ function buildTaskRows(jobs, agents) {
16115
+ const sortedAgents = [...agents.filter((agent) => agent.kind === "child" && (agent.mode === "continuable" || agent.mode === "one-shot" && agent.activity === "running")).map((agent) => ({
16116
+ kind: "subagent",
16117
+ value: `${AGENT_ROW_PREFIX}${agent.id}`,
16118
+ childId: agent.id,
16119
+ label: agent.label ?? agent.id,
16120
+ activity: agent.activity ?? "inactive",
16121
+ hasChildren: agent.hasChildren ?? false
16122
+ }))].sort((a, b) => {
16123
+ if (a.activity !== b.activity) return a.activity === "running" ? -1 : 1;
16124
+ return 0;
16125
+ });
16126
+ const sortedJobs = [...jobs].sort((a, b) => {
16127
+ const aTerminal = !isActiveJob(a.status);
16128
+ if (aTerminal !== !isActiveJob(b.status)) return aTerminal ? 1 : -1;
16129
+ if (!aTerminal) return a.startedAt - b.startedAt;
16130
+ return (b.finishedAt ?? b.startedAt) - (a.finishedAt ?? a.startedAt);
16131
+ }).map((job) => ({
16132
+ kind: "job",
16133
+ value: `${JOB_ROW_PREFIX}${job.id}`,
16134
+ jobId: job.id,
16135
+ jobKind: job.kind,
16136
+ label: job.label,
16137
+ status: job.status,
16138
+ detail: job.detail,
16139
+ startedAt: job.startedAt,
16140
+ finishedAt: job.finishedAt
16141
+ }));
16142
+ return [...sortedAgents, ...sortedJobs];
16143
+ }
16144
+ /** The one-line picker label for a row. */
16145
+ function taskRowLabel(row) {
16146
+ if (row.kind === "job") return `${row.jobKind} · ${row.label}`;
16147
+ return `subagent · ${row.label}`;
16148
+ }
16149
+ /** The picker group a row belongs to. */
16150
+ function rowGroup(row) {
16151
+ return row.kind === "job" ? JOB_GROUP : SUBAGENT_GROUP;
16152
+ }
16153
+ /** The picker description line for a row. */
16154
+ function describeTaskRow(row, now) {
16155
+ if (row.kind === "job") {
16156
+ const elapsed = Math.max(0, Math.floor((now - row.startedAt) / 1e3));
16157
+ return `${row.status}${row.detail === void 0 ? "" : ` — ${row.detail}`} · ${elapsed}s`;
16158
+ }
16159
+ return `${row.activity}${row.hasChildren ? " · has children" : ""}`;
16160
+ }
16161
+ //#endregion
15339
16162
  //#region src/model-menu.ts
15340
16163
  /**
15341
16164
  * In-place submenu components for `/model`: the model list and the
@@ -15616,23 +16439,279 @@ async function loadSessionTitles(query, persistence, ids, signal) {
15616
16439
  return titles;
15617
16440
  }
15618
16441
  //#endregion
15619
- //#region src/commands.ts
16442
+ //#region src/skill-catalog.ts
15620
16443
  /**
15621
- * The TUI-owned slash commands (/exit /settings /sessions /skill /model
15622
- * /new /tasks /preset /subagents /search /title /copy /export /fork
15623
- * /status /login /logout /help), extracted from the runner's monolithic
15624
- * apply() so the registration surface is testable and the runner closure
15625
- * shrinks. Every command reads the live runner state through the
15626
- * {@link TuiCommandRunner} interface, whose accessors re-read the current
15627
- * agent/settings on every access (sessions can swap the live agent).
15628
- * @module @xmoon76/dsh-pi-tui/commands
16444
+ * The standing-scope skill catalog adapter: the SINGLE narrow seam between
16445
+ * the TUI and the dsh skill services (the non-public-API isolation
16446
+ * contract, plan appendix B).
16447
+ *
16448
+ * Everything the TUI knows about dsh-skill and agent-presets lives here:
16449
+ * - structural service types (never upstream classes) with OPTIONAL
16450
+ * capability members — `snapshot` and `standingKeyFor` are detected at
16451
+ * call time, so an upstream change degrades instead of breaking;
16452
+ * - `readHumanSkillCatalog()`: snapshot-first collector (list() fallback),
16453
+ * official `isUserInvocable` filter, field validation, stable sort,
16454
+ * deep freeze, distinct abort propagation;
16455
+ * - `resolveColdSkillTarget()`: capability-gated cold target selection
16456
+ * (standing scope → rosterless global → degraded global with a notice);
16457
+ * - `resolveLiveSkillTarget()`: the agent's own registry + agent scope.
16458
+ *
16459
+ * Failure contract: ordinary provider/composition failures surface as
16460
+ * degraded targets or rethrown errors for the coordinator to classify;
16461
+ * aborts ALWAYS propagate as aborts. No failure may hang or crash the TUI —
16462
+ * the worst outcome is "the corresponding commands are missing".
16463
+ * @module @xmoon76/dsh-pi-tui/skill-catalog
15629
16464
  */
15630
- /** A balanced completed-turn prefix for forking: the log up to (and including)
15631
- * the last `turn/end`. Undefined when no turn has completed yet.
15632
- * @param events - the session log.
15633
- * @returns the fork seed events, or undefined.
16465
+ /**
16466
+ * Subscribe to the dsh-skill invalidation notification. The event carries
16467
+ * no scope or cwd — consumers refetch for their own cwd and scope — and the
16468
+ * subscription itself is optional: an unavailable `on` or a throwing
16469
+ * subscribe degrades to NO subscription (owner switches and `/reload` still
16470
+ * refresh). The CALLER owns the listener's failure totality (route the
16471
+ * refresh through `runOwned`).
16472
+ * @param ctx - the context surface exposing the event bus.
16473
+ * @param listener - the notification listener (must be synchronous and
16474
+ * never throw).
15634
16475
  */
15635
- function forkSeed(events) {
16476
+ function subscribeSkillsChange(ctx, listener) {
16477
+ ctx.on?.("skills/change", listener);
16478
+ }
16479
+ /**
16480
+ * Whether one summary passes the OFFICIAL user-invocation policy — the
16481
+ * adapter's exported guard, used by every human entry point (direct
16482
+ * wrappers, `/skill`, the picker, and final body loads). A malformed
16483
+ * `invocation` (missing, non-object, non-boolean flag) is treated as NOT
16484
+ * user-invocable: the policy defaults are never reinterpreted, and a
16485
+ * hostile entry can neither throw nor sneak into a human surface.
16486
+ */
16487
+ function isUserInvocableSkill(skill) {
16488
+ const invocation = skill.invocation;
16489
+ if (typeof invocation !== "object" || invocation === null) return false;
16490
+ if (typeof invocation.userInvocable !== "boolean") return false;
16491
+ return isUserInvocable(skill);
16492
+ }
16493
+ /** Copy one summary, keeping ONLY the supported display fields. An entry
16494
+ * with a non-string name or description is rejected (never copied, never
16495
+ * trusted); a non-string `whenToUse` is dropped. */
16496
+ function toHumanSummary(skill) {
16497
+ if (typeof skill.name !== "string" || skill.name === "") return void 0;
16498
+ if (typeof skill.description !== "string") return void 0;
16499
+ return Object.freeze({
16500
+ name: skill.name,
16501
+ description: skill.description,
16502
+ ...typeof skill.whenToUse === "string" && skill.whenToUse !== "" ? { whenToUse: skill.whenToUse } : {}
16503
+ });
16504
+ }
16505
+ /** Whether one summary passes the OFFICIAL user-invocation policy. A
16506
+ * malformed `invocation` (missing, non-object, non-boolean flag) is treated
16507
+ * as NOT user-invocable — the policy defaults are never reinterpreted, and
16508
+ * a hostile entry can neither throw nor sneak into the human catalog. */
16509
+ function userInvocable(skill) {
16510
+ return isUserInvocableSkill(skill);
16511
+ }
16512
+ /**
16513
+ * Read one human skill catalog from a registry: snapshot-first, official
16514
+ * policy filter, validated detached copies, stable name sort, deep freeze.
16515
+ * @param registry - the registry to read (the adapter's structural type).
16516
+ * @param options - cwd/scope/signal for the read.
16517
+ * @returns the detached catalog.
16518
+ * @throws on cancellation (ALWAYS, distinctly) and on non-abort read
16519
+ * failures (the coordinator classifies them: retain last-good + diag).
16520
+ */
16521
+ async function readHumanSkillCatalog(registry, options) {
16522
+ options.signal?.throwIfAborted();
16523
+ const observed = typeof registry.snapshot === "function" ? await registry.snapshot(options) : {
16524
+ skills: await registry.list?.(options) ?? [],
16525
+ complete: true
16526
+ };
16527
+ options.signal?.throwIfAborted();
16528
+ const skills = observed.skills.filter(userInvocable).map(toHumanSummary).filter((summary) => summary !== void 0).sort((left, right) => left.name < right.name ? -1 : 1);
16529
+ return Object.freeze({
16530
+ skills: Object.freeze(skills),
16531
+ complete: observed.complete === true
16532
+ });
16533
+ }
16534
+ /**
16535
+ * Resolve the COLD (sessionless) skill target with capability detection:
16536
+ * - a preset roster with `standingKeyFor` → the standing scope of the
16537
+ * effective preset;
16538
+ * - no roster / no `standingKeyFor` / a standing resolution failure → the
16539
+ * global layer (with a one-shot degradation notice on failure);
16540
+ * - no skill registry at all → no target (nothing to read, no notice).
16541
+ * @param ctx - the context surface.
16542
+ * @param presetId - the effective preset id, or undefined for the default.
16543
+ * @param cwd - the workspace cwd for provider selection.
16544
+ */
16545
+ async function resolveColdSkillTarget(ctx, presetId, cwd) {
16546
+ const registry = ctx.get("skills");
16547
+ if (registry === void 0) return {};
16548
+ const presets = ctx.get("agentPresets");
16549
+ if (presets === void 0 || typeof presets.standingKeyFor !== "function") return { target: {
16550
+ kind: "cold-global",
16551
+ registry,
16552
+ cwd,
16553
+ scope: void 0
16554
+ } };
16555
+ try {
16556
+ return { target: {
16557
+ kind: "cold-standing",
16558
+ registry,
16559
+ cwd,
16560
+ scope: await presets.standingKeyFor(presetId)
16561
+ } };
16562
+ } catch (error) {
16563
+ const message = safeErrorMessage(error);
16564
+ return {
16565
+ target: {
16566
+ kind: "cold-global",
16567
+ registry,
16568
+ cwd,
16569
+ scope: void 0
16570
+ },
16571
+ degraded: `skill catalog unavailable for preset "${presetId ?? "default"}": ${message}`
16572
+ };
16573
+ }
16574
+ }
16575
+ /**
16576
+ * Resolve the LIVE skill target: the registry the agent actually sees
16577
+ * (its preset's scoped instance when the preset mounts one, else the host
16578
+ * registry), with the AGENT OBJECT as the view scope.
16579
+ * @param ctx - the context surface.
16580
+ * @param agent - the live agent.
16581
+ * @param cwd - the session workspace cwd.
16582
+ * @returns the live target, or undefined when no registry is reachable.
16583
+ */
16584
+ function resolveLiveSkillTarget(ctx, agent, cwd) {
16585
+ const registry = ctx.get("agentPresets")?.serviceFor?.(agent, "skills") ?? ctx.get("skills");
16586
+ if (registry === void 0) return void 0;
16587
+ return {
16588
+ kind: "live",
16589
+ registry,
16590
+ cwd,
16591
+ scope: agent
16592
+ };
16593
+ }
16594
+ //#endregion
16595
+ //#region src/surface-catalog.ts
16596
+ /**
16597
+ * The in-process global-layer command view. The public type requires an
16598
+ * `Agent`, but `commands.list(undefined)` resolves the global layer only
16599
+ * (ScopedLayers merges no overlays for an undefined key); the current TUI
16600
+ * already depends on this in-process behavior. The cast is isolated HERE so
16601
+ * the undefined key never reaches a remote RPC path, and the helper is the
16602
+ * single seam to replace if upstream ever ships a typed global-list API.
16603
+ * @param commands - the commands service.
16604
+ * @returns the global-layer descriptors (name-sorted by the registry).
16605
+ */
16606
+ function listGlobalCommands(commands) {
16607
+ return commands.list(void 0);
16608
+ }
16609
+ /** Copy one descriptor into a fresh frozen summary (never borrowed). */
16610
+ function commandSummaryOf(descriptor) {
16611
+ return Object.freeze({
16612
+ name: descriptor.name,
16613
+ description: descriptor.description,
16614
+ ...descriptor.input === void 0 ? {} : { input: Object.freeze({ hint: descriptor.input.hint }) }
16615
+ });
16616
+ }
16617
+ /** Whether two descriptors expose identical display fields (origin-blind:
16618
+ * an identical scoped entry needs no override because the visible result and
16619
+ * the real-agent execution are the same either way). */
16620
+ function sameCommand(left, right) {
16621
+ return left.name === right.name && left.description === right.description && left.input?.hint === right.input?.hint;
16622
+ }
16623
+ /**
16624
+ * Read one agent's effective surface catalog: global + scoped commands and
16625
+ * human-invocable skills, fully detached and frozen.
16626
+ *
16627
+ * Provider isolation (per the probe contract):
16628
+ * - lifecycle/refresh cancellation terminates the WHOLE read and propagates;
16629
+ * - an ordinary provider failure empties only that field and records a
16630
+ * detached issue; other providers continue;
16631
+ * - a missing service is that provider's successful empty result (no issue).
16632
+ *
16633
+ * This function only READS: it registers nothing, mutates nothing, and never
16634
+ * touches a runner's live agent. Probe and live agent share this collector
16635
+ * so the two surfaces cannot drift apart.
16636
+ * @param agent - the agent whose effective view to read.
16637
+ * @param signal - lifecycle/refresh cancellation.
16638
+ * @param ctx - the context surface resolving the services.
16639
+ * @returns a frozen, detached snapshot.
16640
+ */
16641
+ async function readSurfaceCatalog(agent, signal, ctx) {
16642
+ signal.throwIfAborted();
16643
+ const issues = [];
16644
+ let commands = [];
16645
+ let scopedCommands = [];
16646
+ const commandsService = ctx.get("commands");
16647
+ if (commandsService !== void 0) try {
16648
+ const global = listGlobalCommands(commandsService);
16649
+ const globalBy = new Map(global.map((descriptor) => [descriptor.name, descriptor]));
16650
+ const effective = commandsService.list(agent);
16651
+ const scoped = [];
16652
+ for (const descriptor of effective) {
16653
+ const summary = commandSummaryOf(descriptor);
16654
+ const baseline = globalBy.get(descriptor.name);
16655
+ if (baseline === void 0 || !sameCommand(descriptor, baseline)) scoped.push(summary);
16656
+ }
16657
+ commands = sortCommands(effective.map(commandSummaryOf));
16658
+ scopedCommands = sortCommands(scoped);
16659
+ } catch (error) {
16660
+ issues.push({
16661
+ provider: "commands",
16662
+ message: safeErrorMessage(error)
16663
+ });
16664
+ }
16665
+ let skills = [];
16666
+ const skillTarget = resolveLiveSkillTarget(ctx, agent, agent.session.header.cwd ?? process.cwd());
16667
+ if (skillTarget !== void 0) try {
16668
+ signal.throwIfAborted();
16669
+ const catalog = await readHumanSkillCatalog(skillTarget.registry, {
16670
+ cwd: skillTarget.cwd,
16671
+ scope: skillTarget.scope,
16672
+ signal
16673
+ });
16674
+ skills = catalog.skills;
16675
+ if (catalog.complete !== true) issues.push({
16676
+ provider: "skills",
16677
+ message: "incomplete skill observation"
16678
+ });
16679
+ } catch (error) {
16680
+ if (signal.aborted) throw error;
16681
+ issues.push({
16682
+ provider: "skills",
16683
+ message: safeErrorMessage(error)
16684
+ });
16685
+ }
16686
+ return Object.freeze({
16687
+ commands,
16688
+ scopedCommands,
16689
+ skills,
16690
+ issues: Object.freeze(issues.map((issue) => Object.freeze({ ...issue })))
16691
+ });
16692
+ }
16693
+ /** Name-stable sort for command summaries (copies, never mutates input). */
16694
+ function sortCommands(commands) {
16695
+ return Object.freeze([...commands].sort((left, right) => left.name < right.name ? -1 : 1));
16696
+ }
16697
+ //#endregion
16698
+ //#region src/commands.ts
16699
+ /**
16700
+ * The TUI-owned slash commands (/exit /settings /sessions /skill /model
16701
+ * /new /tasks /preset /subagents /search /title /rename /copy /export
16702
+ * /fork /status /login /logout /help), extracted from the runner's
16703
+ * monolithic apply() so the registration surface is testable and the runner
16704
+ * closure shrinks. Every command reads the live runner state through the
16705
+ * {@link TuiCommandRunner} interface, whose accessors re-read the current
16706
+ * agent/settings on every access (sessions can swap the live agent).
16707
+ * @module @xmoon76/dsh-pi-tui/commands
16708
+ */
16709
+ /** A balanced completed-turn prefix for forking: the log up to (and including)
16710
+ * the last `turn/end`. Undefined when no turn has completed yet.
16711
+ * @param events - the session log.
16712
+ * @returns the fork seed events, or undefined.
16713
+ */
16714
+ function forkSeed(events) {
15636
16715
  for (let index = events.length - 1; index >= 0; index -= 1) if (events[index]?.type === "turn/end") return events.slice(0, index + 1);
15637
16716
  }
15638
16717
  /** Shorten a session id for read-only display rows, capped at 28 characters. */
@@ -15647,16 +16726,62 @@ function metaOf(cwd, presetId) {
15647
16726
  };
15648
16727
  }
15649
16728
  /**
16729
+ * Display copy for the four shipped agent presets, fixed in English — the
16730
+ * web surface's `BUILT_IN_PRESET_KEYS` mapping (`dsh-client-ui-agent-preset`),
16731
+ * TUI-side. The EFFECTIVE roster root is the dsh install's own
16732
+ * `config/agent-presets`: the dsh CLI's profile composition replaces this
16733
+ * bundle's shipped root with that one at boot (the `composeProfile`
16734
+ * agent-presets overlay), and its preset.yml language is not ours to
16735
+ * control. Mapping the known ids keeps the picker English regardless of
16736
+ * what the files say; everything else renders file metadata.
16737
+ */
16738
+ const BUILT_IN_PRESET_COPY = {
16739
+ standard: {
16740
+ name: "Standard mode",
16741
+ description: "Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows."
16742
+ },
16743
+ code: {
16744
+ name: "Code mode",
16745
+ description: "All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program."
16746
+ },
16747
+ minimal: {
16748
+ name: "Minimal mode",
16749
+ description: "Two-tool coding agent with persistent bash and str_replace_editor."
16750
+ },
16751
+ cordis: {
16752
+ name: "Creator mode",
16753
+ description: "Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
16754
+ }
16755
+ };
16756
+ /** Resolve one roster row's display copy: fixed English for a shipped
16757
+ * (system-trust) preset id, otherwise the preset's file metadata. */
16758
+ function presetDisplayText(preset) {
16759
+ const builtIn = preset.trust === "system" ? BUILT_IN_PRESET_COPY[preset.id] : void 0;
16760
+ if (builtIn !== void 0) return {
16761
+ name: builtIn.name,
16762
+ description: builtIn.description
16763
+ };
16764
+ return {
16765
+ name: preset.name ?? preset.id,
16766
+ ...preset.description === void 0 ? {} : { description: preset.description }
16767
+ };
16768
+ }
16769
+ /**
15650
16770
  * Register the TUI-owned slash commands on the commands service. The
15651
16771
  * completion list is refreshed after every registration so TUI-owned
15652
16772
  * commands appear in the editor's tab list. Registration is sessionless:
15653
16773
  * the commands service's global layer needs no agent, so the whole surface
15654
16774
  * is available before the first session exists (deferred start).
16775
+ *
16776
+ * When an `initial` catalog was prefetched (resume snapshot or cold
16777
+ * standing-scope skills), it installs SYNCHRONOUSLY at the end of
16778
+ * registration — direct skill wrappers plus the completion merge — so the
16779
+ * first input is served by the complete catalog with zero async I/O in
16780
+ * between.
15655
16781
  * @param runner - the live runner surface.
15656
- * @returns a hook that rebuilds the per-skill slash commands (a no-op until
15657
- * the first session exists, since the skill catalog scope is the agent).
16782
+ * @param initial - optional prefetched catalogs installed synchronously.
15658
16783
  */
15659
- function registerTuiCommands(runner) {
16784
+ function registerTuiCommands(runner, initial) {
15660
16785
  const { ctx, app } = runner;
15661
16786
  const cwd = runner.cwd;
15662
16787
  const signal = runner.signal;
@@ -15700,12 +16825,60 @@ function registerTuiCommands(runner) {
15700
16825
  if (agent === void 0) throw new Error("session could not be created");
15701
16826
  return agent;
15702
16827
  };
15703
- const refreshCompletions = () => {
15704
- app.setCommandCompletions(commands.list(runner.liveAgent).map((command) => ({
16828
+ /** The advertised names of the currently installed completion list. */
16829
+ let claims = /* @__PURE__ */ new Set();
16830
+ /**
16831
+ * Install one completion list (sorted, claims refreshed). The single
16832
+ * synchronous seam every catalog commit funnels through.
16833
+ */
16834
+ const installCompletions = (entries) => {
16835
+ const sorted = [...entries].sort((left, right) => left.name < right.name ? -1 : 1);
16836
+ app.setCommandCompletions(sorted.map((command) => ({
15705
16837
  name: command.name,
15706
16838
  description: command.description,
15707
16839
  argumentHint: command.input?.hint
15708
16840
  })), runner.sessionCwd(), fdPath);
16841
+ claims = new Set(sorted.map((command) => command.name));
16842
+ };
16843
+ /** The saved probed scoped overrides (see installSurfaceSnapshot). */
16844
+ let savedScopedCommands = [];
16845
+ /**
16846
+ * The sessionless completion view: the CURRENT global layer (fresh read —
16847
+ * TUI built-ins, global plugins and installed skill wrappers all flow in)
16848
+ * overlaid with the saved scoped overrides from the latest snapshot.
16849
+ */
16850
+ const mergeGlobalAndSavedScoped = () => {
16851
+ const byName = /* @__PURE__ */ new Map();
16852
+ for (const descriptor of listGlobalCommands(commands)) byName.set(descriptor.name, commandSummaryOf(descriptor));
16853
+ for (const scoped of savedScopedCommands) byName.set(scoped.name, scoped);
16854
+ return [...byName.values()];
16855
+ };
16856
+ /**
16857
+ * Refresh completions from the registry: the LIVE agent's effective view
16858
+ * when one exists (global + its scoped shadows), else the global layer
16859
+ * overlaid with the saved scoped overrides (sessionless merge). The agent
16860
+ * may be undefined before the first session exists: in-process
16861
+ * `commands.list(undefined)` safely returns the global layer only (the
16862
+ * remote RPC path's lookup guard does not apply in-process).
16863
+ */
16864
+ const refreshCompletions = () => {
16865
+ const liveAgent = runner.liveAgent;
16866
+ installCompletions(liveAgent === void 0 ? mergeGlobalAndSavedScoped() : commands.list(liveAgent).map(commandSummaryOf));
16867
+ };
16868
+ let commandCommitDepth = 0;
16869
+ let commandCommitDirty = false;
16870
+ /** Run one bulk command commit; registry changes inside are coalesced. */
16871
+ const withCommandCommit = (phase) => {
16872
+ commandCommitDepth += 1;
16873
+ try {
16874
+ phase();
16875
+ } finally {
16876
+ commandCommitDepth -= 1;
16877
+ if (commandCommitDepth === 0 && commandCommitDirty) {
16878
+ commandCommitDirty = false;
16879
+ refreshCompletions();
16880
+ }
16881
+ }
15709
16882
  };
15710
16883
  refreshCompletions();
15711
16884
  const exitHandler = () => {
@@ -15730,6 +16903,7 @@ function registerTuiCommands(runner) {
15730
16903
  const tuiSettings = runner.tuiSettings;
15731
16904
  const theme = tuiSettings?.get().theme ?? "auto";
15732
16905
  const themeValue = theme.startsWith("custom:") ? theme.slice(7) : theme;
16906
+ let lastThemeChoice = themeValue;
15733
16907
  const settings = ctx.get("settings");
15734
16908
  const permissionNames = [...ctx.get("permissionPresets")?.names ?? []];
15735
16909
  let defaultPermission;
@@ -15784,6 +16958,13 @@ function registerTuiCommands(runner) {
15784
16958
  currentValue: app.getFooterPreset(),
15785
16959
  values: ["full", "compact"]
15786
16960
  },
16961
+ {
16962
+ id: "busy-enter",
16963
+ label: "Enter while busy",
16964
+ description: "Steer injects the draft into the running turn; Ctrl+Enter uses the other behavior",
16965
+ currentValue: tuiSettings?.get().busyEnter ?? "queue",
16966
+ values: ["queue", "steer"]
16967
+ },
15787
16968
  {
15788
16969
  id: "fullscreen",
15789
16970
  label: "Fullscreen",
@@ -15837,12 +17018,19 @@ function registerTuiCommands(runner) {
15837
17018
  }]), { notify: true });
15838
17019
  } else if (id === "theme") {
15839
17020
  if (value === "auto" || value === "dark" || value === "light" || customThemeNames().includes(value)) {
15840
- if (value === "auto") detach("theme autodetect", () => app.autoDetectTheme());
15841
- else if (value === "dark" || value === "light") app.applyTheme(value);
15842
- else {
17021
+ lastThemeChoice = value;
17022
+ if (value === "auto") {
17023
+ detach("theme autodetect", () => app.autoDetectTheme({ shouldApply: () => lastThemeChoice === "auto" }));
17024
+ app.trackTerminalTheme(true);
17025
+ } else if (value === "dark" || value === "light") {
17026
+ app.applyTheme(value);
17027
+ app.trackTerminalTheme(false);
17028
+ } else {
15843
17029
  const palette = loadCustomTheme(value);
15844
- if (palette !== void 0) app.applyPalette(palette);
15845
- else {
17030
+ if (palette !== void 0) {
17031
+ app.applyPalette(palette);
17032
+ app.trackTerminalTheme(false);
17033
+ } else {
15846
17034
  app.notify(`theme ${value} not found`, "error");
15847
17035
  return;
15848
17036
  }
@@ -15865,6 +17053,14 @@ function registerTuiCommands(runner) {
15865
17053
  footer: value
15866
17054
  }), { notify: true });
15867
17055
  }
17056
+ } else if (id === "busy-enter") {
17057
+ if (value === "queue" || value === "steer") {
17058
+ const settings = tuiSettings;
17059
+ if (settings !== void 0) detach("settings busy enter write", () => settings.replace({
17060
+ ...settings.get(),
17061
+ busyEnter: value
17062
+ }), { notify: true });
17063
+ }
15868
17064
  } else if (id === "fullscreen") {
15869
17065
  if (value === "off" || value === "on") app.setFullscreen(value === "on");
15870
17066
  }
@@ -15945,29 +17141,44 @@ function registerTuiCommands(runner) {
15945
17141
  return openSessionPicker(invocation, "resume");
15946
17142
  }
15947
17143
  });
15948
- const skillService = (agent) => {
15949
- return ctx.get("agentPresets")?.serviceFor(agent, "skills") ?? ctx.get("skills");
15950
- };
15951
17144
  /** The workspace the live session runs in; fallback to the TUI's cwd. */
15952
17145
  const sessionCwd = (agent) => agent.session.header.cwd ?? cwd;
17146
+ const skillTarget = (agent) => resolveLiveSkillTarget(ctx, agent, sessionCwd(agent));
17147
+ /**
17148
+ * The execution boundary for loading one skill into the live session;
17149
+ * shared by /skill and the per-skill slash commands. The skill is fetched
17150
+ * from the CURRENT agent's registry and its invocation policy is RE-CHECKED
17151
+ * here — a summary that passed the cold/live filter is never execution
17152
+ * authorization. A model-only skill is refused with an explicit error and
17153
+ * never injected.
17154
+ */
15953
17155
  const loadSkill = async (agent, name) => {
15954
- const skills = skillService(agent);
15955
- if (skills === void 0) return {
17156
+ const target = skillTarget(agent);
17157
+ if (target === void 0) return {
15956
17158
  kind: "error",
15957
17159
  text: "skill service unavailable"
15958
17160
  };
15959
- const skill = await skills.get(name, {
15960
- cwd: sessionCwd(agent),
15961
- scope: agent
17161
+ const skill = await target.registry.get?.(name, {
17162
+ cwd: target.cwd,
17163
+ scope: target.scope
15962
17164
  });
15963
17165
  if (skill === void 0) return {
15964
17166
  kind: "error",
15965
17167
  text: "unknown skill \"" + name + "\""
15966
17168
  };
17169
+ if (!isUserInvocableSkill(skill)) return {
17170
+ kind: "error",
17171
+ text: `skill "${name}" is not invocable by the user`
17172
+ };
17173
+ if (typeof skill.name !== "string" || skill.name === "" || typeof skill.description !== "string") return {
17174
+ kind: "error",
17175
+ text: `skill "${name}" returned a malformed definition`
17176
+ };
17177
+ const body = typeof skill.content === "string" && skill.content !== "" ? skill.content : skill.description;
15967
17178
  agent.inject(createUserMessage({
15968
17179
  content: [{
15969
17180
  type: "text",
15970
- text: "Skill loaded by the user: **" + skill.name + "**\n\n" + (skill.content ?? skill.description)
17181
+ text: "Skill loaded by the user: **" + skill.name + "**\n\n" + body
15971
17182
  }],
15972
17183
  source: {
15973
17184
  kind: "plugin",
@@ -15980,21 +17191,30 @@ function registerTuiCommands(runner) {
15980
17191
  };
15981
17192
  };
15982
17193
  const skillDisposers = /* @__PURE__ */ new Map();
15983
- const registerSkillCommands = async () => {
15984
- const generation = runner.sessionGeneration;
17194
+ /**
17195
+ * Synchronously replace every direct skill wrapper from a snapshot. Pure
17196
+ * install: no catalog fetch, no await — the callers (the initial snapshot
17197
+ * commit, the live refresh, the coordinator) provide the data. The target
17198
+ * set is computed FIRST (current global/effective view minus the wrappers
17199
+ * this surface owns, plus the snapshot's scoped overrides), then the old
17200
+ * wrappers are disposed and the new ones registered in one synchronous
17201
+ * commit.
17202
+ * @param skills - the human-invocable summaries to install.
17203
+ * @param scopedNames - the snapshot's scoped command names: a preset-scoped
17204
+ * command must block a same-name wrapper even though the global view
17205
+ * cannot see it yet (sessionless install).
17206
+ * @returns the number of wrappers installed.
17207
+ */
17208
+ const replaceSkillCommands = (skills, scopedNames) => {
17209
+ const owned = new Set(skillDisposers.keys());
17210
+ const taken = /* @__PURE__ */ new Set();
17211
+ const view = commands.list(runner.liveAgent);
17212
+ for (const command of view) if (!owned.has(command.name)) taken.add(command.name);
17213
+ for (const name of scopedNames) taken.add(name);
15985
17214
  for (const dispose of skillDisposers.values()) dispose();
15986
17215
  skillDisposers.clear();
15987
- const liveAgent = runner.liveAgent;
15988
- if (liveAgent === void 0) return 0;
15989
- const skills = skillService(liveAgent);
15990
- if (skills === void 0) return 0;
15991
- const taken = new Set(commands.list(liveAgent).map((command) => command.name));
15992
- const catalog = await skills.list({
15993
- cwd: sessionCwd(liveAgent),
15994
- scope: liveAgent
15995
- });
15996
- if (generation !== runner.sessionGeneration) return 0;
15997
- for (const skill of catalog) {
17216
+ let count = 0;
17217
+ for (const skill of skills) {
15998
17218
  if (taken.has(skill.name)) continue;
15999
17219
  try {
16000
17220
  const dispose = commands.register({
@@ -16006,34 +17226,83 @@ function registerTuiCommands(runner) {
16006
17226
  }
16007
17227
  });
16008
17228
  skillDisposers.set(skill.name, dispose);
17229
+ count += 1;
16009
17230
  } catch {}
16010
17231
  }
16011
- refreshCompletions();
16012
- return catalog.length;
17232
+ return count;
16013
17233
  };
16014
- detach("skill catalog refresh", () => registerSkillCommands());
17234
+ /**
17235
+ * Install one whole snapshot on the surface in ONE synchronous commit:
17236
+ * replace the direct skill wrappers, save the scoped overrides, and merge
17237
+ * the completions (fresh global view + saved overrides). No await between
17238
+ * the pieces, so a first input can never observe a half-installed catalog.
17239
+ * A FAILED skills provider keeps the current wrappers (transitions or the
17240
+ * previous catalog): they re-validate against the current agent at
17241
+ * execution time, so a submitted skill name can never fall through to a
17242
+ * plain model message while the catalog is unavailable.
17243
+ */
17244
+ const installSurfaceSnapshot = (snapshot) => {
17245
+ const scopedNames = new Set(snapshot.scopedCommands.map((command) => command.name));
17246
+ const skillsFailed = snapshot.issues.some((issue) => issue.provider === "skills");
17247
+ withCommandCommit(() => {
17248
+ if (!skillsFailed) replaceSkillCommands(snapshot.skills, scopedNames);
17249
+ savedScopedCommands = snapshot.scopedCommands;
17250
+ installCompletions(mergeGlobalAndSavedScoped());
17251
+ });
17252
+ };
17253
+ /**
17254
+ * The revalidating transition (target/owner change): scoped previews clear
17255
+ * so new inputs complete against the current global view only, and every
17256
+ * old skill wrapper becomes a revalidating transition command whose
17257
+ * handler re-fetches from the CURRENT live agent and re-checks the policy
17258
+ * at execution time. The names survive so an already-submitted skill
17259
+ * command can never become a plain model message mid-switch.
17260
+ */
17261
+ const enterCatalogTransition = () => {
17262
+ const names = [...skillDisposers.keys()];
17263
+ withCommandCommit(() => {
17264
+ for (const dispose of skillDisposers.values()) dispose();
17265
+ skillDisposers.clear();
17266
+ savedScopedCommands = [];
17267
+ for (const name of names) try {
17268
+ const dispose = commands.register({
17269
+ name,
17270
+ description: `[skill: revalidating] ${name}`,
17271
+ handler: async () => {
17272
+ const agent = await requireAgent();
17273
+ return loadSkill(agent, name);
17274
+ }
17275
+ });
17276
+ skillDisposers.set(name, dispose);
17277
+ } catch {}
17278
+ installCompletions(mergeGlobalAndSavedScoped());
17279
+ });
17280
+ };
17281
+ /** Whether one command name is advertised by the CURRENT completion list
17282
+ * (the claim captured at submit time, before any session creation). */
17283
+ const wasAdvertised = (name) => claims.has(name);
16015
17284
  commands.register({
16016
17285
  name: "skill",
16017
17286
  description: "Load a skill into the session context",
16018
17287
  input: { hint: "<name>" },
16019
17288
  handler: async (invocation) => {
16020
17289
  const liveAgent = await requireAgent();
16021
- const skills = skillService(liveAgent);
16022
- if (skills === void 0) return {
17290
+ const target = skillTarget(liveAgent);
17291
+ if (target === void 0) return {
16023
17292
  kind: "error",
16024
17293
  text: "skill service unavailable"
16025
17294
  };
16026
17295
  const name = invocation.rawInput.trim();
16027
17296
  if (name !== "") return loadSkill(liveAgent, name);
16028
- const catalog = await skills.list({
16029
- cwd: sessionCwd(liveAgent),
16030
- scope: liveAgent
17297
+ const catalog = await readHumanSkillCatalog(target.registry, {
17298
+ cwd: target.cwd,
17299
+ scope: target.scope
16031
17300
  });
16032
- if (catalog.length === 0) return {
17301
+ if (catalog.skills.length === 0) return {
16033
17302
  kind: "error",
16034
17303
  text: "no skills available"
16035
17304
  };
16036
- app.openSettings(catalog.map((skill) => ({
17305
+ app.openSettings(catalog.skills.map((skill) => ({
16037
17306
  id: skill.name,
16038
17307
  label: skill.name,
16039
17308
  description: skill.description,
@@ -16049,25 +17318,57 @@ function registerTuiCommands(runner) {
16049
17318
  });
16050
17319
  commands.register({
16051
17320
  name: "reload",
16052
- description: "Reload TUI settings and refresh skill commands",
17321
+ description: "Reload TUI settings and refresh the live command/skill catalog",
16053
17322
  handler: async () => {
16054
- let skillCount = 0;
16055
- try {
16056
- skillCount = await registerSkillCommands();
16057
- } catch {}
17323
+ let catalogText = "";
17324
+ const liveAgent = runner.liveAgent;
17325
+ if (liveAgent !== void 0) {
17326
+ const outcome = await runner.refreshCatalog({
17327
+ source: "reload",
17328
+ target: {
17329
+ kind: "agent",
17330
+ key: runner.sessionGeneration
17331
+ },
17332
+ agent: liveAgent
17333
+ });
17334
+ if (outcome.kind === "applied") {
17335
+ catalogText = `${outcome.snapshot.commands.length} commands \u00b7 ${outcome.snapshot.skills.length} skills`;
17336
+ if (outcome.snapshot.issues.length > 0) catalogText += ` \u00b7 partial: ${outcome.snapshot.issues.map((issue) => issue.provider).join(", ")} unavailable`;
17337
+ } else if (outcome.kind === "failed") catalogText = `catalog refresh failed: ${outcome.error}`;
17338
+ else catalogText = "catalog refresh superseded";
17339
+ } else {
17340
+ const outcome = await runner.refreshCatalog({
17341
+ source: "reload",
17342
+ target: {
17343
+ kind: "preset",
17344
+ presetId: runner.effectivePresetId
17345
+ }
17346
+ });
17347
+ if (outcome.kind === "applied") {
17348
+ catalogText = `${outcome.snapshot.skills.length} human skills`;
17349
+ if (outcome.notice !== void 0) catalogText += ` \u00b7 ${outcome.notice}`;
17350
+ } else if (outcome.kind === "failed") catalogText = `catalog refresh failed: ${outcome.error}`;
17351
+ else catalogText = "catalog refresh superseded";
17352
+ }
16058
17353
  const settings = runner.tuiSettings;
16059
17354
  if (settings !== void 0) {
16060
17355
  const doc = settings.get();
16061
- if (doc.theme === "auto") detach("theme autodetect", () => app.autoDetectTheme());
16062
- else if (doc.theme === "dark" || doc.theme === "light") app.applyTheme(doc.theme);
16063
- else if (doc.theme.startsWith("custom:")) {
16064
- const palette = loadCustomTheme(doc.theme.slice(7));
17356
+ const reloadTheme = doc.theme;
17357
+ if (reloadTheme === "auto") {
17358
+ detach("theme autodetect", () => app.autoDetectTheme({ shouldApply: () => reloadTheme === "auto" }));
17359
+ app.trackTerminalTheme(true);
17360
+ } else if (reloadTheme === "dark" || reloadTheme === "light") {
17361
+ app.applyTheme(reloadTheme);
17362
+ app.trackTerminalTheme(false);
17363
+ } else if (reloadTheme.startsWith("custom:")) {
17364
+ const palette = loadCustomTheme(reloadTheme.slice(7));
16065
17365
  if (palette !== void 0) app.applyPalette(palette);
17366
+ app.trackTerminalTheme(false);
16066
17367
  }
16067
17368
  app.setFooterPreset(doc.footer === "compact" ? "compact" : "full");
16068
17369
  app.setFullscreen(doc.fullscreen === "on");
16069
17370
  }
16070
- app.notify(`reloaded — ${skillCount} skills \u00b7 settings reapplied`, "info");
17371
+ app.notify(`reloaded — ${catalogText} \u00b7 settings reapplied`, "info");
16071
17372
  return { kind: "success" };
16072
17373
  }
16073
17374
  });
@@ -16128,7 +17429,7 @@ function registerTuiCommands(runner) {
16128
17429
  description: "Start a fresh session in this workspace",
16129
17430
  handler: async () => {
16130
17431
  const liveAgent = runner.liveAgent;
16131
- const composition = await runner.compose();
17432
+ const composition = await runner.compose(runner.pendingPreset);
16132
17433
  const presetId = composition.agentPreset;
16133
17434
  const next = await runner.agents.create({
16134
17435
  sessionId: SessionId(`session-${randomUUID()}`),
@@ -16293,13 +17594,13 @@ function registerTuiCommands(runner) {
16293
17594
  description: "Show or switch the session agent preset",
16294
17595
  input: { hint: "[status|<id>|default [<id>]]" },
16295
17596
  handler: async (invocation) => {
16296
- const liveAgent = await requireAgent();
16297
17597
  const presets = ctx.get("agentPresets");
16298
17598
  if (presets === void 0) return {
16299
17599
  kind: "error",
16300
17600
  text: "agent presets unavailable in this deployment"
16301
17601
  };
16302
- const current = presets.composedPreset(liveAgent.ctx) ?? resolveSessionPreset(liveAgent.session);
17602
+ const liveAgent = runner.liveAgent;
17603
+ const current = liveAgent === void 0 ? void 0 : presets.composedPreset(liveAgent.ctx) ?? resolveSessionPreset(liveAgent.session);
16303
17604
  const matched = invocation.rawInput.trim().match(/^(\S+)(?:\s+(.*))?$/);
16304
17605
  const verb = matched?.[1] ?? "";
16305
17606
  const rest = matched?.[2]?.trim() ?? "";
@@ -16318,24 +17619,95 @@ function registerTuiCommands(runner) {
16318
17619
  kind: "success",
16319
17620
  text: `default preset: ${settings.get(ns)?.default ?? presets.defaultId}`
16320
17621
  };
16321
- await settings.mutate(ns, [{
16322
- op: "set",
16323
- path: ["default"],
16324
- value: rest
16325
- }]);
17622
+ try {
17623
+ await settings.mutate(ns, [{
17624
+ op: "set",
17625
+ path: ["default"],
17626
+ value: rest
17627
+ }]);
17628
+ } catch (error) {
17629
+ return {
17630
+ kind: "error",
17631
+ text: safeErrorMessage(error)
17632
+ };
17633
+ }
17634
+ if (runner.effectivePresetId === void 0) {
17635
+ const outcome = await runner.refreshCatalog({
17636
+ source: "preset",
17637
+ target: {
17638
+ kind: "preset",
17639
+ presetId: rest
17640
+ }
17641
+ });
17642
+ if (outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
17643
+ }
16326
17644
  return {
16327
17645
  kind: "success",
16328
17646
  text: `default preset set: ${rest}`
16329
17647
  };
16330
17648
  }
16331
- if (verb !== "") try {
16332
- const outcome = await runner.recomposeBlank(verb);
17649
+ const applyPresetSelection = async (id) => {
17650
+ if (liveAgent === void 0) {
17651
+ const resolved = await presets.resolve(id);
17652
+ runner.pendingPreset = resolved.id;
17653
+ const outcome = await runner.refreshCatalog({
17654
+ source: "preset",
17655
+ target: {
17656
+ kind: "preset",
17657
+ presetId: resolved.id
17658
+ }
17659
+ });
17660
+ if (outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
17661
+ return {
17662
+ kind: "pending",
17663
+ preset: resolved.id
17664
+ };
17665
+ }
17666
+ const outcome = await runner.recomposeBlank(id);
16333
17667
  if (outcome.kind === "locked") return {
16334
- kind: "error",
16335
- text: `session "${liveAgent.session.id}" has already started; its agent preset is fixed`
17668
+ kind: "locked",
17669
+ sessionId: liveAgent.session.id
16336
17670
  };
16337
- refreshCompletions();
17671
+ await runner.refreshCatalog({
17672
+ source: "preset",
17673
+ target: {
17674
+ kind: "agent",
17675
+ key: runner.sessionGeneration
17676
+ },
17677
+ agent: runner.liveAgent
17678
+ });
16338
17679
  runner.updateWelcomeCard();
17680
+ return {
17681
+ kind: "switched",
17682
+ preset: outcome.preset
17683
+ };
17684
+ };
17685
+ const pickPreset = async (id) => {
17686
+ let outcome;
17687
+ try {
17688
+ outcome = await applyPresetSelection(id);
17689
+ } catch (error) {
17690
+ app.notify(safeErrorMessage(error), "error");
17691
+ return;
17692
+ }
17693
+ if (outcome.kind === "pending") app.notify(`new sessions will start on preset ${outcome.preset}`, "info");
17694
+ else if (outcome.kind === "switched") app.notify(`session preset switched to ${outcome.preset}`, "info");
17695
+ else app.notify(`session "${outcome.sessionId}" has already started; its agent preset is fixed — preset switching is only available in a new session`, "error");
17696
+ };
17697
+ if (verb !== "") try {
17698
+ const outcome = await applyPresetSelection(verb);
17699
+ if (outcome.kind === "pending") return {
17700
+ kind: "success",
17701
+ text: `new sessions will start on preset ${outcome.preset}`
17702
+ };
17703
+ if (outcome.kind === "locked") {
17704
+ const message = `session "${outcome.sessionId}" has already started; its agent preset is fixed — preset switching is only available in a new session`;
17705
+ app.notify(message, "error");
17706
+ return {
17707
+ kind: "error",
17708
+ text: message
17709
+ };
17710
+ }
16339
17711
  return {
16340
17712
  kind: "success",
16341
17713
  text: `session preset switched to ${outcome.preset}`
@@ -16351,17 +17723,37 @@ function registerTuiCommands(runner) {
16351
17723
  kind: "success",
16352
17724
  text: "no agent presets configured"
16353
17725
  };
16354
- app.openSettings(roster.map((preset) => ({
16355
- id: preset.id,
16356
- label: preset.name === void 0 ? preset.id : `${preset.name} (${preset.id})`,
16357
- description: [
16358
- preset.trust === "system" ? "system" : "user",
16359
- preset.id === presets.defaultId ? "default" : void 0,
16360
- preset.id === current ? "← current" : void 0,
16361
- preset.broken
16362
- ].filter(Boolean).join(" · "),
16363
- currentValue: ""
16364
- })), () => {}, () => {});
17726
+ if (liveAgent !== void 0 && liveAgent.session.events.some((event) => event.type === "turn/start")) {
17727
+ const message = `preset switching is only available in a new session — session "${liveAgent.session.id}" has already started; its preset is fixed (use /new for a fresh session, or /preset default <id> for future sessions)`;
17728
+ app.notify(message, "error");
17729
+ return {
17730
+ kind: "error",
17731
+ text: message
17732
+ };
17733
+ }
17734
+ const close = app.openSettings(roster.map((preset) => {
17735
+ const display = presetDisplayText(preset);
17736
+ return {
17737
+ id: preset.id,
17738
+ label: `${display.name} (${preset.id})`,
17739
+ description: [
17740
+ display.description,
17741
+ preset.trust === "system" ? "system" : "user",
17742
+ preset.id === presets.defaultId ? "default" : void 0,
17743
+ preset.id === current ? "← current" : void 0,
17744
+ preset.broken
17745
+ ].filter(Boolean).join(" · "),
17746
+ currentValue: "",
17747
+ values: [preset.id]
17748
+ };
17749
+ }), (id) => {
17750
+ close();
17751
+ runOwned("preset pick", () => pickPreset(id), {
17752
+ diag: runner.diag,
17753
+ sessionId: () => runner.liveAgent?.session.id,
17754
+ onError: (error) => app.notify(`preset selection failed: ${safeErrorMessage(error)}`, "error")
17755
+ });
17756
+ }, () => {});
16365
17757
  return { kind: "success" };
16366
17758
  }
16367
17759
  });
@@ -16469,31 +17861,54 @@ function registerTuiCommands(runner) {
16469
17861
  return { kind: "success" };
16470
17862
  }
16471
17863
  });
16472
- commands.register({
16473
- name: "title",
16474
- description: "Set or show the session title",
16475
- input: { hint: "<title>" },
16476
- handler: async (invocation) => {
16477
- const liveAgent = await requireAgent();
16478
- const titles = ctx.get("sessionTitle");
16479
- if (titles === void 0) return {
16480
- kind: "error",
16481
- text: "session title service unavailable"
16482
- };
16483
- const name = invocation.rawInput.trim();
16484
- if (name === "") {
16485
- const current = titles.get(liveAgent.session);
17864
+ const titleHandler = async (invocation) => {
17865
+ const liveAgent = await requireAgent();
17866
+ const titles = ctx.get("sessionTitle");
17867
+ if (titles === void 0) return {
17868
+ kind: "error",
17869
+ text: "session title service unavailable"
17870
+ };
17871
+ const name = invocation.rawInput.trim();
17872
+ if (name !== "") {
17873
+ try {
17874
+ titles.rename(liveAgent.session, name);
17875
+ } catch (error) {
16486
17876
  return {
16487
- kind: "success",
16488
- text: current === void 0 ? "no title set" : `title: ${current.title}`
17877
+ kind: "error",
17878
+ text: safeErrorMessage(error)
16489
17879
  };
16490
17880
  }
16491
- titles.rename(liveAgent.session, name);
16492
17881
  return {
16493
17882
  kind: "success",
16494
17883
  text: `title set: ${name}`
16495
17884
  };
16496
17885
  }
17886
+ try {
17887
+ const regenerated = await titles.refresh(liveAgent.session, invocation.signal);
17888
+ if (regenerated === void 0) {
17889
+ app.notify("no conversation yet — title left as-is", "info");
17890
+ return { kind: "success" };
17891
+ }
17892
+ app.notify(`title regenerated: ${regenerated.title}`, "info");
17893
+ return { kind: "success" };
17894
+ } catch (error) {
17895
+ return {
17896
+ kind: "error",
17897
+ text: safeErrorMessage(error)
17898
+ };
17899
+ }
17900
+ };
17901
+ commands.register({
17902
+ name: "title",
17903
+ description: "Set the session title; without an argument, regenerate it from the conversation (overwrites the current title)",
17904
+ input: { hint: "<title>" },
17905
+ handler: titleHandler
17906
+ });
17907
+ commands.register({
17908
+ name: "rename",
17909
+ description: "Alias of /title: set the session title, or regenerate it without an argument (overwrites the current title)",
17910
+ input: { hint: "<title>" },
17911
+ handler: titleHandler
16497
17912
  });
16498
17913
  commands.register({
16499
17914
  name: "copy",
@@ -16688,7 +18103,13 @@ function registerTuiCommands(runner) {
16688
18103
  {
16689
18104
  id: "k-enter",
16690
18105
  label: "Enter",
16691
- description: "Submit (slash commands dispatch without a model turn)",
18106
+ description: "Submit (steers the running turn while busy when Enter while busy is Steer; skill commands steer too, UI commands run locally)",
18107
+ currentValue: ""
18108
+ },
18109
+ {
18110
+ id: "k-queue",
18111
+ label: "Ctrl+Enter",
18112
+ description: "Queue the draft while the agent is busy (the opposite of Enter while busy)",
16692
18113
  currentValue: ""
16693
18114
  },
16694
18115
  {
@@ -16754,7 +18175,7 @@ function registerTuiCommands(runner) {
16754
18175
  {
16755
18176
  id: "k-bang",
16756
18177
  label: "! cmd",
16757
- description: "Run a shell command locally; !! sends it to the model",
18178
+ description: "Run a shell command and submit the command and its output to the session; !! runs locally without recording",
16758
18179
  currentValue: ""
16759
18180
  },
16760
18181
  {
@@ -16773,8 +18194,28 @@ function registerTuiCommands(runner) {
16773
18194
  return { kind: "success" };
16774
18195
  }
16775
18196
  });
16776
- refreshCompletions();
16777
- return { refreshSkills: () => registerSkillCommands() };
18197
+ if (initial?.snapshot !== void 0) installSurfaceSnapshot(initial.snapshot);
18198
+ else if (initial?.skills !== void 0) withCommandCommit(() => {
18199
+ replaceSkillCommands(initial.skills.skills, /* @__PURE__ */ new Set());
18200
+ savedScopedCommands = [];
18201
+ installCompletions(mergeGlobalAndSavedScoped());
18202
+ });
18203
+ else refreshCompletions();
18204
+ ctx.on("commands/change", () => {
18205
+ if (commandCommitDepth > 0) {
18206
+ commandCommitDirty = true;
18207
+ return;
18208
+ }
18209
+ refreshCompletions();
18210
+ });
18211
+ return {
18212
+ /** The claim test for the dispatch: is /name advertised right now? */
18213
+ wasAdvertised,
18214
+ /** One synchronous catalog commit (the coordinator's install hook). */
18215
+ installSnapshot: (snapshot) => installSurfaceSnapshot(snapshot),
18216
+ /** The revalidating transition (the coordinator's target-change hook). */
18217
+ enterTransition: () => enterCatalogTransition()
18218
+ };
16778
18219
  }
16779
18220
  //#endregion
16780
18221
  //#region src/diag.ts
@@ -17323,13 +18764,15 @@ function sessionUnchanged(locked, agent, generation) {
17323
18764
  * (a queue splice during the guard survives) and steers them with the
17324
18765
  * draft. Any state change — agent switch, generation bump, queue change —
17325
18766
  * aborts with `stale` and a retry notice; nothing is written and nothing
17326
- * is lost.
18767
+ * is lost. With `onlyDraft` the queue is neither read nor removed: the
18768
+ * draft alone is steered (or followed up when the agent is idle).
17327
18769
  */
17328
- async function steerAll(deps, text) {
18770
+ async function steerAll(deps, text, options = {}) {
18771
+ const onlyDraft = options.onlyDraft === true;
17329
18772
  const agent = deps.currentAgent();
17330
18773
  if (agent === void 0) return "ok";
17331
18774
  const generation = deps.currentGeneration();
17332
- const snapshot = [...agent.inbox.nextTurn, ...agent.inbox.nextStep];
18775
+ const snapshot = onlyDraft ? [] : [...agent.inbox.nextTurn, ...agent.inbox.nextStep];
17333
18776
  const verdict = await deps.guard.run(savePayloadIdentity(snapshot, text));
17334
18777
  if (verdict.kind === "blocked") {
17335
18778
  const verbatim = deps.restoreDraft(text);
@@ -17345,6 +18788,13 @@ async function steerAll(deps, text) {
17345
18788
  deps.notify(verbatim ? deps.staleNotice() : deps.mergedNotice(), "error");
17346
18789
  return "stale";
17347
18790
  }
18791
+ if (onlyDraft) {
18792
+ if (verdict.kind === "forced") deps.notify(deps.forcedNotice(), "error");
18793
+ const message = deps.createDraft(text);
18794
+ if (now.status === "running") now.steer(message);
18795
+ else now.followup(message);
18796
+ return "ok";
18797
+ }
17348
18798
  const current = [...now.inbox.nextTurn, ...now.inbox.nextStep];
17349
18799
  if (!(current.length === snapshot.length && current.every((message, index) => message.id === snapshot[index].id))) {
17350
18800
  const verbatim = deps.restoreDraft(text);
@@ -17367,6 +18817,75 @@ async function steerAll(deps, text) {
17367
18817
  return "ok";
17368
18818
  }
17369
18819
  //#endregion
18820
+ //#region src/shell-context.ts
18821
+ /**
18822
+ * `!` shell context submission (kimi parity): a completed local shell run
18823
+ * is submitted to the session as an ordinary guarded user message, so the
18824
+ * model sees the command AND its output on the next turn. `!!` runs stay
18825
+ * purely local — no session write, no model visibility (pi's
18826
+ * excluded-from-context semantics). Extracted from the runner so the
18827
+ * guard/TOCTOU races are testable headless, exactly like steer.ts:
18828
+ *
18829
+ * - The agent/generation identity is captured BEFORE the guard runs.
18830
+ * - AFTER the guard returns, the identity is re-validated: same agent
18831
+ * object, same session generation. A switch while the guard read the
18832
+ * file aborts (`stale`) — the output is never written to a session the
18833
+ * guard did not check.
18834
+ * - A blocked write keeps the caller's card visible (the output is not
18835
+ * lost) and the identical `!` re-run can force through its one-time
18836
+ * guard token.
18837
+ * @module @xmoon76/dsh-pi-tui/shell-context
18838
+ */
18839
+ /**
18840
+ * Submit a completed `!` shell run's command+output to the session:
18841
+ * capture identity → guard → re-validate → followup. No-op without an
18842
+ * agent. `blocked` keeps the caller's card (the output stays visible; the
18843
+ * identical `!` re-run can force through its one-time token); `stale`
18844
+ * aborts for a retry against the new session.
18845
+ */
18846
+ async function submitShellResult(deps, text) {
18847
+ const agent = deps.currentAgent();
18848
+ if (agent === void 0) return "ok";
18849
+ const generation = deps.currentGeneration();
18850
+ const verdict = await deps.guard.run(text);
18851
+ if (verdict.kind === "blocked") {
18852
+ deps.notify(deps.blockedNotice(verdict.reason), "error");
18853
+ return "blocked";
18854
+ }
18855
+ if (!sessionUnchanged({
18856
+ agent,
18857
+ generation
18858
+ }, deps.currentAgent(), deps.currentGeneration())) {
18859
+ deps.notify(deps.staleNotice(), "error");
18860
+ return "stale";
18861
+ }
18862
+ if (verdict.kind === "forced") deps.notify(deps.forcedNotice(), "error");
18863
+ agent.followup(deps.createMessage(text));
18864
+ deps.onSubmitted();
18865
+ return "ok";
18866
+ }
18867
+ /**
18868
+ * Classify one `!` line: 'context' submits the command+output to the
18869
+ * session (kimi parity), 'local' runs purely off-session (pi's `!!`
18870
+ * escape hatch). Returns undefined for a non-`!` line.
18871
+ */
18872
+ function shellModeOf(text) {
18873
+ if (!text.startsWith("!")) return void 0;
18874
+ return text.startsWith("!!") ? "local" : "context";
18875
+ }
18876
+ /** Extract the command after the `!` prefix ('' when nothing follows). */
18877
+ function shellCommandOf(text) {
18878
+ return text.replace(/^!+/, "").trim();
18879
+ }
18880
+ /**
18881
+ * The model-facing submission text: the command echoed `$`-style (kimi
18882
+ * ShellExecution parity) followed by the settled card result (output +
18883
+ * `[exit N]` / truncation lines).
18884
+ */
18885
+ function formatShellSubmitText(command, result) {
18886
+ return `$ ${command}\n${result}`;
18887
+ }
18888
+ //#endregion
17370
18889
  //#region src/bounded-output.ts
17371
18890
  /**
17372
18891
  * Bounded accumulation of local-shell output: the UI card only ever holds
@@ -17653,6 +19172,191 @@ function parseShellWords(line) {
17653
19172
  return words;
17654
19173
  }
17655
19174
  //#endregion
19175
+ //#region src/skill-catalog-refresh.ts
19176
+ /** Whether two targets are the same owner (no transition needed). The
19177
+ * preset identity is the caller-provided presetId compared EXACTLY —
19178
+ * `undefined` (the deployment default) never conflates with a user preset
19179
+ * literally named `'default'`, and a resolved id never conflates with the
19180
+ * raw id it came from (the worst case is a benign extra transition). */
19181
+ function sameTarget(left, right) {
19182
+ if (left === void 0) return false;
19183
+ if (left.kind !== right.kind) return false;
19184
+ return left.kind === "agent" ? left.key === right.key : left.presetId === right.presetId;
19185
+ }
19186
+ /**
19187
+ * The coordinator. One instance per runner; `dispose()` aborts the active
19188
+ * refresh on teardown.
19189
+ */
19190
+ var CatalogRefreshCoordinator = class {
19191
+ epoch = 0;
19192
+ active;
19193
+ appliedTarget;
19194
+ snapshot;
19195
+ hooks;
19196
+ lifecycleSignal;
19197
+ diag;
19198
+ constructor(hooks, lifecycleSignal, diag) {
19199
+ this.hooks = hooks;
19200
+ this.lifecycleSignal = lifecycleSignal;
19201
+ this.diag = diag;
19202
+ }
19203
+ /** The last applied snapshot (for same-target partial merges and the
19204
+ * `/reload` report). */
19205
+ get currentSnapshot() {
19206
+ return this.snapshot;
19207
+ }
19208
+ /**
19209
+ * Run one refresh request. A new request aborts the active one; only the
19210
+ * LATEST epoch may commit. Returns the settled outcome; never rejects
19211
+ * (failures and supersessions are outcomes, not exceptions).
19212
+ */
19213
+ async refresh(request) {
19214
+ this.active?.controller.abort();
19215
+ const epoch = ++this.epoch;
19216
+ const controller = new AbortController();
19217
+ const signal = AbortSignal.any([this.lifecycleSignal, controller.signal]);
19218
+ this.active = {
19219
+ epoch,
19220
+ controller
19221
+ };
19222
+ const targetChanged = !sameTarget(this.appliedTarget, request.target);
19223
+ try {
19224
+ if (targetChanged) this.hooks.enterCatalogTransition();
19225
+ const committed = request.target.kind === "agent" ? { snapshot: await this.hooks.readAgent(request.agent, signal) } : await this.readStandingSnapshot(request.target.presetId, signal);
19226
+ if (signal.aborted || epoch !== this.epoch) {
19227
+ this.diag.debug("catalog refresh superseded", {
19228
+ epoch,
19229
+ source: request.source
19230
+ });
19231
+ return { kind: "superseded" };
19232
+ }
19233
+ const merged = targetChanged ? committed.snapshot : this.mergePartial(committed.snapshot);
19234
+ this.hooks.installSnapshot(merged);
19235
+ this.snapshot = merged;
19236
+ this.appliedTarget = request.target;
19237
+ this.diag.info("catalog applied", {
19238
+ epoch,
19239
+ source: request.source,
19240
+ commands: merged.commands.length,
19241
+ scopedCommands: merged.scopedCommands.length,
19242
+ skills: merged.skills.length,
19243
+ issues: merged.issues.length
19244
+ });
19245
+ return {
19246
+ kind: "applied",
19247
+ snapshot: merged,
19248
+ ...committed.notice === void 0 ? {} : { notice: committed.notice }
19249
+ };
19250
+ } catch (error) {
19251
+ if (signal.aborted || epoch !== this.epoch) {
19252
+ this.diag.debug("catalog refresh superseded", {
19253
+ epoch,
19254
+ source: request.source
19255
+ });
19256
+ return { kind: "superseded" };
19257
+ }
19258
+ const message = safeErrorMessage(error);
19259
+ this.diag.warn("catalog unavailable", {
19260
+ epoch,
19261
+ source: request.source,
19262
+ error: message
19263
+ });
19264
+ return {
19265
+ kind: "failed",
19266
+ error: message
19267
+ };
19268
+ } finally {
19269
+ if (this.active?.epoch === epoch) this.active = void 0;
19270
+ }
19271
+ }
19272
+ /** Abort the active refresh (app teardown). Late results are dropped. */
19273
+ dispose() {
19274
+ this.active?.controller.abort();
19275
+ }
19276
+ /** Same-target partial failure: a provider issue keeps the OLD field for
19277
+ * that provider (the old snapshot is still more useful than a blank
19278
+ * catalog); the successful providers update as usual. */
19279
+ mergePartial(snapshot) {
19280
+ const old = this.snapshot;
19281
+ if (old === void 0) return snapshot;
19282
+ const commandsFailed = snapshot.issues.some((issue) => issue.provider === "commands");
19283
+ const skillsFailed = snapshot.issues.some((issue) => issue.provider === "skills");
19284
+ if (!commandsFailed && !skillsFailed) return snapshot;
19285
+ return Object.freeze({
19286
+ commands: commandsFailed ? old.commands : snapshot.commands,
19287
+ scopedCommands: commandsFailed ? old.scopedCommands : snapshot.scopedCommands,
19288
+ skills: skillsFailed ? old.skills : snapshot.skills,
19289
+ issues: snapshot.issues
19290
+ });
19291
+ }
19292
+ /** One standing read wrapped into a skills-only surface snapshot: the
19293
+ * sessionless view has no agent-scoped commands, and the global commands
19294
+ * layer is already registered — only the skill wrappers are installed.
19295
+ * An INCOMPLETE observation (`complete !== true`) is never an
19296
+ * authoritative blank catalog: it carries a detached skills issue, so a
19297
+ * same-target reload keeps the last-good skills (mergePartial) and a
19298
+ * target change keeps the revalidating transition wrappers
19299
+ * (installSurfaceSnapshot's skillsFailed guard). */
19300
+ async readStandingSnapshot(presetId, signal) {
19301
+ const read = await this.hooks.readStanding(presetId, signal);
19302
+ const incomplete = read.catalog.complete !== true;
19303
+ return {
19304
+ snapshot: Object.freeze({
19305
+ commands: Object.freeze([]),
19306
+ scopedCommands: Object.freeze([]),
19307
+ skills: read.catalog.skills,
19308
+ issues: Object.freeze(incomplete ? [Object.freeze({
19309
+ provider: "skills",
19310
+ message: "incomplete skill observation"
19311
+ })] : [])
19312
+ }),
19313
+ ...read.notice === void 0 ? {} : { notice: read.notice }
19314
+ };
19315
+ }
19316
+ };
19317
+ /**
19318
+ * The deterministic, timer-free coalescer for invalidation notifications
19319
+ * (`skills/change`): while one refresh is in flight, further notifications
19320
+ * mark the gate DIRTY; when the in-flight refresh settles, ONE more refresh
19321
+ * runs if the gate is dirty. A burst therefore costs at most two reads, and
19322
+ * the re-run always observes the CURRENT ownership because the runner's
19323
+ * `start` callback reads live state at run time.
19324
+ */
19325
+ var CoalescingRefreshGate = class {
19326
+ inFlight = false;
19327
+ dirty = false;
19328
+ start;
19329
+ /** @param start - invoked synchronously to START one owned refresh. Must
19330
+ * never throw and must not itself be re-entrant (the gate owns the
19331
+ * in-flight bookkeeping; `start` only launches the owned task). */
19332
+ constructor(start) {
19333
+ this.start = start;
19334
+ }
19335
+ /** One invalidation notification arrived. */
19336
+ notify() {
19337
+ if (this.inFlight) {
19338
+ this.dirty = true;
19339
+ return;
19340
+ }
19341
+ this.inFlight = true;
19342
+ this.start();
19343
+ }
19344
+ /** Terminal settle of the in-flight refresh (called from EVERY terminal
19345
+ * runOwned callback: onResult / onCancel / onError). Clears the in-flight
19346
+ * flag; a dirty gate starts exactly one follow-up refresh. IDEMPOTENT: a
19347
+ * second settle (e.g. onResult throws and runOwned routes to onError)
19348
+ * must not clear the in-flight flag of a follow-up refresh it did not
19349
+ * start. */
19350
+ settled() {
19351
+ if (!this.inFlight) return;
19352
+ this.inFlight = false;
19353
+ if (this.dirty) {
19354
+ this.dirty = false;
19355
+ this.notify();
19356
+ }
19357
+ }
19358
+ };
19359
+ //#endregion
17656
19360
  //#region src/index.ts
17657
19361
  /**
17658
19362
  * @xmoon76/dsh-pi-tui — the bundle's runner plugin. Waits for the startup
@@ -17685,6 +19389,9 @@ const REPAINT_FLUSH_MS = 50;
17685
19389
  * dispatches through `commands.execute`, which creates the session lazily
17686
19390
  * (the command line IS the first user input). Commands in this set must
17687
19391
  * tolerate `liveAgent === undefined` in their handlers.
19392
+ *
19393
+ * Exported for the headless suite: the gate is exactly where a sessionless
19394
+ * command silently starts creating sessions again.
17688
19395
  */
17689
19396
  const SESSIONLESS_COMMANDS = /* @__PURE__ */ new Set([
17690
19397
  "exit",
@@ -17698,14 +19405,123 @@ const SESSIONLESS_COMMANDS = /* @__PURE__ */ new Set([
17698
19405
  "resume",
17699
19406
  "search",
17700
19407
  "new",
17701
- "fork"
19408
+ "fork",
19409
+ "preset"
17702
19410
  ]);
17703
19411
  /**
17704
- * Read an explicit child-session reference from a future/extended job record.
17705
- * Current dsh JobSnapshot records do not expose one, so this normally returns
17706
- * undefined. Crucially, label, registration order and timestamps are never
17707
- * accepted as identity signals.
19412
+ * The LOCAL-execute command set: TUI-owned UI/control commands AND core
19413
+ * control commands the TUI does not itself register (e.g. /kill) that
19414
+ * must ALWAYS run locally through the commands service, never steered,
19415
+ * regardless of the busyEnter preference. Everything NOT in this set —
19416
+ * plain prompts AND non-local commands (the per-skill slash commands like
19417
+ * /grilling or /matrix-cli) — flows through the busy-Enter submission
19418
+ * policy while the agent is running: web parity, where a skill invocation
19419
+ * is a plain `session.prompt` whose leading `/name` line the host's
19420
+ * pre-step listener (dsh-tool-skill) resolves into the injected skill
19421
+ * body — there is no command-execution wire for skills.
17708
19422
  */
19423
+ const LOCAL_COMMANDS = /* @__PURE__ */ new Set([
19424
+ "copy",
19425
+ "exit",
19426
+ "export",
19427
+ "fork",
19428
+ "help",
19429
+ "kill",
19430
+ "login",
19431
+ "logout",
19432
+ "model",
19433
+ "new",
19434
+ "preset",
19435
+ "queue",
19436
+ "quit",
19437
+ "reload",
19438
+ "rename",
19439
+ "resume",
19440
+ "search",
19441
+ "sessions",
19442
+ "settings",
19443
+ "skill",
19444
+ "status",
19445
+ "subagents",
19446
+ "tasks",
19447
+ "title",
19448
+ "yolo"
19449
+ ]);
19450
+ /**
19451
+ * Whether one submission steers under the busy-Enter preference: NOT a
19452
+ * force-queued chord, NOT a TUI-owned local command, and the agent is
19453
+ * running with the preference set to 'steer'. Pure so the dispatch gate
19454
+ * (inside the runner closure) is testable headless.
19455
+ * @param parsed - the parsed slash command, undefined for a plain prompt.
19456
+ * @param running - whether the live agent reports running.
19457
+ * @param busyEnter - the persisted preference value (''/undefined = queue).
19458
+ * @param forceQueue - the Ctrl+Enter chord: always queue, never steer.
19459
+ */
19460
+ function shouldSteerOnEnter(parsed, running, busyEnter, forceQueue) {
19461
+ if (forceQueue) return false;
19462
+ if (parsed !== void 0 && LOCAL_COMMANDS.has(parsed.name)) return false;
19463
+ return running && busyEnter === "steer";
19464
+ }
19465
+ /**
19466
+ * The advertised-claim miss decision (pure, exported for the headless
19467
+ * suite): a slash input whose name was advertised by the completion list at
19468
+ * submit time but that the REAL session's catalog lacks is CONSUMED with an
19469
+ * explicit error — never sent to the model as a plain user message. An
19470
+ * unadvertised miss keeps the existing plain-input fallback (the user may
19471
+ * deliberately send slash text to the model).
19472
+ * @param execution - the settled `commands.execute` outcome.
19473
+ * @param wasAdvertised - the claim captured BEFORE session creation.
19474
+ * @returns whether the miss must be consumed as an advertised miss.
19475
+ */
19476
+ function shouldConsumeAdvertisedMiss(execution, wasAdvertised) {
19477
+ return execution === void 0 && wasAdvertised;
19478
+ }
19479
+ async function resolveInitialCatalog(options) {
19480
+ const { liveAgent, presetId, signal, ctx, diag } = options;
19481
+ if (liveAgent !== void 0) try {
19482
+ const snapshot = await readSurfaceCatalog(liveAgent, signal, ctx);
19483
+ diag.info("surface catalog prefetched", {
19484
+ commands: snapshot.commands.length,
19485
+ scopedCommands: snapshot.scopedCommands.length,
19486
+ skills: snapshot.skills.length
19487
+ });
19488
+ return { snapshot };
19489
+ } catch (error) {
19490
+ if (isCancellation(error)) return {};
19491
+ const message = safeErrorMessage(error);
19492
+ diag.warn("surface catalog unavailable", {
19493
+ phase: "resume",
19494
+ error: message
19495
+ });
19496
+ return { notice: `surface catalog unavailable: ${message}` };
19497
+ }
19498
+ const target = await resolveColdSkillTarget(ctx, presetId, process.cwd());
19499
+ if (target.target === void 0) return {};
19500
+ try {
19501
+ const catalog = await readHumanSkillCatalog(target.target.registry, {
19502
+ cwd: target.target.cwd,
19503
+ scope: target.target.scope,
19504
+ signal
19505
+ });
19506
+ diag.info("skill catalog standing ready", {
19507
+ preset: presetId ?? "default",
19508
+ skills: catalog.skills.length,
19509
+ complete: catalog.complete
19510
+ });
19511
+ return {
19512
+ skills: catalog,
19513
+ ...target.degraded === void 0 ? {} : { notice: target.degraded }
19514
+ };
19515
+ } catch (error) {
19516
+ if (isCancellation(error)) return {};
19517
+ const message = safeErrorMessage(error);
19518
+ diag.warn("skill catalog unavailable", {
19519
+ phase: "cold",
19520
+ error: message
19521
+ });
19522
+ return { notice: `skill catalog unavailable: ${message}` };
19523
+ }
19524
+ }
17709
19525
  function subagentJobTranscriptId(snapshot) {
17710
19526
  if (typeof snapshot !== "object" || snapshot === null) return void 0;
17711
19527
  const childSessionId = snapshot.childSessionId;
@@ -17998,6 +19814,7 @@ function apply(ctx, config) {
17998
19814
  const compose = (presetId) => composeAgent(ctx, selected, presetId);
17999
19815
  const withPresetMeta = (composition) => composition.agentPreset === void 0 ? {} : { agentPreset: composition.agentPreset };
18000
19816
  const launchPreset = startup.presetId ?? (process.env.DSH_PI_TUI_PRESET?.trim() || void 0);
19817
+ let pendingPreset;
18001
19818
  diag.info("boot", {
18002
19819
  pid: process.pid,
18003
19820
  dsh: dshVersion() ?? "unknown",
@@ -18022,7 +19839,7 @@ function apply(ctx, config) {
18022
19839
  /** Resolve the launch composition, falling back to the default on an unknown id. */
18023
19840
  const launchComposition = async () => {
18024
19841
  try {
18025
- return { composition: await compose(launchPreset) };
19842
+ return { composition: await compose(pendingPreset ?? launchPreset) };
18026
19843
  } catch (error) {
18027
19844
  const message = safeErrorMessage(error);
18028
19845
  ctx.logger.warn(`tui-runner: launch preset unavailable: ${message}`);
@@ -18092,6 +19909,29 @@ function apply(ctx, config) {
18092
19909
  let liveHandle = handle;
18093
19910
  let liveAgent = handle?.agent;
18094
19911
  if (liveAgent !== void 0) await liveAgent.whenIdle();
19912
+ let initialSnapshot;
19913
+ let initialSkills;
19914
+ let surfaceNotice;
19915
+ {
19916
+ let effectivePresetId;
19917
+ try {
19918
+ const launched = await launchComposition();
19919
+ if (launched.failure !== void 0) resumeFailure = launched.failure;
19920
+ effectivePresetId = launched.composition.agentPreset;
19921
+ } catch (error) {
19922
+ diag.warn("preset resolution failed at startup", { error: safeErrorMessage(error) });
19923
+ }
19924
+ const resolution = await resolveInitialCatalog({
19925
+ liveAgent,
19926
+ presetId: effectivePresetId,
19927
+ signal: lifecycleController.signal,
19928
+ ctx,
19929
+ diag
19930
+ });
19931
+ initialSnapshot = resolution.snapshot;
19932
+ initialSkills = resolution.skills;
19933
+ surfaceNotice = resolution.notice;
19934
+ }
18095
19935
  let guardState = freshGuardState();
18096
19936
  let guardToken;
18097
19937
  /**
@@ -18228,6 +20068,7 @@ function apply(ctx, config) {
18228
20068
  guardToken = void 0;
18229
20069
  bumpSessionGeneration();
18230
20070
  await initLiveSession(next.agent);
20071
+ await refreshLiveCatalog(next.agent);
18231
20072
  diag.info("switch ok", {
18232
20073
  from: from ?? "(none)",
18233
20074
  to: next.agent.session.id,
@@ -18314,6 +20155,7 @@ function apply(ctx, config) {
18314
20155
  cleanedUp = true;
18315
20156
  guardToken = void 0;
18316
20157
  lifecycleController.abort();
20158
+ catalogCoordinator?.dispose();
18317
20159
  localShellController?.abort();
18318
20160
  for (const file of shellTempFiles) try {
18319
20161
  rmSync(file, { force: true });
@@ -18341,22 +20183,19 @@ function apply(ctx, config) {
18341
20183
  cleanup();
18342
20184
  };
18343
20185
  });
18344
- /** Run a `!` command locally; the output renders as a local card. */
20186
+ /**
20187
+ * Run a `!` shell command. `!` (context mode) runs the command and then
20188
+ * submits the completed command+output to the session as an ordinary
20189
+ * guarded user message (kimi parity: the model sees both on the next
20190
+ * turn; the result wakes a turn but is never steered into a running
20191
+ * one); `!!` (local mode) runs purely off-session — the card is the
20192
+ * only record (pi's excluded-from-context escape hatch).
20193
+ */
18345
20194
  const runLocalShell = (text) => {
18346
- const includeInContext = text.startsWith("!!");
18347
- const command = text.replace(/^!+/, "").trim();
20195
+ const includeInContext = shellModeOf(text) === "context";
20196
+ const command = shellCommandOf(text);
18348
20197
  if (command === "") return;
18349
- if (includeInContext) {
18350
- if (liveAgent === void 0) return;
18351
- liveAgent.followup(createUserMessage({
18352
- content: [{
18353
- type: "text",
18354
- text: command
18355
- }],
18356
- source: { kind: "user" }
18357
- }));
18358
- return;
18359
- }
20198
+ const generationAtRun = sessionGeneration;
18360
20199
  localShellController?.abort();
18361
20200
  localShellController = new AbortController();
18362
20201
  const localSignal = localShellController.signal;
@@ -18372,6 +20211,51 @@ function apply(ctx, config) {
18372
20211
  const releaseController = () => {
18373
20212
  if (localShellController?.signal === localSignal) localShellController = void 0;
18374
20213
  };
20214
+ /**
20215
+ * Submit the completed run to the session (context mode only): guard
20216
+ * → re-validate → followup. Blocked keeps the card (the output stays
20217
+ * visible; the identical `!` re-run forces through its one-time guard
20218
+ * token); accepted clears the settled card — the transcript's user
20219
+ * row becomes the record. An owned workflow: the outcome drives the
20220
+ * notify and the card — runOwned (AGENTS.md), never a bare void.
20221
+ */
20222
+ const submitResult = (result) => {
20223
+ if (sessionGeneration !== generationAtRun) {
20224
+ app.notify("the session changed while the command ran — the output was not submitted", "error");
20225
+ return;
20226
+ }
20227
+ const submitted = formatShellSubmitText(command, result);
20228
+ runOwned("shell submit", () => submitShellResult({
20229
+ currentAgent: () => liveAgent,
20230
+ currentGeneration: () => sessionGeneration,
20231
+ guard: { run: async (identity) => {
20232
+ const verdict = await guardSend("submit", identity);
20233
+ if (verdict.kind === "blocked") return {
20234
+ kind: "blocked",
20235
+ reason: verdict.reason
20236
+ };
20237
+ return { kind: verdict.kind === "forced" ? "forced" : "ok" };
20238
+ } },
20239
+ notify: (message, kind) => app.notify(message, kind),
20240
+ blockedNotice: (reason) => reason === "removed" ? `This session's log was removed externally — the command output was not submitted (it stays on the card). Run the same ! command again to force` : reason === "tail-mismatch" ? "This session file was rewritten by another process (same event count, different content) — the command output was not submitted (it stays on the card). Run the same ! command again to force (may corrupt the session log)" : reason === "unreadable" ? "This session's log could not be read (locked or corrupt) — the command output was not submitted (it stays on the card). Run the same ! command again to force (may corrupt the session log)" : "This session may be open in another dsh process (TUI/web) — the command output was not submitted (it stays on the card). Run the same ! command again to force (may corrupt the session log)",
20241
+ forcedNotice: () => GUARD_FORCED_NOTIFY,
20242
+ staleNotice: () => "the session changed while the submission was being checked — the output was not submitted",
20243
+ createMessage: (text) => createUserMessage({
20244
+ content: [{
20245
+ type: "text",
20246
+ text
20247
+ }],
20248
+ source: { kind: "user" }
20249
+ }),
20250
+ onSubmitted: () => app.clearSettledLocalMessages()
20251
+ }, submitted), {
20252
+ diag,
20253
+ sessionId: () => liveAgent?.session.id,
20254
+ onError: (error) => {
20255
+ app.notify(`shell submit failed: ${safeErrorMessage(error)}`, "error");
20256
+ }
20257
+ });
20258
+ };
18375
20259
  let settled = false;
18376
20260
  const settle = (result, status) => {
18377
20261
  if (settled) return;
@@ -18384,6 +20268,7 @@ function apply(ctx, config) {
18384
20268
  result,
18385
20269
  status
18386
20270
  });
20271
+ if (includeInContext && !localSignal.aborted) submitResult(result);
18387
20272
  };
18388
20273
  const shell = ctx.get("shell");
18389
20274
  if (shell !== void 0) {
@@ -18537,7 +20422,10 @@ function apply(ctx, config) {
18537
20422
  folder: childFolder
18538
20423
  };
18539
20424
  repaint(app, childFolder);
18540
- app.notify(`viewing subagent ${label ?? childId} — Esc returns`, "info");
20425
+ app.setViewerMode({
20426
+ id: childId,
20427
+ label: label ?? childId
20428
+ });
18541
20429
  };
18542
20430
  /** Leave the subagent viewer (single Esc). Returns whether it exited. */
18543
20431
  const exitView = () => {
@@ -18545,6 +20433,7 @@ function apply(ctx, config) {
18545
20433
  viewing = void 0;
18546
20434
  app.clearLocalMessages();
18547
20435
  app.clearNotify();
20436
+ app.setViewerMode(void 0);
18548
20437
  repaint(app, folder);
18549
20438
  refreshStatus();
18550
20439
  return true;
@@ -18566,6 +20455,8 @@ function apply(ctx, config) {
18566
20455
  * user input is the deferred trigger), guard against cross-process
18567
20456
  * divergence, then execute a registered slash command or follow up. */
18568
20457
  const dispatchViaSession = (text) => {
20458
+ const parsedAtSubmit = parseCommand(text);
20459
+ const wasAdvertised = parsedAtSubmit !== void 0 && wasAdvertisedClaim?.(parsedAtSubmit.name) === true;
18569
20460
  runOwned("submit", () => ensureSession().then(async () => {
18570
20461
  const agent = liveAgent;
18571
20462
  if (agent === void 0) return;
@@ -18595,6 +20486,10 @@ function apply(ctx, config) {
18595
20486
  diag,
18596
20487
  sessionId: () => agent.session.id,
18597
20488
  onResult: (execution) => {
20489
+ if (shouldConsumeAdvertisedMiss(execution, wasAdvertised)) {
20490
+ app.notify(`/${parsedAtSubmit?.name ?? "?"} is not available in the created session`, "error");
20491
+ return;
20492
+ }
18598
20493
  if (execution === void 0) {
18599
20494
  if (sessionUnchanged({
18600
20495
  agent,
@@ -18671,44 +20566,119 @@ function apply(ctx, config) {
18671
20566
  }
18672
20567
  });
18673
20568
  };
20569
+ /**
20570
+ * Steer into the running turn with guard re-validation. Shared by
20571
+ * Ctrl+S (the whole queue plus a non-empty draft) and the busy-Enter
20572
+ * preference — Enter while the agent is running with busyEnter=steer
20573
+ * steers the DRAFT ONLY (web busyEnter parity): explicitly queued
20574
+ * messages stay queued until Ctrl+S or the /queue actions, because
20575
+ * already-steered input cannot be pulled back.
20576
+ * @param text - the submitted draft ('' allowed for Ctrl+S).
20577
+ * @param onlyDraft - busy-Enter mode: never read or remove the queue.
20578
+ */
20579
+ const steerNow = (text, onlyDraft = false) => {
20580
+ if (viewing !== void 0) {
20581
+ if (text.trim() !== "") app.setEditorText(mergeDraft(app.getDraft(), text));
20582
+ app.notify("viewing a subagent — Esc returns before steering", "info");
20583
+ return;
20584
+ }
20585
+ app.clearSettledLocalMessages();
20586
+ if (onlyDraft) {
20587
+ if (text.trim() === "") return;
20588
+ } else if (liveAgent !== void 0) {
20589
+ if ([...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].length === 0 && text.trim() === "") return;
20590
+ } else if (text.trim() === "") return;
20591
+ runOwned("steer", () => ensureSession().then(async () => {
20592
+ if (liveAgent === void 0) return;
20593
+ await steerAll({
20594
+ currentAgent: () => liveAgent,
20595
+ currentGeneration: () => sessionGeneration,
20596
+ guard: { run: async (identity) => {
20597
+ const verdict = await guardSend("save", identity);
20598
+ if (verdict.kind === "blocked") return {
20599
+ kind: "blocked",
20600
+ reason: verdict.reason
20601
+ };
20602
+ return { kind: verdict.kind === "forced" ? "forced" : "ok" };
20603
+ } },
20604
+ notify: (message, kind) => app.notify(message, kind),
20605
+ restoreDraft: (draft) => {
20606
+ const merged = mergeDraft(app.getDraft(), draft);
20607
+ app.setEditorText(merged);
20608
+ return merged === draft;
20609
+ },
20610
+ createDraft: (draft) => createUserMessage({
20611
+ content: [{
20612
+ type: "text",
20613
+ text: draft
20614
+ }],
20615
+ source: { kind: "user" }
20616
+ }),
20617
+ blockedNotice: (reason) => reason === "removed" ? GUARD_REMOVED_NOTIFY("save") : reason === "tail-mismatch" ? GUARD_TAIL_MISMATCH_NOTIFY("save") : GUARD_BLOCKED_NOTIFY("save"),
20618
+ forcedNotice: () => GUARD_FORCED_NOTIFY,
20619
+ staleNotice: () => "the queue or session changed while sending — try again",
20620
+ mergedNotice: () => "the draft changed while sending — review it before submitting again (the earlier text was preserved below)"
20621
+ }, text, onlyDraft ? { onlyDraft: true } : void 0);
20622
+ }), {
20623
+ diag,
20624
+ sessionId: () => liveAgent?.session.id,
20625
+ onError: failSubmission(text)
20626
+ });
20627
+ };
20628
+ /**
20629
+ * Dispatch one user submission end to end: the viewer guard, the input-
20630
+ * history persistence, `!` local shells, sessionless commands, the
20631
+ * busy-Enter preference, and the session dispatch. The Ctrl+Enter
20632
+ * opposite chord forces the QUEUE mode (forceQueue), web busyEnter
20633
+ * parity — the accelerated chord uses the other behavior.
20634
+ * @param text - the submitted draft.
20635
+ * @param forceQueue - the chord: never steer, queue instead.
20636
+ */
20637
+ const dispatchUserInput = (text, forceQueue = false) => {
20638
+ if (viewing !== void 0) {
20639
+ app.setEditorText(mergeDraft(app.getDraft(), text));
20640
+ app.notify("viewing a subagent — Esc returns before submitting", "info");
20641
+ return;
20642
+ }
20643
+ app.clearSettledLocalMessages();
20644
+ const history = app.getInputHistory();
20645
+ if (history.length > 0) {
20646
+ const settings = tuiSettings;
20647
+ if (settings !== void 0) runDetached("settings history write", () => settings.replace({
20648
+ ...settings.get(),
20649
+ history: {
20650
+ ...settings.get().history,
20651
+ [sessionCwd()]: history
20652
+ }
20653
+ }), {
20654
+ diag,
20655
+ notify: (message) => app.notify(message, "error"),
20656
+ recoverable: () => true
20657
+ });
20658
+ }
20659
+ if (text.startsWith("!")) {
20660
+ if (text.startsWith("!!")) runLocalShell(text);
20661
+ else if (shellCommandOf(text) !== "") runOwned("contextual shell", () => ensureSession().then(() => runLocalShell(text)), {
20662
+ diag,
20663
+ sessionId: () => liveAgent?.session.id,
20664
+ onError: failSubmission(text)
20665
+ });
20666
+ return;
20667
+ }
20668
+ const parsed = parseCommand(text);
20669
+ if (parsed !== void 0 && liveAgent === void 0 && SESSIONLESS_COMMANDS.has(parsed.name)) {
20670
+ runLocalCommand(parsed, text);
20671
+ return;
20672
+ }
20673
+ if (shouldSteerOnEnter(parsed, liveAgent?.status === "running", tuiSettings?.get().busyEnter, forceQueue)) {
20674
+ steerNow(text, true);
20675
+ return;
20676
+ }
20677
+ dispatchViaSession(text);
20678
+ };
18674
20679
  app = startProcessTui({
18675
- onSubmit: (text) => {
18676
- if (viewing !== void 0) {
18677
- app.setEditorText(mergeDraft(app.getDraft(), text));
18678
- app.notify("viewing a subagent — Esc returns before submitting", "info");
18679
- return;
18680
- }
18681
- const history = app.getInputHistory();
18682
- if (history.length > 0) {
18683
- const settings = tuiSettings;
18684
- if (settings !== void 0) runDetached("settings history write", () => settings.replace({
18685
- ...settings.get(),
18686
- history: {
18687
- ...settings.get().history,
18688
- [sessionCwd()]: history
18689
- }
18690
- }), {
18691
- diag,
18692
- notify: (message) => app.notify(message, "error"),
18693
- recoverable: () => true
18694
- });
18695
- }
18696
- if (text.startsWith("!")) {
18697
- if (text.startsWith("!!")) runOwned("contextual shell", () => ensureSession().then(() => runLocalShell(text)), {
18698
- diag,
18699
- sessionId: () => liveAgent?.session.id,
18700
- onError: failSubmission(text)
18701
- });
18702
- else runLocalShell(text);
18703
- return;
18704
- }
18705
- const parsed = parseCommand(text);
18706
- if (parsed !== void 0 && liveAgent === void 0 && SESSIONLESS_COMMANDS.has(parsed.name)) {
18707
- runLocalCommand(parsed, text);
18708
- return;
18709
- }
18710
- dispatchViaSession(text);
18711
- },
20680
+ onSubmit: (text) => dispatchUserInput(text),
20681
+ onQueueSubmit: (text) => dispatchUserInput(text, true),
18712
20682
  runOwned: (label, task, options) => {
18713
20683
  runOwned(label, task, {
18714
20684
  ...options,
@@ -18724,52 +20694,7 @@ function apply(ctx, config) {
18724
20694
  localShellController?.abort();
18725
20695
  liveAgent?.cancel({ kind: "user" });
18726
20696
  },
18727
- onSteer: (text) => {
18728
- if (viewing !== void 0) {
18729
- if (text.trim() !== "") app.setEditorText(mergeDraft(app.getDraft(), text));
18730
- app.notify("viewing a subagent — Esc returns before steering", "info");
18731
- return;
18732
- }
18733
- if (liveAgent !== void 0) {
18734
- if ([...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].length === 0 && text.trim() === "") return;
18735
- } else if (text.trim() === "") return;
18736
- runOwned("steer", () => ensureSession().then(async () => {
18737
- if (liveAgent === void 0) return;
18738
- await steerAll({
18739
- currentAgent: () => liveAgent,
18740
- currentGeneration: () => sessionGeneration,
18741
- guard: { run: async (identity) => {
18742
- const verdict = await guardSend("save", identity);
18743
- if (verdict.kind === "blocked") return {
18744
- kind: "blocked",
18745
- reason: verdict.reason
18746
- };
18747
- return { kind: verdict.kind === "forced" ? "forced" : "ok" };
18748
- } },
18749
- notify: (message, kind) => app.notify(message, kind),
18750
- restoreDraft: (draft) => {
18751
- const merged = mergeDraft(app.getDraft(), draft);
18752
- app.setEditorText(merged);
18753
- return merged === draft;
18754
- },
18755
- createDraft: (draft) => createUserMessage({
18756
- content: [{
18757
- type: "text",
18758
- text: draft
18759
- }],
18760
- source: { kind: "user" }
18761
- }),
18762
- blockedNotice: (reason) => reason === "removed" ? GUARD_REMOVED_NOTIFY("save") : reason === "tail-mismatch" ? GUARD_TAIL_MISMATCH_NOTIFY("save") : GUARD_BLOCKED_NOTIFY("save"),
18763
- forcedNotice: () => GUARD_FORCED_NOTIFY,
18764
- staleNotice: () => "the queue or session changed while sending — try again",
18765
- mergedNotice: () => "the draft changed while sending — review it before submitting again (the earlier text was preserved below)"
18766
- }, text);
18767
- }), {
18768
- diag,
18769
- sessionId: () => liveAgent?.session.id,
18770
- onError: failSubmission(text)
18771
- });
18772
- },
20697
+ onSteer: (text) => steerNow(text),
18773
20698
  openExternalEditor: async (draft) => {
18774
20699
  const [editor, ...editorArgs] = parseShellWords(process.env.VISUAL ?? process.env.EDITOR ?? "vi");
18775
20700
  if (editor === void 0) throw new Error("empty editor command");
@@ -18858,31 +20783,50 @@ function apply(ctx, config) {
18858
20783
  refreshQueue();
18859
20784
  },
18860
20785
  onOpenTasks: () => {
18861
- if (jobs === void 0 || liveAgent === void 0) return;
18862
- let snapshots;
18863
- try {
18864
- snapshots = jobs.list(liveAgent);
18865
- } catch {
18866
- return;
18867
- }
18868
- if (snapshots.length === 0) return;
20786
+ if (liveAgent === void 0) return;
20787
+ let jobSnapshots = [];
20788
+ if (jobs !== void 0) try {
20789
+ jobSnapshots = jobs.list(liveAgent);
20790
+ } catch {}
18869
20791
  const now = Date.now();
18870
- const terminal = (status) => status !== "running" && status !== "stopping";
18871
- const ordered = [...snapshots].sort((a, b) => {
18872
- const aTerminal = terminal(a.status);
18873
- if (aTerminal !== terminal(b.status)) return aTerminal ? 1 : -1;
18874
- if (!aTerminal) return a.startedAt - b.startedAt;
18875
- return (b.finishedAt ?? b.startedAt) - (a.finishedAt ?? a.startedAt);
18876
- });
18877
- app.openPicker(ordered.map((job) => ({
18878
- value: job.id,
18879
- label: `${job.kind} · ${job.label}`,
18880
- description: `${job.status}${job.detail === void 0 ? "" : ` — ${job.detail}`} · ${Math.max(0, Math.floor((now - job.startedAt) / 1e3))}s`
18881
- })), (jobId) => openJobView(jobId), () => {}, {
18882
- header: `tasks (${snapshots.length})`,
20792
+ let rows = buildTaskRows(jobSnapshots, []);
20793
+ const selectRow = (value) => {
20794
+ const row = rows.find((candidate) => candidate.value === value);
20795
+ if (row === void 0) return;
20796
+ if (row.kind === "subagent") {
20797
+ runOwned("subagent view from tasks", () => enterView(row.childId, row.label), {
20798
+ diag,
20799
+ sessionId: () => liveAgent?.session.id,
20800
+ onError: (error) => app.notify(`could not open the subagent view: ${safeErrorMessage(error)}`, "error")
20801
+ });
20802
+ return;
20803
+ }
20804
+ openJobView(row.jobId);
20805
+ };
20806
+ const pickerItems = (target) => target.map((row) => ({
20807
+ value: row.value,
20808
+ label: taskRowLabel(row),
20809
+ description: describeTaskRow(row, now),
20810
+ group: rowGroup(row)
20811
+ }));
20812
+ const handle = app.openPicker(pickerItems(rows), selectRow, () => {}, {
20813
+ header: "tasks · subagents",
18883
20814
  enableSearch: true,
18884
20815
  showHint: true
18885
20816
  });
20817
+ if (subagents !== void 0) {
20818
+ const sessionId = liveAgent.session.id;
20819
+ const generation = sessionGeneration;
20820
+ runOwned("task browser children", () => subagents.listChildren(sessionId), {
20821
+ diag,
20822
+ sessionId: () => liveAgent?.session.id,
20823
+ onResult: (entries) => {
20824
+ if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
20825
+ rows = buildTaskRows(jobSnapshots, entries);
20826
+ handle.setItems(pickerItems(rows));
20827
+ }
20828
+ });
20829
+ }
18886
20830
  }
18887
20831
  }, {
18888
20832
  present,
@@ -18892,28 +20836,35 @@ function apply(ctx, config) {
18892
20836
  theme: z.string(),
18893
20837
  footer: z.string(),
18894
20838
  fullscreen: z.string(),
20839
+ busyEnter: z.string(),
18895
20840
  history: z.dict(z.array(z.string()))
18896
20841
  }), { base: {
18897
20842
  theme: "auto",
18898
20843
  footer: "full",
18899
20844
  fullscreen: "on",
20845
+ busyEnter: "queue",
18900
20846
  history: {}
18901
20847
  } });
20848
+ if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
18902
20849
  const storedTheme = tuiSettings?.get().theme;
18903
20850
  if (storedTheme === "auto") {
18904
- runDetached("theme autodetect", () => app.autoDetectTheme(), { diag });
20851
+ runDetached("theme autodetect", () => app.autoDetectTheme({ shouldApply: () => tuiSettings?.get().theme === "auto" }), { diag });
18905
20852
  app.onTerminalThemeChange((theme) => {
18906
20853
  if (tuiSettings?.get().theme === "auto") app.applyTheme(theme);
18907
20854
  });
18908
- } else if (storedTheme === "dark" || storedTheme === "light") app.applyTheme(storedTheme);
18909
- else if (storedTheme?.startsWith("custom:")) {
20855
+ app.trackTerminalTheme(true);
20856
+ } else if (storedTheme === "dark" || storedTheme === "light") {
20857
+ app.applyTheme(storedTheme);
20858
+ app.trackTerminalTheme(false);
20859
+ } else if (storedTheme?.startsWith("custom:")) {
18910
20860
  const palette = loadCustomTheme(storedTheme.slice(7));
18911
20861
  if (palette !== void 0) app.applyPalette(palette);
20862
+ app.trackTerminalTheme(false);
18912
20863
  }
18913
20864
  if (tuiSettings?.get().footer === "compact") app.setFooterPreset("compact");
18914
- if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
18915
20865
  refreshStatus();
18916
20866
  let refreshTasks = () => {};
20867
+ let refreshAgents = () => {};
18917
20868
  const jobs = ctx.get("jobs");
18918
20869
  if (jobs !== void 0) {
18919
20870
  refreshTasks = () => {
@@ -18928,9 +20879,36 @@ function apply(ctx, config) {
18928
20879
  } catch {}
18929
20880
  app.setTasks(tasks);
18930
20881
  };
18931
- jobs.onJobsChanged(() => refreshTasks());
20882
+ jobs.onJobsChanged(() => {
20883
+ refreshTasks();
20884
+ refreshAgents();
20885
+ });
18932
20886
  refreshTasks();
18933
20887
  }
20888
+ const subagents = ctx.get("subagents");
20889
+ if (subagents !== void 0) {
20890
+ refreshAgents = () => {
20891
+ if (liveAgent === void 0) {
20892
+ app.setAgents([]);
20893
+ return;
20894
+ }
20895
+ const sessionId = liveAgent.session.id;
20896
+ const generation = sessionGeneration;
20897
+ runOwned("task browser agents refresh", () => subagents.listChildren(sessionId), {
20898
+ diag,
20899
+ sessionId: () => liveAgent?.session.id,
20900
+ onResult: (entries) => {
20901
+ if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
20902
+ app.setAgents(entries.filter((entry) => entry.kind === "child" && entry.activity === "running").map((entry) => ({
20903
+ id: entry.id,
20904
+ label: entry.label ?? entry.id,
20905
+ activity: entry.activity
20906
+ })));
20907
+ }
20908
+ });
20909
+ };
20910
+ refreshAgents();
20911
+ }
18934
20912
  /**
18935
20913
  * Open one job from the task browser: a bash job shows a STATUS viewer
18936
20914
  * (never the output — the job's single read cursor belongs to the agent's
@@ -19018,7 +20996,12 @@ function apply(ctx, config) {
19018
20996
  app.setQueueItems(queue);
19019
20997
  };
19020
20998
  refreshQueue();
19021
- /** Rebuild every live-session surface after resume, create, or swap. */
20999
+ /**
21000
+ * Rebuild every live-session surface after resume, create, or swap.
21001
+ * The surface catalog is NOT touched here: the initial owner's catalog
21002
+ * came from the pre-mount prefetch/probe, and the first deferred create
21003
+ * plus every switch await the coordinator refresh themselves.
21004
+ */
19022
21005
  const initLiveSession = async (agent) => {
19023
21006
  folder = new TranscriptFolder();
19024
21007
  folder.apply(agent.session.events);
@@ -19032,11 +21015,15 @@ function apply(ctx, config) {
19032
21015
  repaint(app, folder);
19033
21016
  refreshStatus();
19034
21017
  refreshQueue();
21018
+ refreshTasks();
21019
+ refreshAgents();
19035
21020
  app.resetInputHistory(tuiSettings?.get().history[sessionCwd()] ?? []);
19036
21021
  setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())} · ${agent.session.id}`);
19037
21022
  updateWelcomeCard();
19038
- registerCommands();
19039
- refreshSkillCommands();
21023
+ registerCommands({
21024
+ snapshot: initialSnapshot,
21025
+ skills: initialSkills
21026
+ });
19040
21027
  };
19041
21028
  /**
19042
21029
  * Create the first session lazily — the FIRST user message triggers it
@@ -19065,6 +21052,7 @@ function apply(ctx, config) {
19065
21052
  await liveAgent.whenIdle();
19066
21053
  bumpSessionGeneration();
19067
21054
  await initLiveSession(liveAgent);
21055
+ await refreshLiveCatalog(liveAgent);
19068
21056
  } catch (error) {
19069
21057
  const message = safeErrorMessage(error);
19070
21058
  ctx.logger.warn(`tui-runner: failed to start with preset "${launchPreset ?? "default"}": ${message}`);
@@ -19088,6 +21076,7 @@ function apply(ctx, config) {
19088
21076
  await liveAgent.whenIdle();
19089
21077
  bumpSessionGeneration();
19090
21078
  await initLiveSession(liveAgent);
21079
+ await refreshLiveCatalog(liveAgent);
19091
21080
  }
19092
21081
  if (resumeFailure !== void 0) {
19093
21082
  app.notify(resumeFailure, "error");
@@ -19099,7 +21088,67 @@ function apply(ctx, config) {
19099
21088
  return creating;
19100
21089
  };
19101
21090
  let commandsRegistered = false;
19102
- let refreshSkills;
21091
+ /** The claim test installed by registerTuiCommands: is a slash name
21092
+ * advertised by the CURRENT completion list? The dispatch captures it
21093
+ * BEFORE any session creation (see dispatchViaSession). */
21094
+ let wasAdvertisedClaim;
21095
+ /** The catalog refresh coordinator: the ONE post-mount refresh owner
21096
+ * (first session, switches, /preset, /reload). Built inside
21097
+ * registerCommands once the surface hooks exist. */
21098
+ let catalogRefreshRequest;
21099
+ let catalogCoordinator;
21100
+ /** `skills/change` coalescing: bursts of invalidation notifications cost
21101
+ * at most two reads, and the follow-up re-read observes the CURRENT
21102
+ * ownership (live agent vs standing preset). */
21103
+ let skillsChangeSubscribed = false;
21104
+ const skillsChangeGate = new CoalescingRefreshGate(() => {
21105
+ runOwned("skills/change refresh", async () => {
21106
+ const refresh = catalogRefreshRequest;
21107
+ if (refresh === void 0) return void 0;
21108
+ const target = liveAgent === void 0 ? {
21109
+ kind: "preset",
21110
+ presetId: pendingPreset ?? launchPreset
21111
+ } : {
21112
+ kind: "agent",
21113
+ key: sessionGeneration
21114
+ };
21115
+ return refresh({
21116
+ source: "invalidation",
21117
+ target,
21118
+ ...target.kind === "agent" ? { agent: liveAgent } : {}
21119
+ });
21120
+ }, {
21121
+ diag,
21122
+ sessionId: () => liveAgent?.session.id,
21123
+ onResult: (outcome) => {
21124
+ if (outcome !== void 0 && outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
21125
+ skillsChangeGate.settled();
21126
+ },
21127
+ onCancel: () => {
21128
+ skillsChangeGate.settled();
21129
+ },
21130
+ onError: (error) => {
21131
+ skillsChangeGate.settled();
21132
+ app.notify(`skill catalog refresh failed: ${safeErrorMessage(error)}`, "error");
21133
+ }
21134
+ });
21135
+ });
21136
+ /** Subscribe to the dsh-skill invalidation notification once, through
21137
+ * the single-point adapter (plan appendix B.1). The event carries no
21138
+ * scope or cwd, so the refresh target follows the CURRENT ownership; an
21139
+ * unavailable or throwing subscription degrades to no subscription —
21140
+ * owner switches and /reload still refresh. The flag is set only after
21141
+ * a successful subscribe, so a throwing subscribe can retry on a later
21142
+ * registration attempt. */
21143
+ const subscribeSkillsChangeEvents = () => {
21144
+ if (skillsChangeSubscribed) return;
21145
+ try {
21146
+ subscribeSkillsChange(ctx, () => skillsChangeGate.notify());
21147
+ skillsChangeSubscribed = true;
21148
+ } catch (error) {
21149
+ diag.warn("skills/change subscription unavailable", { error: safeErrorMessage(error) });
21150
+ }
21151
+ };
19103
21152
  const runner = {
19104
21153
  ctx,
19105
21154
  app,
@@ -19127,6 +21176,26 @@ function apply(ctx, config) {
19127
21176
  return sessionGeneration;
19128
21177
  },
19129
21178
  compose,
21179
+ get pendingPreset() {
21180
+ return pendingPreset;
21181
+ },
21182
+ set pendingPreset(id) {
21183
+ pendingPreset = id;
21184
+ },
21185
+ /** The effective preset id for COLD (sessionless) reads: the run-local
21186
+ * pending override ahead of the launch-time --preset (the SAME
21187
+ * precedence ensureSession uses); undefined = the saved/default
21188
+ * preset applies. */
21189
+ get effectivePresetId() {
21190
+ return pendingPreset ?? launchPreset;
21191
+ },
21192
+ refreshCatalog: (request) => {
21193
+ const refresh = catalogRefreshRequest;
21194
+ return refresh === void 0 ? Promise.resolve({
21195
+ kind: "failed",
21196
+ error: "catalog refresh unavailable"
21197
+ }) : refresh(request);
21198
+ },
19130
21199
  switchSession,
19131
21200
  swapTo: (next) => swapTo(next),
19132
21201
  currentPreset,
@@ -19138,12 +21207,32 @@ function apply(ctx, config) {
19138
21207
  requestExit,
19139
21208
  exit
19140
21209
  };
19141
- const registerCommands = () => {
21210
+ const registerCommands = (initial) => {
19142
21211
  if (commandsRegistered) return;
19143
21212
  if (ctx.get("commands") === void 0) return;
19144
21213
  commandsRegistered = true;
19145
21214
  try {
19146
- refreshSkills = registerTuiCommands(runner).refreshSkills;
21215
+ const installed = registerTuiCommands(runner, initial);
21216
+ wasAdvertisedClaim = installed.wasAdvertised;
21217
+ catalogCoordinator = new CatalogRefreshCoordinator({
21218
+ readAgent: (agent, readSignal) => readSurfaceCatalog(agent, readSignal, ctx),
21219
+ readStanding: async (presetId, readSignal) => {
21220
+ const target = await resolveColdSkillTarget(ctx, presetId, process.cwd());
21221
+ if (target.target === void 0) throw new Error("skill service unavailable");
21222
+ return {
21223
+ catalog: await readHumanSkillCatalog(target.target.registry, {
21224
+ cwd: target.target.cwd,
21225
+ scope: target.target.scope,
21226
+ signal: readSignal
21227
+ }),
21228
+ ...target.degraded === void 0 ? {} : { notice: target.degraded }
21229
+ };
21230
+ },
21231
+ installSnapshot: (next) => installed.installSnapshot(next),
21232
+ enterCatalogTransition: () => installed.enterTransition()
21233
+ }, lifecycleController.signal, diag);
21234
+ catalogRefreshRequest = (request) => catalogCoordinator.refresh(request);
21235
+ subscribeSkillsChangeEvents();
19147
21236
  } catch (error) {
19148
21237
  commandsRegistered = false;
19149
21238
  const message = safeErrorMessage(error);
@@ -19152,13 +21241,21 @@ function apply(ctx, config) {
19152
21241
  app.notify(`command registration failed: ${message}`, "error");
19153
21242
  }
19154
21243
  };
19155
- /** Rebuild the agent-scoped per-skill commands after a session exists. */
19156
- const refreshSkillCommands = () => {
19157
- const refresh = refreshSkills;
21244
+ /** Await one live-owner catalog refresh through the coordinator (the
21245
+ * first deferred create and every session switch): the refresh attempt
21246
+ * settles before the caller continues, and its outcome is an outcome —
21247
+ * provider issues degrade fields, failures warn, the submission or the
21248
+ * switch proceeds either way. */
21249
+ const refreshLiveCatalog = async (agent) => {
21250
+ const refresh = catalogRefreshRequest;
19158
21251
  if (refresh === void 0) return;
19159
- runDetached("skill command refresh", () => refresh(), {
19160
- diag,
19161
- sessionId: () => liveAgent?.session.id
21252
+ await refresh({
21253
+ source: "live-session",
21254
+ target: {
21255
+ kind: "agent",
21256
+ key: sessionGeneration
21257
+ },
21258
+ agent
19162
21259
  });
19163
21260
  };
19164
21261
  if (liveAgent !== void 0) await initLiveSession(liveAgent);
@@ -19167,7 +21264,14 @@ function apply(ctx, config) {
19167
21264
  refreshStatus();
19168
21265
  setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())}`);
19169
21266
  }
19170
- registerCommands();
21267
+ registerCommands({
21268
+ snapshot: initialSnapshot,
21269
+ skills: initialSkills
21270
+ });
21271
+ if (surfaceNotice !== void 0) {
21272
+ app.notify(surfaceNotice, "error");
21273
+ surfaceNotice = void 0;
21274
+ }
19171
21275
  if (resumeFailure !== void 0) {
19172
21276
  app.notify(resumeFailure, "error");
19173
21277
  resumeFailure = void 0;
@@ -19182,8 +21286,10 @@ function apply(ctx, config) {
19182
21286
  return;
19183
21287
  }
19184
21288
  if (session.id !== liveAgent.session.id) return;
19185
- if (event.type === "tool/call") callArgs.set(event.data.callId, typeof event.data.arguments === "string" ? event.data.arguments : JSON.stringify(event.data.arguments));
19186
- else if (event.type === "tool/result") callArgs.delete(event.data.message.content[0]?.toolCallId ?? "");
21289
+ if (event.type === "tool/call") {
21290
+ callArgs.set(event.data.callId, typeof event.data.arguments === "string" ? event.data.arguments : JSON.stringify(event.data.arguments));
21291
+ if (typeof event.data.name === "string" && event.data.name.startsWith("subagent")) refreshAgents();
21292
+ } else if (event.type === "tool/result") callArgs.delete(event.data.message.content[0]?.toolCallId ?? "");
19187
21293
  folder.apply([event]);
19188
21294
  statsFolder.apply([event]);
19189
21295
  if (event.type === "goal/change") goalText = foldGoal([event]);
@@ -19214,6 +21320,8 @@ function apply(ctx, config) {
19214
21320
  });
19215
21321
  } else if (event.type === "step/start") refreshStatus();
19216
21322
  });
21323
+ ctx.on("subagent/start", () => refreshAgents());
21324
+ ctx.on("subagent/end", () => refreshAgents());
19217
21325
  if (liveAgent !== void 0) {
19218
21326
  app.setPlanMode(foldPlanMode(liveAgent.session.events));
19219
21327
  app.setWorking(workingFromLog(liveAgent.session.events));
@@ -19273,4 +21381,4 @@ function apply(ctx, config) {
19273
21381
  });
19274
21382
  }
19275
21383
  //#endregion
19276
- export { Config, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resumeCommand, runningProfile, subagentJobTranscriptId, subagentJobViewHint };
21384
+ export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resolveInitialCatalog, resumeCommand, runningProfile, shouldConsumeAdvertisedMiss, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };