@xmoon76/dsh-pi-tui 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +150 -6
- package/dist/index.mjs +1207 -156
- package/package.json +5 -2
package/dist/index.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { eastAsianWidth } from "get-east-asian-width";
|
|
|
29
29
|
import { performance } from "node:perf_hooks";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { EventEmitter } from "events";
|
|
32
|
+
import { isUserInvocable } from "@deepseek-ai/dsh-skill";
|
|
32
33
|
//#region src/present.ts
|
|
33
34
|
/** Figma row titles per variant (design literals, not translatable copy). */
|
|
34
35
|
const VARIANT_TITLES = {
|
|
@@ -12623,8 +12624,9 @@ const OTHER_ROW = "\0other";
|
|
|
12623
12624
|
/**
|
|
12624
12625
|
* Default total physical-row budget of the question flow itself. The
|
|
12625
12626
|
* QuestionFrame in tui-app.ts re-derives the budget from the terminal height
|
|
12626
|
-
* on every render (60% cap, 8..24 content rows
|
|
12627
|
-
* {@link QuestionFlow.setMaxRows}; 24 is the
|
|
12627
|
+
* on every render (60% cap COLLAPSED, 8..24 content rows; up to 38 when
|
|
12628
|
+
* expanded) and pushes it through {@link QuestionFlow.setMaxRows}; 24 is the
|
|
12629
|
+
* fallback for direct renders.
|
|
12628
12630
|
* The Frame wrapper adds its two border rows, and NOTHING clips the flow's
|
|
12629
12631
|
* output in the editor-seat layout — so EVERYTHING (tabs, question, detail,
|
|
12630
12632
|
* options with their descriptions, scroll marker, skipped note, hint) must
|
|
@@ -12638,13 +12640,24 @@ const DEFAULT_BUDGET = 24;
|
|
|
12638
12640
|
*/
|
|
12639
12641
|
const MIN_SUPPORTED_BUDGET = 8;
|
|
12640
12642
|
/**
|
|
12641
|
-
*
|
|
12642
|
-
*
|
|
12643
|
-
*
|
|
12643
|
+
* Largest supported content-row budget: the EXPANDED frame cap (80% of the
|
|
12644
|
+
* terminal, at most 40 frame rows) minus the two frame borders. An explicit
|
|
12645
|
+
* body expand ('e' / marker click) grows the panel beyond the default 60%
|
|
12646
|
+
* cap — the user asked for the room; the budget math is linear and holds
|
|
12647
|
+
* for every budget >= MIN_SUPPORTED_BUDGET.
|
|
12644
12648
|
*/
|
|
12645
|
-
const
|
|
12646
|
-
/**
|
|
12647
|
-
|
|
12649
|
+
const MAX_BUDGET = 38;
|
|
12650
|
+
/**
|
|
12651
|
+
* Cap for the WRAPPED page scrollport content (question + detail + options
|
|
12652
|
+
* with their descriptions). Beyond it the content ends in a `... more
|
|
12653
|
+
* content hidden` note — wrapping an unbounded 100KB detail every frame
|
|
12654
|
+
* would be wasteful. 256 rows keeps realistic questions AND all their
|
|
12655
|
+
* answerable options reachable (7 options × ~19 wrapped description rows
|
|
12656
|
+
* at 50 columns fits); the cap is a hard wall, so a page whose content
|
|
12657
|
+
* alone exceeds 256 rows ends in the note — that page is unreachable in
|
|
12658
|
+
* practice, and the note makes the cut non-silent.
|
|
12659
|
+
*/
|
|
12660
|
+
const MAX_CONTENT_ROWS = 256;
|
|
12648
12661
|
/**
|
|
12649
12662
|
* Budgeted push of `content` to `lines`, wrapping it to `width`: pushes at
|
|
12650
12663
|
* most `budget` PHYSICAL (wrapped) rows, appends a `... N more` marker when
|
|
@@ -12736,13 +12749,46 @@ var QuestionFlow = class {
|
|
|
12736
12749
|
/** Review-page action highlight (0 = Submit, 1 = Cancel). */
|
|
12737
12750
|
submitIdx = 0;
|
|
12738
12751
|
/**
|
|
12739
|
-
* Current content-row budget (8..
|
|
12752
|
+
* Current content-row budget (8..38). The editor-seat QuestionFrame in
|
|
12740
12753
|
* tui-app.ts re-derives it from the terminal height on every render and
|
|
12741
12754
|
* pushes it through {@link setMaxRows}; nothing clips the flow's output
|
|
12742
12755
|
* in that layout, so every render must fit the budget exactly.
|
|
12743
12756
|
*/
|
|
12744
12757
|
budget = DEFAULT_BUDGET;
|
|
12745
12758
|
_focused = false;
|
|
12759
|
+
/**
|
|
12760
|
+
* Scroll offset (content rows) into the UNIFIED page scrollport (question,
|
|
12761
|
+
* detail, then every option with its description, then the free-text row).
|
|
12762
|
+
* PageUp/PageDown move it; ↑↓/digits/click move the cursor and the render
|
|
12763
|
+
* follows it into view (pendingCursorScroll).
|
|
12764
|
+
*/
|
|
12765
|
+
bodyScroll = 0;
|
|
12766
|
+
/**
|
|
12767
|
+
* Expanded mode ('e' / marker click): the FRAME grows toward 80% (the
|
|
12768
|
+
* QuestionFrame reads isBodyExpanded each render), so the scrollport is
|
|
12769
|
+
* taller and more of the page — including option descriptions — is visible
|
|
12770
|
+
* at once. Everything stays reachable by scrolling either way.
|
|
12771
|
+
*/
|
|
12772
|
+
bodyExpanded = false;
|
|
12773
|
+
/** Cursor moved since the last render: the next render must scroll the
|
|
12774
|
+
* cursor's rows into view. */
|
|
12775
|
+
pendingCursorScroll = false;
|
|
12776
|
+
/** Hit map from the last render: content row -> option row key. Built each
|
|
12777
|
+
* render; drives fullscreen click-to-select. */
|
|
12778
|
+
hitMap = /* @__PURE__ */ new Map();
|
|
12779
|
+
/** Content row of the scroll marker in the last render (-1 = none);
|
|
12780
|
+
* clicking it toggles the expanded panel. */
|
|
12781
|
+
lastMarkerRow = -1;
|
|
12782
|
+
/** Scrollport height from the last render (scroll page math). */
|
|
12783
|
+
lastRegionHeight = 0;
|
|
12784
|
+
/** Wrapped page content length from the last render. */
|
|
12785
|
+
lastContentRows = 0;
|
|
12786
|
+
/** Content rows visible in the region from the last render (region minus
|
|
12787
|
+
* the marker row when overflowing). */
|
|
12788
|
+
lastVisibleRows = 0;
|
|
12789
|
+
/** Whether the page content overflows the scrollport on the last render —
|
|
12790
|
+
* 'e' (and the scroll verbs) are available when true. */
|
|
12791
|
+
lastExpandable = false;
|
|
12746
12792
|
constructor(questions, onDone, onCancel) {
|
|
12747
12793
|
this.questions = questions;
|
|
12748
12794
|
this.onDone = onDone;
|
|
@@ -12773,10 +12819,144 @@ var QuestionFlow = class {
|
|
|
12773
12819
|
* Replace the content-row budget (the Frame wrapper adds its two border
|
|
12774
12820
|
* rows on top). Called by QuestionFrame on every render immediately
|
|
12775
12821
|
* before the flow renders, so no invalidation is needed; values outside
|
|
12776
|
-
* [MIN_SUPPORTED_BUDGET,
|
|
12822
|
+
* [MIN_SUPPORTED_BUDGET, MAX_BUDGET] are clamped.
|
|
12777
12823
|
*/
|
|
12778
12824
|
setMaxRows(rows) {
|
|
12779
|
-
this.budget = Math.max(MIN_SUPPORTED_BUDGET, Math.min(
|
|
12825
|
+
this.budget = Math.max(MIN_SUPPORTED_BUDGET, Math.min(MAX_BUDGET, Math.floor(rows)));
|
|
12826
|
+
}
|
|
12827
|
+
/** Whether the panel is expanded (the frame grows toward 80% of the
|
|
12828
|
+
* terminal — QuestionFrame reads this every render). */
|
|
12829
|
+
isBodyExpanded() {
|
|
12830
|
+
return this.bodyExpanded;
|
|
12831
|
+
}
|
|
12832
|
+
/**
|
|
12833
|
+
* Reset the view (scroll + expanded + pending cursor follow) — called on
|
|
12834
|
+
* every tab change so each question starts at its top, collapsed.
|
|
12835
|
+
*/
|
|
12836
|
+
resetBodyView() {
|
|
12837
|
+
this.bodyScroll = 0;
|
|
12838
|
+
this.bodyExpanded = false;
|
|
12839
|
+
this.pendingCursorScroll = false;
|
|
12840
|
+
}
|
|
12841
|
+
/**
|
|
12842
|
+
* Page the unified scrollport. One page = the visible content rows. No-op
|
|
12843
|
+
* when the content fits the region.
|
|
12844
|
+
*/
|
|
12845
|
+
scrollBody(direction) {
|
|
12846
|
+
const visible = this.lastVisibleRows;
|
|
12847
|
+
if (visible <= 0 || this.lastContentRows <= visible) return;
|
|
12848
|
+
const maxScroll = this.lastContentRows - visible;
|
|
12849
|
+
this.bodyScroll = Math.max(0, Math.min(this.bodyScroll + direction * visible, maxScroll));
|
|
12850
|
+
}
|
|
12851
|
+
/** Toggle the expanded panel ('e' or a click on the scroll marker).
|
|
12852
|
+
* Expanding is only meaningful when the page content overflows the
|
|
12853
|
+
* scrollport (everything fits -> a taller panel shows nothing new). The
|
|
12854
|
+
* SCROLL POSITION is kept: the taller region reveals MORE rows where the
|
|
12855
|
+
* user is looking (e.g. the option descriptions they scrolled to), instead
|
|
12856
|
+
* of jumping back to the top — the render clamps the offset to the new
|
|
12857
|
+
* maxScroll. */
|
|
12858
|
+
toggleExpanded() {
|
|
12859
|
+
if (!this.bodyExpanded && !this.lastExpandable) return;
|
|
12860
|
+
this.bodyExpanded = !this.bodyExpanded;
|
|
12861
|
+
}
|
|
12862
|
+
/**
|
|
12863
|
+
* Primary-click routing (fullscreen): an option row selects it (single-
|
|
12864
|
+
* select advances, multi-select toggles, the "Type something." row enters
|
|
12865
|
+
* free-text), and the scroll marker toggles the expanded panel. The hit
|
|
12866
|
+
* map reflects the LAST rendered frame, which is what the user sees.
|
|
12867
|
+
*/
|
|
12868
|
+
clickRow(row) {
|
|
12869
|
+
if (row < 0 || this.tab >= this.questions.length) return;
|
|
12870
|
+
const key = this.hitMap.get(row);
|
|
12871
|
+
if (key === OTHER_ROW) {
|
|
12872
|
+
if (this.editingOther) return;
|
|
12873
|
+
const index = this.rows().findIndex((candidate) => candidate.key === OTHER_ROW);
|
|
12874
|
+
if (index >= 0) {
|
|
12875
|
+
this.cursor = index;
|
|
12876
|
+
this.pendingCursorScroll = true;
|
|
12877
|
+
this.enterOther();
|
|
12878
|
+
}
|
|
12879
|
+
return;
|
|
12880
|
+
}
|
|
12881
|
+
if (key !== void 0) {
|
|
12882
|
+
this.cursor = Number(key);
|
|
12883
|
+
this.pendingCursorScroll = true;
|
|
12884
|
+
this.confirm();
|
|
12885
|
+
return;
|
|
12886
|
+
}
|
|
12887
|
+
if (row === this.lastMarkerRow) this.toggleExpanded();
|
|
12888
|
+
}
|
|
12889
|
+
/**
|
|
12890
|
+
* Build the UNIFIED page scrollport content: the question + detail first
|
|
12891
|
+
* (required-first — row 0 is always the question's first physical row),
|
|
12892
|
+
* a separator, then every option row (label + description), then the
|
|
12893
|
+
* free-text row. Each option row maps to its key for click hit-testing.
|
|
12894
|
+
* Capped at MAX_CONTENT_ROWS with a trailing `... more content hidden`
|
|
12895
|
+
* note, so wrapping an unbounded detail/description stays cheap.
|
|
12896
|
+
*/
|
|
12897
|
+
buildPageContent(question, rows, draft, multi, width) {
|
|
12898
|
+
const lines = [];
|
|
12899
|
+
const hits = [];
|
|
12900
|
+
let left = MAX_CONTENT_ROWS;
|
|
12901
|
+
left = appendContentFirst(lines, `${color.primary("?")} `, " ", color.textStrong(question.question), width, left);
|
|
12902
|
+
hits.push(...new Array(lines.length).fill(void 0));
|
|
12903
|
+
if (question.detail !== void 0 && question.detail !== "") for (const line of question.detail.split("\n")) {
|
|
12904
|
+
if (left <= 1) {
|
|
12905
|
+
appendWrappedBudgeted(lines, " ", " ", color.textDim("... more content hidden"), width, left);
|
|
12906
|
+
break;
|
|
12907
|
+
}
|
|
12908
|
+
left = appendWrappedBudgeted(lines, " ", " ", color.textDim(line), width, left);
|
|
12909
|
+
}
|
|
12910
|
+
hits.push(...new Array(lines.length - hits.length).fill(void 0));
|
|
12911
|
+
lines.push("");
|
|
12912
|
+
hits.push(void 0);
|
|
12913
|
+
for (const row of rows) {
|
|
12914
|
+
const isCursor = rows[this.cursor] === row;
|
|
12915
|
+
const selected = row.key === OTHER_ROW ? draft.custom !== "" : draft.selected.has(question.options?.[Number(row.key)]?.label ?? "");
|
|
12916
|
+
const marker = multi || row.key === OTHER_ROW ? selected ? color.success("[✓]") : color.textDim("[ ]") : selected ? color.success(`[${Number(row.key) + 1}]`) : color.textDim(`[${Number(row.key) + 1}]`);
|
|
12917
|
+
const prefix = `${isCursor ? color.primary("→") : " "} ${marker} `;
|
|
12918
|
+
const indent = " ".repeat(visibleWidth(prefix));
|
|
12919
|
+
const badge = row.recommended ? ` ${color.primary("[recommended]")}` : "";
|
|
12920
|
+
const label = isCursor ? color.textStrong(row.label) : row.label;
|
|
12921
|
+
if (row.key === OTHER_ROW && this.editingOther) {
|
|
12922
|
+
const inputLine = this.otherInput.render(Math.max(1, width - visibleWidth(prefix)))[0] ?? "";
|
|
12923
|
+
const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
|
|
12924
|
+
lines.push(prefix + (this.otherInput.getValue() === "" ? color.textDim(row.label) : stripped));
|
|
12925
|
+
hits.push(OTHER_ROW);
|
|
12926
|
+
continue;
|
|
12927
|
+
}
|
|
12928
|
+
const labelWidth = Math.max(1, width - visibleWidth(prefix));
|
|
12929
|
+
for (const wrapped of wrapTextWithAnsi(`${label}${badge}`, labelWidth)) if (!this.pushPageRow(lines, hits, prefix + wrapped, row.key)) return {
|
|
12930
|
+
lines,
|
|
12931
|
+
hits
|
|
12932
|
+
};
|
|
12933
|
+
if (row.description !== void 0 && row.description !== "" && !(row.key === OTHER_ROW && this.editingOther)) {
|
|
12934
|
+
const descWidth = Math.max(1, width - visibleWidth(indent));
|
|
12935
|
+
for (const wrapped of wrapTextWithAnsi(color.textDim(row.description), descWidth)) if (!this.pushPageRow(lines, hits, indent + wrapped, row.key)) return {
|
|
12936
|
+
lines,
|
|
12937
|
+
hits
|
|
12938
|
+
};
|
|
12939
|
+
}
|
|
12940
|
+
}
|
|
12941
|
+
return {
|
|
12942
|
+
lines,
|
|
12943
|
+
hits
|
|
12944
|
+
};
|
|
12945
|
+
}
|
|
12946
|
+
/** Push one page-content row, capping at MAX_CONTENT_ROWS with a single
|
|
12947
|
+
* `... more content hidden` note (never a second one after the detail's).
|
|
12948
|
+
* @returns false when the cap was hit (callers stop building). */
|
|
12949
|
+
pushPageRow(lines, hits, line, key) {
|
|
12950
|
+
if (lines.length >= MAX_CONTENT_ROWS) {
|
|
12951
|
+
if (lines[lines.length - 1]?.includes("more content hidden") !== true) {
|
|
12952
|
+
lines.push(color.textDim("... more content hidden"));
|
|
12953
|
+
hits.push(void 0);
|
|
12954
|
+
}
|
|
12955
|
+
return false;
|
|
12956
|
+
}
|
|
12957
|
+
lines.push(line);
|
|
12958
|
+
hits.push(key);
|
|
12959
|
+
return true;
|
|
12780
12960
|
}
|
|
12781
12961
|
/** The rows of the current question (options plus the free-text row). */
|
|
12782
12962
|
rows() {
|
|
@@ -12846,6 +13026,7 @@ var QuestionFlow = class {
|
|
|
12846
13026
|
}
|
|
12847
13027
|
/** Optionless questions edit text directly; options start in list mode. */
|
|
12848
13028
|
syncEditMode() {
|
|
13029
|
+
this.resetBodyView();
|
|
12849
13030
|
const question = this.questions[this.tab];
|
|
12850
13031
|
const optionless = question !== void 0 && (question.options?.length ?? 0) === 0;
|
|
12851
13032
|
this.editingOther = optionless;
|
|
@@ -12920,6 +13101,7 @@ var QuestionFlow = class {
|
|
|
12920
13101
|
if (this.editingOther) {
|
|
12921
13102
|
if (data === "\r" || data === "\n") this.commitOther(this.otherInput.getValue());
|
|
12922
13103
|
else if (data === "\x1B") this.exitOther();
|
|
13104
|
+
else if (data === "\x1B[5~" || data === "\x1B[6~") this.scrollBody(data === "\x1B[5~" ? -1 : 1);
|
|
12923
13105
|
else if (data === "\x1B[D") {
|
|
12924
13106
|
if (this.tab > 0) {
|
|
12925
13107
|
this.tab -= 1;
|
|
@@ -12962,16 +13144,27 @@ var QuestionFlow = class {
|
|
|
12962
13144
|
const row = rows[Number(digit[0]) - 1];
|
|
12963
13145
|
if (row !== void 0) {
|
|
12964
13146
|
this.cursor = rows.indexOf(row);
|
|
13147
|
+
this.pendingCursorScroll = true;
|
|
12965
13148
|
this.confirm();
|
|
12966
13149
|
}
|
|
12967
13150
|
return;
|
|
12968
13151
|
}
|
|
12969
13152
|
if (data === "\x1B[A" || data === "k") {
|
|
12970
13153
|
if (rows.length > 0) this.cursor = (this.cursor - 1 + rows.length) % rows.length;
|
|
13154
|
+
this.pendingCursorScroll = true;
|
|
12971
13155
|
return;
|
|
12972
13156
|
}
|
|
12973
13157
|
if (data === "\x1B[B" || data === "j") {
|
|
12974
13158
|
if (rows.length > 0) this.cursor = (this.cursor + 1) % rows.length;
|
|
13159
|
+
this.pendingCursorScroll = true;
|
|
13160
|
+
return;
|
|
13161
|
+
}
|
|
13162
|
+
if (data === "\x1B[5~" || data === "\x1B[6~") {
|
|
13163
|
+
this.scrollBody(data === "\x1B[5~" ? -1 : 1);
|
|
13164
|
+
return;
|
|
13165
|
+
}
|
|
13166
|
+
if (data === "e") {
|
|
13167
|
+
this.toggleExpanded();
|
|
12975
13168
|
return;
|
|
12976
13169
|
}
|
|
12977
13170
|
if (data === "\r" || data === "\n") {
|
|
@@ -12992,6 +13185,7 @@ var QuestionFlow = class {
|
|
|
12992
13185
|
this.tab += 1;
|
|
12993
13186
|
this.cursor = 0;
|
|
12994
13187
|
this.submitIdx = 0;
|
|
13188
|
+
this.syncEditMode();
|
|
12995
13189
|
} else {
|
|
12996
13190
|
this.tab = this.questions.length;
|
|
12997
13191
|
this.cursor = 0;
|
|
@@ -13012,6 +13206,8 @@ var QuestionFlow = class {
|
|
|
13012
13206
|
render(width) {
|
|
13013
13207
|
const safeWidth = Math.max(1, width);
|
|
13014
13208
|
const lines = [];
|
|
13209
|
+
this.hitMap.clear();
|
|
13210
|
+
this.lastMarkerRow = -1;
|
|
13015
13211
|
const tabs = this.questions.map((_, index) => {
|
|
13016
13212
|
const draft = this.drafts[index];
|
|
13017
13213
|
const answered = draft !== void 0 && (draft.selected.size > 0 || draft.custom !== "" || draft.skipped);
|
|
@@ -13053,102 +13249,73 @@ var QuestionFlow = class {
|
|
|
13053
13249
|
if (question === void 0 || draft === void 0) return lines;
|
|
13054
13250
|
const rows = this.rows();
|
|
13055
13251
|
const multi = question.multiSelect === true;
|
|
13056
|
-
this.editingOther && rows.some((row) => row.key === OTHER_ROW);
|
|
13057
13252
|
const skippedRow = draft.skipped ? 1 : 0;
|
|
13058
13253
|
const optionless = rows.length === 0 || rows.length === 1 && rows[0]?.key === OTHER_ROW;
|
|
13059
|
-
const tail = optionless ? 3 + skippedRow :
|
|
13060
|
-
let bodyAllowance = this.budget - lines.length - tail;
|
|
13254
|
+
const tail = optionless ? 3 + skippedRow : 2 + skippedRow;
|
|
13061
13255
|
const header = question.header;
|
|
13062
|
-
if (header !== void 0 && header !== "" &&
|
|
13063
|
-
|
|
13064
|
-
|
|
13065
|
-
|
|
13066
|
-
const
|
|
13067
|
-
|
|
13068
|
-
|
|
13069
|
-
|
|
13070
|
-
|
|
13071
|
-
|
|
13072
|
-
|
|
13073
|
-
|
|
13074
|
-
|
|
13075
|
-
|
|
13076
|
-
|
|
13077
|
-
|
|
13078
|
-
|
|
13079
|
-
|
|
13080
|
-
|
|
13081
|
-
|
|
13082
|
-
const start = Math.max(0, Math.min(this.cursor - half, maxStart));
|
|
13083
|
-
const end = Math.min(rows.length, start + visibleCount);
|
|
13084
|
-
{
|
|
13085
|
-
const cursorRow = rows[this.cursor];
|
|
13086
|
-
let reserved = 1;
|
|
13087
|
-
if (cursorRow !== void 0 && cursorRow.key !== OTHER_ROW) {
|
|
13088
|
-
const prefix = `→ ${multi || cursorRow.key === OTHER_ROW ? draft.selected.has(question.options?.[Number(cursorRow.key)]?.label ?? "") ? "[✓]" : "[ ]" : draft.selected.has(question.options?.[Number(cursorRow.key)]?.label ?? "") ? `[${Number(cursorRow.key) + 1}]` : `[${Number(cursorRow.key) + 1}]`} `;
|
|
13089
|
-
const badge = cursorRow.recommended ? ` ${color.primary("[recommended]")}` : "";
|
|
13090
|
-
reserved = Math.min(Math.max(1, wrapTextWithAnsi(`${color.textStrong(cursorRow.label)}${badge}`, Math.max(1, safeWidth - visibleWidth(prefix))).length), maxWindow);
|
|
13091
|
-
}
|
|
13092
|
-
let left = optionBudget;
|
|
13093
|
-
let cursorRendered = false;
|
|
13094
|
-
for (let index = start; index < end; index++) {
|
|
13095
|
-
const row = rows[index];
|
|
13096
|
-
if (row === void 0) continue;
|
|
13097
|
-
const isCursor = index === this.cursor;
|
|
13098
|
-
if (!isCursor && (cursorRendered ? left <= 0 : left <= reserved)) continue;
|
|
13099
|
-
if (row.key === OTHER_ROW && this.editingOther) {
|
|
13100
|
-
const prefix = `${color.primary("→")} ${color.textDim(`[${index + 1}]`)} `;
|
|
13101
|
-
const inputLine = this.otherInput.render(Math.max(1, safeWidth - visibleWidth(prefix)))[0] ?? "";
|
|
13102
|
-
const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
|
|
13103
|
-
left -= 1;
|
|
13104
|
-
lines.push(prefix + stripped);
|
|
13105
|
-
if (isCursor) cursorRendered = true;
|
|
13106
|
-
continue;
|
|
13107
|
-
}
|
|
13108
|
-
const selected = row.key === OTHER_ROW ? draft.custom !== "" : draft.selected.has(question.options?.[Number(row.key)]?.label ?? "");
|
|
13109
|
-
const marker = multi || row.key === OTHER_ROW ? selected ? color.success("[✓]") : color.textDim("[ ]") : selected ? color.success(`[${Number(row.key) + 1}]`) : color.textDim(`[${Number(row.key) + 1}]`);
|
|
13110
|
-
const prefix = `${isCursor ? color.primary("→") : " "} ${marker} `;
|
|
13111
|
-
const badge = row.recommended ? ` ${color.primary("[recommended]")}` : "";
|
|
13112
|
-
const label = isCursor ? color.textStrong(row.label) : row.label;
|
|
13113
|
-
const indent = " ".repeat(visibleWidth(prefix));
|
|
13114
|
-
const available = isCursor || cursorRendered ? left : left - reserved;
|
|
13115
|
-
const beforeLabel = lines.length;
|
|
13116
|
-
if (isCursor) appendContentFirst(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
|
|
13117
|
-
else appendWrappedBudgeted(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
|
|
13118
|
-
left -= lines.length - beforeLabel;
|
|
13119
|
-
if (row.description !== void 0 && row.description !== "" && left > 0) {
|
|
13120
|
-
const descCap = isCursor || cursorRendered ? left : left - reserved;
|
|
13121
|
-
const beforeDescription = lines.length;
|
|
13122
|
-
appendWrappedBudgeted(lines, indent, indent, color.textDim(row.description), safeWidth, descCap);
|
|
13123
|
-
left -= lines.length - beforeDescription;
|
|
13124
|
-
}
|
|
13125
|
-
if (isCursor) cursorRendered = true;
|
|
13256
|
+
if (header !== void 0 && header !== "" && this.budget - lines.length - tail >= 2) lines.push(color.textDim(header));
|
|
13257
|
+
const content = this.buildPageContent(question, rows, draft, multi, safeWidth);
|
|
13258
|
+
const regionHeight = Math.max(1, this.budget - lines.length - tail);
|
|
13259
|
+
const overflow = content.lines.length > regionHeight;
|
|
13260
|
+
const visible = overflow && regionHeight >= 2 ? regionHeight - 1 : regionHeight;
|
|
13261
|
+
this.lastRegionHeight = regionHeight;
|
|
13262
|
+
this.lastContentRows = content.lines.length;
|
|
13263
|
+
this.lastVisibleRows = visible;
|
|
13264
|
+
this.lastExpandable = overflow && regionHeight >= 2;
|
|
13265
|
+
const maxScroll = Math.max(0, content.lines.length - visible);
|
|
13266
|
+
this.bodyScroll = Math.min(this.bodyScroll, maxScroll);
|
|
13267
|
+
if (this.pendingCursorScroll) {
|
|
13268
|
+
this.pendingCursorScroll = false;
|
|
13269
|
+
const cursorKey = rows[this.cursor]?.key;
|
|
13270
|
+
if (cursorKey !== void 0) {
|
|
13271
|
+
const first = content.hits.indexOf(cursorKey);
|
|
13272
|
+
if (first >= 0) {
|
|
13273
|
+
if (first < this.bodyScroll) this.bodyScroll = first;
|
|
13274
|
+
else if (first >= this.bodyScroll + visible) this.bodyScroll = first - visible + 1;
|
|
13275
|
+
this.bodyScroll = Math.max(0, Math.min(this.bodyScroll, maxScroll));
|
|
13126
13276
|
}
|
|
13127
|
-
if (rows.length > MAX_VISIBLE_OPTIONS && left > 0) left = appendWrappedBudgeted(lines, " ", " ", `showing ${start + 1}-${end} of ${rows.length}`, safeWidth, left);
|
|
13128
13277
|
}
|
|
13129
|
-
}
|
|
13278
|
+
}
|
|
13279
|
+
for (let index = this.bodyScroll; index < this.bodyScroll + visible && index < content.lines.length; index++) {
|
|
13280
|
+
const hit = content.hits[index];
|
|
13281
|
+
if (hit !== void 0) this.hitMap.set(lines.length, hit);
|
|
13282
|
+
lines.push(content.lines[index]);
|
|
13283
|
+
}
|
|
13284
|
+
if (overflow && regionHeight >= 2) {
|
|
13285
|
+
const above = this.bodyScroll;
|
|
13286
|
+
const below = content.lines.length - (this.bodyScroll + visible);
|
|
13287
|
+
const marker = above > 0 && below > 0 ? `↑ ${above} up · ↓ ${below} more lines` : above > 0 ? `↑ ${above} up` : `↓ ${below} more lines`;
|
|
13288
|
+
this.lastMarkerRow = lines.length;
|
|
13289
|
+
lines.push(color.textDim(marker));
|
|
13290
|
+
} else this.lastMarkerRow = -1;
|
|
13291
|
+
if (optionless) {
|
|
13130
13292
|
const inputLine = this.otherInput.render(Math.max(1, safeWidth - 2))[0] ?? "";
|
|
13131
13293
|
const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
|
|
13132
|
-
lines.push(` ${stripped}`);
|
|
13294
|
+
lines.push(this.otherInput.getValue() === "" ? color.textDim(" Type your answer…") : ` ${stripped}`);
|
|
13133
13295
|
}
|
|
13134
13296
|
if (draft.skipped) lines.push(color.textDim("(skipped)"));
|
|
13135
13297
|
lines.push("");
|
|
13136
13298
|
const optionCount = Math.min(rows.length, 9);
|
|
13299
|
+
const scrollable = this.lastExpandable;
|
|
13137
13300
|
const hintParts = [
|
|
13138
13301
|
"↑↓ select",
|
|
13139
13302
|
optionCount > 0 ? `1-${optionCount} choose` : "",
|
|
13140
13303
|
multi ? "↵ toggle" : "↵ confirm",
|
|
13304
|
+
scrollable ? "pgup/pgdn scroll" : "",
|
|
13305
|
+
!this.editingOther ? this.bodyExpanded ? "e collapse" : scrollable ? "e expand" : "" : "",
|
|
13141
13306
|
this.questions.length > 1 ? "←→ switch" : "",
|
|
13142
13307
|
"s skip",
|
|
13143
13308
|
"esc cancel"
|
|
13144
13309
|
].filter((part) => part !== "");
|
|
13310
|
+
const cancel = "esc cancel";
|
|
13311
|
+
const verbs = hintParts.filter((part) => part !== cancel);
|
|
13145
13312
|
let hint = "";
|
|
13146
|
-
for (const part of
|
|
13313
|
+
for (const part of verbs) {
|
|
13147
13314
|
const next = hint === "" ? part : `${hint} · ${part}`;
|
|
13148
|
-
if (visibleWidth(next) > safeWidth) break;
|
|
13315
|
+
if (visibleWidth(`${next} · ${cancel}`) > safeWidth) break;
|
|
13149
13316
|
hint = next;
|
|
13150
13317
|
}
|
|
13151
|
-
lines.push(color.textDim(hint === "" ?
|
|
13318
|
+
lines.push(color.textDim(hint === "" ? cancel : `${hint} · ${cancel}`));
|
|
13152
13319
|
return lines;
|
|
13153
13320
|
}
|
|
13154
13321
|
};
|
|
@@ -13705,6 +13872,10 @@ var Frame = class {
|
|
|
13705
13872
|
var QuestionFrame = class extends Frame {
|
|
13706
13873
|
flow;
|
|
13707
13874
|
heightOf;
|
|
13875
|
+
/** Rendered height of the last frame (fullscreen click hit-testing). */
|
|
13876
|
+
lastRows = 0;
|
|
13877
|
+
/** Terminal height the last render used (click staleness guard). */
|
|
13878
|
+
lastTermRows = 0;
|
|
13708
13879
|
constructor(flow, heightOf) {
|
|
13709
13880
|
super(flow, true);
|
|
13710
13881
|
this.flow = flow;
|
|
@@ -13712,9 +13883,20 @@ var QuestionFrame = class extends Frame {
|
|
|
13712
13883
|
}
|
|
13713
13884
|
render(width) {
|
|
13714
13885
|
const rows = Math.max(1, this.heightOf());
|
|
13715
|
-
|
|
13886
|
+
this.lastTermRows = rows;
|
|
13887
|
+
const frameRows = this.flow.isBodyExpanded() ? Math.max(10, Math.min(40, Math.floor(rows * .8))) : Math.max(10, Math.min(26, Math.floor(rows * .6)));
|
|
13716
13888
|
this.flow.setMaxRows(frameRows - 2);
|
|
13717
|
-
|
|
13889
|
+
const out = super.render(width);
|
|
13890
|
+
this.lastRows = out.length;
|
|
13891
|
+
return out;
|
|
13892
|
+
}
|
|
13893
|
+
/** The frame's height from the last render (0 before the first one). */
|
|
13894
|
+
get rows() {
|
|
13895
|
+
return this.lastRows;
|
|
13896
|
+
}
|
|
13897
|
+
/** The terminal height the last render used. */
|
|
13898
|
+
get termRows() {
|
|
13899
|
+
return this.lastTermRows;
|
|
13718
13900
|
}
|
|
13719
13901
|
get focused() {
|
|
13720
13902
|
return this.flow.focused;
|
|
@@ -14714,6 +14896,23 @@ var TuiApp = class TuiApp {
|
|
|
14714
14896
|
* The global Ctrl+O fold still wins, so keyboard behavior is unchanged.
|
|
14715
14897
|
*/
|
|
14716
14898
|
handleFullscreenClick(x, y) {
|
|
14899
|
+
const question = this.activeQuestions;
|
|
14900
|
+
if (question?.frame !== void 0) {
|
|
14901
|
+
if (this.terminal.rows !== question.frame.termRows) return;
|
|
14902
|
+
const width = this.terminal.columns;
|
|
14903
|
+
const height = this.terminal.rows;
|
|
14904
|
+
const footerHeight = this.footer.render(width).length;
|
|
14905
|
+
const seatHeight = question.frame.rows;
|
|
14906
|
+
const seatBottom = height - footerHeight;
|
|
14907
|
+
const seatTop = seatBottom - seatHeight;
|
|
14908
|
+
if (y >= seatTop && y < seatBottom) {
|
|
14909
|
+
if (x >= 2 && x <= width - 3) {
|
|
14910
|
+
question.flow.clickRow(y - seatTop - 1);
|
|
14911
|
+
this.requestRender();
|
|
14912
|
+
}
|
|
14913
|
+
return;
|
|
14914
|
+
}
|
|
14915
|
+
}
|
|
14717
14916
|
const width = this.terminal.columns;
|
|
14718
14917
|
const rowInScroll = y - this.header.render(width).length;
|
|
14719
14918
|
if (rowInScroll < 0) return;
|
|
@@ -16240,6 +16439,262 @@ async function loadSessionTitles(query, persistence, ids, signal) {
|
|
|
16240
16439
|
return titles;
|
|
16241
16440
|
}
|
|
16242
16441
|
//#endregion
|
|
16442
|
+
//#region src/skill-catalog.ts
|
|
16443
|
+
/**
|
|
16444
|
+
* The standing-scope skill catalog adapter: the SINGLE narrow seam between
|
|
16445
|
+
* the TUI and the dsh skill services (the non-public-API isolation
|
|
16446
|
+
* contract, plan appendix B).
|
|
16447
|
+
*
|
|
16448
|
+
* Everything the TUI knows about dsh-skill and agent-presets lives here:
|
|
16449
|
+
* - structural service types (never upstream classes) with OPTIONAL
|
|
16450
|
+
* capability members — `snapshot` and `standingKeyFor` are detected at
|
|
16451
|
+
* call time, so an upstream change degrades instead of breaking;
|
|
16452
|
+
* - `readHumanSkillCatalog()`: snapshot-first collector (list() fallback),
|
|
16453
|
+
* official `isUserInvocable` filter, field validation, stable sort,
|
|
16454
|
+
* deep freeze, distinct abort propagation;
|
|
16455
|
+
* - `resolveColdSkillTarget()`: capability-gated cold target selection
|
|
16456
|
+
* (standing scope → rosterless global → degraded global with a notice);
|
|
16457
|
+
* - `resolveLiveSkillTarget()`: the agent's own registry + agent scope.
|
|
16458
|
+
*
|
|
16459
|
+
* Failure contract: ordinary provider/composition failures surface as
|
|
16460
|
+
* degraded targets or rethrown errors for the coordinator to classify;
|
|
16461
|
+
* aborts ALWAYS propagate as aborts. No failure may hang or crash the TUI —
|
|
16462
|
+
* the worst outcome is "the corresponding commands are missing".
|
|
16463
|
+
* @module @xmoon76/dsh-pi-tui/skill-catalog
|
|
16464
|
+
*/
|
|
16465
|
+
/**
|
|
16466
|
+
* Subscribe to the dsh-skill invalidation notification. The event carries
|
|
16467
|
+
* no scope or cwd — consumers refetch for their own cwd and scope — and the
|
|
16468
|
+
* subscription itself is optional: an unavailable `on` or a throwing
|
|
16469
|
+
* subscribe degrades to NO subscription (owner switches and `/reload` still
|
|
16470
|
+
* refresh). The CALLER owns the listener's failure totality (route the
|
|
16471
|
+
* refresh through `runOwned`).
|
|
16472
|
+
* @param ctx - the context surface exposing the event bus.
|
|
16473
|
+
* @param listener - the notification listener (must be synchronous and
|
|
16474
|
+
* never throw).
|
|
16475
|
+
*/
|
|
16476
|
+
function subscribeSkillsChange(ctx, listener) {
|
|
16477
|
+
ctx.on?.("skills/change", listener);
|
|
16478
|
+
}
|
|
16479
|
+
/**
|
|
16480
|
+
* Whether one summary passes the OFFICIAL user-invocation policy — the
|
|
16481
|
+
* adapter's exported guard, used by every human entry point (direct
|
|
16482
|
+
* wrappers, `/skill`, the picker, and final body loads). A malformed
|
|
16483
|
+
* `invocation` (missing, non-object, non-boolean flag) is treated as NOT
|
|
16484
|
+
* user-invocable: the policy defaults are never reinterpreted, and a
|
|
16485
|
+
* hostile entry can neither throw nor sneak into a human surface.
|
|
16486
|
+
*/
|
|
16487
|
+
function isUserInvocableSkill(skill) {
|
|
16488
|
+
const invocation = skill.invocation;
|
|
16489
|
+
if (typeof invocation !== "object" || invocation === null) return false;
|
|
16490
|
+
if (typeof invocation.userInvocable !== "boolean") return false;
|
|
16491
|
+
return isUserInvocable(skill);
|
|
16492
|
+
}
|
|
16493
|
+
/** Copy one summary, keeping ONLY the supported display fields. An entry
|
|
16494
|
+
* with a non-string name or description is rejected (never copied, never
|
|
16495
|
+
* trusted); a non-string `whenToUse` is dropped. */
|
|
16496
|
+
function toHumanSummary(skill) {
|
|
16497
|
+
if (typeof skill.name !== "string" || skill.name === "") return void 0;
|
|
16498
|
+
if (typeof skill.description !== "string") return void 0;
|
|
16499
|
+
return Object.freeze({
|
|
16500
|
+
name: skill.name,
|
|
16501
|
+
description: skill.description,
|
|
16502
|
+
...typeof skill.whenToUse === "string" && skill.whenToUse !== "" ? { whenToUse: skill.whenToUse } : {}
|
|
16503
|
+
});
|
|
16504
|
+
}
|
|
16505
|
+
/** Whether one summary passes the OFFICIAL user-invocation policy. A
|
|
16506
|
+
* malformed `invocation` (missing, non-object, non-boolean flag) is treated
|
|
16507
|
+
* as NOT user-invocable — the policy defaults are never reinterpreted, and
|
|
16508
|
+
* a hostile entry can neither throw nor sneak into the human catalog. */
|
|
16509
|
+
function userInvocable(skill) {
|
|
16510
|
+
return isUserInvocableSkill(skill);
|
|
16511
|
+
}
|
|
16512
|
+
/**
|
|
16513
|
+
* Read one human skill catalog from a registry: snapshot-first, official
|
|
16514
|
+
* policy filter, validated detached copies, stable name sort, deep freeze.
|
|
16515
|
+
* @param registry - the registry to read (the adapter's structural type).
|
|
16516
|
+
* @param options - cwd/scope/signal for the read.
|
|
16517
|
+
* @returns the detached catalog.
|
|
16518
|
+
* @throws on cancellation (ALWAYS, distinctly) and on non-abort read
|
|
16519
|
+
* failures (the coordinator classifies them: retain last-good + diag).
|
|
16520
|
+
*/
|
|
16521
|
+
async function readHumanSkillCatalog(registry, options) {
|
|
16522
|
+
options.signal?.throwIfAborted();
|
|
16523
|
+
const observed = typeof registry.snapshot === "function" ? await registry.snapshot(options) : {
|
|
16524
|
+
skills: await registry.list?.(options) ?? [],
|
|
16525
|
+
complete: true
|
|
16526
|
+
};
|
|
16527
|
+
options.signal?.throwIfAborted();
|
|
16528
|
+
const skills = observed.skills.filter(userInvocable).map(toHumanSummary).filter((summary) => summary !== void 0).sort((left, right) => left.name < right.name ? -1 : 1);
|
|
16529
|
+
return Object.freeze({
|
|
16530
|
+
skills: Object.freeze(skills),
|
|
16531
|
+
complete: observed.complete === true
|
|
16532
|
+
});
|
|
16533
|
+
}
|
|
16534
|
+
/**
|
|
16535
|
+
* Resolve the COLD (sessionless) skill target with capability detection:
|
|
16536
|
+
* - a preset roster with `standingKeyFor` → the standing scope of the
|
|
16537
|
+
* effective preset;
|
|
16538
|
+
* - no roster / no `standingKeyFor` / a standing resolution failure → the
|
|
16539
|
+
* global layer (with a one-shot degradation notice on failure);
|
|
16540
|
+
* - no skill registry at all → no target (nothing to read, no notice).
|
|
16541
|
+
* @param ctx - the context surface.
|
|
16542
|
+
* @param presetId - the effective preset id, or undefined for the default.
|
|
16543
|
+
* @param cwd - the workspace cwd for provider selection.
|
|
16544
|
+
*/
|
|
16545
|
+
async function resolveColdSkillTarget(ctx, presetId, cwd) {
|
|
16546
|
+
const registry = ctx.get("skills");
|
|
16547
|
+
if (registry === void 0) return {};
|
|
16548
|
+
const presets = ctx.get("agentPresets");
|
|
16549
|
+
if (presets === void 0 || typeof presets.standingKeyFor !== "function") return { target: {
|
|
16550
|
+
kind: "cold-global",
|
|
16551
|
+
registry,
|
|
16552
|
+
cwd,
|
|
16553
|
+
scope: void 0
|
|
16554
|
+
} };
|
|
16555
|
+
try {
|
|
16556
|
+
return { target: {
|
|
16557
|
+
kind: "cold-standing",
|
|
16558
|
+
registry,
|
|
16559
|
+
cwd,
|
|
16560
|
+
scope: await presets.standingKeyFor(presetId)
|
|
16561
|
+
} };
|
|
16562
|
+
} catch (error) {
|
|
16563
|
+
const message = safeErrorMessage(error);
|
|
16564
|
+
return {
|
|
16565
|
+
target: {
|
|
16566
|
+
kind: "cold-global",
|
|
16567
|
+
registry,
|
|
16568
|
+
cwd,
|
|
16569
|
+
scope: void 0
|
|
16570
|
+
},
|
|
16571
|
+
degraded: `skill catalog unavailable for preset "${presetId ?? "default"}": ${message}`
|
|
16572
|
+
};
|
|
16573
|
+
}
|
|
16574
|
+
}
|
|
16575
|
+
/**
|
|
16576
|
+
* Resolve the LIVE skill target: the registry the agent actually sees
|
|
16577
|
+
* (its preset's scoped instance when the preset mounts one, else the host
|
|
16578
|
+
* registry), with the AGENT OBJECT as the view scope.
|
|
16579
|
+
* @param ctx - the context surface.
|
|
16580
|
+
* @param agent - the live agent.
|
|
16581
|
+
* @param cwd - the session workspace cwd.
|
|
16582
|
+
* @returns the live target, or undefined when no registry is reachable.
|
|
16583
|
+
*/
|
|
16584
|
+
function resolveLiveSkillTarget(ctx, agent, cwd) {
|
|
16585
|
+
const registry = ctx.get("agentPresets")?.serviceFor?.(agent, "skills") ?? ctx.get("skills");
|
|
16586
|
+
if (registry === void 0) return void 0;
|
|
16587
|
+
return {
|
|
16588
|
+
kind: "live",
|
|
16589
|
+
registry,
|
|
16590
|
+
cwd,
|
|
16591
|
+
scope: agent
|
|
16592
|
+
};
|
|
16593
|
+
}
|
|
16594
|
+
//#endregion
|
|
16595
|
+
//#region src/surface-catalog.ts
|
|
16596
|
+
/**
|
|
16597
|
+
* The in-process global-layer command view. The public type requires an
|
|
16598
|
+
* `Agent`, but `commands.list(undefined)` resolves the global layer only
|
|
16599
|
+
* (ScopedLayers merges no overlays for an undefined key); the current TUI
|
|
16600
|
+
* already depends on this in-process behavior. The cast is isolated HERE so
|
|
16601
|
+
* the undefined key never reaches a remote RPC path, and the helper is the
|
|
16602
|
+
* single seam to replace if upstream ever ships a typed global-list API.
|
|
16603
|
+
* @param commands - the commands service.
|
|
16604
|
+
* @returns the global-layer descriptors (name-sorted by the registry).
|
|
16605
|
+
*/
|
|
16606
|
+
function listGlobalCommands(commands) {
|
|
16607
|
+
return commands.list(void 0);
|
|
16608
|
+
}
|
|
16609
|
+
/** Copy one descriptor into a fresh frozen summary (never borrowed). */
|
|
16610
|
+
function commandSummaryOf(descriptor) {
|
|
16611
|
+
return Object.freeze({
|
|
16612
|
+
name: descriptor.name,
|
|
16613
|
+
description: descriptor.description,
|
|
16614
|
+
...descriptor.input === void 0 ? {} : { input: Object.freeze({ hint: descriptor.input.hint }) }
|
|
16615
|
+
});
|
|
16616
|
+
}
|
|
16617
|
+
/** Whether two descriptors expose identical display fields (origin-blind:
|
|
16618
|
+
* an identical scoped entry needs no override because the visible result and
|
|
16619
|
+
* the real-agent execution are the same either way). */
|
|
16620
|
+
function sameCommand(left, right) {
|
|
16621
|
+
return left.name === right.name && left.description === right.description && left.input?.hint === right.input?.hint;
|
|
16622
|
+
}
|
|
16623
|
+
/**
|
|
16624
|
+
* Read one agent's effective surface catalog: global + scoped commands and
|
|
16625
|
+
* human-invocable skills, fully detached and frozen.
|
|
16626
|
+
*
|
|
16627
|
+
* Provider isolation (per the probe contract):
|
|
16628
|
+
* - lifecycle/refresh cancellation terminates the WHOLE read and propagates;
|
|
16629
|
+
* - an ordinary provider failure empties only that field and records a
|
|
16630
|
+
* detached issue; other providers continue;
|
|
16631
|
+
* - a missing service is that provider's successful empty result (no issue).
|
|
16632
|
+
*
|
|
16633
|
+
* This function only READS: it registers nothing, mutates nothing, and never
|
|
16634
|
+
* touches a runner's live agent. Probe and live agent share this collector
|
|
16635
|
+
* so the two surfaces cannot drift apart.
|
|
16636
|
+
* @param agent - the agent whose effective view to read.
|
|
16637
|
+
* @param signal - lifecycle/refresh cancellation.
|
|
16638
|
+
* @param ctx - the context surface resolving the services.
|
|
16639
|
+
* @returns a frozen, detached snapshot.
|
|
16640
|
+
*/
|
|
16641
|
+
async function readSurfaceCatalog(agent, signal, ctx) {
|
|
16642
|
+
signal.throwIfAborted();
|
|
16643
|
+
const issues = [];
|
|
16644
|
+
let commands = [];
|
|
16645
|
+
let scopedCommands = [];
|
|
16646
|
+
const commandsService = ctx.get("commands");
|
|
16647
|
+
if (commandsService !== void 0) try {
|
|
16648
|
+
const global = listGlobalCommands(commandsService);
|
|
16649
|
+
const globalBy = new Map(global.map((descriptor) => [descriptor.name, descriptor]));
|
|
16650
|
+
const effective = commandsService.list(agent);
|
|
16651
|
+
const scoped = [];
|
|
16652
|
+
for (const descriptor of effective) {
|
|
16653
|
+
const summary = commandSummaryOf(descriptor);
|
|
16654
|
+
const baseline = globalBy.get(descriptor.name);
|
|
16655
|
+
if (baseline === void 0 || !sameCommand(descriptor, baseline)) scoped.push(summary);
|
|
16656
|
+
}
|
|
16657
|
+
commands = sortCommands(effective.map(commandSummaryOf));
|
|
16658
|
+
scopedCommands = sortCommands(scoped);
|
|
16659
|
+
} catch (error) {
|
|
16660
|
+
issues.push({
|
|
16661
|
+
provider: "commands",
|
|
16662
|
+
message: safeErrorMessage(error)
|
|
16663
|
+
});
|
|
16664
|
+
}
|
|
16665
|
+
let skills = [];
|
|
16666
|
+
const skillTarget = resolveLiveSkillTarget(ctx, agent, agent.session.header.cwd ?? process.cwd());
|
|
16667
|
+
if (skillTarget !== void 0) try {
|
|
16668
|
+
signal.throwIfAborted();
|
|
16669
|
+
const catalog = await readHumanSkillCatalog(skillTarget.registry, {
|
|
16670
|
+
cwd: skillTarget.cwd,
|
|
16671
|
+
scope: skillTarget.scope,
|
|
16672
|
+
signal
|
|
16673
|
+
});
|
|
16674
|
+
skills = catalog.skills;
|
|
16675
|
+
if (catalog.complete !== true) issues.push({
|
|
16676
|
+
provider: "skills",
|
|
16677
|
+
message: "incomplete skill observation"
|
|
16678
|
+
});
|
|
16679
|
+
} catch (error) {
|
|
16680
|
+
if (signal.aborted) throw error;
|
|
16681
|
+
issues.push({
|
|
16682
|
+
provider: "skills",
|
|
16683
|
+
message: safeErrorMessage(error)
|
|
16684
|
+
});
|
|
16685
|
+
}
|
|
16686
|
+
return Object.freeze({
|
|
16687
|
+
commands,
|
|
16688
|
+
scopedCommands,
|
|
16689
|
+
skills,
|
|
16690
|
+
issues: Object.freeze(issues.map((issue) => Object.freeze({ ...issue })))
|
|
16691
|
+
});
|
|
16692
|
+
}
|
|
16693
|
+
/** Name-stable sort for command summaries (copies, never mutates input). */
|
|
16694
|
+
function sortCommands(commands) {
|
|
16695
|
+
return Object.freeze([...commands].sort((left, right) => left.name < right.name ? -1 : 1));
|
|
16696
|
+
}
|
|
16697
|
+
//#endregion
|
|
16243
16698
|
//#region src/commands.ts
|
|
16244
16699
|
/**
|
|
16245
16700
|
* The TUI-owned slash commands (/exit /settings /sessions /skill /model
|
|
@@ -16317,11 +16772,16 @@ function presetDisplayText(preset) {
|
|
|
16317
16772
|
* commands appear in the editor's tab list. Registration is sessionless:
|
|
16318
16773
|
* the commands service's global layer needs no agent, so the whole surface
|
|
16319
16774
|
* is available before the first session exists (deferred start).
|
|
16775
|
+
*
|
|
16776
|
+
* When an `initial` catalog was prefetched (resume snapshot or cold
|
|
16777
|
+
* standing-scope skills), it installs SYNCHRONOUSLY at the end of
|
|
16778
|
+
* registration — direct skill wrappers plus the completion merge — so the
|
|
16779
|
+
* first input is served by the complete catalog with zero async I/O in
|
|
16780
|
+
* between.
|
|
16320
16781
|
* @param runner - the live runner surface.
|
|
16321
|
-
* @
|
|
16322
|
-
* the first session exists, since the skill catalog scope is the agent).
|
|
16782
|
+
* @param initial - optional prefetched catalogs installed synchronously.
|
|
16323
16783
|
*/
|
|
16324
|
-
function registerTuiCommands(runner) {
|
|
16784
|
+
function registerTuiCommands(runner, initial) {
|
|
16325
16785
|
const { ctx, app } = runner;
|
|
16326
16786
|
const cwd = runner.cwd;
|
|
16327
16787
|
const signal = runner.signal;
|
|
@@ -16365,12 +16825,60 @@ function registerTuiCommands(runner) {
|
|
|
16365
16825
|
if (agent === void 0) throw new Error("session could not be created");
|
|
16366
16826
|
return agent;
|
|
16367
16827
|
};
|
|
16368
|
-
|
|
16369
|
-
|
|
16828
|
+
/** The advertised names of the currently installed completion list. */
|
|
16829
|
+
let claims = /* @__PURE__ */ new Set();
|
|
16830
|
+
/**
|
|
16831
|
+
* Install one completion list (sorted, claims refreshed). The single
|
|
16832
|
+
* synchronous seam every catalog commit funnels through.
|
|
16833
|
+
*/
|
|
16834
|
+
const installCompletions = (entries) => {
|
|
16835
|
+
const sorted = [...entries].sort((left, right) => left.name < right.name ? -1 : 1);
|
|
16836
|
+
app.setCommandCompletions(sorted.map((command) => ({
|
|
16370
16837
|
name: command.name,
|
|
16371
16838
|
description: command.description,
|
|
16372
16839
|
argumentHint: command.input?.hint
|
|
16373
16840
|
})), runner.sessionCwd(), fdPath);
|
|
16841
|
+
claims = new Set(sorted.map((command) => command.name));
|
|
16842
|
+
};
|
|
16843
|
+
/** The saved probed scoped overrides (see installSurfaceSnapshot). */
|
|
16844
|
+
let savedScopedCommands = [];
|
|
16845
|
+
/**
|
|
16846
|
+
* The sessionless completion view: the CURRENT global layer (fresh read —
|
|
16847
|
+
* TUI built-ins, global plugins and installed skill wrappers all flow in)
|
|
16848
|
+
* overlaid with the saved scoped overrides from the latest snapshot.
|
|
16849
|
+
*/
|
|
16850
|
+
const mergeGlobalAndSavedScoped = () => {
|
|
16851
|
+
const byName = /* @__PURE__ */ new Map();
|
|
16852
|
+
for (const descriptor of listGlobalCommands(commands)) byName.set(descriptor.name, commandSummaryOf(descriptor));
|
|
16853
|
+
for (const scoped of savedScopedCommands) byName.set(scoped.name, scoped);
|
|
16854
|
+
return [...byName.values()];
|
|
16855
|
+
};
|
|
16856
|
+
/**
|
|
16857
|
+
* Refresh completions from the registry: the LIVE agent's effective view
|
|
16858
|
+
* when one exists (global + its scoped shadows), else the global layer
|
|
16859
|
+
* overlaid with the saved scoped overrides (sessionless merge). The agent
|
|
16860
|
+
* may be undefined before the first session exists: in-process
|
|
16861
|
+
* `commands.list(undefined)` safely returns the global layer only (the
|
|
16862
|
+
* remote RPC path's lookup guard does not apply in-process).
|
|
16863
|
+
*/
|
|
16864
|
+
const refreshCompletions = () => {
|
|
16865
|
+
const liveAgent = runner.liveAgent;
|
|
16866
|
+
installCompletions(liveAgent === void 0 ? mergeGlobalAndSavedScoped() : commands.list(liveAgent).map(commandSummaryOf));
|
|
16867
|
+
};
|
|
16868
|
+
let commandCommitDepth = 0;
|
|
16869
|
+
let commandCommitDirty = false;
|
|
16870
|
+
/** Run one bulk command commit; registry changes inside are coalesced. */
|
|
16871
|
+
const withCommandCommit = (phase) => {
|
|
16872
|
+
commandCommitDepth += 1;
|
|
16873
|
+
try {
|
|
16874
|
+
phase();
|
|
16875
|
+
} finally {
|
|
16876
|
+
commandCommitDepth -= 1;
|
|
16877
|
+
if (commandCommitDepth === 0 && commandCommitDirty) {
|
|
16878
|
+
commandCommitDirty = false;
|
|
16879
|
+
refreshCompletions();
|
|
16880
|
+
}
|
|
16881
|
+
}
|
|
16374
16882
|
};
|
|
16375
16883
|
refreshCompletions();
|
|
16376
16884
|
const exitHandler = () => {
|
|
@@ -16633,29 +17141,44 @@ function registerTuiCommands(runner) {
|
|
|
16633
17141
|
return openSessionPicker(invocation, "resume");
|
|
16634
17142
|
}
|
|
16635
17143
|
});
|
|
16636
|
-
const skillService = (agent) => {
|
|
16637
|
-
return ctx.get("agentPresets")?.serviceFor(agent, "skills") ?? ctx.get("skills");
|
|
16638
|
-
};
|
|
16639
17144
|
/** The workspace the live session runs in; fallback to the TUI's cwd. */
|
|
16640
17145
|
const sessionCwd = (agent) => agent.session.header.cwd ?? cwd;
|
|
17146
|
+
const skillTarget = (agent) => resolveLiveSkillTarget(ctx, agent, sessionCwd(agent));
|
|
17147
|
+
/**
|
|
17148
|
+
* The execution boundary for loading one skill into the live session;
|
|
17149
|
+
* shared by /skill and the per-skill slash commands. The skill is fetched
|
|
17150
|
+
* from the CURRENT agent's registry and its invocation policy is RE-CHECKED
|
|
17151
|
+
* here — a summary that passed the cold/live filter is never execution
|
|
17152
|
+
* authorization. A model-only skill is refused with an explicit error and
|
|
17153
|
+
* never injected.
|
|
17154
|
+
*/
|
|
16641
17155
|
const loadSkill = async (agent, name) => {
|
|
16642
|
-
const
|
|
16643
|
-
if (
|
|
17156
|
+
const target = skillTarget(agent);
|
|
17157
|
+
if (target === void 0) return {
|
|
16644
17158
|
kind: "error",
|
|
16645
17159
|
text: "skill service unavailable"
|
|
16646
17160
|
};
|
|
16647
|
-
const skill = await
|
|
16648
|
-
cwd:
|
|
16649
|
-
scope:
|
|
17161
|
+
const skill = await target.registry.get?.(name, {
|
|
17162
|
+
cwd: target.cwd,
|
|
17163
|
+
scope: target.scope
|
|
16650
17164
|
});
|
|
16651
17165
|
if (skill === void 0) return {
|
|
16652
17166
|
kind: "error",
|
|
16653
17167
|
text: "unknown skill \"" + name + "\""
|
|
16654
17168
|
};
|
|
17169
|
+
if (!isUserInvocableSkill(skill)) return {
|
|
17170
|
+
kind: "error",
|
|
17171
|
+
text: `skill "${name}" is not invocable by the user`
|
|
17172
|
+
};
|
|
17173
|
+
if (typeof skill.name !== "string" || skill.name === "" || typeof skill.description !== "string") return {
|
|
17174
|
+
kind: "error",
|
|
17175
|
+
text: `skill "${name}" returned a malformed definition`
|
|
17176
|
+
};
|
|
17177
|
+
const body = typeof skill.content === "string" && skill.content !== "" ? skill.content : skill.description;
|
|
16655
17178
|
agent.inject(createUserMessage({
|
|
16656
17179
|
content: [{
|
|
16657
17180
|
type: "text",
|
|
16658
|
-
text: "Skill loaded by the user: **" + skill.name + "**\n\n" +
|
|
17181
|
+
text: "Skill loaded by the user: **" + skill.name + "**\n\n" + body
|
|
16659
17182
|
}],
|
|
16660
17183
|
source: {
|
|
16661
17184
|
kind: "plugin",
|
|
@@ -16668,21 +17191,30 @@ function registerTuiCommands(runner) {
|
|
|
16668
17191
|
};
|
|
16669
17192
|
};
|
|
16670
17193
|
const skillDisposers = /* @__PURE__ */ new Map();
|
|
16671
|
-
|
|
16672
|
-
|
|
17194
|
+
/**
|
|
17195
|
+
* Synchronously replace every direct skill wrapper from a snapshot. Pure
|
|
17196
|
+
* install: no catalog fetch, no await — the callers (the initial snapshot
|
|
17197
|
+
* commit, the live refresh, the coordinator) provide the data. The target
|
|
17198
|
+
* set is computed FIRST (current global/effective view minus the wrappers
|
|
17199
|
+
* this surface owns, plus the snapshot's scoped overrides), then the old
|
|
17200
|
+
* wrappers are disposed and the new ones registered in one synchronous
|
|
17201
|
+
* commit.
|
|
17202
|
+
* @param skills - the human-invocable summaries to install.
|
|
17203
|
+
* @param scopedNames - the snapshot's scoped command names: a preset-scoped
|
|
17204
|
+
* command must block a same-name wrapper even though the global view
|
|
17205
|
+
* cannot see it yet (sessionless install).
|
|
17206
|
+
* @returns the number of wrappers installed.
|
|
17207
|
+
*/
|
|
17208
|
+
const replaceSkillCommands = (skills, scopedNames) => {
|
|
17209
|
+
const owned = new Set(skillDisposers.keys());
|
|
17210
|
+
const taken = /* @__PURE__ */ new Set();
|
|
17211
|
+
const view = commands.list(runner.liveAgent);
|
|
17212
|
+
for (const command of view) if (!owned.has(command.name)) taken.add(command.name);
|
|
17213
|
+
for (const name of scopedNames) taken.add(name);
|
|
16673
17214
|
for (const dispose of skillDisposers.values()) dispose();
|
|
16674
17215
|
skillDisposers.clear();
|
|
16675
|
-
|
|
16676
|
-
|
|
16677
|
-
const skills = skillService(liveAgent);
|
|
16678
|
-
if (skills === void 0) return 0;
|
|
16679
|
-
const taken = new Set(commands.list(liveAgent).map((command) => command.name));
|
|
16680
|
-
const catalog = await skills.list({
|
|
16681
|
-
cwd: sessionCwd(liveAgent),
|
|
16682
|
-
scope: liveAgent
|
|
16683
|
-
});
|
|
16684
|
-
if (generation !== runner.sessionGeneration) return 0;
|
|
16685
|
-
for (const skill of catalog) {
|
|
17216
|
+
let count = 0;
|
|
17217
|
+
for (const skill of skills) {
|
|
16686
17218
|
if (taken.has(skill.name)) continue;
|
|
16687
17219
|
try {
|
|
16688
17220
|
const dispose = commands.register({
|
|
@@ -16694,34 +17226,83 @@ function registerTuiCommands(runner) {
|
|
|
16694
17226
|
}
|
|
16695
17227
|
});
|
|
16696
17228
|
skillDisposers.set(skill.name, dispose);
|
|
17229
|
+
count += 1;
|
|
16697
17230
|
} catch {}
|
|
16698
17231
|
}
|
|
16699
|
-
|
|
16700
|
-
|
|
17232
|
+
return count;
|
|
17233
|
+
};
|
|
17234
|
+
/**
|
|
17235
|
+
* Install one whole snapshot on the surface in ONE synchronous commit:
|
|
17236
|
+
* replace the direct skill wrappers, save the scoped overrides, and merge
|
|
17237
|
+
* the completions (fresh global view + saved overrides). No await between
|
|
17238
|
+
* the pieces, so a first input can never observe a half-installed catalog.
|
|
17239
|
+
* A FAILED skills provider keeps the current wrappers (transitions or the
|
|
17240
|
+
* previous catalog): they re-validate against the current agent at
|
|
17241
|
+
* execution time, so a submitted skill name can never fall through to a
|
|
17242
|
+
* plain model message while the catalog is unavailable.
|
|
17243
|
+
*/
|
|
17244
|
+
const installSurfaceSnapshot = (snapshot) => {
|
|
17245
|
+
const scopedNames = new Set(snapshot.scopedCommands.map((command) => command.name));
|
|
17246
|
+
const skillsFailed = snapshot.issues.some((issue) => issue.provider === "skills");
|
|
17247
|
+
withCommandCommit(() => {
|
|
17248
|
+
if (!skillsFailed) replaceSkillCommands(snapshot.skills, scopedNames);
|
|
17249
|
+
savedScopedCommands = snapshot.scopedCommands;
|
|
17250
|
+
installCompletions(mergeGlobalAndSavedScoped());
|
|
17251
|
+
});
|
|
16701
17252
|
};
|
|
16702
|
-
|
|
17253
|
+
/**
|
|
17254
|
+
* The revalidating transition (target/owner change): scoped previews clear
|
|
17255
|
+
* so new inputs complete against the current global view only, and every
|
|
17256
|
+
* old skill wrapper becomes a revalidating transition command whose
|
|
17257
|
+
* handler re-fetches from the CURRENT live agent and re-checks the policy
|
|
17258
|
+
* at execution time. The names survive so an already-submitted skill
|
|
17259
|
+
* command can never become a plain model message mid-switch.
|
|
17260
|
+
*/
|
|
17261
|
+
const enterCatalogTransition = () => {
|
|
17262
|
+
const names = [...skillDisposers.keys()];
|
|
17263
|
+
withCommandCommit(() => {
|
|
17264
|
+
for (const dispose of skillDisposers.values()) dispose();
|
|
17265
|
+
skillDisposers.clear();
|
|
17266
|
+
savedScopedCommands = [];
|
|
17267
|
+
for (const name of names) try {
|
|
17268
|
+
const dispose = commands.register({
|
|
17269
|
+
name,
|
|
17270
|
+
description: `[skill: revalidating] ${name}`,
|
|
17271
|
+
handler: async () => {
|
|
17272
|
+
const agent = await requireAgent();
|
|
17273
|
+
return loadSkill(agent, name);
|
|
17274
|
+
}
|
|
17275
|
+
});
|
|
17276
|
+
skillDisposers.set(name, dispose);
|
|
17277
|
+
} catch {}
|
|
17278
|
+
installCompletions(mergeGlobalAndSavedScoped());
|
|
17279
|
+
});
|
|
17280
|
+
};
|
|
17281
|
+
/** Whether one command name is advertised by the CURRENT completion list
|
|
17282
|
+
* (the claim captured at submit time, before any session creation). */
|
|
17283
|
+
const wasAdvertised = (name) => claims.has(name);
|
|
16703
17284
|
commands.register({
|
|
16704
17285
|
name: "skill",
|
|
16705
17286
|
description: "Load a skill into the session context",
|
|
16706
17287
|
input: { hint: "<name>" },
|
|
16707
17288
|
handler: async (invocation) => {
|
|
16708
17289
|
const liveAgent = await requireAgent();
|
|
16709
|
-
const
|
|
16710
|
-
if (
|
|
17290
|
+
const target = skillTarget(liveAgent);
|
|
17291
|
+
if (target === void 0) return {
|
|
16711
17292
|
kind: "error",
|
|
16712
17293
|
text: "skill service unavailable"
|
|
16713
17294
|
};
|
|
16714
17295
|
const name = invocation.rawInput.trim();
|
|
16715
17296
|
if (name !== "") return loadSkill(liveAgent, name);
|
|
16716
|
-
const catalog = await
|
|
16717
|
-
cwd:
|
|
16718
|
-
scope:
|
|
17297
|
+
const catalog = await readHumanSkillCatalog(target.registry, {
|
|
17298
|
+
cwd: target.cwd,
|
|
17299
|
+
scope: target.scope
|
|
16719
17300
|
});
|
|
16720
|
-
if (catalog.length === 0) return {
|
|
17301
|
+
if (catalog.skills.length === 0) return {
|
|
16721
17302
|
kind: "error",
|
|
16722
17303
|
text: "no skills available"
|
|
16723
17304
|
};
|
|
16724
|
-
app.openSettings(catalog.map((skill) => ({
|
|
17305
|
+
app.openSettings(catalog.skills.map((skill) => ({
|
|
16725
17306
|
id: skill.name,
|
|
16726
17307
|
label: skill.name,
|
|
16727
17308
|
description: skill.description,
|
|
@@ -16737,12 +17318,38 @@ function registerTuiCommands(runner) {
|
|
|
16737
17318
|
});
|
|
16738
17319
|
commands.register({
|
|
16739
17320
|
name: "reload",
|
|
16740
|
-
description: "Reload TUI settings and refresh skill
|
|
17321
|
+
description: "Reload TUI settings and refresh the live command/skill catalog",
|
|
16741
17322
|
handler: async () => {
|
|
16742
|
-
let
|
|
16743
|
-
|
|
16744
|
-
|
|
16745
|
-
|
|
17323
|
+
let catalogText = "";
|
|
17324
|
+
const liveAgent = runner.liveAgent;
|
|
17325
|
+
if (liveAgent !== void 0) {
|
|
17326
|
+
const outcome = await runner.refreshCatalog({
|
|
17327
|
+
source: "reload",
|
|
17328
|
+
target: {
|
|
17329
|
+
kind: "agent",
|
|
17330
|
+
key: runner.sessionGeneration
|
|
17331
|
+
},
|
|
17332
|
+
agent: liveAgent
|
|
17333
|
+
});
|
|
17334
|
+
if (outcome.kind === "applied") {
|
|
17335
|
+
catalogText = `${outcome.snapshot.commands.length} commands \u00b7 ${outcome.snapshot.skills.length} skills`;
|
|
17336
|
+
if (outcome.snapshot.issues.length > 0) catalogText += ` \u00b7 partial: ${outcome.snapshot.issues.map((issue) => issue.provider).join(", ")} unavailable`;
|
|
17337
|
+
} else if (outcome.kind === "failed") catalogText = `catalog refresh failed: ${outcome.error}`;
|
|
17338
|
+
else catalogText = "catalog refresh superseded";
|
|
17339
|
+
} else {
|
|
17340
|
+
const outcome = await runner.refreshCatalog({
|
|
17341
|
+
source: "reload",
|
|
17342
|
+
target: {
|
|
17343
|
+
kind: "preset",
|
|
17344
|
+
presetId: runner.effectivePresetId
|
|
17345
|
+
}
|
|
17346
|
+
});
|
|
17347
|
+
if (outcome.kind === "applied") {
|
|
17348
|
+
catalogText = `${outcome.snapshot.skills.length} human skills`;
|
|
17349
|
+
if (outcome.notice !== void 0) catalogText += ` \u00b7 ${outcome.notice}`;
|
|
17350
|
+
} else if (outcome.kind === "failed") catalogText = `catalog refresh failed: ${outcome.error}`;
|
|
17351
|
+
else catalogText = "catalog refresh superseded";
|
|
17352
|
+
}
|
|
16746
17353
|
const settings = runner.tuiSettings;
|
|
16747
17354
|
if (settings !== void 0) {
|
|
16748
17355
|
const doc = settings.get();
|
|
@@ -16761,7 +17368,7 @@ function registerTuiCommands(runner) {
|
|
|
16761
17368
|
app.setFooterPreset(doc.footer === "compact" ? "compact" : "full");
|
|
16762
17369
|
app.setFullscreen(doc.fullscreen === "on");
|
|
16763
17370
|
}
|
|
16764
|
-
app.notify(`reloaded — ${
|
|
17371
|
+
app.notify(`reloaded — ${catalogText} \u00b7 settings reapplied`, "info");
|
|
16765
17372
|
return { kind: "success" };
|
|
16766
17373
|
}
|
|
16767
17374
|
});
|
|
@@ -17012,11 +17619,28 @@ function registerTuiCommands(runner) {
|
|
|
17012
17619
|
kind: "success",
|
|
17013
17620
|
text: `default preset: ${settings.get(ns)?.default ?? presets.defaultId}`
|
|
17014
17621
|
};
|
|
17015
|
-
|
|
17016
|
-
|
|
17017
|
-
|
|
17018
|
-
|
|
17019
|
-
|
|
17622
|
+
try {
|
|
17623
|
+
await settings.mutate(ns, [{
|
|
17624
|
+
op: "set",
|
|
17625
|
+
path: ["default"],
|
|
17626
|
+
value: rest
|
|
17627
|
+
}]);
|
|
17628
|
+
} catch (error) {
|
|
17629
|
+
return {
|
|
17630
|
+
kind: "error",
|
|
17631
|
+
text: safeErrorMessage(error)
|
|
17632
|
+
};
|
|
17633
|
+
}
|
|
17634
|
+
if (runner.effectivePresetId === void 0) {
|
|
17635
|
+
const outcome = await runner.refreshCatalog({
|
|
17636
|
+
source: "preset",
|
|
17637
|
+
target: {
|
|
17638
|
+
kind: "preset",
|
|
17639
|
+
presetId: rest
|
|
17640
|
+
}
|
|
17641
|
+
});
|
|
17642
|
+
if (outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
|
|
17643
|
+
}
|
|
17020
17644
|
return {
|
|
17021
17645
|
kind: "success",
|
|
17022
17646
|
text: `default preset set: ${rest}`
|
|
@@ -17026,6 +17650,14 @@ function registerTuiCommands(runner) {
|
|
|
17026
17650
|
if (liveAgent === void 0) {
|
|
17027
17651
|
const resolved = await presets.resolve(id);
|
|
17028
17652
|
runner.pendingPreset = resolved.id;
|
|
17653
|
+
const outcome = await runner.refreshCatalog({
|
|
17654
|
+
source: "preset",
|
|
17655
|
+
target: {
|
|
17656
|
+
kind: "preset",
|
|
17657
|
+
presetId: resolved.id
|
|
17658
|
+
}
|
|
17659
|
+
});
|
|
17660
|
+
if (outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
|
|
17029
17661
|
return {
|
|
17030
17662
|
kind: "pending",
|
|
17031
17663
|
preset: resolved.id
|
|
@@ -17036,7 +17668,14 @@ function registerTuiCommands(runner) {
|
|
|
17036
17668
|
kind: "locked",
|
|
17037
17669
|
sessionId: liveAgent.session.id
|
|
17038
17670
|
};
|
|
17039
|
-
|
|
17671
|
+
await runner.refreshCatalog({
|
|
17672
|
+
source: "preset",
|
|
17673
|
+
target: {
|
|
17674
|
+
kind: "agent",
|
|
17675
|
+
key: runner.sessionGeneration
|
|
17676
|
+
},
|
|
17677
|
+
agent: runner.liveAgent
|
|
17678
|
+
});
|
|
17040
17679
|
runner.updateWelcomeCard();
|
|
17041
17680
|
return {
|
|
17042
17681
|
kind: "switched",
|
|
@@ -17109,7 +17748,11 @@ function registerTuiCommands(runner) {
|
|
|
17109
17748
|
};
|
|
17110
17749
|
}), (id) => {
|
|
17111
17750
|
close();
|
|
17112
|
-
|
|
17751
|
+
runOwned("preset pick", () => pickPreset(id), {
|
|
17752
|
+
diag: runner.diag,
|
|
17753
|
+
sessionId: () => runner.liveAgent?.session.id,
|
|
17754
|
+
onError: (error) => app.notify(`preset selection failed: ${safeErrorMessage(error)}`, "error")
|
|
17755
|
+
});
|
|
17113
17756
|
}, () => {});
|
|
17114
17757
|
return { kind: "success" };
|
|
17115
17758
|
}
|
|
@@ -17551,8 +18194,28 @@ function registerTuiCommands(runner) {
|
|
|
17551
18194
|
return { kind: "success" };
|
|
17552
18195
|
}
|
|
17553
18196
|
});
|
|
17554
|
-
|
|
17555
|
-
|
|
18197
|
+
if (initial?.snapshot !== void 0) installSurfaceSnapshot(initial.snapshot);
|
|
18198
|
+
else if (initial?.skills !== void 0) withCommandCommit(() => {
|
|
18199
|
+
replaceSkillCommands(initial.skills.skills, /* @__PURE__ */ new Set());
|
|
18200
|
+
savedScopedCommands = [];
|
|
18201
|
+
installCompletions(mergeGlobalAndSavedScoped());
|
|
18202
|
+
});
|
|
18203
|
+
else refreshCompletions();
|
|
18204
|
+
ctx.on("commands/change", () => {
|
|
18205
|
+
if (commandCommitDepth > 0) {
|
|
18206
|
+
commandCommitDirty = true;
|
|
18207
|
+
return;
|
|
18208
|
+
}
|
|
18209
|
+
refreshCompletions();
|
|
18210
|
+
});
|
|
18211
|
+
return {
|
|
18212
|
+
/** The claim test for the dispatch: is /name advertised right now? */
|
|
18213
|
+
wasAdvertised,
|
|
18214
|
+
/** One synchronous catalog commit (the coordinator's install hook). */
|
|
18215
|
+
installSnapshot: (snapshot) => installSurfaceSnapshot(snapshot),
|
|
18216
|
+
/** The revalidating transition (the coordinator's target-change hook). */
|
|
18217
|
+
enterTransition: () => enterCatalogTransition()
|
|
18218
|
+
};
|
|
17556
18219
|
}
|
|
17557
18220
|
//#endregion
|
|
17558
18221
|
//#region src/diag.ts
|
|
@@ -18509,6 +19172,191 @@ function parseShellWords(line) {
|
|
|
18509
19172
|
return words;
|
|
18510
19173
|
}
|
|
18511
19174
|
//#endregion
|
|
19175
|
+
//#region src/skill-catalog-refresh.ts
|
|
19176
|
+
/** Whether two targets are the same owner (no transition needed). The
|
|
19177
|
+
* preset identity is the caller-provided presetId compared EXACTLY —
|
|
19178
|
+
* `undefined` (the deployment default) never conflates with a user preset
|
|
19179
|
+
* literally named `'default'`, and a resolved id never conflates with the
|
|
19180
|
+
* raw id it came from (the worst case is a benign extra transition). */
|
|
19181
|
+
function sameTarget(left, right) {
|
|
19182
|
+
if (left === void 0) return false;
|
|
19183
|
+
if (left.kind !== right.kind) return false;
|
|
19184
|
+
return left.kind === "agent" ? left.key === right.key : left.presetId === right.presetId;
|
|
19185
|
+
}
|
|
19186
|
+
/**
|
|
19187
|
+
* The coordinator. One instance per runner; `dispose()` aborts the active
|
|
19188
|
+
* refresh on teardown.
|
|
19189
|
+
*/
|
|
19190
|
+
var CatalogRefreshCoordinator = class {
|
|
19191
|
+
epoch = 0;
|
|
19192
|
+
active;
|
|
19193
|
+
appliedTarget;
|
|
19194
|
+
snapshot;
|
|
19195
|
+
hooks;
|
|
19196
|
+
lifecycleSignal;
|
|
19197
|
+
diag;
|
|
19198
|
+
constructor(hooks, lifecycleSignal, diag) {
|
|
19199
|
+
this.hooks = hooks;
|
|
19200
|
+
this.lifecycleSignal = lifecycleSignal;
|
|
19201
|
+
this.diag = diag;
|
|
19202
|
+
}
|
|
19203
|
+
/** The last applied snapshot (for same-target partial merges and the
|
|
19204
|
+
* `/reload` report). */
|
|
19205
|
+
get currentSnapshot() {
|
|
19206
|
+
return this.snapshot;
|
|
19207
|
+
}
|
|
19208
|
+
/**
|
|
19209
|
+
* Run one refresh request. A new request aborts the active one; only the
|
|
19210
|
+
* LATEST epoch may commit. Returns the settled outcome; never rejects
|
|
19211
|
+
* (failures and supersessions are outcomes, not exceptions).
|
|
19212
|
+
*/
|
|
19213
|
+
async refresh(request) {
|
|
19214
|
+
this.active?.controller.abort();
|
|
19215
|
+
const epoch = ++this.epoch;
|
|
19216
|
+
const controller = new AbortController();
|
|
19217
|
+
const signal = AbortSignal.any([this.lifecycleSignal, controller.signal]);
|
|
19218
|
+
this.active = {
|
|
19219
|
+
epoch,
|
|
19220
|
+
controller
|
|
19221
|
+
};
|
|
19222
|
+
const targetChanged = !sameTarget(this.appliedTarget, request.target);
|
|
19223
|
+
try {
|
|
19224
|
+
if (targetChanged) this.hooks.enterCatalogTransition();
|
|
19225
|
+
const committed = request.target.kind === "agent" ? { snapshot: await this.hooks.readAgent(request.agent, signal) } : await this.readStandingSnapshot(request.target.presetId, signal);
|
|
19226
|
+
if (signal.aborted || epoch !== this.epoch) {
|
|
19227
|
+
this.diag.debug("catalog refresh superseded", {
|
|
19228
|
+
epoch,
|
|
19229
|
+
source: request.source
|
|
19230
|
+
});
|
|
19231
|
+
return { kind: "superseded" };
|
|
19232
|
+
}
|
|
19233
|
+
const merged = targetChanged ? committed.snapshot : this.mergePartial(committed.snapshot);
|
|
19234
|
+
this.hooks.installSnapshot(merged);
|
|
19235
|
+
this.snapshot = merged;
|
|
19236
|
+
this.appliedTarget = request.target;
|
|
19237
|
+
this.diag.info("catalog applied", {
|
|
19238
|
+
epoch,
|
|
19239
|
+
source: request.source,
|
|
19240
|
+
commands: merged.commands.length,
|
|
19241
|
+
scopedCommands: merged.scopedCommands.length,
|
|
19242
|
+
skills: merged.skills.length,
|
|
19243
|
+
issues: merged.issues.length
|
|
19244
|
+
});
|
|
19245
|
+
return {
|
|
19246
|
+
kind: "applied",
|
|
19247
|
+
snapshot: merged,
|
|
19248
|
+
...committed.notice === void 0 ? {} : { notice: committed.notice }
|
|
19249
|
+
};
|
|
19250
|
+
} catch (error) {
|
|
19251
|
+
if (signal.aborted || epoch !== this.epoch) {
|
|
19252
|
+
this.diag.debug("catalog refresh superseded", {
|
|
19253
|
+
epoch,
|
|
19254
|
+
source: request.source
|
|
19255
|
+
});
|
|
19256
|
+
return { kind: "superseded" };
|
|
19257
|
+
}
|
|
19258
|
+
const message = safeErrorMessage(error);
|
|
19259
|
+
this.diag.warn("catalog unavailable", {
|
|
19260
|
+
epoch,
|
|
19261
|
+
source: request.source,
|
|
19262
|
+
error: message
|
|
19263
|
+
});
|
|
19264
|
+
return {
|
|
19265
|
+
kind: "failed",
|
|
19266
|
+
error: message
|
|
19267
|
+
};
|
|
19268
|
+
} finally {
|
|
19269
|
+
if (this.active?.epoch === epoch) this.active = void 0;
|
|
19270
|
+
}
|
|
19271
|
+
}
|
|
19272
|
+
/** Abort the active refresh (app teardown). Late results are dropped. */
|
|
19273
|
+
dispose() {
|
|
19274
|
+
this.active?.controller.abort();
|
|
19275
|
+
}
|
|
19276
|
+
/** Same-target partial failure: a provider issue keeps the OLD field for
|
|
19277
|
+
* that provider (the old snapshot is still more useful than a blank
|
|
19278
|
+
* catalog); the successful providers update as usual. */
|
|
19279
|
+
mergePartial(snapshot) {
|
|
19280
|
+
const old = this.snapshot;
|
|
19281
|
+
if (old === void 0) return snapshot;
|
|
19282
|
+
const commandsFailed = snapshot.issues.some((issue) => issue.provider === "commands");
|
|
19283
|
+
const skillsFailed = snapshot.issues.some((issue) => issue.provider === "skills");
|
|
19284
|
+
if (!commandsFailed && !skillsFailed) return snapshot;
|
|
19285
|
+
return Object.freeze({
|
|
19286
|
+
commands: commandsFailed ? old.commands : snapshot.commands,
|
|
19287
|
+
scopedCommands: commandsFailed ? old.scopedCommands : snapshot.scopedCommands,
|
|
19288
|
+
skills: skillsFailed ? old.skills : snapshot.skills,
|
|
19289
|
+
issues: snapshot.issues
|
|
19290
|
+
});
|
|
19291
|
+
}
|
|
19292
|
+
/** One standing read wrapped into a skills-only surface snapshot: the
|
|
19293
|
+
* sessionless view has no agent-scoped commands, and the global commands
|
|
19294
|
+
* layer is already registered — only the skill wrappers are installed.
|
|
19295
|
+
* An INCOMPLETE observation (`complete !== true`) is never an
|
|
19296
|
+
* authoritative blank catalog: it carries a detached skills issue, so a
|
|
19297
|
+
* same-target reload keeps the last-good skills (mergePartial) and a
|
|
19298
|
+
* target change keeps the revalidating transition wrappers
|
|
19299
|
+
* (installSurfaceSnapshot's skillsFailed guard). */
|
|
19300
|
+
async readStandingSnapshot(presetId, signal) {
|
|
19301
|
+
const read = await this.hooks.readStanding(presetId, signal);
|
|
19302
|
+
const incomplete = read.catalog.complete !== true;
|
|
19303
|
+
return {
|
|
19304
|
+
snapshot: Object.freeze({
|
|
19305
|
+
commands: Object.freeze([]),
|
|
19306
|
+
scopedCommands: Object.freeze([]),
|
|
19307
|
+
skills: read.catalog.skills,
|
|
19308
|
+
issues: Object.freeze(incomplete ? [Object.freeze({
|
|
19309
|
+
provider: "skills",
|
|
19310
|
+
message: "incomplete skill observation"
|
|
19311
|
+
})] : [])
|
|
19312
|
+
}),
|
|
19313
|
+
...read.notice === void 0 ? {} : { notice: read.notice }
|
|
19314
|
+
};
|
|
19315
|
+
}
|
|
19316
|
+
};
|
|
19317
|
+
/**
|
|
19318
|
+
* The deterministic, timer-free coalescer for invalidation notifications
|
|
19319
|
+
* (`skills/change`): while one refresh is in flight, further notifications
|
|
19320
|
+
* mark the gate DIRTY; when the in-flight refresh settles, ONE more refresh
|
|
19321
|
+
* runs if the gate is dirty. A burst therefore costs at most two reads, and
|
|
19322
|
+
* the re-run always observes the CURRENT ownership because the runner's
|
|
19323
|
+
* `start` callback reads live state at run time.
|
|
19324
|
+
*/
|
|
19325
|
+
var CoalescingRefreshGate = class {
|
|
19326
|
+
inFlight = false;
|
|
19327
|
+
dirty = false;
|
|
19328
|
+
start;
|
|
19329
|
+
/** @param start - invoked synchronously to START one owned refresh. Must
|
|
19330
|
+
* never throw and must not itself be re-entrant (the gate owns the
|
|
19331
|
+
* in-flight bookkeeping; `start` only launches the owned task). */
|
|
19332
|
+
constructor(start) {
|
|
19333
|
+
this.start = start;
|
|
19334
|
+
}
|
|
19335
|
+
/** One invalidation notification arrived. */
|
|
19336
|
+
notify() {
|
|
19337
|
+
if (this.inFlight) {
|
|
19338
|
+
this.dirty = true;
|
|
19339
|
+
return;
|
|
19340
|
+
}
|
|
19341
|
+
this.inFlight = true;
|
|
19342
|
+
this.start();
|
|
19343
|
+
}
|
|
19344
|
+
/** Terminal settle of the in-flight refresh (called from EVERY terminal
|
|
19345
|
+
* runOwned callback: onResult / onCancel / onError). Clears the in-flight
|
|
19346
|
+
* flag; a dirty gate starts exactly one follow-up refresh. IDEMPOTENT: a
|
|
19347
|
+
* second settle (e.g. onResult throws and runOwned routes to onError)
|
|
19348
|
+
* must not clear the in-flight flag of a follow-up refresh it did not
|
|
19349
|
+
* start. */
|
|
19350
|
+
settled() {
|
|
19351
|
+
if (!this.inFlight) return;
|
|
19352
|
+
this.inFlight = false;
|
|
19353
|
+
if (this.dirty) {
|
|
19354
|
+
this.dirty = false;
|
|
19355
|
+
this.notify();
|
|
19356
|
+
}
|
|
19357
|
+
}
|
|
19358
|
+
};
|
|
19359
|
+
//#endregion
|
|
18512
19360
|
//#region src/index.ts
|
|
18513
19361
|
/**
|
|
18514
19362
|
* @xmoon76/dsh-pi-tui — the bundle's runner plugin. Waits for the startup
|
|
@@ -18615,11 +19463,65 @@ function shouldSteerOnEnter(parsed, running, busyEnter, forceQueue) {
|
|
|
18615
19463
|
return running && busyEnter === "steer";
|
|
18616
19464
|
}
|
|
18617
19465
|
/**
|
|
18618
|
-
*
|
|
18619
|
-
*
|
|
18620
|
-
*
|
|
18621
|
-
*
|
|
19466
|
+
* The advertised-claim miss decision (pure, exported for the headless
|
|
19467
|
+
* suite): a slash input whose name was advertised by the completion list at
|
|
19468
|
+
* submit time but that the REAL session's catalog lacks is CONSUMED with an
|
|
19469
|
+
* explicit error — never sent to the model as a plain user message. An
|
|
19470
|
+
* unadvertised miss keeps the existing plain-input fallback (the user may
|
|
19471
|
+
* deliberately send slash text to the model).
|
|
19472
|
+
* @param execution - the settled `commands.execute` outcome.
|
|
19473
|
+
* @param wasAdvertised - the claim captured BEFORE session creation.
|
|
19474
|
+
* @returns whether the miss must be consumed as an advertised miss.
|
|
18622
19475
|
*/
|
|
19476
|
+
function shouldConsumeAdvertisedMiss(execution, wasAdvertised) {
|
|
19477
|
+
return execution === void 0 && wasAdvertised;
|
|
19478
|
+
}
|
|
19479
|
+
async function resolveInitialCatalog(options) {
|
|
19480
|
+
const { liveAgent, presetId, signal, ctx, diag } = options;
|
|
19481
|
+
if (liveAgent !== void 0) try {
|
|
19482
|
+
const snapshot = await readSurfaceCatalog(liveAgent, signal, ctx);
|
|
19483
|
+
diag.info("surface catalog prefetched", {
|
|
19484
|
+
commands: snapshot.commands.length,
|
|
19485
|
+
scopedCommands: snapshot.scopedCommands.length,
|
|
19486
|
+
skills: snapshot.skills.length
|
|
19487
|
+
});
|
|
19488
|
+
return { snapshot };
|
|
19489
|
+
} catch (error) {
|
|
19490
|
+
if (isCancellation(error)) return {};
|
|
19491
|
+
const message = safeErrorMessage(error);
|
|
19492
|
+
diag.warn("surface catalog unavailable", {
|
|
19493
|
+
phase: "resume",
|
|
19494
|
+
error: message
|
|
19495
|
+
});
|
|
19496
|
+
return { notice: `surface catalog unavailable: ${message}` };
|
|
19497
|
+
}
|
|
19498
|
+
const target = await resolveColdSkillTarget(ctx, presetId, process.cwd());
|
|
19499
|
+
if (target.target === void 0) return {};
|
|
19500
|
+
try {
|
|
19501
|
+
const catalog = await readHumanSkillCatalog(target.target.registry, {
|
|
19502
|
+
cwd: target.target.cwd,
|
|
19503
|
+
scope: target.target.scope,
|
|
19504
|
+
signal
|
|
19505
|
+
});
|
|
19506
|
+
diag.info("skill catalog standing ready", {
|
|
19507
|
+
preset: presetId ?? "default",
|
|
19508
|
+
skills: catalog.skills.length,
|
|
19509
|
+
complete: catalog.complete
|
|
19510
|
+
});
|
|
19511
|
+
return {
|
|
19512
|
+
skills: catalog,
|
|
19513
|
+
...target.degraded === void 0 ? {} : { notice: target.degraded }
|
|
19514
|
+
};
|
|
19515
|
+
} catch (error) {
|
|
19516
|
+
if (isCancellation(error)) return {};
|
|
19517
|
+
const message = safeErrorMessage(error);
|
|
19518
|
+
diag.warn("skill catalog unavailable", {
|
|
19519
|
+
phase: "cold",
|
|
19520
|
+
error: message
|
|
19521
|
+
});
|
|
19522
|
+
return { notice: `skill catalog unavailable: ${message}` };
|
|
19523
|
+
}
|
|
19524
|
+
}
|
|
18623
19525
|
function subagentJobTranscriptId(snapshot) {
|
|
18624
19526
|
if (typeof snapshot !== "object" || snapshot === null) return void 0;
|
|
18625
19527
|
const childSessionId = snapshot.childSessionId;
|
|
@@ -19007,6 +19909,29 @@ function apply(ctx, config) {
|
|
|
19007
19909
|
let liveHandle = handle;
|
|
19008
19910
|
let liveAgent = handle?.agent;
|
|
19009
19911
|
if (liveAgent !== void 0) await liveAgent.whenIdle();
|
|
19912
|
+
let initialSnapshot;
|
|
19913
|
+
let initialSkills;
|
|
19914
|
+
let surfaceNotice;
|
|
19915
|
+
{
|
|
19916
|
+
let effectivePresetId;
|
|
19917
|
+
try {
|
|
19918
|
+
const launched = await launchComposition();
|
|
19919
|
+
if (launched.failure !== void 0) resumeFailure = launched.failure;
|
|
19920
|
+
effectivePresetId = launched.composition.agentPreset;
|
|
19921
|
+
} catch (error) {
|
|
19922
|
+
diag.warn("preset resolution failed at startup", { error: safeErrorMessage(error) });
|
|
19923
|
+
}
|
|
19924
|
+
const resolution = await resolveInitialCatalog({
|
|
19925
|
+
liveAgent,
|
|
19926
|
+
presetId: effectivePresetId,
|
|
19927
|
+
signal: lifecycleController.signal,
|
|
19928
|
+
ctx,
|
|
19929
|
+
diag
|
|
19930
|
+
});
|
|
19931
|
+
initialSnapshot = resolution.snapshot;
|
|
19932
|
+
initialSkills = resolution.skills;
|
|
19933
|
+
surfaceNotice = resolution.notice;
|
|
19934
|
+
}
|
|
19010
19935
|
let guardState = freshGuardState();
|
|
19011
19936
|
let guardToken;
|
|
19012
19937
|
/**
|
|
@@ -19143,6 +20068,7 @@ function apply(ctx, config) {
|
|
|
19143
20068
|
guardToken = void 0;
|
|
19144
20069
|
bumpSessionGeneration();
|
|
19145
20070
|
await initLiveSession(next.agent);
|
|
20071
|
+
await refreshLiveCatalog(next.agent);
|
|
19146
20072
|
diag.info("switch ok", {
|
|
19147
20073
|
from: from ?? "(none)",
|
|
19148
20074
|
to: next.agent.session.id,
|
|
@@ -19229,6 +20155,7 @@ function apply(ctx, config) {
|
|
|
19229
20155
|
cleanedUp = true;
|
|
19230
20156
|
guardToken = void 0;
|
|
19231
20157
|
lifecycleController.abort();
|
|
20158
|
+
catalogCoordinator?.dispose();
|
|
19232
20159
|
localShellController?.abort();
|
|
19233
20160
|
for (const file of shellTempFiles) try {
|
|
19234
20161
|
rmSync(file, { force: true });
|
|
@@ -19528,6 +20455,8 @@ function apply(ctx, config) {
|
|
|
19528
20455
|
* user input is the deferred trigger), guard against cross-process
|
|
19529
20456
|
* divergence, then execute a registered slash command or follow up. */
|
|
19530
20457
|
const dispatchViaSession = (text) => {
|
|
20458
|
+
const parsedAtSubmit = parseCommand(text);
|
|
20459
|
+
const wasAdvertised = parsedAtSubmit !== void 0 && wasAdvertisedClaim?.(parsedAtSubmit.name) === true;
|
|
19531
20460
|
runOwned("submit", () => ensureSession().then(async () => {
|
|
19532
20461
|
const agent = liveAgent;
|
|
19533
20462
|
if (agent === void 0) return;
|
|
@@ -19557,6 +20486,10 @@ function apply(ctx, config) {
|
|
|
19557
20486
|
diag,
|
|
19558
20487
|
sessionId: () => agent.session.id,
|
|
19559
20488
|
onResult: (execution) => {
|
|
20489
|
+
if (shouldConsumeAdvertisedMiss(execution, wasAdvertised)) {
|
|
20490
|
+
app.notify(`/${parsedAtSubmit?.name ?? "?"} is not available in the created session`, "error");
|
|
20491
|
+
return;
|
|
20492
|
+
}
|
|
19560
20493
|
if (execution === void 0) {
|
|
19561
20494
|
if (sessionUnchanged({
|
|
19562
20495
|
agent,
|
|
@@ -20063,7 +20996,12 @@ function apply(ctx, config) {
|
|
|
20063
20996
|
app.setQueueItems(queue);
|
|
20064
20997
|
};
|
|
20065
20998
|
refreshQueue();
|
|
20066
|
-
/**
|
|
20999
|
+
/**
|
|
21000
|
+
* Rebuild every live-session surface after resume, create, or swap.
|
|
21001
|
+
* The surface catalog is NOT touched here: the initial owner's catalog
|
|
21002
|
+
* came from the pre-mount prefetch/probe, and the first deferred create
|
|
21003
|
+
* plus every switch await the coordinator refresh themselves.
|
|
21004
|
+
*/
|
|
20067
21005
|
const initLiveSession = async (agent) => {
|
|
20068
21006
|
folder = new TranscriptFolder();
|
|
20069
21007
|
folder.apply(agent.session.events);
|
|
@@ -20082,8 +21020,10 @@ function apply(ctx, config) {
|
|
|
20082
21020
|
app.resetInputHistory(tuiSettings?.get().history[sessionCwd()] ?? []);
|
|
20083
21021
|
setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())} · ${agent.session.id}`);
|
|
20084
21022
|
updateWelcomeCard();
|
|
20085
|
-
registerCommands(
|
|
20086
|
-
|
|
21023
|
+
registerCommands({
|
|
21024
|
+
snapshot: initialSnapshot,
|
|
21025
|
+
skills: initialSkills
|
|
21026
|
+
});
|
|
20087
21027
|
};
|
|
20088
21028
|
/**
|
|
20089
21029
|
* Create the first session lazily — the FIRST user message triggers it
|
|
@@ -20112,6 +21052,7 @@ function apply(ctx, config) {
|
|
|
20112
21052
|
await liveAgent.whenIdle();
|
|
20113
21053
|
bumpSessionGeneration();
|
|
20114
21054
|
await initLiveSession(liveAgent);
|
|
21055
|
+
await refreshLiveCatalog(liveAgent);
|
|
20115
21056
|
} catch (error) {
|
|
20116
21057
|
const message = safeErrorMessage(error);
|
|
20117
21058
|
ctx.logger.warn(`tui-runner: failed to start with preset "${launchPreset ?? "default"}": ${message}`);
|
|
@@ -20135,6 +21076,7 @@ function apply(ctx, config) {
|
|
|
20135
21076
|
await liveAgent.whenIdle();
|
|
20136
21077
|
bumpSessionGeneration();
|
|
20137
21078
|
await initLiveSession(liveAgent);
|
|
21079
|
+
await refreshLiveCatalog(liveAgent);
|
|
20138
21080
|
}
|
|
20139
21081
|
if (resumeFailure !== void 0) {
|
|
20140
21082
|
app.notify(resumeFailure, "error");
|
|
@@ -20146,7 +21088,67 @@ function apply(ctx, config) {
|
|
|
20146
21088
|
return creating;
|
|
20147
21089
|
};
|
|
20148
21090
|
let commandsRegistered = false;
|
|
20149
|
-
|
|
21091
|
+
/** The claim test installed by registerTuiCommands: is a slash name
|
|
21092
|
+
* advertised by the CURRENT completion list? The dispatch captures it
|
|
21093
|
+
* BEFORE any session creation (see dispatchViaSession). */
|
|
21094
|
+
let wasAdvertisedClaim;
|
|
21095
|
+
/** The catalog refresh coordinator: the ONE post-mount refresh owner
|
|
21096
|
+
* (first session, switches, /preset, /reload). Built inside
|
|
21097
|
+
* registerCommands once the surface hooks exist. */
|
|
21098
|
+
let catalogRefreshRequest;
|
|
21099
|
+
let catalogCoordinator;
|
|
21100
|
+
/** `skills/change` coalescing: bursts of invalidation notifications cost
|
|
21101
|
+
* at most two reads, and the follow-up re-read observes the CURRENT
|
|
21102
|
+
* ownership (live agent vs standing preset). */
|
|
21103
|
+
let skillsChangeSubscribed = false;
|
|
21104
|
+
const skillsChangeGate = new CoalescingRefreshGate(() => {
|
|
21105
|
+
runOwned("skills/change refresh", async () => {
|
|
21106
|
+
const refresh = catalogRefreshRequest;
|
|
21107
|
+
if (refresh === void 0) return void 0;
|
|
21108
|
+
const target = liveAgent === void 0 ? {
|
|
21109
|
+
kind: "preset",
|
|
21110
|
+
presetId: pendingPreset ?? launchPreset
|
|
21111
|
+
} : {
|
|
21112
|
+
kind: "agent",
|
|
21113
|
+
key: sessionGeneration
|
|
21114
|
+
};
|
|
21115
|
+
return refresh({
|
|
21116
|
+
source: "invalidation",
|
|
21117
|
+
target,
|
|
21118
|
+
...target.kind === "agent" ? { agent: liveAgent } : {}
|
|
21119
|
+
});
|
|
21120
|
+
}, {
|
|
21121
|
+
diag,
|
|
21122
|
+
sessionId: () => liveAgent?.session.id,
|
|
21123
|
+
onResult: (outcome) => {
|
|
21124
|
+
if (outcome !== void 0 && outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
|
|
21125
|
+
skillsChangeGate.settled();
|
|
21126
|
+
},
|
|
21127
|
+
onCancel: () => {
|
|
21128
|
+
skillsChangeGate.settled();
|
|
21129
|
+
},
|
|
21130
|
+
onError: (error) => {
|
|
21131
|
+
skillsChangeGate.settled();
|
|
21132
|
+
app.notify(`skill catalog refresh failed: ${safeErrorMessage(error)}`, "error");
|
|
21133
|
+
}
|
|
21134
|
+
});
|
|
21135
|
+
});
|
|
21136
|
+
/** Subscribe to the dsh-skill invalidation notification once, through
|
|
21137
|
+
* the single-point adapter (plan appendix B.1). The event carries no
|
|
21138
|
+
* scope or cwd, so the refresh target follows the CURRENT ownership; an
|
|
21139
|
+
* unavailable or throwing subscription degrades to no subscription —
|
|
21140
|
+
* owner switches and /reload still refresh. The flag is set only after
|
|
21141
|
+
* a successful subscribe, so a throwing subscribe can retry on a later
|
|
21142
|
+
* registration attempt. */
|
|
21143
|
+
const subscribeSkillsChangeEvents = () => {
|
|
21144
|
+
if (skillsChangeSubscribed) return;
|
|
21145
|
+
try {
|
|
21146
|
+
subscribeSkillsChange(ctx, () => skillsChangeGate.notify());
|
|
21147
|
+
skillsChangeSubscribed = true;
|
|
21148
|
+
} catch (error) {
|
|
21149
|
+
diag.warn("skills/change subscription unavailable", { error: safeErrorMessage(error) });
|
|
21150
|
+
}
|
|
21151
|
+
};
|
|
20150
21152
|
const runner = {
|
|
20151
21153
|
ctx,
|
|
20152
21154
|
app,
|
|
@@ -20180,6 +21182,20 @@ function apply(ctx, config) {
|
|
|
20180
21182
|
set pendingPreset(id) {
|
|
20181
21183
|
pendingPreset = id;
|
|
20182
21184
|
},
|
|
21185
|
+
/** The effective preset id for COLD (sessionless) reads: the run-local
|
|
21186
|
+
* pending override ahead of the launch-time --preset (the SAME
|
|
21187
|
+
* precedence ensureSession uses); undefined = the saved/default
|
|
21188
|
+
* preset applies. */
|
|
21189
|
+
get effectivePresetId() {
|
|
21190
|
+
return pendingPreset ?? launchPreset;
|
|
21191
|
+
},
|
|
21192
|
+
refreshCatalog: (request) => {
|
|
21193
|
+
const refresh = catalogRefreshRequest;
|
|
21194
|
+
return refresh === void 0 ? Promise.resolve({
|
|
21195
|
+
kind: "failed",
|
|
21196
|
+
error: "catalog refresh unavailable"
|
|
21197
|
+
}) : refresh(request);
|
|
21198
|
+
},
|
|
20183
21199
|
switchSession,
|
|
20184
21200
|
swapTo: (next) => swapTo(next),
|
|
20185
21201
|
currentPreset,
|
|
@@ -20191,12 +21207,32 @@ function apply(ctx, config) {
|
|
|
20191
21207
|
requestExit,
|
|
20192
21208
|
exit
|
|
20193
21209
|
};
|
|
20194
|
-
const registerCommands = () => {
|
|
21210
|
+
const registerCommands = (initial) => {
|
|
20195
21211
|
if (commandsRegistered) return;
|
|
20196
21212
|
if (ctx.get("commands") === void 0) return;
|
|
20197
21213
|
commandsRegistered = true;
|
|
20198
21214
|
try {
|
|
20199
|
-
|
|
21215
|
+
const installed = registerTuiCommands(runner, initial);
|
|
21216
|
+
wasAdvertisedClaim = installed.wasAdvertised;
|
|
21217
|
+
catalogCoordinator = new CatalogRefreshCoordinator({
|
|
21218
|
+
readAgent: (agent, readSignal) => readSurfaceCatalog(agent, readSignal, ctx),
|
|
21219
|
+
readStanding: async (presetId, readSignal) => {
|
|
21220
|
+
const target = await resolveColdSkillTarget(ctx, presetId, process.cwd());
|
|
21221
|
+
if (target.target === void 0) throw new Error("skill service unavailable");
|
|
21222
|
+
return {
|
|
21223
|
+
catalog: await readHumanSkillCatalog(target.target.registry, {
|
|
21224
|
+
cwd: target.target.cwd,
|
|
21225
|
+
scope: target.target.scope,
|
|
21226
|
+
signal: readSignal
|
|
21227
|
+
}),
|
|
21228
|
+
...target.degraded === void 0 ? {} : { notice: target.degraded }
|
|
21229
|
+
};
|
|
21230
|
+
},
|
|
21231
|
+
installSnapshot: (next) => installed.installSnapshot(next),
|
|
21232
|
+
enterCatalogTransition: () => installed.enterTransition()
|
|
21233
|
+
}, lifecycleController.signal, diag);
|
|
21234
|
+
catalogRefreshRequest = (request) => catalogCoordinator.refresh(request);
|
|
21235
|
+
subscribeSkillsChangeEvents();
|
|
20200
21236
|
} catch (error) {
|
|
20201
21237
|
commandsRegistered = false;
|
|
20202
21238
|
const message = safeErrorMessage(error);
|
|
@@ -20205,13 +21241,21 @@ function apply(ctx, config) {
|
|
|
20205
21241
|
app.notify(`command registration failed: ${message}`, "error");
|
|
20206
21242
|
}
|
|
20207
21243
|
};
|
|
20208
|
-
/**
|
|
20209
|
-
|
|
20210
|
-
|
|
21244
|
+
/** Await one live-owner catalog refresh through the coordinator (the
|
|
21245
|
+
* first deferred create and every session switch): the refresh attempt
|
|
21246
|
+
* settles before the caller continues, and its outcome is an outcome —
|
|
21247
|
+
* provider issues degrade fields, failures warn, the submission or the
|
|
21248
|
+
* switch proceeds either way. */
|
|
21249
|
+
const refreshLiveCatalog = async (agent) => {
|
|
21250
|
+
const refresh = catalogRefreshRequest;
|
|
20211
21251
|
if (refresh === void 0) return;
|
|
20212
|
-
|
|
20213
|
-
|
|
20214
|
-
|
|
21252
|
+
await refresh({
|
|
21253
|
+
source: "live-session",
|
|
21254
|
+
target: {
|
|
21255
|
+
kind: "agent",
|
|
21256
|
+
key: sessionGeneration
|
|
21257
|
+
},
|
|
21258
|
+
agent
|
|
20215
21259
|
});
|
|
20216
21260
|
};
|
|
20217
21261
|
if (liveAgent !== void 0) await initLiveSession(liveAgent);
|
|
@@ -20220,7 +21264,14 @@ function apply(ctx, config) {
|
|
|
20220
21264
|
refreshStatus();
|
|
20221
21265
|
setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())}`);
|
|
20222
21266
|
}
|
|
20223
|
-
registerCommands(
|
|
21267
|
+
registerCommands({
|
|
21268
|
+
snapshot: initialSnapshot,
|
|
21269
|
+
skills: initialSkills
|
|
21270
|
+
});
|
|
21271
|
+
if (surfaceNotice !== void 0) {
|
|
21272
|
+
app.notify(surfaceNotice, "error");
|
|
21273
|
+
surfaceNotice = void 0;
|
|
21274
|
+
}
|
|
20224
21275
|
if (resumeFailure !== void 0) {
|
|
20225
21276
|
app.notify(resumeFailure, "error");
|
|
20226
21277
|
resumeFailure = void 0;
|
|
@@ -20330,4 +21381,4 @@ function apply(ctx, config) {
|
|
|
20330
21381
|
});
|
|
20331
21382
|
}
|
|
20332
21383
|
//#endregion
|
|
20333
|
-
export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resumeCommand, runningProfile, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };
|
|
21384
|
+
export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resolveInitialCatalog, resumeCommand, runningProfile, shouldConsumeAdvertisedMiss, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };
|