@xmoon76/dsh-pi-tui 0.1.3 → 0.1.4

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
@@ -12607,25 +12607,36 @@ var TranscriptSearchComponent = class {
12607
12607
  //#endregion
12608
12608
  //#region src/question.ts
12609
12609
  /**
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).
12610
+ * The user-questions dialog flow (ask_user_question v2): the component that
12611
+ * sits in the EDITOR SEAT (kimi's mountEditorReplacement pattern) while the
12612
+ * agent asks — one question at a time, drafts per question, whole batch
12613
+ * submitted at the end — the Web QuestionComposer semantics (per-question
12614
+ * paging, batch submit, skip) with pi/kimi keyboard ergonomics (↑↓
12615
+ * highlight, digits, real Input for free text, review page).
12615
12616
  *
12616
- * Pure component: the app layer owns the promise/abort plumbing and routes
12617
- * input here while the flow is active.
12617
+ * Pure component: the app layer owns the promise/abort plumbing, the seat
12618
+ * swap, and routes input here while the flow is active.
12618
12619
  * @module @xmoon76/dsh-pi-tui/question
12619
12620
  */
12620
12621
  /** The "type your own answer" row shown below options (pi isOther parity). */
12621
12622
  const OTHER_ROW = "\0other";
12622
12623
  /**
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.
12624
+ * Default total physical-row budget of the question flow itself. The
12625
+ * QuestionFrame in tui-app.ts re-derives the budget from the terminal height
12626
+ * on every render (60% cap, 8..24 content rows) and pushes it through
12627
+ * {@link QuestionFlow.setMaxRows}; 24 is the fallback for direct renders.
12628
+ * The Frame wrapper adds its two border rows, and NOTHING clips the flow's
12629
+ * output in the editor-seat layout — so EVERYTHING (tabs, question, detail,
12630
+ * options with their descriptions, scroll marker, skipped note, hint) must
12631
+ * fit in the budget.
12627
12632
  */
12628
- const DIALOG_BUDGET = 24;
12633
+ const DEFAULT_BUDGET = 24;
12634
+ /**
12635
+ * Smallest supported content-row budget (a 16-row terminal caps the frame at
12636
+ * 10 rows). Below this the required rows (tabs, the question's first row,
12637
+ * the current option, the hint) cannot all coexist.
12638
+ */
12639
+ const MIN_SUPPORTED_BUDGET = 8;
12629
12640
  /**
12630
12641
  * Total physical-row budget for the question body (question text + detail),
12631
12642
  * shared so a single unbroken long line cannot push the options and hints
@@ -12658,14 +12669,15 @@ function appendWrappedBudgeted(lines, firstPrefix, continuationPrefix, content,
12658
12669
  return Math.max(0, budget - used);
12659
12670
  }
12660
12671
  /**
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.
12672
+ * Required-first push: the FIRST physical row always carries `content` —
12673
+ * never just a cut marker — while extra rows fill `budget` and a `... N
12674
+ * more` marker reports content that was itself cut, only when a row remains
12675
+ * after the content. Used for the question body and the highlighted option
12676
+ * label, whose first rows may never vanish (a 1-row budget shows the
12677
+ * content, not the marker).
12666
12678
  * @returns the remaining budget (≥ 0).
12667
12679
  */
12668
- function appendCurrentLabel(lines, firstPrefix, continuationPrefix, content, width, budget) {
12680
+ function appendContentFirst(lines, firstPrefix, continuationPrefix, content, width, budget) {
12669
12681
  if (budget <= 0) return 0;
12670
12682
  const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
12671
12683
  const wrapped = wrapTextWithAnsi(content, Math.max(1, width - prefixWidth));
@@ -12723,7 +12735,14 @@ var QuestionFlow = class {
12723
12735
  otherInput = new Input();
12724
12736
  /** Review-page action highlight (0 = Submit, 1 = Cancel). */
12725
12737
  submitIdx = 0;
12726
- focused = false;
12738
+ /**
12739
+ * Current content-row budget (8..24). The editor-seat QuestionFrame in
12740
+ * tui-app.ts re-derives it from the terminal height on every render and
12741
+ * pushes it through {@link setMaxRows}; nothing clips the flow's output
12742
+ * in that layout, so every render must fit the budget exactly.
12743
+ */
12744
+ budget = DEFAULT_BUDGET;
12745
+ _focused = false;
12727
12746
  constructor(questions, onDone, onCancel) {
12728
12747
  this.questions = questions;
12729
12748
  this.onDone = onDone;
@@ -12737,6 +12756,28 @@ var QuestionFlow = class {
12737
12756
  this.otherInput.onEscape = () => this.exitOther();
12738
12757
  this.syncEditMode();
12739
12758
  }
12759
+ /**
12760
+ * Focus mirror for the editor-seat layout: the app focuses the wrapping
12761
+ * QuestionFrame, which forwards here. The free-text Input renders its
12762
+ * hardware cursor only while focused AND editing, so the mirror must sync
12763
+ * it — a plain field would leave the cursor permanently hidden.
12764
+ */
12765
+ get focused() {
12766
+ return this._focused;
12767
+ }
12768
+ set focused(value) {
12769
+ this._focused = value;
12770
+ if (this.editingOther) this.otherInput.focused = value;
12771
+ }
12772
+ /**
12773
+ * Replace the content-row budget (the Frame wrapper adds its two border
12774
+ * rows on top). Called by QuestionFrame on every render immediately
12775
+ * before the flow renders, so no invalidation is needed; values outside
12776
+ * [MIN_SUPPORTED_BUDGET, DEFAULT_BUDGET] are clamped.
12777
+ */
12778
+ setMaxRows(rows) {
12779
+ this.budget = Math.max(MIN_SUPPORTED_BUDGET, Math.min(DEFAULT_BUDGET, Math.floor(rows)));
12780
+ }
12740
12781
  /** The rows of the current question (options plus the free-text row). */
12741
12782
  rows() {
12742
12783
  const question = this.questions[this.tab];
@@ -12983,7 +13024,7 @@ var QuestionFlow = class {
12983
13024
  lines.push(tabs.join(" "));
12984
13025
  lines.push("");
12985
13026
  if (this.tab >= this.questions.length) {
12986
- let reviewBudget = 19;
13027
+ let reviewBudget = this.budget - lines.length - 2;
12987
13028
  reviewBudget = appendWrappedBudgeted(lines, "", "", color.textStrong("Review your answer before submit"), safeWidth, reviewBudget);
12988
13029
  if (reviewBudget > 0) {
12989
13030
  lines.push("");
@@ -13010,31 +13051,37 @@ var QuestionFlow = class {
13010
13051
  const question = this.questions[this.tab];
13011
13052
  const draft = this.draft();
13012
13053
  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);
13054
+ const rows = this.rows();
13055
+ const multi = question.multiSelect === true;
13056
+ this.editingOther && rows.some((row) => row.key === OTHER_ROW);
13057
+ const skippedRow = draft.skipped ? 1 : 0;
13058
+ const optionless = rows.length === 0 || rows.length === 1 && rows[0]?.key === OTHER_ROW;
13059
+ const tail = optionless ? 3 + skippedRow : 4 + skippedRow;
13060
+ let bodyAllowance = this.budget - lines.length - tail;
13061
+ const header = question.header;
13062
+ if (header !== void 0 && header !== "" && bodyAllowance >= 2) {
13063
+ lines.push(color.textDim(header));
13064
+ bodyAllowance -= 1;
13065
+ }
13066
+ const bodyBudget = Math.min(MAX_BODY_LINES, Math.max(1, bodyAllowance));
13067
+ let bodyLeft = appendContentFirst(lines, `${color.primary("?")} `, " ", color.textStrong(question.question), safeWidth, bodyBudget);
13016
13068
  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);
13069
+ if (bodyLeft <= 1) {
13070
+ appendWrappedBudgeted(lines, " ", " ", color.textDim("... more content hidden"), safeWidth, bodyLeft);
13019
13071
  break;
13020
13072
  }
13021
- bodyBudget = appendWrappedBudgeted(lines, " ", " ", color.textDim(line), safeWidth, bodyBudget);
13073
+ bodyLeft = appendWrappedBudgeted(lines, " ", " ", color.textDim(line), safeWidth, bodyLeft);
13022
13074
  }
13023
- const rows = this.rows();
13024
- 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)) {
13075
+ if (!optionless) {
13027
13076
  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;
13077
+ const optionBudget = this.budget - lines.length - skippedRow - 2;
13031
13078
  const maxWindow = Math.max(1, Math.min(MAX_VISIBLE_OPTIONS, optionBudget));
13032
13079
  const visibleCount = Math.min(rows.length, maxWindow);
13033
13080
  const half = Math.floor(maxWindow / 2);
13034
13081
  const maxStart = Math.max(0, rows.length - visibleCount);
13035
13082
  const start = Math.max(0, Math.min(this.cursor - half, maxStart));
13036
13083
  const end = Math.min(rows.length, start + visibleCount);
13037
- if (optionBudget > 0) {
13084
+ {
13038
13085
  const cursorRow = rows[this.cursor];
13039
13086
  let reserved = 1;
13040
13087
  if (cursorRow !== void 0 && cursorRow.key !== OTHER_ROW) {
@@ -13066,7 +13113,7 @@ var QuestionFlow = class {
13066
13113
  const indent = " ".repeat(visibleWidth(prefix));
13067
13114
  const available = isCursor || cursorRendered ? left : left - reserved;
13068
13115
  const beforeLabel = lines.length;
13069
- if (isCursor) appendCurrentLabel(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
13116
+ if (isCursor) appendContentFirst(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
13070
13117
  else appendWrappedBudgeted(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
13071
13118
  left -= lines.length - beforeLabel;
13072
13119
  if (row.description !== void 0 && row.description !== "" && left > 0) {
@@ -13077,9 +13124,9 @@ var QuestionFlow = class {
13077
13124
  }
13078
13125
  if (isCursor) cursorRendered = true;
13079
13126
  }
13080
- if (showingRow === 1 && left > 0) left = appendWrappedBudgeted(lines, " ", " ", `showing ${start + 1}-${end} of ${rows.length}`, safeWidth, left);
13127
+ if (rows.length > MAX_VISIBLE_OPTIONS && left > 0) left = appendWrappedBudgeted(lines, " ", " ", `showing ${start + 1}-${end} of ${rows.length}`, safeWidth, left);
13081
13128
  }
13082
- } else if (this.editingOther || (question.options?.length ?? 0) === 0) {
13129
+ } else {
13083
13130
  const inputLine = this.otherInput.render(Math.max(1, safeWidth - 2))[0] ?? "";
13084
13131
  const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
13085
13132
  lines.push(` ${stripped}`);
@@ -13610,13 +13657,16 @@ function preview(text, lines) {
13610
13657
  const CONTEXT_BAR_WIDTH = 12;
13611
13658
  /**
13612
13659
  * 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.
13660
+ * token, one cell of padding, width sized to the content. With `fillWidth`
13661
+ * the frame keeps the overlay's full width instead of hugging the widest
13662
+ * content row. Keyboard input forwards to the wrapped component.
13615
13663
  */
13616
13664
  var Frame = class {
13617
13665
  child;
13618
- constructor(child) {
13666
+ fillWidth;
13667
+ constructor(child, fillWidth = false) {
13619
13668
  this.child = child;
13669
+ this.fillWidth = fillWidth;
13620
13670
  }
13621
13671
  invalidate() {
13622
13672
  this.child.invalidate?.();
@@ -13630,7 +13680,7 @@ var Frame = class {
13630
13680
  render(width) {
13631
13681
  const inner = Math.max(1, Math.floor(width) - 4);
13632
13682
  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))));
13683
+ const contentWidth = this.fillWidth ? inner : Math.min(inner, Math.max(1, ...lines.map((line) => visibleWidth(line))));
13634
13684
  const frameWidth = contentWidth + 4;
13635
13685
  const b = color.border;
13636
13686
  const out = [b(`╭${"─".repeat(frameWidth - 2)}╮`)];
@@ -13643,6 +13693,36 @@ var Frame = class {
13643
13693
  return out;
13644
13694
  }
13645
13695
  };
13696
+ /**
13697
+ * Frame for the question flow in the EDITOR SEAT: it re-derives the flow's
13698
+ * row budget from the terminal height on EVERY render (60% cap, 8..24
13699
+ * content rows — the flow's render output IS its height in the seat layout,
13700
+ * nothing clips it), so an active resize or a queued flow presented later
13701
+ * always budgets against the current terminal. It also forwards focus to the
13702
+ * flow so its free-text Input keeps the hardware cursor (a plain Frame would
13703
+ * swallow the focus flag).
13704
+ */
13705
+ var QuestionFrame = class extends Frame {
13706
+ flow;
13707
+ heightOf;
13708
+ constructor(flow, heightOf) {
13709
+ super(flow, true);
13710
+ this.flow = flow;
13711
+ this.heightOf = heightOf;
13712
+ }
13713
+ render(width) {
13714
+ const rows = Math.max(1, this.heightOf());
13715
+ const frameRows = Math.max(10, Math.min(26, Math.floor(rows * .6)));
13716
+ this.flow.setMaxRows(frameRows - 2);
13717
+ return super.render(width);
13718
+ }
13719
+ get focused() {
13720
+ return this.flow.focused;
13721
+ }
13722
+ set focused(value) {
13723
+ this.flow.focused = value;
13724
+ }
13725
+ };
13646
13726
  /** The session head card: identity facts, wrapped to the available width so
13647
13727
  * nothing is truncated, framed with a box whose width matches the editor's
13648
13728
  * border below it (a fixed-width rule looked misaligned next to the frame). */
@@ -13712,6 +13792,134 @@ var Spacer = class {
13712
13792
  return [""];
13713
13793
  }
13714
13794
  };
13795
+ /**
13796
+ * Bullet + continuation-indent wrapper that keeps its child LIVE, so a
13797
+ * terminal resize re-renders the child at the new width instead of
13798
+ * re-wrapping a frozen render (the 5a76526 regression: assistant/user
13799
+ * messages were flattened to a static Text at build time, so markdown
13800
+ * tables could never reflow and border lines wrapped as plain text on
13801
+ * narrow windows). The bullet leads the FIRST line; wrapped continuation
13802
+ * lines indent under it (kimi prefix+indent parity).
13803
+ *
13804
+ * The prefixed output keeps a REFERENCE-STABLE cache: when the child
13805
+ * returns the same array instance (its own text+width cache hit) at the
13806
+ * same width, the wrapper returns the same prefixed array — so the fork's
13807
+ * per-frame processed-line reuse (fork AGENTS.md divergence 5) keeps
13808
+ * hitting on steady frames instead of re-normalizing every line.
13809
+ */
13810
+ var BulletedComponent = class {
13811
+ child;
13812
+ prefix;
13813
+ prefixWidth;
13814
+ indent;
13815
+ lastChild;
13816
+ lastWidth = -1;
13817
+ cached;
13818
+ constructor(child, prefix) {
13819
+ this.child = child;
13820
+ this.prefix = prefix;
13821
+ this.prefixWidth = visibleWidth(prefix);
13822
+ this.indent = " ".repeat(this.prefixWidth);
13823
+ }
13824
+ invalidate() {
13825
+ this.child.invalidate?.();
13826
+ }
13827
+ dispose() {
13828
+ this.child.dispose?.();
13829
+ }
13830
+ render(width) {
13831
+ const inner = Math.max(1, width - this.prefixWidth);
13832
+ const child = this.child.render(inner);
13833
+ if (child === this.lastChild && width === this.lastWidth && this.cached !== void 0) return this.cached;
13834
+ this.lastChild = child;
13835
+ this.lastWidth = width;
13836
+ this.cached = child.map((line, index) => (index === 0 ? this.prefix : this.indent) + line);
13837
+ return this.cached;
13838
+ }
13839
+ };
13840
+ /**
13841
+ * Longest prefix of `text` whose WRAPPED height fits `budget` rows at
13842
+ * `width`, with an ellipsis marking a cut — the approval dialog's height
13843
+ * budget must count wrapped rows, not raw lines, because a single long
13844
+ * line can wrap across many display rows. Wrapped height is monotonic in
13845
+ * the prefix length, so a binary search bounds the wrap calls. The
13846
+ * ellipsis reserves its own row when truncating (a full last row would
13847
+ * otherwise push it onto a new row and overflow the budget).
13848
+ * @param text - the candidate text ('' yields '').
13849
+ * @param width - the wrap width.
13850
+ * @param budget - the row budget; 0 or negative yields '…' for non-empty.
13851
+ * @returns the fitted text and whether it was truncated.
13852
+ */
13853
+ function capWrappedToHeight(text, width, budget) {
13854
+ if (text === "") return {
13855
+ text: "",
13856
+ truncated: false
13857
+ };
13858
+ if (budget <= 0) return {
13859
+ text: "",
13860
+ truncated: true
13861
+ };
13862
+ const fits = (candidate, rows) => wrapTextWithAnsi(candidate, width).length <= rows;
13863
+ if (fits(text, budget)) return {
13864
+ text,
13865
+ truncated: false
13866
+ };
13867
+ if (budget === 1) return {
13868
+ text: truncateToWidth(text, width, "…"),
13869
+ truncated: true
13870
+ };
13871
+ const target = budget - 1;
13872
+ let low = 0;
13873
+ let high = text.length;
13874
+ while (low < high) {
13875
+ const mid = Math.ceil((low + high) / 2);
13876
+ if (fits(text.slice(0, mid), target)) low = mid;
13877
+ else high = mid - 1;
13878
+ }
13879
+ return {
13880
+ text: `${text.slice(0, low)}…`,
13881
+ truncated: true
13882
+ };
13883
+ }
13884
+ /**
13885
+ * Fit `text` into `budget` wrapped rows for the approval dialog, ending with
13886
+ * a dimmed `... N more` marker row when the content is cut. The marker rides
13887
+ * INSIDE the budget (content rows cap at budget−1, the marker itself is
13888
+ * width-cropped so it can never wrap), so a section can never silently
13889
+ * overflow the dialog's maxHeight — same marker semantics as the
13890
+ * question flow's `appendWrappedBudgeted`. A single-row budget keeps
13891
+ * the old width-cropped ellipsis (a bare marker row would waste the row).
13892
+ * @returns the display text (rows joined with '\n') and the hidden row count.
13893
+ */
13894
+ function capWrappedToMarker(text, width, budget) {
13895
+ if (text === "" || budget <= 0) return {
13896
+ text: "",
13897
+ hidden: 0
13898
+ };
13899
+ const total = wrapTextWithAnsi(text, width).length;
13900
+ if (total <= budget) return {
13901
+ text,
13902
+ hidden: 0
13903
+ };
13904
+ if (budget === 1) return {
13905
+ text: truncateToWidth(text, width, "…"),
13906
+ hidden: total - 1
13907
+ };
13908
+ const target = budget - 1;
13909
+ let low = 0;
13910
+ let high = text.length;
13911
+ while (low < high) {
13912
+ const mid = Math.ceil((low + high) / 2);
13913
+ if (wrapTextWithAnsi(text.slice(0, mid), width).length <= target) low = mid;
13914
+ else high = mid - 1;
13915
+ }
13916
+ const hidden = total - target;
13917
+ const marker = truncateToWidth(color.textDim(`... ${hidden} more line${hidden > 1 ? "s" : ""}`), width, "…");
13918
+ return {
13919
+ text: `${text.slice(0, low)}\n${marker}`,
13920
+ hidden
13921
+ };
13922
+ }
13715
13923
  /** The live job-output viewer body: a title line + refreshable text panel. */
13716
13924
  var OutputViewerPanel = class {
13717
13925
  title;
@@ -13751,6 +13959,13 @@ var TuiApp = class TuiApp {
13751
13959
  terminal;
13752
13960
  tui;
13753
13961
  editor;
13962
+ /**
13963
+ * The editor's SEAT (kimi's editorContainer): holds the editor normally,
13964
+ * the QuestionFrame while a question flow is active — so the dialog
13965
+ * renders in the editor's row position (full width, above the footer)
13966
+ * instead of a centered modal that covered the transcript.
13967
+ */
13968
+ editorSeat;
13754
13969
  header;
13755
13970
  messagesView;
13756
13971
  footer;
@@ -13789,6 +14004,8 @@ var TuiApp = class TuiApp {
13789
14004
  dock;
13790
14005
  /** Active background tasks for the dock/footer lines (label + status). */
13791
14006
  dockTasks = [];
14007
+ /** Live child subagents (continuable or running one-shot) for the dock/footer lines (never jobs records). */
14008
+ dockAgents = [];
13792
14009
  /**
13793
14010
  * The queued-input pane below the todo panel (kimi QueuePane parity):
13794
14011
  * a border rule plus one `❯ text` row per pending message and a dim hint.
@@ -13810,6 +14027,19 @@ var TuiApp = class TuiApp {
13810
14027
  }
13811
14028
  /** Fullscreen (alt-screen) instance; absent in regular mode. */
13812
14029
  fullscreen;
14030
+ /** One shared in-flight autodetect; concurrent callers coalesce onto it
14031
+ * (overlapping OSC 11 queries would mis-pair replies by FIFO order). */
14032
+ autoDetectInFlight;
14033
+ /** The LATEST shouldApply guard of the in-flight autodetect; consulted at
14034
+ * settle time so a late result can never override a newer explicit
14035
+ * choice; reset when the flight settles. */
14036
+ autoDetectGuard;
14037
+ /** Whether the terminal's live scheme reports are being tracked. */
14038
+ followingTerminalTheme = false;
14039
+ /** Terminal-scheme listeners, fanned out from every screen's reports. */
14040
+ terminalSchemeListeners = /* @__PURE__ */ new Set();
14041
+ /** Per-screen scheme-report registrations, rebuilt on screen switches. */
14042
+ schemeDisposers = [];
13813
14043
  /** The mounted transcript-search overlay, while one is open. */
13814
14044
  searchOverlay;
13815
14045
  /** The search input component, while one is open (for match counts). */
@@ -13874,6 +14104,13 @@ var TuiApp = class TuiApp {
13874
14104
  externalEditorInFlight = false;
13875
14105
  /** The live session's auto-generated title, shown in the header when set. */
13876
14106
  sessionTitleText = "";
14107
+ /** The read-only subagent viewer: while set, the editor bar shows a
14108
+ * placeholder, the editor border switches to the accent color, and the
14109
+ * header carries a persistent badge — the transient notify line is not
14110
+ * the only signal. */
14111
+ viewerMode;
14112
+ /** The real draft preserved while the viewer covers the editor bar. */
14113
+ draftBeforeViewer;
13877
14114
  constructor(terminal, events, options = {}) {
13878
14115
  if (events.openExternalEditor !== void 0 && events.runOwned === void 0) throw new Error("openExternalEditor requires runOwned (the owned-task entry — AGENTS.md)");
13879
14116
  this.terminal = terminal;
@@ -13899,13 +14136,15 @@ var TuiApp = class TuiApp {
13899
14136
  this.queuePane = new Text("", 0, 0);
13900
14137
  this.working = new WorkingIndicator(() => this.requestRender(), options.workingIntervalMs === void 0 ? {} : { intervalMs: options.workingIntervalMs });
13901
14138
  this.footer = new Text("", 0, 0);
14139
+ this.editorSeat = new Container();
14140
+ this.editorSeat.addChild(this.editor);
13902
14141
  this.tui.addChild(this.header);
13903
14142
  this.tui.addChild(this.messagesView);
13904
14143
  this.tui.addChild(this.dock);
13905
14144
  this.tui.addChild(this.todoPanel);
13906
14145
  this.tui.addChild(this.queuePane);
13907
14146
  this.tui.addChild(this.working);
13908
- this.tui.addChild(this.editor);
14147
+ this.tui.addChild(this.editorSeat);
13909
14148
  this.tui.addChild(this.footer);
13910
14149
  this.tui.setFocus(this.editor);
13911
14150
  this.tui.addInputListener((data) => this.routeInput(data));
@@ -13941,6 +14180,8 @@ var TuiApp = class TuiApp {
13941
14180
  this.clearNotify();
13942
14181
  this.working.dispose();
13943
14182
  this.cancelQuestionFlows();
14183
+ for (const dispose of this.schemeDisposers) dispose();
14184
+ this.schemeDisposers = [];
13944
14185
  this.tui.stop();
13945
14186
  this.fullscreen?.stop();
13946
14187
  this.fullscreen = void 0;
@@ -13950,6 +14191,7 @@ var TuiApp = class TuiApp {
13950
14191
  if (isKeyRelease(data) || isKeyRepeat(data)) return void 0;
13951
14192
  if (this.activeQuestions !== void 0) return this.handleQuestionKey(data);
13952
14193
  if (this.activeApproval !== void 0) return this.handleApprovalKey(data);
14194
+ if (this.viewerMode !== void 0 && !this.overlayHost.hasOverlayEntries && !matchesKey(data, "escape") && !matchesKey(data, "ctrl+o")) return { consume: true };
13953
14195
  if (this.searchOverlay !== void 0) {
13954
14196
  if (matchesKey(data, "escape")) {
13955
14197
  this.closeTranscriptSearch();
@@ -13983,6 +14225,17 @@ var TuiApp = class TuiApp {
13983
14225
  this.events.onDequeue?.();
13984
14226
  return { consume: true };
13985
14227
  }
14228
+ if (matchesKey(data, "ctrl+enter")) {
14229
+ if (this.overlayHost.hasOverlayEntries) return void 0;
14230
+ if (this.events.onQueueSubmit === void 0) return void 0;
14231
+ const text = this.editor.getText();
14232
+ if (text.trim() === "") return void 0;
14233
+ this.rememberInput(text);
14234
+ this.clearNotify();
14235
+ this.editor.setText("");
14236
+ this.events.onQueueSubmit(text);
14237
+ return { consume: true };
14238
+ }
13986
14239
  if (matchesKey(data, "escape")) {
13987
14240
  if (this.overlayHost.hasOverlayEntries) return void 0;
13988
14241
  if (this.events.onSingleEscape?.() === true) return { consume: true };
@@ -14047,6 +14300,14 @@ var TuiApp = class TuiApp {
14047
14300
  * compositor interleaves stacked boxes line by line (two different-width
14048
14301
  * dialogs would garble into one frame), and modal hiding is done here so
14049
14302
  * the vendored fork stays pristine.
14303
+ *
14304
+ * While a question flow owns the seat (a logical capturing modal), a NEW
14305
+ * overlay joins the question's suspension set instead of appearing on top:
14306
+ * it is hidden, the question's directly suspended handles become ITS
14307
+ * dependents (kept hidden, restored only when it closes), and the overlay
14308
+ * itself becomes the question's frontmost suspended handle — so reverse
14309
+ * modal order survives the question. Question input keeps first priority
14310
+ * either way (routeInput).
14050
14311
  * @param component - the overlay content.
14051
14312
  * @param options - overlay sizing/positioning.
14052
14313
  * @returns the handle; hide() also forgets the handle.
@@ -14054,6 +14315,27 @@ var TuiApp = class TuiApp {
14054
14315
  showOverlayOnHost(component, options) {
14055
14316
  const handle = this.overlayHost.showOverlay(component, options);
14056
14317
  this.overlayHandles.add(handle);
14318
+ const question = this.activeQuestions;
14319
+ if (question !== void 0) {
14320
+ for (const other of this.overlayHandles) if (other !== handle && !other.isHidden()) {
14321
+ other.setHidden(true);
14322
+ question.suspendedOverlays.add(other);
14323
+ }
14324
+ if (options?.nonCapturing !== true) {
14325
+ const dependents = /* @__PURE__ */ new Set();
14326
+ for (const other of question.suspendedOverlays) dependents.add(other);
14327
+ if (dependents.size > 0) {
14328
+ for (const other of dependents) question.suspendedOverlays.delete(other);
14329
+ this.overlayDependents.set(handle, dependents);
14330
+ }
14331
+ }
14332
+ handle.setHidden(true);
14333
+ question.suspendedOverlays.add(handle);
14334
+ return {
14335
+ ...handle,
14336
+ hide: () => this.closeOverlayHandle(handle)
14337
+ };
14338
+ }
14057
14339
  if (options?.nonCapturing !== true) {
14058
14340
  const hidden = /* @__PURE__ */ new Set();
14059
14341
  for (const other of this.overlayHandles) if (other !== handle && !other.isHidden()) {
@@ -14064,15 +14346,33 @@ var TuiApp = class TuiApp {
14064
14346
  }
14065
14347
  return {
14066
14348
  ...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
- }
14349
+ hide: () => this.closeOverlayHandle(handle)
14073
14350
  };
14074
14351
  }
14075
14352
  /**
14353
+ * Question-aware close for one tracked overlay handle (the wrapper's
14354
+ * hide). Without an active question this matches the historical behavior:
14355
+ * the handle's dependents are unhidden, the graph is cleaned, and the
14356
+ * overlay is removed. While a question owns the seat, the handle leaves
14357
+ * the question's suspension set, every dependency set drops it (no parent
14358
+ * retains a dead child), and its still-mounted dependents remain hidden
14359
+ * and become DIRECTLY owned by the question — they must not flash back
14360
+ * while the question is still up.
14361
+ */
14362
+ closeOverlayHandle(handle) {
14363
+ const question = this.activeQuestions;
14364
+ if (question !== void 0) question.suspendedOverlays.delete(handle);
14365
+ for (const dependents of this.overlayDependents.values()) dependents.delete(handle);
14366
+ const owned = this.overlayDependents.get(handle);
14367
+ if (owned !== void 0) {
14368
+ this.overlayDependents.delete(handle);
14369
+ if (question !== void 0) for (const dependent of owned) question.suspendedOverlays.add(dependent);
14370
+ else for (const dependent of owned) dependent.setHidden(false);
14371
+ }
14372
+ this.overlayHandles.delete(handle);
14373
+ handle.hide();
14374
+ }
14375
+ /**
14076
14376
  * Launch the external editor with the current draft. The TUI stops first
14077
14377
  * (raw mode released) and restarts after the editor returns; a fullscreen
14078
14378
  * mode is not restored (the editor session ends in regular mode).
@@ -14189,6 +14489,20 @@ var TuiApp = class TuiApp {
14189
14489
  this.rebuildMessages();
14190
14490
  }
14191
14491
  /**
14492
+ * Drop SETTLED local cards (completed `!`/`!!` shell runs); running cards
14493
+ * survive so a live stream is never dismissed by a concurrent submit.
14494
+ * Local cards are a live view, not a record: context runs clear on submit
14495
+ * success (the transcript's user row takes over), local-only runs on the
14496
+ * next user submission (dispatchUserInput / steerNow).
14497
+ */
14498
+ clearSettledLocalMessages() {
14499
+ const running = this.localMessages.filter((message) => message.kind === "tool" && message.status === "running");
14500
+ if (running.length === this.localMessages.length) return;
14501
+ this.localMessages.length = 0;
14502
+ this.localMessages.push(...running);
14503
+ this.rebuildMessages();
14504
+ }
14505
+ /**
14192
14506
  * Toggle between regular (terminal scrollback) and fullscreen (alt screen).
14193
14507
  * Overlays live on the active screen, so the switch hides every mounted
14194
14508
  * overlay; a pending approval prompt is re-rendered on the new screen.
@@ -14211,6 +14525,8 @@ var TuiApp = class TuiApp {
14211
14525
  pending?.handle?.hide();
14212
14526
  for (const handle of this.overlayHandles) handle.hide();
14213
14527
  this.overlayHandles.clear();
14528
+ this.overlayDependents.clear();
14529
+ if (this.activeQuestions !== void 0) this.activeQuestions.suspendedOverlays.clear();
14214
14530
  if (enabled) {
14215
14531
  const alt = new TuiAltScreen(this.terminal, void 0, void 0, { onCellClick: (x, y) => this.handleFullscreenClick(x, y) });
14216
14532
  this.fullscreenScroll = new ScrollView(this.messagesView, {
@@ -14244,7 +14560,7 @@ var TuiApp = class TuiApp {
14244
14560
  shrink: 0
14245
14561
  },
14246
14562
  {
14247
- component: this.editor,
14563
+ component: this.editorSeat,
14248
14564
  shrink: 0
14249
14565
  },
14250
14566
  {
@@ -14258,6 +14574,7 @@ var TuiApp = class TuiApp {
14258
14574
  alt.start();
14259
14575
  alt.setFocus(this.editor);
14260
14576
  this.fullscreen = alt;
14577
+ this.refreshSchemeRegistrations();
14261
14578
  } else {
14262
14579
  this.fullscreen?.stop();
14263
14580
  this.fullscreen = void 0;
@@ -14265,9 +14582,12 @@ var TuiApp = class TuiApp {
14265
14582
  this.tui.start();
14266
14583
  this.tui.requestRender(true);
14267
14584
  this.tui.setFocus(this.editor);
14585
+ this.refreshSchemeRegistrations();
14268
14586
  }
14269
14587
  this.events.onFullscreenChange?.(enabled);
14270
14588
  if (pending !== void 0) this.renderApprovalDialog(pending);
14589
+ const question = this.activeQuestions;
14590
+ if (question?.frame !== void 0) (this.fullscreen ?? this.tui).setFocus(question.frame);
14271
14591
  }
14272
14592
  /**
14273
14593
  * Open the transcript-search overlay (Ctrl+Shift+F) and focus its input.
@@ -14467,11 +14787,46 @@ var TuiApp = class TuiApp {
14467
14787
  /**
14468
14788
  * Replace the editor draft. The runner restores a submission that the
14469
14789
  * divergence guard blocked, so the user's text survives for a retry.
14790
+ * While the subagent viewer covers the editor, the write goes to the
14791
+ * preserved draft (the placeholder bar stays up; the draft is restored
14792
+ * on exit).
14470
14793
  */
14471
14794
  setEditorText(text) {
14795
+ if (this.viewerMode !== void 0) {
14796
+ this.draftBeforeViewer = text;
14797
+ return;
14798
+ }
14472
14799
  this.editor.setText(text);
14473
14800
  }
14474
14801
  /**
14802
+ * Enter or leave the read-only subagent viewer. While viewing, the
14803
+ * editor bar is covered by a placeholder (the real draft is preserved
14804
+ * and restored on exit), the editor border switches to the accent color,
14805
+ * and the header shows a persistent `[viewing subagent]` badge — so the
14806
+ * mode reads at a glance instead of relying on the transient notify.
14807
+ * @param mode - the child identity + label; `undefined` leaves the viewer.
14808
+ */
14809
+ setViewerMode(mode) {
14810
+ if (mode === void 0) {
14811
+ if (this.viewerMode === void 0) return;
14812
+ this.viewerMode = void 0;
14813
+ this.editor.borderColor = this.editorBorder;
14814
+ this.editor.setText(this.draftBeforeViewer ?? "");
14815
+ this.draftBeforeViewer = void 0;
14816
+ this.editor.invalidate();
14817
+ this.renderHeader();
14818
+ this.requestRender();
14819
+ return;
14820
+ }
14821
+ if (this.viewerMode === void 0) this.draftBeforeViewer = this.editor.getText();
14822
+ this.viewerMode = mode;
14823
+ this.editor.borderColor = color.accent;
14824
+ this.editor.setText(`viewing subagent: ${mode.label} — read-only · Esc returns`);
14825
+ this.editor.invalidate();
14826
+ this.renderHeader();
14827
+ this.requestRender();
14828
+ }
14829
+ /**
14475
14830
  * Set the session head rendered above the transcript: the session identity,
14476
14831
  * model, version, preset, and cwd, wrapped to the terminal width with a
14477
14832
  * full-width rule beneath. Replaces any previous head.
@@ -14568,16 +14923,8 @@ var TuiApp = class TuiApp {
14568
14923
  }
14569
14924
  /** Render one transcript message as a pi-tui component. */
14570
14925
  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
- }
14926
+ if (message.kind === "user") return new BulletedComponent(new Text(message.text, 0, 0), `${color.roleUser("❯")} `);
14927
+ if (message.kind === "assistant") return new BulletedComponent(new Markdown(message.text, 0, 0, markdownTheme), `${color.primary("🐋")} `);
14581
14928
  if (message.kind === "thinking") {
14582
14929
  if (message.turn >= boundary || this.expandedOverride.get(message) === true) return new Text(color.textDimItalic(`🐳 ${message.text}`), 0, 0);
14583
14930
  const lines = message.text.split("\n").filter((line) => line !== "");
@@ -14649,6 +14996,34 @@ var TuiApp = class TuiApp {
14649
14996
  return card;
14650
14997
  }
14651
14998
  /**
14999
+ * The expanded card's terminal-command row: `$ cmd` (bash) or `PS> cmd`
15000
+ * (pwsh) in the shell prompt token, dimmed (kimi ShellExecution parity —
15001
+ * the folded card shows the command; the expanded body must not drop
15002
+ * it). Multi-line commands render every line under the prompt. No-op for
15003
+ * an empty command.
15004
+ * @param card - the card container to fill.
15005
+ * @param command - the command text.
15006
+ * @param prompt - the shell prompt token (`$ ` or `PS> `).
15007
+ */
15008
+ addTerminalCommandRow(card, command, prompt) {
15009
+ if (command === "") return;
15010
+ const styledPrompt = color.shellMode(prompt);
15011
+ const lines = command.split("\n");
15012
+ for (const [index, line] of lines.entries()) {
15013
+ const prefix = index === 0 ? styledPrompt : " ".repeat(visibleWidth(styledPrompt));
15014
+ card.addChild(new Text(`${prefix}${color.textDim(line)}`, 0, 0));
15015
+ }
15016
+ }
15017
+ /** The bash/pwsh command from the raw args ('' when not a terminal call). */
15018
+ terminalCommand(name, argsRaw) {
15019
+ const call = parseCallPreview(name, argsRaw);
15020
+ return call?.kind === "bash" ? call.command : "";
15021
+ }
15022
+ /** The shell prompt token for a terminal tool (pwsh renders `PS> `). */
15023
+ shellPrompt(name) {
15024
+ return name === "pwsh" ? "PS> " : "$ ";
15025
+ }
15026
+ /**
14652
15027
  * Render one expanded tool card's body. When the runner wired a presenter,
14653
15028
  * the body follows the tool's own render intent (presentResult): a read
14654
15029
  * card shows numbered lines plus the relativized path and total line
@@ -14689,12 +15064,18 @@ var TuiApp = class TuiApp {
14689
15064
  this.renderDiffBody(card, callView.diffs, explicitlyExpanded);
14690
15065
  return;
14691
15066
  }
15067
+ if (callView.card === "terminal") {
15068
+ this.addTerminalCommandRow(card, callView.title, this.shellPrompt(message.name));
15069
+ return;
15070
+ }
14692
15071
  if (callView.card === "generic" && callView.rawInput !== void 0) {
15072
+ this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
14693
15073
  const raw = typeof callView.rawInput === "string" ? callView.rawInput : JSON.stringify(callView.rawInput, null, 2);
14694
15074
  card.addChild(new Text(color.textDim(raw), 0, 0));
14695
15075
  return;
14696
15076
  }
14697
15077
  }
15078
+ this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
14698
15079
  return;
14699
15080
  }
14700
15081
  if (message.result === "" && (message.resultBlocks?.length ?? 0) === 0) return;
@@ -14722,6 +15103,7 @@ var TuiApp = class TuiApp {
14722
15103
  }
14723
15104
  return;
14724
15105
  case "terminal": {
15106
+ this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
14725
15107
  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
15108
  const exit = resultView.exitCode !== void 0 ? `[exit ${resultView.exitCode}]` : resultView.signal !== void 0 ? `[signal ${resultView.signal}]` : "";
14727
15109
  if (exit !== "") card.addChild(new Text(color.textDim(exit), 0, 0));
@@ -14755,7 +15137,10 @@ var TuiApp = class TuiApp {
14755
15137
  }
14756
15138
  }
14757
15139
  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));
15140
+ else {
15141
+ this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
15142
+ card.addChild(new Text(color.textDim(message.result), 0, 0));
15143
+ }
14759
15144
  }
14760
15145
  /**
14761
15146
  * Fill a tool card with a diff body: LCS-aligned clustered lines, capped
@@ -14846,8 +15231,9 @@ var TuiApp = class TuiApp {
14846
15231
  * switch only has to re-run this. */
14847
15232
  renderHeader() {
14848
15233
  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}`);
15234
+ const viewerBadge = this.viewerMode === void 0 ? "" : ` ${color.accent("[viewing subagent]")}`;
15235
+ const title = this.viewerMode !== void 0 ? ` · ${color.textMuted(this.viewerMode.label)}` : this.sessionTitleText === "" ? "" : ` · ${color.textMuted(this.sessionTitleText)}`;
15236
+ this.header.setText(`🐋 dsh-pi-tui${title}${this.todoText}${badge}${viewerBadge}`);
14851
15237
  this.requestRender();
14852
15238
  }
14853
15239
  /**
@@ -14871,7 +15257,20 @@ var TuiApp = class TuiApp {
14871
15257
  */
14872
15258
  setTasks(tasks) {
14873
15259
  this.dockTasks = tasks;
14874
- this.tasksActive = tasks.length > 0;
15260
+ this.tasksActive = tasks.length > 0 || this.dockAgents.length > 0;
15261
+ this.renderDock();
15262
+ this.renderFooter();
15263
+ }
15264
+ /**
15265
+ * Replace the live child-subagent list for the dock lines and the
15266
+ * footer badge. Continuable children and foreground one-shot children
15267
+ * never register jobs records (AGENTS.md), so they arm the ↓/Ctrl+J
15268
+ * trigger through this channel.
15269
+ * @param agents - live children (id + label + activity), empty to hide.
15270
+ */
15271
+ setAgents(agents) {
15272
+ this.dockAgents = agents;
15273
+ this.tasksActive = this.dockTasks.length > 0 || agents.length > 0;
14875
15274
  this.renderDock();
14876
15275
  this.renderFooter();
14877
15276
  }
@@ -14911,15 +15310,35 @@ var TuiApp = class TuiApp {
14911
15310
  }
14912
15311
  /**
14913
15312
  * Replace the editor draft wholesale (the Alt+↑ dequeue path pulls every
14914
- * queued message back into the editor for editing).
15313
+ * queued message back into the editor for editing). While the subagent
15314
+ * viewer covers the editor, the write goes to the preserved draft.
14915
15315
  */
14916
15316
  setDraft(text) {
15317
+ if (this.viewerMode !== void 0) {
15318
+ this.draftBeforeViewer = text;
15319
+ return;
15320
+ }
14917
15321
  this.editor.setText(text);
14918
15322
  this.requestRender();
14919
15323
  }
14920
- /** The editor's current draft text (the Alt+↑ dequeue merge reads it). */
15324
+ /** The editor's current draft text (the Alt+↑ dequeue merge reads it);
15325
+ * while the viewer covers the editor, the preserved draft is returned. */
14921
15326
  getDraft() {
14922
- return this.editor.getText();
15327
+ return this.viewerMode !== void 0 && this.draftBeforeViewer !== void 0 ? this.draftBeforeViewer : this.editor.getText();
15328
+ }
15329
+ /**
15330
+ * Headless-test hook: current overlay tracking-graph sizes. The graph
15331
+ * (overlayHandles / overlayDependents / the active question's suspension)
15332
+ * is behaviorally invisible — stale entries only leak memory — so the
15333
+ * headless suite asserts its sizes directly (e.g. the fullscreen teardown
15334
+ * must leave it empty instead of retaining dead handles).
15335
+ */
15336
+ overlayGraphState() {
15337
+ return {
15338
+ handles: this.overlayHandles.size,
15339
+ dependents: this.overlayDependents.size,
15340
+ suspended: this.activeQuestions?.suspendedOverlays.size ?? 0
15341
+ };
14923
15342
  }
14924
15343
  /**
14925
15344
  * Rebuild the persistent dock strip above the todo panel: todo summary and
@@ -14952,6 +15371,14 @@ var TuiApp = class TuiApp {
14952
15371
  }
14953
15372
  if (this.dockTasks.length > 3) lines.push(color.textDim(` … ${this.dockTasks.length - 3} more`));
14954
15373
  }
15374
+ if (this.dockAgents.length > 0) {
15375
+ const shown = this.dockAgents.slice(0, 3);
15376
+ for (const agent of shown) {
15377
+ const label = agent.label.length > 40 ? `${agent.label.slice(0, 40)}…` : agent.label;
15378
+ lines.push(color.textDim(`🤖 ${agent.id} · ${label}`));
15379
+ }
15380
+ if (this.dockAgents.length > 3) lines.push(color.textDim(` … ${this.dockAgents.length - 3} more`));
15381
+ }
14955
15382
  this.dock.setText(lines.join("\n"));
14956
15383
  this.requestRender();
14957
15384
  }
@@ -14969,12 +15396,17 @@ var TuiApp = class TuiApp {
14969
15396
  /** Rebuild the two footer lines from the current status and plan badge. */
14970
15397
  renderFooter() {
14971
15398
  const context = this.status.contextTokens !== void 0 && this.status.contextWindow !== void 0 && this.status.contextWindow > 0 ? contextBar(this.status.contextTokens, this.status.contextWindow) : "";
15399
+ 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]") : "";
15400
+ const badgeParts = [];
15401
+ if (this.dockTasks.length > 0) badgeParts.push(`${this.dockTasks.length} task${this.dockTasks.length === 1 ? "" : "s"} running`);
15402
+ if (this.dockAgents.length > 0) badgeParts.push(`${this.dockAgents.length} agent${this.dockAgents.length === 1 ? "" : "s"}`);
15403
+ const taskBadge = badgeParts.length === 0 ? "" : color.primary(`[${badgeParts.join(" · ")}${this.editor.getText().trim() === "" ? " · ↓ view" : ""}]`);
14972
15404
  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]") : "",
15405
+ permissionBadge,
14974
15406
  this.planMode ? color.warning("[plan]") : "",
14975
15407
  this.status.goal === void 0 || this.status.goal === "" ? "" : color.primary(this.status.goal),
14976
15408
  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" : ""}]`) : "",
15409
+ taskBadge,
14978
15410
  this.status.cwd,
14979
15411
  this.status.branch === "" ? "" : this.status.branch,
14980
15412
  context,
@@ -15121,25 +15553,85 @@ var TuiApp = class TuiApp {
15121
15553
  * FORCE_COLOR=0 / CI) stay dark without querying; a terminal that never
15122
15554
  * answers falls back to COLORFGBG (VT100/xterm convention); a terminal
15123
15555
  * with neither leaves the current theme untouched.
15556
+ *
15557
+ * The query targets the screen that OWNS the terminal input handler (the
15558
+ * alt screen in fullscreen mode): a query on the stopped main screen
15559
+ * would have its reply swallowed by the alt screen's OSC 11 consumer and
15560
+ * time out, silently turning `auto` into a no-op.
15561
+ *
15562
+ * Concurrent calls COALESCE onto one shared in-flight query — overlapping
15563
+ * OSC 11 queries have no sequence ids, so replies would mis-pair by FIFO
15564
+ * order. The LATEST {@link AutoDetectOptions.shouldApply} guard is
15565
+ * consulted at settle time, so a late result can never override a newer
15566
+ * explicit theme choice.
15567
+ * @param options.shouldApply - returns false to drop the settled result
15568
+ * instead of applying it (default: always apply).
15124
15569
  */
15125
- async autoDetectTheme() {
15570
+ async autoDetectTheme(options) {
15126
15571
  if (themeOptOut()) return;
15127
- const rgb = await this.tui.queryTerminalBackgroundColor({ timeoutMs: 800 });
15572
+ if (options?.shouldApply !== void 0) this.autoDetectGuard = options.shouldApply;
15573
+ if (this.autoDetectInFlight === void 0) this.autoDetectInFlight = this.runAutoDetect().finally(() => {
15574
+ this.autoDetectInFlight = void 0;
15575
+ this.autoDetectGuard = void 0;
15576
+ });
15577
+ return this.autoDetectInFlight;
15578
+ }
15579
+ async runAutoDetect() {
15580
+ const rgb = await this.overlayHost.queryTerminalBackgroundColor({ timeoutMs: 800 });
15581
+ const guard = this.autoDetectGuard;
15582
+ const apply = () => guard === void 0 || guard();
15128
15583
  if (rgb !== void 0) {
15129
- this.applyTheme(detectThemeFromBackground(rgb));
15584
+ if (apply()) this.applyTheme(detectThemeFromBackground(rgb));
15130
15585
  return;
15131
15586
  }
15132
15587
  const fromEnv = detectThemeFromColorFgBg();
15133
- if (fromEnv !== void 0) this.applyTheme(fromEnv);
15588
+ if (fromEnv !== void 0 && apply()) this.applyTheme(fromEnv);
15589
+ }
15590
+ /**
15591
+ * Follow the terminal's live colour-scheme reports while `following` is
15592
+ * true. Enabling sends one DSR 996 query — xterm-class terminals only
15593
+ * START reporting after being asked — and reported schemes fan out to the
15594
+ * {@link onTerminalThemeChange} listeners (which guard whether to apply,
15595
+ * typically against the persisted `auto` preference). Disabling stops
15596
+ * tracking. Idempotent.
15597
+ * @param following - whether to track and apply live scheme reports.
15598
+ */
15599
+ trackTerminalTheme(following) {
15600
+ if (following === this.followingTerminalTheme) return;
15601
+ this.followingTerminalTheme = following;
15602
+ if (!following) return;
15603
+ this.overlayHost.queryTerminalColorScheme({ timeoutMs: 800 }).then((scheme) => {
15604
+ if (scheme === void 0 || !this.followingTerminalTheme) return;
15605
+ for (const listener of [...this.terminalSchemeListeners]) listener(scheme);
15606
+ }).catch(() => {});
15134
15607
  }
15135
15608
  /**
15136
15609
  * Register a live terminal-theme listener (colour-scheme reports). The
15137
- * runner uses it to follow the terminal when the preference is `auto`.
15610
+ * listener is registered on EVERY screen: reports arrive only at the
15611
+ * screen that owns the terminal input handler (the alt screen in
15612
+ * fullscreen mode), so the registrations never double-fire. The caller
15613
+ * guards whether to apply (typically: only while the persisted preference
15614
+ * is `auto`).
15138
15615
  * @param listener - receives the detected palette family.
15139
15616
  * @returns a disposer.
15140
15617
  */
15141
15618
  onTerminalThemeChange(listener) {
15142
- return this.tui.onTerminalColorSchemeChange((scheme) => listener(scheme));
15619
+ this.terminalSchemeListeners.add(listener);
15620
+ this.refreshSchemeRegistrations();
15621
+ return () => {
15622
+ this.terminalSchemeListeners.delete(listener);
15623
+ this.refreshSchemeRegistrations();
15624
+ };
15625
+ }
15626
+ /** (Re)register the scheme-report fan-out on every screen. */
15627
+ refreshSchemeRegistrations() {
15628
+ for (const dispose of this.schemeDisposers) dispose();
15629
+ this.schemeDisposers = [];
15630
+ const screens = [this.tui];
15631
+ if (this.fullscreen !== void 0) screens.push(this.fullscreen);
15632
+ for (const screen of screens) this.schemeDisposers.push(screen.onTerminalColorSchemeChange((scheme) => {
15633
+ for (const listener of [...this.terminalSchemeListeners]) listener(scheme);
15634
+ }));
15143
15635
  }
15144
15636
  /**
15145
15637
  * Queue an approval prompt and resolve when the user decides. Requests
@@ -15181,19 +15673,31 @@ var TuiApp = class TuiApp {
15181
15673
  }
15182
15674
  /** Build and mount the approval dialog for one prompt on the active screen. */
15183
15675
  renderApprovalDialog(pending) {
15676
+ const width = this.terminal.columns;
15677
+ const maxHeight = Math.max(8, Math.min(16, this.terminal.rows - 2));
15678
+ const contentWidth = Math.max(1, width - 8);
15679
+ const titleShown = truncateToWidth(`Approve ${pending.request.toolName}?`, contentWidth, "…");
15680
+ const dangerShown = pending.request.danger === true ? truncateToWidth("⚠ DANGEROUS COMMAND — confirm carefully", contentWidth, "…") : "";
15681
+ 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;
15682
+ const hintWrapped = hintShown === "" ? 0 : wrapTextWithAnsi(hintShown, contentWidth).length;
15683
+ const chrome = 1 + (dangerShown === "" ? 0 : 1) + 1 + hintWrapped + 2 + 2;
15684
+ const reasonBudget = Math.max(0, maxHeight - chrome);
15685
+ const reasonShown = capWrappedToMarker(pending.request.reason ?? "", contentWidth, reasonBudget).text;
15686
+ const reasonWrapped = reasonShown === "" ? 0 : wrapTextWithAnsi(reasonShown, contentWidth).length;
15687
+ const previewBudget = Math.max(0, maxHeight - chrome - reasonWrapped);
15184
15688
  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"));
15689
+ dialog.addChild(new Text(titleShown, 1, 0));
15690
+ if (dangerShown !== "") dialog.addChild(new Text(color.error(dangerShown), 1, 0));
15691
+ if (pending.request.arguments !== void 0 && pending.request.arguments !== "" && previewBudget > 0) {
15692
+ const previewShown = capWrappedToMarker(pending.request.arguments.split("\n").slice(0, 6).join("\n"), contentWidth, previewBudget).text;
15693
+ if (previewShown !== "") dialog.addChild(new Text(color.textDim(previewShown), 1, 0));
15694
+ }
15695
+ if (reasonShown !== "") dialog.addChild(new Text(reasonShown, 1, 0));
15696
+ dialog.addChild(new Text(" ", 1, 0));
15697
+ dialog.addChild(new Text(hintShown, 1, 0));
15194
15698
  pending.handle = this.showOverlayOnHost(new Frame(dialog), {
15195
- width: 60,
15196
- maxHeight: 16
15699
+ width,
15700
+ maxHeight
15197
15701
  });
15198
15702
  }
15199
15703
  /** Route a key while a prompt is showing; every key is consumed. */
@@ -15252,6 +15756,7 @@ var TuiApp = class TuiApp {
15252
15756
  ...question.multiSelect !== void 0 ? { multiSelect: question.multiSelect } : {},
15253
15757
  ...question.intent !== void 0 ? { intent: question.intent } : {}
15254
15758
  })), (answers) => this.settleQuestions(state, answers), () => this.settleQuestions(state, void 0)),
15759
+ suspendedOverlays: /* @__PURE__ */ new Set(),
15255
15760
  resolve,
15256
15761
  reject,
15257
15762
  signal
@@ -15269,13 +15774,20 @@ var TuiApp = class TuiApp {
15269
15774
  else this.questionQueue.push(state);
15270
15775
  });
15271
15776
  }
15272
- /** Mount one flow's overlay and make it the active one. */
15777
+ /** Mount one flow into the editor seat and make it the active one. */
15273
15778
  presentQuestion(state) {
15274
15779
  this.activeQuestions = state;
15275
- state.handle = this.showOverlayOnHost(new Frame(state.flow), {
15276
- width: 76,
15277
- maxHeight: 26
15278
- });
15780
+ for (const handle of this.overlayHandles) if (!handle.isHidden()) {
15781
+ handle.setHidden(true);
15782
+ state.suspendedOverlays.add(handle);
15783
+ }
15784
+ const frame = new QuestionFrame(state.flow, () => this.terminal.rows);
15785
+ state.frame = frame;
15786
+ this.editorSeat.clear();
15787
+ this.editorSeat.addChild(frame);
15788
+ const screen = this.fullscreen ?? this.tui;
15789
+ screen.setFocus(frame);
15790
+ screen.requestRender();
15279
15791
  }
15280
15792
  /** Abort one flow (its signal fired). The ACTIVE flow settles rejected
15281
15793
  * and the queue advances; a QUEUED flow is removed from the queue and
@@ -15293,12 +15805,6 @@ var TuiApp = class TuiApp {
15293
15805
  if (index !== -1) this.questionQueue.splice(index, 1);
15294
15806
  state.reject(cancellationError("question flow cancelled"));
15295
15807
  }
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
15808
  /** Cancel every pending flow: each promise rejects exactly once. */
15303
15809
  cancelQuestionFlows() {
15304
15810
  for (const state of [...this.questionQueue]) this.abortQuestion(state);
@@ -15316,13 +15822,36 @@ var TuiApp = class TuiApp {
15316
15822
  settleQuestions(state, answers) {
15317
15823
  if (this.activeQuestions !== state || state.settled === true) return;
15318
15824
  state.settled = true;
15319
- this.activeQuestions = void 0;
15320
- state.handle?.hide();
15321
15825
  if (state.onAbort !== void 0 && state.signal !== void 0) state.signal.removeEventListener("abort", state.onAbort);
15322
- this.overlayHost.setFocus(this.editor);
15826
+ const next = this.questionQueue.shift();
15827
+ if (next !== void 0) {
15828
+ next.suspendedOverlays = state.suspendedOverlays;
15829
+ state.suspendedOverlays = /* @__PURE__ */ new Set();
15830
+ const frame = new QuestionFrame(next.flow, () => this.terminal.rows);
15831
+ next.frame = frame;
15832
+ this.editorSeat.clear();
15833
+ this.editorSeat.addChild(frame);
15834
+ this.activeQuestions = next;
15835
+ const screen = this.fullscreen ?? this.tui;
15836
+ screen.setFocus(frame);
15837
+ screen.requestRender();
15838
+ this.settle(state, answers);
15839
+ return;
15840
+ }
15841
+ this.editorSeat.clear();
15842
+ this.editorSeat.addChild(this.editor);
15843
+ this.activeQuestions = void 0;
15844
+ const screen = this.fullscreen ?? this.tui;
15845
+ screen.setFocus(this.editor);
15846
+ for (const handle of state.suspendedOverlays) if (this.overlayHandles.has(handle)) handle.setHidden(false);
15847
+ state.suspendedOverlays.clear();
15848
+ screen.requestRender();
15849
+ this.settle(state, answers);
15850
+ }
15851
+ /** Resolve or reject the settled promise (exactly once, by construction). */
15852
+ settle(state, answers) {
15323
15853
  if (answers === void 0) state.reject(cancellationError("question flow cancelled"));
15324
15854
  else state.resolve(answers);
15325
- this.nextQuestion();
15326
15855
  }
15327
15856
  };
15328
15857
  const dim = color.textDim;
@@ -15336,6 +15865,101 @@ function startProcessTui(events, options = {}) {
15336
15865
  return app;
15337
15866
  }
15338
15867
  //#endregion
15868
+ //#region src/tasks-browser.ts
15869
+ /**
15870
+ * Task-browser row model — the merged view over the two background surfaces.
15871
+ *
15872
+ * Source A — the jobs registry (`ctx.jobs`): bash jobs and one-shot
15873
+ * subagent jobs. A job row is STATUS-ONLY:
15874
+ * - a bash job's output has a single read cursor owned by the model's
15875
+ * `job_output`; consuming it from the UI would swallow the main
15876
+ * session's result;
15877
+ * - a one-shot subagent job record carries no child session id, so it can
15878
+ * never be matched to its child transcript — label/order/time are never
15879
+ * identity (see subagentJobTranscriptId).
15880
+ *
15881
+ * Source B — the subagent registry (`ctx.subagents.listChildren`):
15882
+ * live child subagents. Continuable children are separate durable
15883
+ * conversations that deliver no result to the parent, so Enter may open
15884
+ * the read-only transcript viewer directly. A RUNNING one-shot child
15885
+ * (the parent's pending foreground tool call, which registers no job
15886
+ * record) is merged the same way, so the ↓ trigger stays armed while a
15887
+ * foreground delegation is in flight. A finished one-shot child is not
15888
+ * merged — its work is over and `/subagents` remains its surface.
15889
+ *
15890
+ * Deliberate overlap: a running BACKGROUND one-shot has BOTH a job record
15891
+ * and a child record with no cross-reference, so it may appear twice — the
15892
+ * job row (status-only) and the child row (transcript viewable). Dedup is
15893
+ * impossible without the missing childSessionId on the job record; the
15894
+ * viewable child row is strictly more useful, so the overlap is accepted.
15895
+ *
15896
+ * Pure and injectable so row typing, ordering, and descriptions are
15897
+ * unit-testable without any dsh service.
15898
+ * @module @xmoon76/dsh-pi-tui/tasks-browser
15899
+ */
15900
+ /** Picker-value prefix for a subagent row. */
15901
+ const AGENT_ROW_PREFIX = "agent:";
15902
+ /** Picker-value prefix for a job row. */
15903
+ const JOB_ROW_PREFIX = "job:";
15904
+ /** Picker group label for continuable subagents. */
15905
+ const SUBAGENT_GROUP = "subagents";
15906
+ /** Picker group label for jobs. */
15907
+ const JOB_GROUP = "jobs";
15908
+ const isActiveJob = (status) => status === "running" || status === "stopping";
15909
+ /**
15910
+ * Ordering shared with kimi's tasks browser: active rows first, terminal
15911
+ * rows last. Jobs keep their registry order within each class (active by
15912
+ * startedAt, terminal newest-finish first); subagents keep the registry's
15913
+ * createdAt order (running before inactive).
15914
+ */
15915
+ function buildTaskRows(jobs, agents) {
15916
+ const sortedAgents = [...agents.filter((agent) => agent.kind === "child" && (agent.mode === "continuable" || agent.mode === "one-shot" && agent.activity === "running")).map((agent) => ({
15917
+ kind: "subagent",
15918
+ value: `${AGENT_ROW_PREFIX}${agent.id}`,
15919
+ childId: agent.id,
15920
+ label: agent.label ?? agent.id,
15921
+ activity: agent.activity ?? "inactive",
15922
+ hasChildren: agent.hasChildren ?? false
15923
+ }))].sort((a, b) => {
15924
+ if (a.activity !== b.activity) return a.activity === "running" ? -1 : 1;
15925
+ return 0;
15926
+ });
15927
+ const sortedJobs = [...jobs].sort((a, b) => {
15928
+ const aTerminal = !isActiveJob(a.status);
15929
+ if (aTerminal !== !isActiveJob(b.status)) return aTerminal ? 1 : -1;
15930
+ if (!aTerminal) return a.startedAt - b.startedAt;
15931
+ return (b.finishedAt ?? b.startedAt) - (a.finishedAt ?? a.startedAt);
15932
+ }).map((job) => ({
15933
+ kind: "job",
15934
+ value: `${JOB_ROW_PREFIX}${job.id}`,
15935
+ jobId: job.id,
15936
+ jobKind: job.kind,
15937
+ label: job.label,
15938
+ status: job.status,
15939
+ detail: job.detail,
15940
+ startedAt: job.startedAt,
15941
+ finishedAt: job.finishedAt
15942
+ }));
15943
+ return [...sortedAgents, ...sortedJobs];
15944
+ }
15945
+ /** The one-line picker label for a row. */
15946
+ function taskRowLabel(row) {
15947
+ if (row.kind === "job") return `${row.jobKind} · ${row.label}`;
15948
+ return `subagent · ${row.label}`;
15949
+ }
15950
+ /** The picker group a row belongs to. */
15951
+ function rowGroup(row) {
15952
+ return row.kind === "job" ? JOB_GROUP : SUBAGENT_GROUP;
15953
+ }
15954
+ /** The picker description line for a row. */
15955
+ function describeTaskRow(row, now) {
15956
+ if (row.kind === "job") {
15957
+ const elapsed = Math.max(0, Math.floor((now - row.startedAt) / 1e3));
15958
+ return `${row.status}${row.detail === void 0 ? "" : ` — ${row.detail}`} · ${elapsed}s`;
15959
+ }
15960
+ return `${row.activity}${row.hasChildren ? " · has children" : ""}`;
15961
+ }
15962
+ //#endregion
15339
15963
  //#region src/model-menu.ts
15340
15964
  /**
15341
15965
  * In-place submenu components for `/model`: the model list and the
@@ -15619,10 +16243,10 @@ async function loadSessionTitles(query, persistence, ids, signal) {
15619
16243
  //#region src/commands.ts
15620
16244
  /**
15621
16245
  * 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
16246
+ * /new /tasks /preset /subagents /search /title /rename /copy /export
16247
+ * /fork /status /login /logout /help), extracted from the runner's
16248
+ * monolithic apply() so the registration surface is testable and the runner
16249
+ * closure shrinks. Every command reads the live runner state through the
15626
16250
  * {@link TuiCommandRunner} interface, whose accessors re-read the current
15627
16251
  * agent/settings on every access (sessions can swap the live agent).
15628
16252
  * @module @xmoon76/dsh-pi-tui/commands
@@ -15647,6 +16271,47 @@ function metaOf(cwd, presetId) {
15647
16271
  };
15648
16272
  }
15649
16273
  /**
16274
+ * Display copy for the four shipped agent presets, fixed in English — the
16275
+ * web surface's `BUILT_IN_PRESET_KEYS` mapping (`dsh-client-ui-agent-preset`),
16276
+ * TUI-side. The EFFECTIVE roster root is the dsh install's own
16277
+ * `config/agent-presets`: the dsh CLI's profile composition replaces this
16278
+ * bundle's shipped root with that one at boot (the `composeProfile`
16279
+ * agent-presets overlay), and its preset.yml language is not ours to
16280
+ * control. Mapping the known ids keeps the picker English regardless of
16281
+ * what the files say; everything else renders file metadata.
16282
+ */
16283
+ const BUILT_IN_PRESET_COPY = {
16284
+ standard: {
16285
+ name: "Standard mode",
16286
+ description: "Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows."
16287
+ },
16288
+ code: {
16289
+ name: "Code mode",
16290
+ 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."
16291
+ },
16292
+ minimal: {
16293
+ name: "Minimal mode",
16294
+ description: "Two-tool coding agent with persistent bash and str_replace_editor."
16295
+ },
16296
+ cordis: {
16297
+ name: "Creator mode",
16298
+ description: "Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
16299
+ }
16300
+ };
16301
+ /** Resolve one roster row's display copy: fixed English for a shipped
16302
+ * (system-trust) preset id, otherwise the preset's file metadata. */
16303
+ function presetDisplayText(preset) {
16304
+ const builtIn = preset.trust === "system" ? BUILT_IN_PRESET_COPY[preset.id] : void 0;
16305
+ if (builtIn !== void 0) return {
16306
+ name: builtIn.name,
16307
+ description: builtIn.description
16308
+ };
16309
+ return {
16310
+ name: preset.name ?? preset.id,
16311
+ ...preset.description === void 0 ? {} : { description: preset.description }
16312
+ };
16313
+ }
16314
+ /**
15650
16315
  * Register the TUI-owned slash commands on the commands service. The
15651
16316
  * completion list is refreshed after every registration so TUI-owned
15652
16317
  * commands appear in the editor's tab list. Registration is sessionless:
@@ -15730,6 +16395,7 @@ function registerTuiCommands(runner) {
15730
16395
  const tuiSettings = runner.tuiSettings;
15731
16396
  const theme = tuiSettings?.get().theme ?? "auto";
15732
16397
  const themeValue = theme.startsWith("custom:") ? theme.slice(7) : theme;
16398
+ let lastThemeChoice = themeValue;
15733
16399
  const settings = ctx.get("settings");
15734
16400
  const permissionNames = [...ctx.get("permissionPresets")?.names ?? []];
15735
16401
  let defaultPermission;
@@ -15784,6 +16450,13 @@ function registerTuiCommands(runner) {
15784
16450
  currentValue: app.getFooterPreset(),
15785
16451
  values: ["full", "compact"]
15786
16452
  },
16453
+ {
16454
+ id: "busy-enter",
16455
+ label: "Enter while busy",
16456
+ description: "Steer injects the draft into the running turn; Ctrl+Enter uses the other behavior",
16457
+ currentValue: tuiSettings?.get().busyEnter ?? "queue",
16458
+ values: ["queue", "steer"]
16459
+ },
15787
16460
  {
15788
16461
  id: "fullscreen",
15789
16462
  label: "Fullscreen",
@@ -15837,12 +16510,19 @@ function registerTuiCommands(runner) {
15837
16510
  }]), { notify: true });
15838
16511
  } else if (id === "theme") {
15839
16512
  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 {
16513
+ lastThemeChoice = value;
16514
+ if (value === "auto") {
16515
+ detach("theme autodetect", () => app.autoDetectTheme({ shouldApply: () => lastThemeChoice === "auto" }));
16516
+ app.trackTerminalTheme(true);
16517
+ } else if (value === "dark" || value === "light") {
16518
+ app.applyTheme(value);
16519
+ app.trackTerminalTheme(false);
16520
+ } else {
15843
16521
  const palette = loadCustomTheme(value);
15844
- if (palette !== void 0) app.applyPalette(palette);
15845
- else {
16522
+ if (palette !== void 0) {
16523
+ app.applyPalette(palette);
16524
+ app.trackTerminalTheme(false);
16525
+ } else {
15846
16526
  app.notify(`theme ${value} not found`, "error");
15847
16527
  return;
15848
16528
  }
@@ -15865,6 +16545,14 @@ function registerTuiCommands(runner) {
15865
16545
  footer: value
15866
16546
  }), { notify: true });
15867
16547
  }
16548
+ } else if (id === "busy-enter") {
16549
+ if (value === "queue" || value === "steer") {
16550
+ const settings = tuiSettings;
16551
+ if (settings !== void 0) detach("settings busy enter write", () => settings.replace({
16552
+ ...settings.get(),
16553
+ busyEnter: value
16554
+ }), { notify: true });
16555
+ }
15868
16556
  } else if (id === "fullscreen") {
15869
16557
  if (value === "off" || value === "on") app.setFullscreen(value === "on");
15870
16558
  }
@@ -16058,11 +16746,17 @@ function registerTuiCommands(runner) {
16058
16746
  const settings = runner.tuiSettings;
16059
16747
  if (settings !== void 0) {
16060
16748
  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));
16749
+ const reloadTheme = doc.theme;
16750
+ if (reloadTheme === "auto") {
16751
+ detach("theme autodetect", () => app.autoDetectTheme({ shouldApply: () => reloadTheme === "auto" }));
16752
+ app.trackTerminalTheme(true);
16753
+ } else if (reloadTheme === "dark" || reloadTheme === "light") {
16754
+ app.applyTheme(reloadTheme);
16755
+ app.trackTerminalTheme(false);
16756
+ } else if (reloadTheme.startsWith("custom:")) {
16757
+ const palette = loadCustomTheme(reloadTheme.slice(7));
16065
16758
  if (palette !== void 0) app.applyPalette(palette);
16759
+ app.trackTerminalTheme(false);
16066
16760
  }
16067
16761
  app.setFooterPreset(doc.footer === "compact" ? "compact" : "full");
16068
16762
  app.setFullscreen(doc.fullscreen === "on");
@@ -16128,7 +16822,7 @@ function registerTuiCommands(runner) {
16128
16822
  description: "Start a fresh session in this workspace",
16129
16823
  handler: async () => {
16130
16824
  const liveAgent = runner.liveAgent;
16131
- const composition = await runner.compose();
16825
+ const composition = await runner.compose(runner.pendingPreset);
16132
16826
  const presetId = composition.agentPreset;
16133
16827
  const next = await runner.agents.create({
16134
16828
  sessionId: SessionId(`session-${randomUUID()}`),
@@ -16293,13 +16987,13 @@ function registerTuiCommands(runner) {
16293
16987
  description: "Show or switch the session agent preset",
16294
16988
  input: { hint: "[status|<id>|default [<id>]]" },
16295
16989
  handler: async (invocation) => {
16296
- const liveAgent = await requireAgent();
16297
16990
  const presets = ctx.get("agentPresets");
16298
16991
  if (presets === void 0) return {
16299
16992
  kind: "error",
16300
16993
  text: "agent presets unavailable in this deployment"
16301
16994
  };
16302
- const current = presets.composedPreset(liveAgent.ctx) ?? resolveSessionPreset(liveAgent.session);
16995
+ const liveAgent = runner.liveAgent;
16996
+ const current = liveAgent === void 0 ? void 0 : presets.composedPreset(liveAgent.ctx) ?? resolveSessionPreset(liveAgent.session);
16303
16997
  const matched = invocation.rawInput.trim().match(/^(\S+)(?:\s+(.*))?$/);
16304
16998
  const verb = matched?.[1] ?? "";
16305
16999
  const rest = matched?.[2]?.trim() ?? "";
@@ -16328,14 +17022,53 @@ function registerTuiCommands(runner) {
16328
17022
  text: `default preset set: ${rest}`
16329
17023
  };
16330
17024
  }
16331
- if (verb !== "") try {
16332
- const outcome = await runner.recomposeBlank(verb);
17025
+ const applyPresetSelection = async (id) => {
17026
+ if (liveAgent === void 0) {
17027
+ const resolved = await presets.resolve(id);
17028
+ runner.pendingPreset = resolved.id;
17029
+ return {
17030
+ kind: "pending",
17031
+ preset: resolved.id
17032
+ };
17033
+ }
17034
+ const outcome = await runner.recomposeBlank(id);
16333
17035
  if (outcome.kind === "locked") return {
16334
- kind: "error",
16335
- text: `session "${liveAgent.session.id}" has already started; its agent preset is fixed`
17036
+ kind: "locked",
17037
+ sessionId: liveAgent.session.id
16336
17038
  };
16337
17039
  refreshCompletions();
16338
17040
  runner.updateWelcomeCard();
17041
+ return {
17042
+ kind: "switched",
17043
+ preset: outcome.preset
17044
+ };
17045
+ };
17046
+ const pickPreset = async (id) => {
17047
+ let outcome;
17048
+ try {
17049
+ outcome = await applyPresetSelection(id);
17050
+ } catch (error) {
17051
+ app.notify(safeErrorMessage(error), "error");
17052
+ return;
17053
+ }
17054
+ if (outcome.kind === "pending") app.notify(`new sessions will start on preset ${outcome.preset}`, "info");
17055
+ else if (outcome.kind === "switched") app.notify(`session preset switched to ${outcome.preset}`, "info");
17056
+ else app.notify(`session "${outcome.sessionId}" has already started; its agent preset is fixed — preset switching is only available in a new session`, "error");
17057
+ };
17058
+ if (verb !== "") try {
17059
+ const outcome = await applyPresetSelection(verb);
17060
+ if (outcome.kind === "pending") return {
17061
+ kind: "success",
17062
+ text: `new sessions will start on preset ${outcome.preset}`
17063
+ };
17064
+ if (outcome.kind === "locked") {
17065
+ const message = `session "${outcome.sessionId}" has already started; its agent preset is fixed — preset switching is only available in a new session`;
17066
+ app.notify(message, "error");
17067
+ return {
17068
+ kind: "error",
17069
+ text: message
17070
+ };
17071
+ }
16339
17072
  return {
16340
17073
  kind: "success",
16341
17074
  text: `session preset switched to ${outcome.preset}`
@@ -16351,17 +17084,33 @@ function registerTuiCommands(runner) {
16351
17084
  kind: "success",
16352
17085
  text: "no agent presets configured"
16353
17086
  };
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
- })), () => {}, () => {});
17087
+ if (liveAgent !== void 0 && liveAgent.session.events.some((event) => event.type === "turn/start")) {
17088
+ 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)`;
17089
+ app.notify(message, "error");
17090
+ return {
17091
+ kind: "error",
17092
+ text: message
17093
+ };
17094
+ }
17095
+ const close = app.openSettings(roster.map((preset) => {
17096
+ const display = presetDisplayText(preset);
17097
+ return {
17098
+ id: preset.id,
17099
+ label: `${display.name} (${preset.id})`,
17100
+ description: [
17101
+ display.description,
17102
+ preset.trust === "system" ? "system" : "user",
17103
+ preset.id === presets.defaultId ? "default" : void 0,
17104
+ preset.id === current ? "← current" : void 0,
17105
+ preset.broken
17106
+ ].filter(Boolean).join(" · "),
17107
+ currentValue: "",
17108
+ values: [preset.id]
17109
+ };
17110
+ }), (id) => {
17111
+ close();
17112
+ detach("preset pick", () => pickPreset(id));
17113
+ }, () => {});
16365
17114
  return { kind: "success" };
16366
17115
  }
16367
17116
  });
@@ -16469,31 +17218,54 @@ function registerTuiCommands(runner) {
16469
17218
  return { kind: "success" };
16470
17219
  }
16471
17220
  });
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);
17221
+ const titleHandler = async (invocation) => {
17222
+ const liveAgent = await requireAgent();
17223
+ const titles = ctx.get("sessionTitle");
17224
+ if (titles === void 0) return {
17225
+ kind: "error",
17226
+ text: "session title service unavailable"
17227
+ };
17228
+ const name = invocation.rawInput.trim();
17229
+ if (name !== "") {
17230
+ try {
17231
+ titles.rename(liveAgent.session, name);
17232
+ } catch (error) {
16486
17233
  return {
16487
- kind: "success",
16488
- text: current === void 0 ? "no title set" : `title: ${current.title}`
17234
+ kind: "error",
17235
+ text: safeErrorMessage(error)
16489
17236
  };
16490
17237
  }
16491
- titles.rename(liveAgent.session, name);
16492
17238
  return {
16493
17239
  kind: "success",
16494
17240
  text: `title set: ${name}`
16495
17241
  };
16496
17242
  }
17243
+ try {
17244
+ const regenerated = await titles.refresh(liveAgent.session, invocation.signal);
17245
+ if (regenerated === void 0) {
17246
+ app.notify("no conversation yet — title left as-is", "info");
17247
+ return { kind: "success" };
17248
+ }
17249
+ app.notify(`title regenerated: ${regenerated.title}`, "info");
17250
+ return { kind: "success" };
17251
+ } catch (error) {
17252
+ return {
17253
+ kind: "error",
17254
+ text: safeErrorMessage(error)
17255
+ };
17256
+ }
17257
+ };
17258
+ commands.register({
17259
+ name: "title",
17260
+ description: "Set the session title; without an argument, regenerate it from the conversation (overwrites the current title)",
17261
+ input: { hint: "<title>" },
17262
+ handler: titleHandler
17263
+ });
17264
+ commands.register({
17265
+ name: "rename",
17266
+ description: "Alias of /title: set the session title, or regenerate it without an argument (overwrites the current title)",
17267
+ input: { hint: "<title>" },
17268
+ handler: titleHandler
16497
17269
  });
16498
17270
  commands.register({
16499
17271
  name: "copy",
@@ -16688,7 +17460,13 @@ function registerTuiCommands(runner) {
16688
17460
  {
16689
17461
  id: "k-enter",
16690
17462
  label: "Enter",
16691
- description: "Submit (slash commands dispatch without a model turn)",
17463
+ description: "Submit (steers the running turn while busy when Enter while busy is Steer; skill commands steer too, UI commands run locally)",
17464
+ currentValue: ""
17465
+ },
17466
+ {
17467
+ id: "k-queue",
17468
+ label: "Ctrl+Enter",
17469
+ description: "Queue the draft while the agent is busy (the opposite of Enter while busy)",
16692
17470
  currentValue: ""
16693
17471
  },
16694
17472
  {
@@ -16754,7 +17532,7 @@ function registerTuiCommands(runner) {
16754
17532
  {
16755
17533
  id: "k-bang",
16756
17534
  label: "! cmd",
16757
- description: "Run a shell command locally; !! sends it to the model",
17535
+ description: "Run a shell command and submit the command and its output to the session; !! runs locally without recording",
16758
17536
  currentValue: ""
16759
17537
  },
16760
17538
  {
@@ -17323,13 +18101,15 @@ function sessionUnchanged(locked, agent, generation) {
17323
18101
  * (a queue splice during the guard survives) and steers them with the
17324
18102
  * draft. Any state change — agent switch, generation bump, queue change —
17325
18103
  * aborts with `stale` and a retry notice; nothing is written and nothing
17326
- * is lost.
18104
+ * is lost. With `onlyDraft` the queue is neither read nor removed: the
18105
+ * draft alone is steered (or followed up when the agent is idle).
17327
18106
  */
17328
- async function steerAll(deps, text) {
18107
+ async function steerAll(deps, text, options = {}) {
18108
+ const onlyDraft = options.onlyDraft === true;
17329
18109
  const agent = deps.currentAgent();
17330
18110
  if (agent === void 0) return "ok";
17331
18111
  const generation = deps.currentGeneration();
17332
- const snapshot = [...agent.inbox.nextTurn, ...agent.inbox.nextStep];
18112
+ const snapshot = onlyDraft ? [] : [...agent.inbox.nextTurn, ...agent.inbox.nextStep];
17333
18113
  const verdict = await deps.guard.run(savePayloadIdentity(snapshot, text));
17334
18114
  if (verdict.kind === "blocked") {
17335
18115
  const verbatim = deps.restoreDraft(text);
@@ -17345,6 +18125,13 @@ async function steerAll(deps, text) {
17345
18125
  deps.notify(verbatim ? deps.staleNotice() : deps.mergedNotice(), "error");
17346
18126
  return "stale";
17347
18127
  }
18128
+ if (onlyDraft) {
18129
+ if (verdict.kind === "forced") deps.notify(deps.forcedNotice(), "error");
18130
+ const message = deps.createDraft(text);
18131
+ if (now.status === "running") now.steer(message);
18132
+ else now.followup(message);
18133
+ return "ok";
18134
+ }
17348
18135
  const current = [...now.inbox.nextTurn, ...now.inbox.nextStep];
17349
18136
  if (!(current.length === snapshot.length && current.every((message, index) => message.id === snapshot[index].id))) {
17350
18137
  const verbatim = deps.restoreDraft(text);
@@ -17367,6 +18154,75 @@ async function steerAll(deps, text) {
17367
18154
  return "ok";
17368
18155
  }
17369
18156
  //#endregion
18157
+ //#region src/shell-context.ts
18158
+ /**
18159
+ * `!` shell context submission (kimi parity): a completed local shell run
18160
+ * is submitted to the session as an ordinary guarded user message, so the
18161
+ * model sees the command AND its output on the next turn. `!!` runs stay
18162
+ * purely local — no session write, no model visibility (pi's
18163
+ * excluded-from-context semantics). Extracted from the runner so the
18164
+ * guard/TOCTOU races are testable headless, exactly like steer.ts:
18165
+ *
18166
+ * - The agent/generation identity is captured BEFORE the guard runs.
18167
+ * - AFTER the guard returns, the identity is re-validated: same agent
18168
+ * object, same session generation. A switch while the guard read the
18169
+ * file aborts (`stale`) — the output is never written to a session the
18170
+ * guard did not check.
18171
+ * - A blocked write keeps the caller's card visible (the output is not
18172
+ * lost) and the identical `!` re-run can force through its one-time
18173
+ * guard token.
18174
+ * @module @xmoon76/dsh-pi-tui/shell-context
18175
+ */
18176
+ /**
18177
+ * Submit a completed `!` shell run's command+output to the session:
18178
+ * capture identity → guard → re-validate → followup. No-op without an
18179
+ * agent. `blocked` keeps the caller's card (the output stays visible; the
18180
+ * identical `!` re-run can force through its one-time token); `stale`
18181
+ * aborts for a retry against the new session.
18182
+ */
18183
+ async function submitShellResult(deps, text) {
18184
+ const agent = deps.currentAgent();
18185
+ if (agent === void 0) return "ok";
18186
+ const generation = deps.currentGeneration();
18187
+ const verdict = await deps.guard.run(text);
18188
+ if (verdict.kind === "blocked") {
18189
+ deps.notify(deps.blockedNotice(verdict.reason), "error");
18190
+ return "blocked";
18191
+ }
18192
+ if (!sessionUnchanged({
18193
+ agent,
18194
+ generation
18195
+ }, deps.currentAgent(), deps.currentGeneration())) {
18196
+ deps.notify(deps.staleNotice(), "error");
18197
+ return "stale";
18198
+ }
18199
+ if (verdict.kind === "forced") deps.notify(deps.forcedNotice(), "error");
18200
+ agent.followup(deps.createMessage(text));
18201
+ deps.onSubmitted();
18202
+ return "ok";
18203
+ }
18204
+ /**
18205
+ * Classify one `!` line: 'context' submits the command+output to the
18206
+ * session (kimi parity), 'local' runs purely off-session (pi's `!!`
18207
+ * escape hatch). Returns undefined for a non-`!` line.
18208
+ */
18209
+ function shellModeOf(text) {
18210
+ if (!text.startsWith("!")) return void 0;
18211
+ return text.startsWith("!!") ? "local" : "context";
18212
+ }
18213
+ /** Extract the command after the `!` prefix ('' when nothing follows). */
18214
+ function shellCommandOf(text) {
18215
+ return text.replace(/^!+/, "").trim();
18216
+ }
18217
+ /**
18218
+ * The model-facing submission text: the command echoed `$`-style (kimi
18219
+ * ShellExecution parity) followed by the settled card result (output +
18220
+ * `[exit N]` / truncation lines).
18221
+ */
18222
+ function formatShellSubmitText(command, result) {
18223
+ return `$ ${command}\n${result}`;
18224
+ }
18225
+ //#endregion
17370
18226
  //#region src/bounded-output.ts
17371
18227
  /**
17372
18228
  * Bounded accumulation of local-shell output: the UI card only ever holds
@@ -17685,6 +18541,9 @@ const REPAINT_FLUSH_MS = 50;
17685
18541
  * dispatches through `commands.execute`, which creates the session lazily
17686
18542
  * (the command line IS the first user input). Commands in this set must
17687
18543
  * tolerate `liveAgent === undefined` in their handlers.
18544
+ *
18545
+ * Exported for the headless suite: the gate is exactly where a sessionless
18546
+ * command silently starts creating sessions again.
17688
18547
  */
17689
18548
  const SESSIONLESS_COMMANDS = /* @__PURE__ */ new Set([
17690
18549
  "exit",
@@ -17698,9 +18557,64 @@ const SESSIONLESS_COMMANDS = /* @__PURE__ */ new Set([
17698
18557
  "resume",
17699
18558
  "search",
17700
18559
  "new",
17701
- "fork"
18560
+ "fork",
18561
+ "preset"
17702
18562
  ]);
17703
18563
  /**
18564
+ * The LOCAL-execute command set: TUI-owned UI/control commands AND core
18565
+ * control commands the TUI does not itself register (e.g. /kill) that
18566
+ * must ALWAYS run locally through the commands service, never steered,
18567
+ * regardless of the busyEnter preference. Everything NOT in this set —
18568
+ * plain prompts AND non-local commands (the per-skill slash commands like
18569
+ * /grilling or /matrix-cli) — flows through the busy-Enter submission
18570
+ * policy while the agent is running: web parity, where a skill invocation
18571
+ * is a plain `session.prompt` whose leading `/name` line the host's
18572
+ * pre-step listener (dsh-tool-skill) resolves into the injected skill
18573
+ * body — there is no command-execution wire for skills.
18574
+ */
18575
+ const LOCAL_COMMANDS = /* @__PURE__ */ new Set([
18576
+ "copy",
18577
+ "exit",
18578
+ "export",
18579
+ "fork",
18580
+ "help",
18581
+ "kill",
18582
+ "login",
18583
+ "logout",
18584
+ "model",
18585
+ "new",
18586
+ "preset",
18587
+ "queue",
18588
+ "quit",
18589
+ "reload",
18590
+ "rename",
18591
+ "resume",
18592
+ "search",
18593
+ "sessions",
18594
+ "settings",
18595
+ "skill",
18596
+ "status",
18597
+ "subagents",
18598
+ "tasks",
18599
+ "title",
18600
+ "yolo"
18601
+ ]);
18602
+ /**
18603
+ * Whether one submission steers under the busy-Enter preference: NOT a
18604
+ * force-queued chord, NOT a TUI-owned local command, and the agent is
18605
+ * running with the preference set to 'steer'. Pure so the dispatch gate
18606
+ * (inside the runner closure) is testable headless.
18607
+ * @param parsed - the parsed slash command, undefined for a plain prompt.
18608
+ * @param running - whether the live agent reports running.
18609
+ * @param busyEnter - the persisted preference value (''/undefined = queue).
18610
+ * @param forceQueue - the Ctrl+Enter chord: always queue, never steer.
18611
+ */
18612
+ function shouldSteerOnEnter(parsed, running, busyEnter, forceQueue) {
18613
+ if (forceQueue) return false;
18614
+ if (parsed !== void 0 && LOCAL_COMMANDS.has(parsed.name)) return false;
18615
+ return running && busyEnter === "steer";
18616
+ }
18617
+ /**
17704
18618
  * Read an explicit child-session reference from a future/extended job record.
17705
18619
  * Current dsh JobSnapshot records do not expose one, so this normally returns
17706
18620
  * undefined. Crucially, label, registration order and timestamps are never
@@ -17998,6 +18912,7 @@ function apply(ctx, config) {
17998
18912
  const compose = (presetId) => composeAgent(ctx, selected, presetId);
17999
18913
  const withPresetMeta = (composition) => composition.agentPreset === void 0 ? {} : { agentPreset: composition.agentPreset };
18000
18914
  const launchPreset = startup.presetId ?? (process.env.DSH_PI_TUI_PRESET?.trim() || void 0);
18915
+ let pendingPreset;
18001
18916
  diag.info("boot", {
18002
18917
  pid: process.pid,
18003
18918
  dsh: dshVersion() ?? "unknown",
@@ -18022,7 +18937,7 @@ function apply(ctx, config) {
18022
18937
  /** Resolve the launch composition, falling back to the default on an unknown id. */
18023
18938
  const launchComposition = async () => {
18024
18939
  try {
18025
- return { composition: await compose(launchPreset) };
18940
+ return { composition: await compose(pendingPreset ?? launchPreset) };
18026
18941
  } catch (error) {
18027
18942
  const message = safeErrorMessage(error);
18028
18943
  ctx.logger.warn(`tui-runner: launch preset unavailable: ${message}`);
@@ -18341,22 +19256,19 @@ function apply(ctx, config) {
18341
19256
  cleanup();
18342
19257
  };
18343
19258
  });
18344
- /** Run a `!` command locally; the output renders as a local card. */
19259
+ /**
19260
+ * Run a `!` shell command. `!` (context mode) runs the command and then
19261
+ * submits the completed command+output to the session as an ordinary
19262
+ * guarded user message (kimi parity: the model sees both on the next
19263
+ * turn; the result wakes a turn but is never steered into a running
19264
+ * one); `!!` (local mode) runs purely off-session — the card is the
19265
+ * only record (pi's excluded-from-context escape hatch).
19266
+ */
18345
19267
  const runLocalShell = (text) => {
18346
- const includeInContext = text.startsWith("!!");
18347
- const command = text.replace(/^!+/, "").trim();
19268
+ const includeInContext = shellModeOf(text) === "context";
19269
+ const command = shellCommandOf(text);
18348
19270
  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
- }
19271
+ const generationAtRun = sessionGeneration;
18360
19272
  localShellController?.abort();
18361
19273
  localShellController = new AbortController();
18362
19274
  const localSignal = localShellController.signal;
@@ -18372,6 +19284,51 @@ function apply(ctx, config) {
18372
19284
  const releaseController = () => {
18373
19285
  if (localShellController?.signal === localSignal) localShellController = void 0;
18374
19286
  };
19287
+ /**
19288
+ * Submit the completed run to the session (context mode only): guard
19289
+ * → re-validate → followup. Blocked keeps the card (the output stays
19290
+ * visible; the identical `!` re-run forces through its one-time guard
19291
+ * token); accepted clears the settled card — the transcript's user
19292
+ * row becomes the record. An owned workflow: the outcome drives the
19293
+ * notify and the card — runOwned (AGENTS.md), never a bare void.
19294
+ */
19295
+ const submitResult = (result) => {
19296
+ if (sessionGeneration !== generationAtRun) {
19297
+ app.notify("the session changed while the command ran — the output was not submitted", "error");
19298
+ return;
19299
+ }
19300
+ const submitted = formatShellSubmitText(command, result);
19301
+ runOwned("shell submit", () => submitShellResult({
19302
+ currentAgent: () => liveAgent,
19303
+ currentGeneration: () => sessionGeneration,
19304
+ guard: { run: async (identity) => {
19305
+ const verdict = await guardSend("submit", identity);
19306
+ if (verdict.kind === "blocked") return {
19307
+ kind: "blocked",
19308
+ reason: verdict.reason
19309
+ };
19310
+ return { kind: verdict.kind === "forced" ? "forced" : "ok" };
19311
+ } },
19312
+ notify: (message, kind) => app.notify(message, kind),
19313
+ 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)",
19314
+ forcedNotice: () => GUARD_FORCED_NOTIFY,
19315
+ staleNotice: () => "the session changed while the submission was being checked — the output was not submitted",
19316
+ createMessage: (text) => createUserMessage({
19317
+ content: [{
19318
+ type: "text",
19319
+ text
19320
+ }],
19321
+ source: { kind: "user" }
19322
+ }),
19323
+ onSubmitted: () => app.clearSettledLocalMessages()
19324
+ }, submitted), {
19325
+ diag,
19326
+ sessionId: () => liveAgent?.session.id,
19327
+ onError: (error) => {
19328
+ app.notify(`shell submit failed: ${safeErrorMessage(error)}`, "error");
19329
+ }
19330
+ });
19331
+ };
18375
19332
  let settled = false;
18376
19333
  const settle = (result, status) => {
18377
19334
  if (settled) return;
@@ -18384,6 +19341,7 @@ function apply(ctx, config) {
18384
19341
  result,
18385
19342
  status
18386
19343
  });
19344
+ if (includeInContext && !localSignal.aborted) submitResult(result);
18387
19345
  };
18388
19346
  const shell = ctx.get("shell");
18389
19347
  if (shell !== void 0) {
@@ -18537,7 +19495,10 @@ function apply(ctx, config) {
18537
19495
  folder: childFolder
18538
19496
  };
18539
19497
  repaint(app, childFolder);
18540
- app.notify(`viewing subagent ${label ?? childId} — Esc returns`, "info");
19498
+ app.setViewerMode({
19499
+ id: childId,
19500
+ label: label ?? childId
19501
+ });
18541
19502
  };
18542
19503
  /** Leave the subagent viewer (single Esc). Returns whether it exited. */
18543
19504
  const exitView = () => {
@@ -18545,6 +19506,7 @@ function apply(ctx, config) {
18545
19506
  viewing = void 0;
18546
19507
  app.clearLocalMessages();
18547
19508
  app.clearNotify();
19509
+ app.setViewerMode(void 0);
18548
19510
  repaint(app, folder);
18549
19511
  refreshStatus();
18550
19512
  return true;
@@ -18671,44 +19633,119 @@ function apply(ctx, config) {
18671
19633
  }
18672
19634
  });
18673
19635
  };
19636
+ /**
19637
+ * Steer into the running turn with guard re-validation. Shared by
19638
+ * Ctrl+S (the whole queue plus a non-empty draft) and the busy-Enter
19639
+ * preference — Enter while the agent is running with busyEnter=steer
19640
+ * steers the DRAFT ONLY (web busyEnter parity): explicitly queued
19641
+ * messages stay queued until Ctrl+S or the /queue actions, because
19642
+ * already-steered input cannot be pulled back.
19643
+ * @param text - the submitted draft ('' allowed for Ctrl+S).
19644
+ * @param onlyDraft - busy-Enter mode: never read or remove the queue.
19645
+ */
19646
+ const steerNow = (text, onlyDraft = false) => {
19647
+ if (viewing !== void 0) {
19648
+ if (text.trim() !== "") app.setEditorText(mergeDraft(app.getDraft(), text));
19649
+ app.notify("viewing a subagent — Esc returns before steering", "info");
19650
+ return;
19651
+ }
19652
+ app.clearSettledLocalMessages();
19653
+ if (onlyDraft) {
19654
+ if (text.trim() === "") return;
19655
+ } else if (liveAgent !== void 0) {
19656
+ if ([...liveAgent.inbox.nextTurn, ...liveAgent.inbox.nextStep].length === 0 && text.trim() === "") return;
19657
+ } else if (text.trim() === "") return;
19658
+ runOwned("steer", () => ensureSession().then(async () => {
19659
+ if (liveAgent === void 0) return;
19660
+ await steerAll({
19661
+ currentAgent: () => liveAgent,
19662
+ currentGeneration: () => sessionGeneration,
19663
+ guard: { run: async (identity) => {
19664
+ const verdict = await guardSend("save", identity);
19665
+ if (verdict.kind === "blocked") return {
19666
+ kind: "blocked",
19667
+ reason: verdict.reason
19668
+ };
19669
+ return { kind: verdict.kind === "forced" ? "forced" : "ok" };
19670
+ } },
19671
+ notify: (message, kind) => app.notify(message, kind),
19672
+ restoreDraft: (draft) => {
19673
+ const merged = mergeDraft(app.getDraft(), draft);
19674
+ app.setEditorText(merged);
19675
+ return merged === draft;
19676
+ },
19677
+ createDraft: (draft) => createUserMessage({
19678
+ content: [{
19679
+ type: "text",
19680
+ text: draft
19681
+ }],
19682
+ source: { kind: "user" }
19683
+ }),
19684
+ blockedNotice: (reason) => reason === "removed" ? GUARD_REMOVED_NOTIFY("save") : reason === "tail-mismatch" ? GUARD_TAIL_MISMATCH_NOTIFY("save") : GUARD_BLOCKED_NOTIFY("save"),
19685
+ forcedNotice: () => GUARD_FORCED_NOTIFY,
19686
+ staleNotice: () => "the queue or session changed while sending — try again",
19687
+ mergedNotice: () => "the draft changed while sending — review it before submitting again (the earlier text was preserved below)"
19688
+ }, text, onlyDraft ? { onlyDraft: true } : void 0);
19689
+ }), {
19690
+ diag,
19691
+ sessionId: () => liveAgent?.session.id,
19692
+ onError: failSubmission(text)
19693
+ });
19694
+ };
19695
+ /**
19696
+ * Dispatch one user submission end to end: the viewer guard, the input-
19697
+ * history persistence, `!` local shells, sessionless commands, the
19698
+ * busy-Enter preference, and the session dispatch. The Ctrl+Enter
19699
+ * opposite chord forces the QUEUE mode (forceQueue), web busyEnter
19700
+ * parity — the accelerated chord uses the other behavior.
19701
+ * @param text - the submitted draft.
19702
+ * @param forceQueue - the chord: never steer, queue instead.
19703
+ */
19704
+ const dispatchUserInput = (text, forceQueue = false) => {
19705
+ if (viewing !== void 0) {
19706
+ app.setEditorText(mergeDraft(app.getDraft(), text));
19707
+ app.notify("viewing a subagent — Esc returns before submitting", "info");
19708
+ return;
19709
+ }
19710
+ app.clearSettledLocalMessages();
19711
+ const history = app.getInputHistory();
19712
+ if (history.length > 0) {
19713
+ const settings = tuiSettings;
19714
+ if (settings !== void 0) runDetached("settings history write", () => settings.replace({
19715
+ ...settings.get(),
19716
+ history: {
19717
+ ...settings.get().history,
19718
+ [sessionCwd()]: history
19719
+ }
19720
+ }), {
19721
+ diag,
19722
+ notify: (message) => app.notify(message, "error"),
19723
+ recoverable: () => true
19724
+ });
19725
+ }
19726
+ if (text.startsWith("!")) {
19727
+ if (text.startsWith("!!")) runLocalShell(text);
19728
+ else if (shellCommandOf(text) !== "") runOwned("contextual shell", () => ensureSession().then(() => runLocalShell(text)), {
19729
+ diag,
19730
+ sessionId: () => liveAgent?.session.id,
19731
+ onError: failSubmission(text)
19732
+ });
19733
+ return;
19734
+ }
19735
+ const parsed = parseCommand(text);
19736
+ if (parsed !== void 0 && liveAgent === void 0 && SESSIONLESS_COMMANDS.has(parsed.name)) {
19737
+ runLocalCommand(parsed, text);
19738
+ return;
19739
+ }
19740
+ if (shouldSteerOnEnter(parsed, liveAgent?.status === "running", tuiSettings?.get().busyEnter, forceQueue)) {
19741
+ steerNow(text, true);
19742
+ return;
19743
+ }
19744
+ dispatchViaSession(text);
19745
+ };
18674
19746
  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
- },
19747
+ onSubmit: (text) => dispatchUserInput(text),
19748
+ onQueueSubmit: (text) => dispatchUserInput(text, true),
18712
19749
  runOwned: (label, task, options) => {
18713
19750
  runOwned(label, task, {
18714
19751
  ...options,
@@ -18724,52 +19761,7 @@ function apply(ctx, config) {
18724
19761
  localShellController?.abort();
18725
19762
  liveAgent?.cancel({ kind: "user" });
18726
19763
  },
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
- },
19764
+ onSteer: (text) => steerNow(text),
18773
19765
  openExternalEditor: async (draft) => {
18774
19766
  const [editor, ...editorArgs] = parseShellWords(process.env.VISUAL ?? process.env.EDITOR ?? "vi");
18775
19767
  if (editor === void 0) throw new Error("empty editor command");
@@ -18858,31 +19850,50 @@ function apply(ctx, config) {
18858
19850
  refreshQueue();
18859
19851
  },
18860
19852
  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;
19853
+ if (liveAgent === void 0) return;
19854
+ let jobSnapshots = [];
19855
+ if (jobs !== void 0) try {
19856
+ jobSnapshots = jobs.list(liveAgent);
19857
+ } catch {}
18869
19858
  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})`,
19859
+ let rows = buildTaskRows(jobSnapshots, []);
19860
+ const selectRow = (value) => {
19861
+ const row = rows.find((candidate) => candidate.value === value);
19862
+ if (row === void 0) return;
19863
+ if (row.kind === "subagent") {
19864
+ runOwned("subagent view from tasks", () => enterView(row.childId, row.label), {
19865
+ diag,
19866
+ sessionId: () => liveAgent?.session.id,
19867
+ onError: (error) => app.notify(`could not open the subagent view: ${safeErrorMessage(error)}`, "error")
19868
+ });
19869
+ return;
19870
+ }
19871
+ openJobView(row.jobId);
19872
+ };
19873
+ const pickerItems = (target) => target.map((row) => ({
19874
+ value: row.value,
19875
+ label: taskRowLabel(row),
19876
+ description: describeTaskRow(row, now),
19877
+ group: rowGroup(row)
19878
+ }));
19879
+ const handle = app.openPicker(pickerItems(rows), selectRow, () => {}, {
19880
+ header: "tasks · subagents",
18883
19881
  enableSearch: true,
18884
19882
  showHint: true
18885
19883
  });
19884
+ if (subagents !== void 0) {
19885
+ const sessionId = liveAgent.session.id;
19886
+ const generation = sessionGeneration;
19887
+ runOwned("task browser children", () => subagents.listChildren(sessionId), {
19888
+ diag,
19889
+ sessionId: () => liveAgent?.session.id,
19890
+ onResult: (entries) => {
19891
+ if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
19892
+ rows = buildTaskRows(jobSnapshots, entries);
19893
+ handle.setItems(pickerItems(rows));
19894
+ }
19895
+ });
19896
+ }
18886
19897
  }
18887
19898
  }, {
18888
19899
  present,
@@ -18892,28 +19903,35 @@ function apply(ctx, config) {
18892
19903
  theme: z.string(),
18893
19904
  footer: z.string(),
18894
19905
  fullscreen: z.string(),
19906
+ busyEnter: z.string(),
18895
19907
  history: z.dict(z.array(z.string()))
18896
19908
  }), { base: {
18897
19909
  theme: "auto",
18898
19910
  footer: "full",
18899
19911
  fullscreen: "on",
19912
+ busyEnter: "queue",
18900
19913
  history: {}
18901
19914
  } });
19915
+ if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
18902
19916
  const storedTheme = tuiSettings?.get().theme;
18903
19917
  if (storedTheme === "auto") {
18904
- runDetached("theme autodetect", () => app.autoDetectTheme(), { diag });
19918
+ runDetached("theme autodetect", () => app.autoDetectTheme({ shouldApply: () => tuiSettings?.get().theme === "auto" }), { diag });
18905
19919
  app.onTerminalThemeChange((theme) => {
18906
19920
  if (tuiSettings?.get().theme === "auto") app.applyTheme(theme);
18907
19921
  });
18908
- } else if (storedTheme === "dark" || storedTheme === "light") app.applyTheme(storedTheme);
18909
- else if (storedTheme?.startsWith("custom:")) {
19922
+ app.trackTerminalTheme(true);
19923
+ } else if (storedTheme === "dark" || storedTheme === "light") {
19924
+ app.applyTheme(storedTheme);
19925
+ app.trackTerminalTheme(false);
19926
+ } else if (storedTheme?.startsWith("custom:")) {
18910
19927
  const palette = loadCustomTheme(storedTheme.slice(7));
18911
19928
  if (palette !== void 0) app.applyPalette(palette);
19929
+ app.trackTerminalTheme(false);
18912
19930
  }
18913
19931
  if (tuiSettings?.get().footer === "compact") app.setFooterPreset("compact");
18914
- if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
18915
19932
  refreshStatus();
18916
19933
  let refreshTasks = () => {};
19934
+ let refreshAgents = () => {};
18917
19935
  const jobs = ctx.get("jobs");
18918
19936
  if (jobs !== void 0) {
18919
19937
  refreshTasks = () => {
@@ -18928,9 +19946,36 @@ function apply(ctx, config) {
18928
19946
  } catch {}
18929
19947
  app.setTasks(tasks);
18930
19948
  };
18931
- jobs.onJobsChanged(() => refreshTasks());
19949
+ jobs.onJobsChanged(() => {
19950
+ refreshTasks();
19951
+ refreshAgents();
19952
+ });
18932
19953
  refreshTasks();
18933
19954
  }
19955
+ const subagents = ctx.get("subagents");
19956
+ if (subagents !== void 0) {
19957
+ refreshAgents = () => {
19958
+ if (liveAgent === void 0) {
19959
+ app.setAgents([]);
19960
+ return;
19961
+ }
19962
+ const sessionId = liveAgent.session.id;
19963
+ const generation = sessionGeneration;
19964
+ runOwned("task browser agents refresh", () => subagents.listChildren(sessionId), {
19965
+ diag,
19966
+ sessionId: () => liveAgent?.session.id,
19967
+ onResult: (entries) => {
19968
+ if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
19969
+ app.setAgents(entries.filter((entry) => entry.kind === "child" && entry.activity === "running").map((entry) => ({
19970
+ id: entry.id,
19971
+ label: entry.label ?? entry.id,
19972
+ activity: entry.activity
19973
+ })));
19974
+ }
19975
+ });
19976
+ };
19977
+ refreshAgents();
19978
+ }
18934
19979
  /**
18935
19980
  * Open one job from the task browser: a bash job shows a STATUS viewer
18936
19981
  * (never the output — the job's single read cursor belongs to the agent's
@@ -19032,6 +20077,8 @@ function apply(ctx, config) {
19032
20077
  repaint(app, folder);
19033
20078
  refreshStatus();
19034
20079
  refreshQueue();
20080
+ refreshTasks();
20081
+ refreshAgents();
19035
20082
  app.resetInputHistory(tuiSettings?.get().history[sessionCwd()] ?? []);
19036
20083
  setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())} · ${agent.session.id}`);
19037
20084
  updateWelcomeCard();
@@ -19127,6 +20174,12 @@ function apply(ctx, config) {
19127
20174
  return sessionGeneration;
19128
20175
  },
19129
20176
  compose,
20177
+ get pendingPreset() {
20178
+ return pendingPreset;
20179
+ },
20180
+ set pendingPreset(id) {
20181
+ pendingPreset = id;
20182
+ },
19130
20183
  switchSession,
19131
20184
  swapTo: (next) => swapTo(next),
19132
20185
  currentPreset,
@@ -19182,8 +20235,10 @@ function apply(ctx, config) {
19182
20235
  return;
19183
20236
  }
19184
20237
  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 ?? "");
20238
+ if (event.type === "tool/call") {
20239
+ callArgs.set(event.data.callId, typeof event.data.arguments === "string" ? event.data.arguments : JSON.stringify(event.data.arguments));
20240
+ if (typeof event.data.name === "string" && event.data.name.startsWith("subagent")) refreshAgents();
20241
+ } else if (event.type === "tool/result") callArgs.delete(event.data.message.content[0]?.toolCallId ?? "");
19187
20242
  folder.apply([event]);
19188
20243
  statsFolder.apply([event]);
19189
20244
  if (event.type === "goal/change") goalText = foldGoal([event]);
@@ -19214,6 +20269,8 @@ function apply(ctx, config) {
19214
20269
  });
19215
20270
  } else if (event.type === "step/start") refreshStatus();
19216
20271
  });
20272
+ ctx.on("subagent/start", () => refreshAgents());
20273
+ ctx.on("subagent/end", () => refreshAgents());
19217
20274
  if (liveAgent !== void 0) {
19218
20275
  app.setPlanMode(foldPlanMode(liveAgent.session.events));
19219
20276
  app.setWorking(workingFromLog(liveAgent.session.events));
@@ -19273,4 +20330,4 @@ function apply(ctx, config) {
19273
20330
  });
19274
20331
  }
19275
20332
  //#endregion
19276
- export { Config, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resumeCommand, runningProfile, subagentJobTranscriptId, subagentJobViewHint };
20333
+ export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resumeCommand, runningProfile, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };