@xmoon76/dsh-pi-tui 0.1.4 → 0.1.6
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 +243 -6
- package/dist/index.mjs +2611 -321
- package/package.json +5 -3
- package/scripts/naming-gate.mjs +43 -1
- package/scripts/repair-core.mjs +35 -1
package/dist/index.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import { TUI_STARTUP_SERVICE } from "./startup.mjs";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
|
-
import { execSync, spawn } from "node:child_process";
|
|
5
|
+
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
|
-
import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
7
|
+
import { accessSync, appendFileSync, closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
@@ -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 = {
|
|
@@ -147,6 +148,37 @@ function deriveSummary(variant, argsRaw) {
|
|
|
147
148
|
for (const value of Object.values(args)) if (typeof value === "string" && value !== "") return firstLine(value);
|
|
148
149
|
return firstLine(argsRaw);
|
|
149
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Tool-specific args summaries for tools whose natural one-line identity is
|
|
153
|
+
* not a raw arg value (Web TodoRow/AskQuestionRow parity): `todo_write`
|
|
154
|
+
* reads `done/total done` plus the first active item, `ask_user_question`
|
|
155
|
+
* reads the first question text. Returns undefined for every other tool,
|
|
156
|
+
* letting the generic derivation own the summary.
|
|
157
|
+
* @param name - the tool name.
|
|
158
|
+
* @param argsRaw - the raw arguments JSON.
|
|
159
|
+
* @returns the tool-specific summary, or undefined when none applies.
|
|
160
|
+
*/
|
|
161
|
+
function summarizeToolArgs(name, argsRaw) {
|
|
162
|
+
const parsed = parseArgs(argsRaw);
|
|
163
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
164
|
+
const args = parsed;
|
|
165
|
+
if (name === "ask_user_question") {
|
|
166
|
+
const questions = args.questions;
|
|
167
|
+
if (!Array.isArray(questions) || questions.length === 0) return void 0;
|
|
168
|
+
const first = questions[0];
|
|
169
|
+
if (typeof first !== "object" || first === null) return void 0;
|
|
170
|
+
const question = first.question;
|
|
171
|
+
if (typeof question !== "string" || question === "") return void 0;
|
|
172
|
+
return firstLine(question);
|
|
173
|
+
}
|
|
174
|
+
if (name !== "todo_write") return void 0;
|
|
175
|
+
const todos = args.todos;
|
|
176
|
+
if (!Array.isArray(todos)) return void 0;
|
|
177
|
+
const done = todos.filter((todo) => typeof todo === "object" && todo !== null && todo.status === "completed").length;
|
|
178
|
+
const active = todos.find((todo) => typeof todo === "object" && todo !== null && todo.status !== "completed");
|
|
179
|
+
const content = active === void 0 ? "" : String(active.content ?? "");
|
|
180
|
+
return `${done}/${todos.length} done${content === "" ? "" : ` · ${firstLine(content)}`}`;
|
|
181
|
+
}
|
|
150
182
|
/** Classify a tool name into its row variant; unknown names are `others`. */
|
|
151
183
|
function classifyTool(name) {
|
|
152
184
|
return TOOL_VARIANTS[name] ?? "others";
|
|
@@ -294,7 +326,8 @@ function readFoldedPreview(result) {
|
|
|
294
326
|
function toolCardHeader(name, argsRaw, cwd) {
|
|
295
327
|
const variant = classifyTool(name);
|
|
296
328
|
const toolTitle = TOOL_TITLES[name] ?? TUI_TOOL_TITLES[name];
|
|
297
|
-
const
|
|
329
|
+
const toolSummary = summarizeToolArgs(name, argsRaw);
|
|
330
|
+
const base = argsRaw === "" ? "" : toolSummary ?? relativizeToCwd(deriveSummary(variant, argsRaw), cwd);
|
|
298
331
|
const summary = variant === "others" && toolTitle === void 0 ? base === "" ? "" : name + " · " + base : base;
|
|
299
332
|
return {
|
|
300
333
|
title: toolTitle ?? (name.startsWith("/") ? name.slice(1) : VARIANT_TITLES[variant]),
|
|
@@ -302,6 +335,123 @@ function toolCardHeader(name, argsRaw, cwd) {
|
|
|
302
335
|
};
|
|
303
336
|
}
|
|
304
337
|
/**
|
|
338
|
+
* Render a completed web retrieval as display lines (Web WebBlock parity):
|
|
339
|
+
* a `search` shows the provider answer and the source list (title — url,
|
|
340
|
+
* snippet under each), a `fetch` shows the URL and HTTP status. Truncation
|
|
341
|
+
* is marked in the same place the Web marks it: under the list/status.
|
|
342
|
+
* Colors are the caller's (the render layer owns the palette).
|
|
343
|
+
* @param view - the web result view from presentResult.
|
|
344
|
+
* @returns the display lines; empty when the view carries nothing to show.
|
|
345
|
+
*/
|
|
346
|
+
function webCardLines(view) {
|
|
347
|
+
if (view.kind === "search") {
|
|
348
|
+
const lines = [];
|
|
349
|
+
if (view.answer !== void 0 && view.answer !== "") lines.push(view.answer);
|
|
350
|
+
for (const source of view.sources) {
|
|
351
|
+
const head = source.title === void 0 || source.title === "" ? source.url : `${source.title} — ${source.url}`;
|
|
352
|
+
lines.push(`• ${head}`);
|
|
353
|
+
if (source.snippet !== void 0 && source.snippet !== "") lines.push(` ${source.snippet}`);
|
|
354
|
+
}
|
|
355
|
+
if (view.truncated) lines.push("… truncated — more sources omitted");
|
|
356
|
+
return lines;
|
|
357
|
+
}
|
|
358
|
+
const lines = [`${view.url} — HTTP ${view.statusCode}`];
|
|
359
|
+
if (view.truncated) lines.push("… truncated — content capped");
|
|
360
|
+
return lines;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Render a generic card's rawInput as display lines, structured per tool
|
|
364
|
+
* where the payload has a natural one-line shape (web TodoRow parity for
|
|
365
|
+
* `todo_write`, a session/terminal target line for the rest). Unknown
|
|
366
|
+
* object payloads fall back to pretty JSON — the same fallback the Web's
|
|
367
|
+
* generic body uses. A string rawInput renders verbatim.
|
|
368
|
+
* @param name - the tool name.
|
|
369
|
+
* @param rawInput - the presenter's salient raw input.
|
|
370
|
+
* @returns the display lines.
|
|
371
|
+
*/
|
|
372
|
+
/** Checklist display lines for a todo list: `●` in progress, `○` pending,
|
|
373
|
+
* `✓` completed (Web TodoRow parity). Malformed items are skipped. */
|
|
374
|
+
function todoChecklistLines(todos) {
|
|
375
|
+
const lines = [];
|
|
376
|
+
for (const todo of todos) {
|
|
377
|
+
if (typeof todo !== "object" || todo === null) continue;
|
|
378
|
+
const item = todo;
|
|
379
|
+
const content = typeof item.content === "string" ? item.content : String(item.content ?? "");
|
|
380
|
+
const mark = item.status === "completed" ? "✓" : item.status === "in_progress" ? "●" : "○";
|
|
381
|
+
lines.push(`${mark} ${content}`);
|
|
382
|
+
}
|
|
383
|
+
return lines;
|
|
384
|
+
}
|
|
385
|
+
function genericRawInputLines(name, rawInput) {
|
|
386
|
+
if (typeof rawInput === "string") return rawInput === "" ? [] : [rawInput];
|
|
387
|
+
if (typeof rawInput !== "object" || rawInput === null) return rawInput === void 0 ? [] : [String(rawInput)];
|
|
388
|
+
if (name === "todo_write" && Array.isArray(rawInput)) return todoChecklistLines(rawInput);
|
|
389
|
+
const args = rawInput;
|
|
390
|
+
if (name === "todo_write") {
|
|
391
|
+
const todos = args.todos;
|
|
392
|
+
if (Array.isArray(todos)) return todoChecklistLines(todos);
|
|
393
|
+
}
|
|
394
|
+
if ((name === "terminal_read" || name === "terminal_signal" || name === "terminal_send") && typeof args.sessionId === "string") {
|
|
395
|
+
const line = `session ${args.sessionId}`;
|
|
396
|
+
return name === "terminal_send" && typeof args.text === "string" ? [`${line}: ${args.text}`] : [line];
|
|
397
|
+
}
|
|
398
|
+
if ((name === "session_event_trace" || name === "session_event_read" || name === "session_trace") && (args.seq !== void 0 || args.session_id !== void 0)) {
|
|
399
|
+
const parts = [];
|
|
400
|
+
if (args.session_id !== void 0) parts.push(String(args.session_id));
|
|
401
|
+
if (args.seq !== void 0) parts.push(`seq ${String(args.seq)}`);
|
|
402
|
+
return [parts.join(" · ")];
|
|
403
|
+
}
|
|
404
|
+
return JSON.stringify(rawInput, null, 2).split("\n");
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Flatten a settled result's content blocks to display lines, with the Web's
|
|
408
|
+
* `resultText` semantics: text blocks verbatim, other block shapes as pretty
|
|
409
|
+
* JSON. Empty content on a failed call falls back to the structured error's
|
|
410
|
+
* `name: code` line (the Web's error summary).
|
|
411
|
+
* @param blocks - the result content blocks.
|
|
412
|
+
* @param error - the structured error, when the call failed.
|
|
413
|
+
* @returns the display lines (may be empty).
|
|
414
|
+
*/
|
|
415
|
+
function resultTextLines(blocks, error) {
|
|
416
|
+
const lines = [];
|
|
417
|
+
for (const block of blocks) if (block.type === "text") lines.push(...block.text.split("\n"));
|
|
418
|
+
else lines.push(JSON.stringify(block, null, 2));
|
|
419
|
+
if (lines.length === 0 && error !== void 0) lines.push(`${error.name}: ${error.code}`);
|
|
420
|
+
return lines;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The folded-card call preview for tools whose args carry a one-line
|
|
424
|
+
* identity (Web TodoRow/WebRow folded parity): `todo_write` summarizes
|
|
425
|
+
* `done/total` plus the first active item, `web_search`/`web_fetch` show
|
|
426
|
+
* the query/URL. Empty for every other tool (their folded row keeps the
|
|
427
|
+
* header + result preview).
|
|
428
|
+
* @param name - the tool name.
|
|
429
|
+
* @param argsRaw - the raw arguments JSON.
|
|
430
|
+
* @returns the preview line, or '' when the tool has none.
|
|
431
|
+
*/
|
|
432
|
+
function foldedCallPreview(name, argsRaw) {
|
|
433
|
+
const parsed = parseArgs(argsRaw);
|
|
434
|
+
if (typeof parsed !== "object" || parsed === null) return "";
|
|
435
|
+
const args = parsed;
|
|
436
|
+
if (name === "todo_write") {
|
|
437
|
+
const summary = summarizeToolArgs(name, argsRaw);
|
|
438
|
+
return summary === void 0 ? "" : ` — ${summary}`;
|
|
439
|
+
}
|
|
440
|
+
if (name === "web_search") {
|
|
441
|
+
const query = args.query;
|
|
442
|
+
return typeof query === "string" && query !== "" ? ` — ${firstLine(query)}` : "";
|
|
443
|
+
}
|
|
444
|
+
if (name === "web_fetch") {
|
|
445
|
+
const url = args.url;
|
|
446
|
+
return typeof url === "string" && url !== "" ? ` — ${firstLine(url)}` : "";
|
|
447
|
+
}
|
|
448
|
+
if (name === "skill") {
|
|
449
|
+
const skill = args.name;
|
|
450
|
+
return typeof skill === "string" && skill !== "" ? ` — ${firstLine(skill)}` : "";
|
|
451
|
+
}
|
|
452
|
+
return "";
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
305
455
|
* Build the presentation bridge over a tool-definition lookup (the runner
|
|
306
456
|
* passes a scoped registry read, e.g. `ctx.get('tools')?.get(name, scope)`).
|
|
307
457
|
* Mirrors the host apiproxy's presenter invocations: args are JSON-parsed and
|
|
@@ -1530,6 +1680,24 @@ const selectListTheme = {
|
|
|
1530
1680
|
groupHeader: (text) => chalk.bold.hex(currentPalette.textMuted)(text)
|
|
1531
1681
|
};
|
|
1532
1682
|
/**
|
|
1683
|
+
* Status-dot colour for a background-job status (dsh-web StateDot parity:
|
|
1684
|
+
* running = ongoing/primary, stopping = warning, completed = done/dim,
|
|
1685
|
+
* failed/killed/timed-out/lost = error). Unknown statuses fall back to the
|
|
1686
|
+
* muted token so a future wire status never crashes the renderer.
|
|
1687
|
+
*/
|
|
1688
|
+
function taskStatusColor(status) {
|
|
1689
|
+
switch (status) {
|
|
1690
|
+
case "running": return color.primary;
|
|
1691
|
+
case "stopping": return color.warning;
|
|
1692
|
+
case "completed": return color.textDim;
|
|
1693
|
+
case "failed":
|
|
1694
|
+
case "killed":
|
|
1695
|
+
case "timed_out":
|
|
1696
|
+
case "lost": return color.error;
|
|
1697
|
+
default: return color.textMuted;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1533
1701
|
* SettingsList palette from the semantic tokens. A FUNCTION, not a
|
|
1534
1702
|
* module-level constant: `cursor` is a pre-rendered ANSI string, so a
|
|
1535
1703
|
* constant would freeze the cursor colour at module load and never follow
|
|
@@ -6078,6 +6246,7 @@ var Editor = class {
|
|
|
6078
6246
|
isInPaste = false;
|
|
6079
6247
|
pasteBurst = new PasteBurst();
|
|
6080
6248
|
disablePasteBurst = false;
|
|
6249
|
+
inlineSlashTrigger = false;
|
|
6081
6250
|
history = [];
|
|
6082
6251
|
historyIndex = -1;
|
|
6083
6252
|
historyDraft = null;
|
|
@@ -6117,6 +6286,7 @@ var Editor = class {
|
|
|
6117
6286
|
const maxVisible = options.autocompleteMaxVisible ?? 5;
|
|
6118
6287
|
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
6119
6288
|
this.disablePasteBurst = options.disablePasteBurst ?? false;
|
|
6289
|
+
this.inlineSlashTrigger = options.inlineSlashTrigger ?? false;
|
|
6120
6290
|
}
|
|
6121
6291
|
/** Set of currently valid paste IDs, for marker-aware segmentation. */
|
|
6122
6292
|
validPasteIds() {
|
|
@@ -6404,7 +6574,7 @@ var Editor = class {
|
|
|
6404
6574
|
this.state.lines = result.lines;
|
|
6405
6575
|
this.state.cursorLine = result.cursorLine;
|
|
6406
6576
|
this.setCursorCol(result.cursorCol);
|
|
6407
|
-
if (this.autocompletePrefix.startsWith("/")) this.cancelAutocomplete();
|
|
6577
|
+
if (this.autocompletePrefix.startsWith("/") && selected.data?.["inlineSkill"] !== true) this.cancelAutocomplete();
|
|
6408
6578
|
else {
|
|
6409
6579
|
this.cancelAutocomplete();
|
|
6410
6580
|
if (this.onChange) this.onChange(this.getText());
|
|
@@ -6710,15 +6880,16 @@ var Editor = class {
|
|
|
6710
6880
|
this.setCursorCol(this.state.cursorCol + char.length);
|
|
6711
6881
|
if (this.onChange) this.onChange(this.getText());
|
|
6712
6882
|
if (!this.autocompleteState) {
|
|
6713
|
-
if (char === "/" && this.isAtStartOfMessage()) this.tryTriggerAutocomplete();
|
|
6883
|
+
if (char === "/" && (this.isAtStartOfMessage() || this.inlineSlashTrigger && this.isAtInlineSlashTrigger())) this.tryTriggerAutocomplete();
|
|
6714
6884
|
else if (this.autocompleteTriggerCharacters.includes(char)) {
|
|
6715
6885
|
const textBeforeCursor = (this.state.lines[this.state.cursorLine] || "").slice(0, this.state.cursorCol);
|
|
6716
6886
|
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
|
6717
6887
|
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === " ") this.tryTriggerAutocomplete();
|
|
6718
|
-
} else if (/[a-zA-Z0-9
|
|
6888
|
+
} else if (/[a-zA-Z0-9.:\-_]/.test(char)) {
|
|
6719
6889
|
const textBeforeCursor = (this.state.lines[this.state.cursorLine] || "").slice(0, this.state.cursorCol);
|
|
6720
6890
|
if (this.isInSlashCommandContext(textBeforeCursor)) this.tryTriggerAutocomplete();
|
|
6721
6891
|
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) this.tryTriggerAutocomplete();
|
|
6892
|
+
else if (this.isInInlineSlashContext(textBeforeCursor)) this.tryTriggerAutocomplete();
|
|
6722
6893
|
}
|
|
6723
6894
|
} else this.updateAutocomplete();
|
|
6724
6895
|
}
|
|
@@ -7342,6 +7513,20 @@ var Editor = class {
|
|
|
7342
7513
|
const beforeCursor = (this.state.lines[this.state.cursorLine] || "").slice(0, this.state.cursorCol);
|
|
7343
7514
|
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
|
|
7344
7515
|
}
|
|
7516
|
+
isAtInlineSlashTrigger() {
|
|
7517
|
+
if (this.isAtStartOfMessage()) return false;
|
|
7518
|
+
const beforeCursor = (this.state.lines[this.state.cursorLine] || "").slice(0, this.state.cursorCol);
|
|
7519
|
+
if (!beforeCursor.endsWith("/")) return false;
|
|
7520
|
+
if (this.state.cursorLine > 0 && beforeCursor.trim() === "/") return true;
|
|
7521
|
+
const charBeforeSlash = beforeCursor[beforeCursor.length - 2];
|
|
7522
|
+
return charBeforeSlash === " " || charBeforeSlash === " ";
|
|
7523
|
+
}
|
|
7524
|
+
isInInlineSlashContext(textBeforeCursor) {
|
|
7525
|
+
if (!this.inlineSlashTrigger) return false;
|
|
7526
|
+
if (this.isInSlashCommandContext(textBeforeCursor)) return false;
|
|
7527
|
+
if (/[ \t]\/[a-zA-Z0-9.:\-_]*$/.test(textBeforeCursor)) return true;
|
|
7528
|
+
return this.state.cursorLine > 0 && /^\/[a-zA-Z0-9.:\-_]*$/.test(textBeforeCursor);
|
|
7529
|
+
}
|
|
7345
7530
|
isInSlashCommandContext(textBeforeCursor) {
|
|
7346
7531
|
return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
|
|
7347
7532
|
}
|
|
@@ -12554,6 +12739,249 @@ function renderDiffView(diffs, cwd, options = {}) {
|
|
|
12554
12739
|
return out;
|
|
12555
12740
|
}
|
|
12556
12741
|
//#endregion
|
|
12742
|
+
//#region src/task-panel.ts
|
|
12743
|
+
/**
|
|
12744
|
+
* TaskBrowserPanel — the visual layer over the task-browser row model
|
|
12745
|
+
* (tasks-browser.ts). Rendered as an overlay inside a Frame (the ↓ / Ctrl+J
|
|
12746
|
+
* trigger with an empty editor, and /tasks), it replaces the generic
|
|
12747
|
+
* SelectList with a dsh-web JobListAction-style list: status dots, aligned
|
|
12748
|
+
* kind · label / status · elapsed columns, group headers, live 1s elapsed
|
|
12749
|
+
* ticking, and type-to-filter search.
|
|
12750
|
+
*
|
|
12751
|
+
* The row MODEL stays in tasks-browser.ts (buildTaskRows / taskRowLabel /
|
|
12752
|
+
* describeTaskRow / rowGroup) — this component only presents it.
|
|
12753
|
+
* @module @xmoon76/dsh-pi-tui/task-panel
|
|
12754
|
+
*/
|
|
12755
|
+
/**
|
|
12756
|
+
* Render the `status · elapsed` tail: elapsed is humanized as `2s` / `1m5s` /
|
|
12757
|
+
* `1h2m` (dsh-web duration parity), omitted when absent.
|
|
12758
|
+
*/
|
|
12759
|
+
function formatElapsed(elapsed) {
|
|
12760
|
+
if (elapsed === void 0 || !Number.isFinite(elapsed)) return "";
|
|
12761
|
+
const total = Math.max(0, Math.floor(elapsed));
|
|
12762
|
+
const seconds = total % 60;
|
|
12763
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
12764
|
+
const hours = Math.floor(total / 3600);
|
|
12765
|
+
if (hours > 0) return `${hours}h${minutes}m`;
|
|
12766
|
+
if (minutes > 0) return `${minutes}m${seconds}s`;
|
|
12767
|
+
return `${seconds}s`;
|
|
12768
|
+
}
|
|
12769
|
+
const POINTER = "→";
|
|
12770
|
+
const DOT = "●";
|
|
12771
|
+
/**
|
|
12772
|
+
* The task browser surface: a searchable, live-updating list of task rows
|
|
12773
|
+
* with status dots and aligned columns. Owns its own scroll window so the
|
|
12774
|
+
* list can grow beyond the overlay height.
|
|
12775
|
+
*/
|
|
12776
|
+
var TaskBrowserPanel = class {
|
|
12777
|
+
items = [];
|
|
12778
|
+
/** The filtered view (search query applied). */
|
|
12779
|
+
filtered = [];
|
|
12780
|
+
selected = 0;
|
|
12781
|
+
/** Scroll offset into the filtered list. */
|
|
12782
|
+
scroll = 0;
|
|
12783
|
+
maxVisible;
|
|
12784
|
+
options;
|
|
12785
|
+
searchInput = new Input();
|
|
12786
|
+
searchEnabled;
|
|
12787
|
+
onSelect;
|
|
12788
|
+
onCancel;
|
|
12789
|
+
/** Live tick: bumped every second while a running row is visible. */
|
|
12790
|
+
now = Date.now();
|
|
12791
|
+
tickTimer;
|
|
12792
|
+
/** Latched by dispose(): an in-flight tick callback must not render. */
|
|
12793
|
+
disposed = false;
|
|
12794
|
+
requestRender;
|
|
12795
|
+
_focused = false;
|
|
12796
|
+
constructor(items, maxVisible, options, onSelect, onCancel, requestRender) {
|
|
12797
|
+
this.items = [...items];
|
|
12798
|
+
this.filtered = [...items];
|
|
12799
|
+
this.maxVisible = Math.max(1, maxVisible);
|
|
12800
|
+
this.options = options;
|
|
12801
|
+
this.onSelect = onSelect;
|
|
12802
|
+
this.onCancel = onCancel;
|
|
12803
|
+
this.requestRender = requestRender;
|
|
12804
|
+
this.searchEnabled = options.enableSearch ?? false;
|
|
12805
|
+
this.searchInput.onEscape = () => this.onCancel();
|
|
12806
|
+
this.searchInput.onSubmit = (value) => {
|
|
12807
|
+
const item = this.filtered[this.selected];
|
|
12808
|
+
if (item !== void 0) this.onSelect(item.value);
|
|
12809
|
+
};
|
|
12810
|
+
const initial = options.initialQuery ?? "";
|
|
12811
|
+
if (initial !== "") {
|
|
12812
|
+
this.searchInput.setValue(initial);
|
|
12813
|
+
this.applyFilter(initial);
|
|
12814
|
+
}
|
|
12815
|
+
this.startTick();
|
|
12816
|
+
}
|
|
12817
|
+
/** Replace the row list; the active search query re-applies and the
|
|
12818
|
+
* selection survives when its value is still present. */
|
|
12819
|
+
setItems(items) {
|
|
12820
|
+
const previousValue = this.filtered[this.selected]?.value;
|
|
12821
|
+
this.items = [...items];
|
|
12822
|
+
this.applyFilter(this.searchInput.getValue() ?? "");
|
|
12823
|
+
if (previousValue !== void 0) {
|
|
12824
|
+
const index = this.filtered.findIndex((item) => item.value === previousValue);
|
|
12825
|
+
if (index !== -1) this.selected = index;
|
|
12826
|
+
}
|
|
12827
|
+
}
|
|
12828
|
+
/** The current search query. */
|
|
12829
|
+
getFilter() {
|
|
12830
|
+
return this.searchInput.getValue() ?? "";
|
|
12831
|
+
}
|
|
12832
|
+
/** The number of rows currently visible (after filtering). */
|
|
12833
|
+
get filteredCount() {
|
|
12834
|
+
return this.filtered.length;
|
|
12835
|
+
}
|
|
12836
|
+
startTick() {
|
|
12837
|
+
this.tickTimer = setInterval(() => {
|
|
12838
|
+
if (this.disposed) return;
|
|
12839
|
+
if (!this.items.some((item) => item.startedAt !== void 0 && (item.status === "running" || item.status === "stopping"))) return;
|
|
12840
|
+
this.now = Date.now();
|
|
12841
|
+
this.requestRender();
|
|
12842
|
+
}, 1e3);
|
|
12843
|
+
this.tickTimer.unref();
|
|
12844
|
+
}
|
|
12845
|
+
/** Stop the tick (the overlay is closing). */
|
|
12846
|
+
dispose() {
|
|
12847
|
+
this.disposed = true;
|
|
12848
|
+
if (this.tickTimer !== void 0) clearInterval(this.tickTimer);
|
|
12849
|
+
this.tickTimer = void 0;
|
|
12850
|
+
}
|
|
12851
|
+
get focused() {
|
|
12852
|
+
return this._focused;
|
|
12853
|
+
}
|
|
12854
|
+
set focused(value) {
|
|
12855
|
+
this._focused = value;
|
|
12856
|
+
this.searchInput.focused = value;
|
|
12857
|
+
}
|
|
12858
|
+
invalidate() {
|
|
12859
|
+
this.searchInput.invalidate();
|
|
12860
|
+
}
|
|
12861
|
+
handleInput(data) {
|
|
12862
|
+
const isNavUp = data === "\x1B[A" || !this.searchEnabled && data === "k";
|
|
12863
|
+
const isNavDown = data === "\x1B[B" || !this.searchEnabled && data === "j";
|
|
12864
|
+
const isPage = data === "\x1B[5~" || data === "\x1B[6~";
|
|
12865
|
+
if (!isNavUp && !isNavDown && !isPage && data !== "\r" && data !== "\n" && data !== "\x1B" && this.searchEnabled) {
|
|
12866
|
+
this.searchInput.handleInput(data);
|
|
12867
|
+
this.applyFilter(this.searchInput.getValue() ?? "");
|
|
12868
|
+
return;
|
|
12869
|
+
}
|
|
12870
|
+
if (isNavUp) {
|
|
12871
|
+
if (this.filtered.length === 0) return;
|
|
12872
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
12873
|
+
this.ensureVisible();
|
|
12874
|
+
return;
|
|
12875
|
+
}
|
|
12876
|
+
if (isNavDown) {
|
|
12877
|
+
if (this.filtered.length === 0) return;
|
|
12878
|
+
this.selected = Math.min(this.filtered.length - 1, this.selected + 1);
|
|
12879
|
+
this.ensureVisible();
|
|
12880
|
+
return;
|
|
12881
|
+
}
|
|
12882
|
+
if (isPage) {
|
|
12883
|
+
if (this.filtered.length === 0) return;
|
|
12884
|
+
if (data === "\x1B[5~") this.selected = Math.max(0, this.selected - this.maxVisible);
|
|
12885
|
+
else this.selected = Math.min(this.filtered.length - 1, this.selected + this.maxVisible);
|
|
12886
|
+
this.ensureVisible();
|
|
12887
|
+
return;
|
|
12888
|
+
}
|
|
12889
|
+
if (data === "\r" || data === "\n") {
|
|
12890
|
+
const item = this.filtered[this.selected];
|
|
12891
|
+
if (item !== void 0) this.onSelect(item.value);
|
|
12892
|
+
return;
|
|
12893
|
+
}
|
|
12894
|
+
if (data === "\x1B") this.onCancel();
|
|
12895
|
+
}
|
|
12896
|
+
ensureVisible() {
|
|
12897
|
+
if (this.selected < this.scroll) this.scroll = this.selected;
|
|
12898
|
+
else if (this.selected >= this.scroll + this.maxVisible) this.scroll = this.selected - this.maxVisible + 1;
|
|
12899
|
+
this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, this.filtered.length - this.maxVisible)));
|
|
12900
|
+
}
|
|
12901
|
+
applyFilter(query) {
|
|
12902
|
+
if (query === "") this.filtered = this.items;
|
|
12903
|
+
else {
|
|
12904
|
+
const needle = query.toLowerCase();
|
|
12905
|
+
this.filtered = this.items.filter((item) => `${item.value}\n${item.label}\n${item.status}\n${item.detail ?? ""}`.toLowerCase().includes(needle));
|
|
12906
|
+
}
|
|
12907
|
+
this.selected = 0;
|
|
12908
|
+
this.scroll = 0;
|
|
12909
|
+
}
|
|
12910
|
+
render(width) {
|
|
12911
|
+
const safeWidth = Math.max(1, width);
|
|
12912
|
+
const lines = [];
|
|
12913
|
+
if (this.options.header !== void 0) {
|
|
12914
|
+
const counts = this.counts();
|
|
12915
|
+
const headerText = counts === "" ? this.options.header : `${this.options.header} ${counts}`;
|
|
12916
|
+
lines.push(color.textStrong(truncateToWidth(headerText, safeWidth, "…")));
|
|
12917
|
+
lines.push("");
|
|
12918
|
+
}
|
|
12919
|
+
if (this.searchEnabled) {
|
|
12920
|
+
const searchLine = this.searchInput.render(Math.max(1, safeWidth - 2))[0] ?? "";
|
|
12921
|
+
const stripped = searchLine.startsWith("> ") ? searchLine.slice(2) : searchLine;
|
|
12922
|
+
lines.push(this.searchInput.getValue() === "" ? color.textDim(" search…") : ` ${stripped}`);
|
|
12923
|
+
lines.push("");
|
|
12924
|
+
}
|
|
12925
|
+
if (this.filtered.length === 0) {
|
|
12926
|
+
lines.push(color.textDim(this.options.noMatchText ?? "no active tasks"));
|
|
12927
|
+
lines.push("");
|
|
12928
|
+
lines.push(color.textMuted(` ${this.hint()}`));
|
|
12929
|
+
return lines;
|
|
12930
|
+
}
|
|
12931
|
+
this.ensureVisible();
|
|
12932
|
+
const start = this.scroll;
|
|
12933
|
+
const end = Math.min(this.filtered.length, start + this.maxVisible);
|
|
12934
|
+
let lastGroup;
|
|
12935
|
+
for (let i = start; i < end; i++) {
|
|
12936
|
+
const item = this.filtered[i];
|
|
12937
|
+
if (item === void 0) continue;
|
|
12938
|
+
if (item.group !== lastGroup) {
|
|
12939
|
+
if (item.group !== void 0) lines.push(color.textMuted(`── ${item.group} ──`));
|
|
12940
|
+
lastGroup = item.group;
|
|
12941
|
+
}
|
|
12942
|
+
lines.push(...this.renderRow(item, i === this.selected, safeWidth));
|
|
12943
|
+
}
|
|
12944
|
+
if (this.filtered.length > this.maxVisible) lines.push(color.textMuted(` ${this.selected + 1}/${this.filtered.length}`));
|
|
12945
|
+
lines.push("");
|
|
12946
|
+
lines.push(color.textMuted(` ${this.hint()}`));
|
|
12947
|
+
return lines;
|
|
12948
|
+
}
|
|
12949
|
+
counts() {
|
|
12950
|
+
const running = this.items.filter((item) => item.status === "running" || item.status === "stopping").length;
|
|
12951
|
+
const done = this.items.filter((item) => item.status === "completed").length;
|
|
12952
|
+
const failed = this.items.filter((item) => item.status === "failed" || item.status === "killed" || item.status === "timed_out" || item.status === "lost").length;
|
|
12953
|
+
const parts = [];
|
|
12954
|
+
if (running > 0) parts.push(color.primary(`${running} running`));
|
|
12955
|
+
if (done > 0) parts.push(color.textDim(`${done} done`));
|
|
12956
|
+
if (failed > 0) parts.push(color.error(`${failed} failed`));
|
|
12957
|
+
if (parts.length === 0) parts.push(color.textDim(`${this.items.length} total`));
|
|
12958
|
+
return parts.join(" · ");
|
|
12959
|
+
}
|
|
12960
|
+
renderRow(item, selected, width) {
|
|
12961
|
+
const dot = taskStatusColor(item.status)(DOT);
|
|
12962
|
+
const leftPrefix = `${selected ? color.primary(POINTER) : " "} ${dot} `;
|
|
12963
|
+
const leftWidth = visibleWidth(leftPrefix);
|
|
12964
|
+
const label = selected ? color.textStrong(item.label) : color.text(item.label);
|
|
12965
|
+
const statusText = taskStatusColor(item.status)(item.status);
|
|
12966
|
+
const elapsedSeconds = item.startedAt === void 0 ? void 0 : Math.max(0, Math.floor((this.now - item.startedAt) / 1e3));
|
|
12967
|
+
const tail = [statusText, elapsedSeconds === void 0 ? "" : color.textMuted(formatElapsed(elapsedSeconds))].filter((part) => part !== "").join(" ");
|
|
12968
|
+
const tailWidth = visibleWidth(tail);
|
|
12969
|
+
const available = width - leftWidth;
|
|
12970
|
+
const leftFinal = leftPrefix + truncateToWidth(label, Math.max(1, available - tailWidth - 2), "…");
|
|
12971
|
+
const pad = Math.max(1, available - visibleWidth(leftFinal) - tailWidth);
|
|
12972
|
+
const out = [truncateToWidth(leftFinal + " ".repeat(pad) + tail, width, "…")];
|
|
12973
|
+
if (item.detail !== void 0 && item.detail !== "") {
|
|
12974
|
+
const indent = " ".repeat(leftWidth);
|
|
12975
|
+
const detailLines = wrapTextWithAnsi(color.textDim(item.detail), Math.max(1, width - leftWidth));
|
|
12976
|
+
for (const wrapped of detailLines.slice(0, 2)) out.push(truncateToWidth(indent + wrapped, width, "…"));
|
|
12977
|
+
}
|
|
12978
|
+
return out;
|
|
12979
|
+
}
|
|
12980
|
+
hint() {
|
|
12981
|
+
return `${this.searchEnabled ? "type to filter · " : ""}↑↓ navigate · enter open · esc close`;
|
|
12982
|
+
}
|
|
12983
|
+
};
|
|
12984
|
+
//#endregion
|
|
12557
12985
|
//#region src/search.ts
|
|
12558
12986
|
/**
|
|
12559
12987
|
* The transcript-search overlay component: a one-line query input with a
|
|
@@ -12623,8 +13051,9 @@ const OTHER_ROW = "\0other";
|
|
|
12623
13051
|
/**
|
|
12624
13052
|
* Default total physical-row budget of the question flow itself. The
|
|
12625
13053
|
* 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
|
|
13054
|
+
* on every render (60% cap COLLAPSED, 8..24 content rows; up to 38 when
|
|
13055
|
+
* expanded) and pushes it through {@link QuestionFlow.setMaxRows}; 24 is the
|
|
13056
|
+
* fallback for direct renders.
|
|
12628
13057
|
* The Frame wrapper adds its two border rows, and NOTHING clips the flow's
|
|
12629
13058
|
* output in the editor-seat layout — so EVERYTHING (tabs, question, detail,
|
|
12630
13059
|
* options with their descriptions, scroll marker, skipped note, hint) must
|
|
@@ -12638,13 +13067,24 @@ const DEFAULT_BUDGET = 24;
|
|
|
12638
13067
|
*/
|
|
12639
13068
|
const MIN_SUPPORTED_BUDGET = 8;
|
|
12640
13069
|
/**
|
|
12641
|
-
*
|
|
12642
|
-
*
|
|
12643
|
-
*
|
|
13070
|
+
* Largest supported content-row budget: the EXPANDED frame cap (80% of the
|
|
13071
|
+
* terminal, at most 40 frame rows) minus the two frame borders. An explicit
|
|
13072
|
+
* body expand ('e' / marker click) grows the panel beyond the default 60%
|
|
13073
|
+
* cap — the user asked for the room; the budget math is linear and holds
|
|
13074
|
+
* for every budget >= MIN_SUPPORTED_BUDGET.
|
|
13075
|
+
*/
|
|
13076
|
+
const MAX_BUDGET = 38;
|
|
13077
|
+
/**
|
|
13078
|
+
* Cap for the WRAPPED page scrollport content (question + detail + options
|
|
13079
|
+
* with their descriptions). Beyond it the content ends in a `... more
|
|
13080
|
+
* content hidden` note — wrapping an unbounded 100KB detail every frame
|
|
13081
|
+
* would be wasteful. 256 rows keeps realistic questions AND all their
|
|
13082
|
+
* answerable options reachable (7 options × ~19 wrapped description rows
|
|
13083
|
+
* at 50 columns fits); the cap is a hard wall, so a page whose content
|
|
13084
|
+
* alone exceeds 256 rows ends in the note — that page is unreachable in
|
|
13085
|
+
* practice, and the note makes the cut non-silent.
|
|
12644
13086
|
*/
|
|
12645
|
-
const
|
|
12646
|
-
/** Max visible option rows before the list windows around the cursor. */
|
|
12647
|
-
const MAX_VISIBLE_OPTIONS = 7;
|
|
13087
|
+
const MAX_CONTENT_ROWS = 256;
|
|
12648
13088
|
/**
|
|
12649
13089
|
* Budgeted push of `content` to `lines`, wrapping it to `width`: pushes at
|
|
12650
13090
|
* most `budget` PHYSICAL (wrapped) rows, appends a `... N more` marker when
|
|
@@ -12736,13 +13176,46 @@ var QuestionFlow = class {
|
|
|
12736
13176
|
/** Review-page action highlight (0 = Submit, 1 = Cancel). */
|
|
12737
13177
|
submitIdx = 0;
|
|
12738
13178
|
/**
|
|
12739
|
-
* Current content-row budget (8..
|
|
13179
|
+
* Current content-row budget (8..38). The editor-seat QuestionFrame in
|
|
12740
13180
|
* tui-app.ts re-derives it from the terminal height on every render and
|
|
12741
13181
|
* pushes it through {@link setMaxRows}; nothing clips the flow's output
|
|
12742
13182
|
* in that layout, so every render must fit the budget exactly.
|
|
12743
13183
|
*/
|
|
12744
13184
|
budget = DEFAULT_BUDGET;
|
|
12745
13185
|
_focused = false;
|
|
13186
|
+
/**
|
|
13187
|
+
* Scroll offset (content rows) into the UNIFIED page scrollport (question,
|
|
13188
|
+
* detail, then every option with its description, then the free-text row).
|
|
13189
|
+
* PageUp/PageDown move it; ↑↓/digits/click move the cursor and the render
|
|
13190
|
+
* follows it into view (pendingCursorScroll).
|
|
13191
|
+
*/
|
|
13192
|
+
bodyScroll = 0;
|
|
13193
|
+
/**
|
|
13194
|
+
* Expanded mode ('e' / marker click): the FRAME grows toward 80% (the
|
|
13195
|
+
* QuestionFrame reads isBodyExpanded each render), so the scrollport is
|
|
13196
|
+
* taller and more of the page — including option descriptions — is visible
|
|
13197
|
+
* at once. Everything stays reachable by scrolling either way.
|
|
13198
|
+
*/
|
|
13199
|
+
bodyExpanded = false;
|
|
13200
|
+
/** Cursor moved since the last render: the next render must scroll the
|
|
13201
|
+
* cursor's rows into view. */
|
|
13202
|
+
pendingCursorScroll = false;
|
|
13203
|
+
/** Hit map from the last render: content row -> option row key. Built each
|
|
13204
|
+
* render; drives fullscreen click-to-select. */
|
|
13205
|
+
hitMap = /* @__PURE__ */ new Map();
|
|
13206
|
+
/** Content row of the scroll marker in the last render (-1 = none);
|
|
13207
|
+
* clicking it toggles the expanded panel. */
|
|
13208
|
+
lastMarkerRow = -1;
|
|
13209
|
+
/** Scrollport height from the last render (scroll page math). */
|
|
13210
|
+
lastRegionHeight = 0;
|
|
13211
|
+
/** Wrapped page content length from the last render. */
|
|
13212
|
+
lastContentRows = 0;
|
|
13213
|
+
/** Content rows visible in the region from the last render (region minus
|
|
13214
|
+
* the marker row when overflowing). */
|
|
13215
|
+
lastVisibleRows = 0;
|
|
13216
|
+
/** Whether the page content overflows the scrollport on the last render —
|
|
13217
|
+
* 'e' (and the scroll verbs) are available when true. */
|
|
13218
|
+
lastExpandable = false;
|
|
12746
13219
|
constructor(questions, onDone, onCancel) {
|
|
12747
13220
|
this.questions = questions;
|
|
12748
13221
|
this.onDone = onDone;
|
|
@@ -12773,10 +13246,144 @@ var QuestionFlow = class {
|
|
|
12773
13246
|
* Replace the content-row budget (the Frame wrapper adds its two border
|
|
12774
13247
|
* rows on top). Called by QuestionFrame on every render immediately
|
|
12775
13248
|
* before the flow renders, so no invalidation is needed; values outside
|
|
12776
|
-
* [MIN_SUPPORTED_BUDGET,
|
|
13249
|
+
* [MIN_SUPPORTED_BUDGET, MAX_BUDGET] are clamped.
|
|
12777
13250
|
*/
|
|
12778
13251
|
setMaxRows(rows) {
|
|
12779
|
-
this.budget = Math.max(MIN_SUPPORTED_BUDGET, Math.min(
|
|
13252
|
+
this.budget = Math.max(MIN_SUPPORTED_BUDGET, Math.min(MAX_BUDGET, Math.floor(rows)));
|
|
13253
|
+
}
|
|
13254
|
+
/** Whether the panel is expanded (the frame grows toward 80% of the
|
|
13255
|
+
* terminal — QuestionFrame reads this every render). */
|
|
13256
|
+
isBodyExpanded() {
|
|
13257
|
+
return this.bodyExpanded;
|
|
13258
|
+
}
|
|
13259
|
+
/**
|
|
13260
|
+
* Reset the view (scroll + expanded + pending cursor follow) — called on
|
|
13261
|
+
* every tab change so each question starts at its top, collapsed.
|
|
13262
|
+
*/
|
|
13263
|
+
resetBodyView() {
|
|
13264
|
+
this.bodyScroll = 0;
|
|
13265
|
+
this.bodyExpanded = false;
|
|
13266
|
+
this.pendingCursorScroll = false;
|
|
13267
|
+
}
|
|
13268
|
+
/**
|
|
13269
|
+
* Page the unified scrollport. One page = the visible content rows. No-op
|
|
13270
|
+
* when the content fits the region.
|
|
13271
|
+
*/
|
|
13272
|
+
scrollBody(direction) {
|
|
13273
|
+
const visible = this.lastVisibleRows;
|
|
13274
|
+
if (visible <= 0 || this.lastContentRows <= visible) return;
|
|
13275
|
+
const maxScroll = this.lastContentRows - visible;
|
|
13276
|
+
this.bodyScroll = Math.max(0, Math.min(this.bodyScroll + direction * visible, maxScroll));
|
|
13277
|
+
}
|
|
13278
|
+
/** Toggle the expanded panel ('e' or a click on the scroll marker).
|
|
13279
|
+
* Expanding is only meaningful when the page content overflows the
|
|
13280
|
+
* scrollport (everything fits -> a taller panel shows nothing new). The
|
|
13281
|
+
* SCROLL POSITION is kept: the taller region reveals MORE rows where the
|
|
13282
|
+
* user is looking (e.g. the option descriptions they scrolled to), instead
|
|
13283
|
+
* of jumping back to the top — the render clamps the offset to the new
|
|
13284
|
+
* maxScroll. */
|
|
13285
|
+
toggleExpanded() {
|
|
13286
|
+
if (!this.bodyExpanded && !this.lastExpandable) return;
|
|
13287
|
+
this.bodyExpanded = !this.bodyExpanded;
|
|
13288
|
+
}
|
|
13289
|
+
/**
|
|
13290
|
+
* Primary-click routing (fullscreen): an option row selects it (single-
|
|
13291
|
+
* select advances, multi-select toggles, the "Type something." row enters
|
|
13292
|
+
* free-text), and the scroll marker toggles the expanded panel. The hit
|
|
13293
|
+
* map reflects the LAST rendered frame, which is what the user sees.
|
|
13294
|
+
*/
|
|
13295
|
+
clickRow(row) {
|
|
13296
|
+
if (row < 0 || this.tab >= this.questions.length) return;
|
|
13297
|
+
const key = this.hitMap.get(row);
|
|
13298
|
+
if (key === OTHER_ROW) {
|
|
13299
|
+
if (this.editingOther) return;
|
|
13300
|
+
const index = this.rows().findIndex((candidate) => candidate.key === OTHER_ROW);
|
|
13301
|
+
if (index >= 0) {
|
|
13302
|
+
this.cursor = index;
|
|
13303
|
+
this.pendingCursorScroll = true;
|
|
13304
|
+
this.enterOther();
|
|
13305
|
+
}
|
|
13306
|
+
return;
|
|
13307
|
+
}
|
|
13308
|
+
if (key !== void 0) {
|
|
13309
|
+
this.cursor = Number(key);
|
|
13310
|
+
this.pendingCursorScroll = true;
|
|
13311
|
+
this.confirm();
|
|
13312
|
+
return;
|
|
13313
|
+
}
|
|
13314
|
+
if (row === this.lastMarkerRow) this.toggleExpanded();
|
|
13315
|
+
}
|
|
13316
|
+
/**
|
|
13317
|
+
* Build the UNIFIED page scrollport content: the question + detail first
|
|
13318
|
+
* (required-first — row 0 is always the question's first physical row),
|
|
13319
|
+
* a separator, then every option row (label + description), then the
|
|
13320
|
+
* free-text row. Each option row maps to its key for click hit-testing.
|
|
13321
|
+
* Capped at MAX_CONTENT_ROWS with a trailing `... more content hidden`
|
|
13322
|
+
* note, so wrapping an unbounded detail/description stays cheap.
|
|
13323
|
+
*/
|
|
13324
|
+
buildPageContent(question, rows, draft, multi, width) {
|
|
13325
|
+
const lines = [];
|
|
13326
|
+
const hits = [];
|
|
13327
|
+
let left = MAX_CONTENT_ROWS;
|
|
13328
|
+
left = appendContentFirst(lines, `${color.primary("?")} `, " ", color.textStrong(question.question), width, left);
|
|
13329
|
+
hits.push(...new Array(lines.length).fill(void 0));
|
|
13330
|
+
if (question.detail !== void 0 && question.detail !== "") for (const line of question.detail.split("\n")) {
|
|
13331
|
+
if (left <= 1) {
|
|
13332
|
+
appendWrappedBudgeted(lines, " ", " ", color.textDim("... more content hidden"), width, left);
|
|
13333
|
+
break;
|
|
13334
|
+
}
|
|
13335
|
+
left = appendWrappedBudgeted(lines, " ", " ", color.textDim(line), width, left);
|
|
13336
|
+
}
|
|
13337
|
+
hits.push(...new Array(lines.length - hits.length).fill(void 0));
|
|
13338
|
+
lines.push("");
|
|
13339
|
+
hits.push(void 0);
|
|
13340
|
+
for (const row of rows) {
|
|
13341
|
+
const isCursor = rows[this.cursor] === row;
|
|
13342
|
+
const selected = row.key === OTHER_ROW ? draft.custom !== "" : draft.selected.has(question.options?.[Number(row.key)]?.label ?? "");
|
|
13343
|
+
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}]`);
|
|
13344
|
+
const prefix = `${isCursor ? color.primary("→") : " "} ${marker} `;
|
|
13345
|
+
const indent = " ".repeat(visibleWidth(prefix));
|
|
13346
|
+
const badge = row.recommended ? ` ${color.primary("[recommended]")}` : "";
|
|
13347
|
+
const label = isCursor ? color.textStrong(row.label) : row.label;
|
|
13348
|
+
if (row.key === OTHER_ROW && this.editingOther) {
|
|
13349
|
+
const inputLine = this.otherInput.render(Math.max(1, width - visibleWidth(prefix)))[0] ?? "";
|
|
13350
|
+
const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
|
|
13351
|
+
lines.push(prefix + (this.otherInput.getValue() === "" ? color.textDim(row.label) : stripped));
|
|
13352
|
+
hits.push(OTHER_ROW);
|
|
13353
|
+
continue;
|
|
13354
|
+
}
|
|
13355
|
+
const labelWidth = Math.max(1, width - visibleWidth(prefix));
|
|
13356
|
+
for (const wrapped of wrapTextWithAnsi(`${label}${badge}`, labelWidth)) if (!this.pushPageRow(lines, hits, prefix + wrapped, row.key)) return {
|
|
13357
|
+
lines,
|
|
13358
|
+
hits
|
|
13359
|
+
};
|
|
13360
|
+
if (row.description !== void 0 && row.description !== "" && !(row.key === OTHER_ROW && this.editingOther)) {
|
|
13361
|
+
const descWidth = Math.max(1, width - visibleWidth(indent));
|
|
13362
|
+
for (const wrapped of wrapTextWithAnsi(color.textDim(row.description), descWidth)) if (!this.pushPageRow(lines, hits, indent + wrapped, row.key)) return {
|
|
13363
|
+
lines,
|
|
13364
|
+
hits
|
|
13365
|
+
};
|
|
13366
|
+
}
|
|
13367
|
+
}
|
|
13368
|
+
return {
|
|
13369
|
+
lines,
|
|
13370
|
+
hits
|
|
13371
|
+
};
|
|
13372
|
+
}
|
|
13373
|
+
/** Push one page-content row, capping at MAX_CONTENT_ROWS with a single
|
|
13374
|
+
* `... more content hidden` note (never a second one after the detail's).
|
|
13375
|
+
* @returns false when the cap was hit (callers stop building). */
|
|
13376
|
+
pushPageRow(lines, hits, line, key) {
|
|
13377
|
+
if (lines.length >= MAX_CONTENT_ROWS) {
|
|
13378
|
+
if (lines[lines.length - 1]?.includes("more content hidden") !== true) {
|
|
13379
|
+
lines.push(color.textDim("... more content hidden"));
|
|
13380
|
+
hits.push(void 0);
|
|
13381
|
+
}
|
|
13382
|
+
return false;
|
|
13383
|
+
}
|
|
13384
|
+
lines.push(line);
|
|
13385
|
+
hits.push(key);
|
|
13386
|
+
return true;
|
|
12780
13387
|
}
|
|
12781
13388
|
/** The rows of the current question (options plus the free-text row). */
|
|
12782
13389
|
rows() {
|
|
@@ -12846,6 +13453,7 @@ var QuestionFlow = class {
|
|
|
12846
13453
|
}
|
|
12847
13454
|
/** Optionless questions edit text directly; options start in list mode. */
|
|
12848
13455
|
syncEditMode() {
|
|
13456
|
+
this.resetBodyView();
|
|
12849
13457
|
const question = this.questions[this.tab];
|
|
12850
13458
|
const optionless = question !== void 0 && (question.options?.length ?? 0) === 0;
|
|
12851
13459
|
this.editingOther = optionless;
|
|
@@ -12920,6 +13528,7 @@ var QuestionFlow = class {
|
|
|
12920
13528
|
if (this.editingOther) {
|
|
12921
13529
|
if (data === "\r" || data === "\n") this.commitOther(this.otherInput.getValue());
|
|
12922
13530
|
else if (data === "\x1B") this.exitOther();
|
|
13531
|
+
else if (data === "\x1B[5~" || data === "\x1B[6~") this.scrollBody(data === "\x1B[5~" ? -1 : 1);
|
|
12923
13532
|
else if (data === "\x1B[D") {
|
|
12924
13533
|
if (this.tab > 0) {
|
|
12925
13534
|
this.tab -= 1;
|
|
@@ -12962,16 +13571,39 @@ var QuestionFlow = class {
|
|
|
12962
13571
|
const row = rows[Number(digit[0]) - 1];
|
|
12963
13572
|
if (row !== void 0) {
|
|
12964
13573
|
this.cursor = rows.indexOf(row);
|
|
13574
|
+
this.pendingCursorScroll = true;
|
|
12965
13575
|
this.confirm();
|
|
12966
13576
|
}
|
|
12967
13577
|
return;
|
|
12968
13578
|
}
|
|
12969
13579
|
if (data === "\x1B[A" || data === "k") {
|
|
12970
|
-
if (rows.length
|
|
13580
|
+
if (rows.length === 0) return;
|
|
13581
|
+
if (this.cursor === 0 && this.bodyScroll > 0) {
|
|
13582
|
+
this.scrollBody(-1);
|
|
13583
|
+
return;
|
|
13584
|
+
}
|
|
13585
|
+
this.cursor = (this.cursor - 1 + rows.length) % rows.length;
|
|
13586
|
+
this.pendingCursorScroll = true;
|
|
12971
13587
|
return;
|
|
12972
13588
|
}
|
|
12973
13589
|
if (data === "\x1B[B" || data === "j") {
|
|
12974
|
-
if (rows.length
|
|
13590
|
+
if (rows.length === 0) return;
|
|
13591
|
+
const atLastRow = this.cursor === rows.length - 1;
|
|
13592
|
+
const scrolled = this.lastExpandable && this.lastContentRows > this.bodyScroll + this.lastVisibleRows;
|
|
13593
|
+
if (atLastRow && scrolled) {
|
|
13594
|
+
this.scrollBody(1);
|
|
13595
|
+
return;
|
|
13596
|
+
}
|
|
13597
|
+
this.cursor = (this.cursor + 1) % rows.length;
|
|
13598
|
+
this.pendingCursorScroll = true;
|
|
13599
|
+
return;
|
|
13600
|
+
}
|
|
13601
|
+
if (data === "\x1B[5~" || data === "\x1B[6~") {
|
|
13602
|
+
this.scrollBody(data === "\x1B[5~" ? -1 : 1);
|
|
13603
|
+
return;
|
|
13604
|
+
}
|
|
13605
|
+
if (data === "e") {
|
|
13606
|
+
this.toggleExpanded();
|
|
12975
13607
|
return;
|
|
12976
13608
|
}
|
|
12977
13609
|
if (data === "\r" || data === "\n") {
|
|
@@ -12992,6 +13624,7 @@ var QuestionFlow = class {
|
|
|
12992
13624
|
this.tab += 1;
|
|
12993
13625
|
this.cursor = 0;
|
|
12994
13626
|
this.submitIdx = 0;
|
|
13627
|
+
this.syncEditMode();
|
|
12995
13628
|
} else {
|
|
12996
13629
|
this.tab = this.questions.length;
|
|
12997
13630
|
this.cursor = 0;
|
|
@@ -13012,6 +13645,8 @@ var QuestionFlow = class {
|
|
|
13012
13645
|
render(width) {
|
|
13013
13646
|
const safeWidth = Math.max(1, width);
|
|
13014
13647
|
const lines = [];
|
|
13648
|
+
this.hitMap.clear();
|
|
13649
|
+
this.lastMarkerRow = -1;
|
|
13015
13650
|
const tabs = this.questions.map((_, index) => {
|
|
13016
13651
|
const draft = this.drafts[index];
|
|
13017
13652
|
const answered = draft !== void 0 && (draft.selected.size > 0 || draft.custom !== "" || draft.skipped);
|
|
@@ -13053,102 +13688,73 @@ var QuestionFlow = class {
|
|
|
13053
13688
|
if (question === void 0 || draft === void 0) return lines;
|
|
13054
13689
|
const rows = this.rows();
|
|
13055
13690
|
const multi = question.multiSelect === true;
|
|
13056
|
-
this.editingOther && rows.some((row) => row.key === OTHER_ROW);
|
|
13057
13691
|
const skippedRow = draft.skipped ? 1 : 0;
|
|
13058
13692
|
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;
|
|
13693
|
+
const tail = optionless ? 3 + skippedRow : 2 + skippedRow;
|
|
13061
13694
|
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;
|
|
13695
|
+
if (header !== void 0 && header !== "" && this.budget - lines.length - tail >= 2) lines.push(color.textDim(header));
|
|
13696
|
+
const content = this.buildPageContent(question, rows, draft, multi, safeWidth);
|
|
13697
|
+
const regionHeight = Math.max(1, this.budget - lines.length - tail);
|
|
13698
|
+
const overflow = content.lines.length > regionHeight;
|
|
13699
|
+
const visible = overflow && regionHeight >= 2 ? regionHeight - 1 : regionHeight;
|
|
13700
|
+
this.lastRegionHeight = regionHeight;
|
|
13701
|
+
this.lastContentRows = content.lines.length;
|
|
13702
|
+
this.lastVisibleRows = visible;
|
|
13703
|
+
this.lastExpandable = overflow && regionHeight >= 2;
|
|
13704
|
+
const maxScroll = Math.max(0, content.lines.length - visible);
|
|
13705
|
+
this.bodyScroll = Math.min(this.bodyScroll, maxScroll);
|
|
13706
|
+
if (this.pendingCursorScroll) {
|
|
13707
|
+
this.pendingCursorScroll = false;
|
|
13708
|
+
const cursorKey = rows[this.cursor]?.key;
|
|
13709
|
+
if (cursorKey !== void 0) {
|
|
13710
|
+
const first = content.hits.indexOf(cursorKey);
|
|
13711
|
+
if (first >= 0) {
|
|
13712
|
+
if (first < this.bodyScroll) this.bodyScroll = first;
|
|
13713
|
+
else if (first >= this.bodyScroll + visible) this.bodyScroll = first - visible + 1;
|
|
13714
|
+
this.bodyScroll = Math.max(0, Math.min(this.bodyScroll, maxScroll));
|
|
13126
13715
|
}
|
|
13127
|
-
if (rows.length > MAX_VISIBLE_OPTIONS && left > 0) left = appendWrappedBudgeted(lines, " ", " ", `showing ${start + 1}-${end} of ${rows.length}`, safeWidth, left);
|
|
13128
13716
|
}
|
|
13129
|
-
}
|
|
13717
|
+
}
|
|
13718
|
+
for (let index = this.bodyScroll; index < this.bodyScroll + visible && index < content.lines.length; index++) {
|
|
13719
|
+
const hit = content.hits[index];
|
|
13720
|
+
if (hit !== void 0) this.hitMap.set(lines.length, hit);
|
|
13721
|
+
lines.push(content.lines[index]);
|
|
13722
|
+
}
|
|
13723
|
+
if (overflow && regionHeight >= 2) {
|
|
13724
|
+
const above = this.bodyScroll;
|
|
13725
|
+
const below = content.lines.length - (this.bodyScroll + visible);
|
|
13726
|
+
const marker = above > 0 && below > 0 ? `↑ ${above} up · ↓ ${below} more lines` : above > 0 ? `↑ ${above} up` : `↓ ${below} more lines`;
|
|
13727
|
+
this.lastMarkerRow = lines.length;
|
|
13728
|
+
lines.push(color.textDim(marker));
|
|
13729
|
+
} else this.lastMarkerRow = -1;
|
|
13730
|
+
if (optionless) {
|
|
13130
13731
|
const inputLine = this.otherInput.render(Math.max(1, safeWidth - 2))[0] ?? "";
|
|
13131
13732
|
const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
|
|
13132
|
-
lines.push(` ${stripped}`);
|
|
13733
|
+
lines.push(this.otherInput.getValue() === "" ? color.textDim(" Type your answer…") : ` ${stripped}`);
|
|
13133
13734
|
}
|
|
13134
13735
|
if (draft.skipped) lines.push(color.textDim("(skipped)"));
|
|
13135
13736
|
lines.push("");
|
|
13136
13737
|
const optionCount = Math.min(rows.length, 9);
|
|
13738
|
+
const scrollable = this.lastExpandable;
|
|
13137
13739
|
const hintParts = [
|
|
13138
13740
|
"↑↓ select",
|
|
13139
13741
|
optionCount > 0 ? `1-${optionCount} choose` : "",
|
|
13140
13742
|
multi ? "↵ toggle" : "↵ confirm",
|
|
13743
|
+
scrollable ? "pgup/pgdn scroll" : "",
|
|
13744
|
+
!this.editingOther ? this.bodyExpanded ? "e collapse" : scrollable ? "e expand" : "" : "",
|
|
13141
13745
|
this.questions.length > 1 ? "←→ switch" : "",
|
|
13142
13746
|
"s skip",
|
|
13143
13747
|
"esc cancel"
|
|
13144
13748
|
].filter((part) => part !== "");
|
|
13749
|
+
const cancel = "esc cancel";
|
|
13750
|
+
const verbs = hintParts.filter((part) => part !== cancel);
|
|
13145
13751
|
let hint = "";
|
|
13146
|
-
for (const part of
|
|
13752
|
+
for (const part of verbs) {
|
|
13147
13753
|
const next = hint === "" ? part : `${hint} · ${part}`;
|
|
13148
|
-
if (visibleWidth(next) > safeWidth) break;
|
|
13754
|
+
if (visibleWidth(`${next} · ${cancel}`) > safeWidth) break;
|
|
13149
13755
|
hint = next;
|
|
13150
13756
|
}
|
|
13151
|
-
lines.push(color.textDim(hint === "" ?
|
|
13757
|
+
lines.push(color.textDim(hint === "" ? cancel : `${hint} · ${cancel}`));
|
|
13152
13758
|
return lines;
|
|
13153
13759
|
}
|
|
13154
13760
|
};
|
|
@@ -13705,6 +14311,10 @@ var Frame = class {
|
|
|
13705
14311
|
var QuestionFrame = class extends Frame {
|
|
13706
14312
|
flow;
|
|
13707
14313
|
heightOf;
|
|
14314
|
+
/** Rendered height of the last frame (fullscreen click hit-testing). */
|
|
14315
|
+
lastRows = 0;
|
|
14316
|
+
/** Terminal height the last render used (click staleness guard). */
|
|
14317
|
+
lastTermRows = 0;
|
|
13708
14318
|
constructor(flow, heightOf) {
|
|
13709
14319
|
super(flow, true);
|
|
13710
14320
|
this.flow = flow;
|
|
@@ -13712,9 +14322,20 @@ var QuestionFrame = class extends Frame {
|
|
|
13712
14322
|
}
|
|
13713
14323
|
render(width) {
|
|
13714
14324
|
const rows = Math.max(1, this.heightOf());
|
|
13715
|
-
|
|
14325
|
+
this.lastTermRows = rows;
|
|
14326
|
+
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
14327
|
this.flow.setMaxRows(frameRows - 2);
|
|
13717
|
-
|
|
14328
|
+
const out = super.render(width);
|
|
14329
|
+
this.lastRows = out.length;
|
|
14330
|
+
return out;
|
|
14331
|
+
}
|
|
14332
|
+
/** The frame's height from the last render (0 before the first one). */
|
|
14333
|
+
get rows() {
|
|
14334
|
+
return this.lastRows;
|
|
14335
|
+
}
|
|
14336
|
+
/** The terminal height the last render used. */
|
|
14337
|
+
get termRows() {
|
|
14338
|
+
return this.lastTermRows;
|
|
13718
14339
|
}
|
|
13719
14340
|
get focused() {
|
|
13720
14341
|
return this.flow.focused;
|
|
@@ -13997,19 +14618,23 @@ var TuiApp = class TuiApp {
|
|
|
13997
14618
|
/** The todo panel Text; empty when hidden. */
|
|
13998
14619
|
todoPanel;
|
|
13999
14620
|
/**
|
|
14000
|
-
* The persistent dock strip directly above the todo panel:
|
|
14001
|
-
*
|
|
14002
|
-
* background tasks (kimi chrome parity). Empty lines drop out entirely.
|
|
14621
|
+
* The persistent dock strip directly above the todo panel: the todo
|
|
14622
|
+
* summary. Empty lines drop out entirely.
|
|
14003
14623
|
*/
|
|
14004
14624
|
dock;
|
|
14005
|
-
/**
|
|
14625
|
+
/**
|
|
14626
|
+
* The goal line between the todo panel and the queue pane: `goal ● …`
|
|
14627
|
+
* rendered only while a goal is set (display-only).
|
|
14628
|
+
*/
|
|
14629
|
+
goalLine;
|
|
14630
|
+
/** Active background tasks for the footer badge (label + status). */
|
|
14006
14631
|
dockTasks = [];
|
|
14007
|
-
/** Live child subagents (continuable or running one-shot) for the
|
|
14632
|
+
/** Live child subagents (continuable or running one-shot) for the footer badge (never jobs records). */
|
|
14008
14633
|
dockAgents = [];
|
|
14009
14634
|
/**
|
|
14010
|
-
* The queued-input pane below the todo panel
|
|
14011
|
-
*
|
|
14012
|
-
*
|
|
14635
|
+
* The queued-input pane below the todo panel: a border rule plus one
|
|
14636
|
+
* `❯ text` row per pending message and a dim hint. Renders nothing while
|
|
14637
|
+
* the queue is empty.
|
|
14013
14638
|
*/
|
|
14014
14639
|
queuePane;
|
|
14015
14640
|
/** The pending inbox messages (next-turn followups and next-step steers). */
|
|
@@ -14062,8 +14687,6 @@ var TuiApp = class TuiApp {
|
|
|
14062
14687
|
planMode = false;
|
|
14063
14688
|
/** The editor's normal border style, restored when plan mode ends. */
|
|
14064
14689
|
editorBorder;
|
|
14065
|
-
/** Todo summary segment of the header (without the base or badges). */
|
|
14066
|
-
todoText = "";
|
|
14067
14690
|
/** Welcome card shown above the transcript; renders nothing without facts. */
|
|
14068
14691
|
welcomeCard = new WelcomeCard();
|
|
14069
14692
|
/** Transient error line shown under the transcript; cleared by the next
|
|
@@ -14133,6 +14756,7 @@ var TuiApp = class TuiApp {
|
|
|
14133
14756
|
this.messagesView = new Container();
|
|
14134
14757
|
this.dock = new Text("", 0, 0);
|
|
14135
14758
|
this.todoPanel = new Text("", 0, 0);
|
|
14759
|
+
this.goalLine = new Text("", 0, 0);
|
|
14136
14760
|
this.queuePane = new Text("", 0, 0);
|
|
14137
14761
|
this.working = new WorkingIndicator(() => this.requestRender(), options.workingIntervalMs === void 0 ? {} : { intervalMs: options.workingIntervalMs });
|
|
14138
14762
|
this.footer = new Text("", 0, 0);
|
|
@@ -14142,6 +14766,7 @@ var TuiApp = class TuiApp {
|
|
|
14142
14766
|
this.tui.addChild(this.messagesView);
|
|
14143
14767
|
this.tui.addChild(this.dock);
|
|
14144
14768
|
this.tui.addChild(this.todoPanel);
|
|
14769
|
+
this.tui.addChild(this.goalLine);
|
|
14145
14770
|
this.tui.addChild(this.queuePane);
|
|
14146
14771
|
this.tui.addChild(this.working);
|
|
14147
14772
|
this.tui.addChild(this.editorSeat);
|
|
@@ -14551,6 +15176,10 @@ var TuiApp = class TuiApp {
|
|
|
14551
15176
|
component: this.todoPanel,
|
|
14552
15177
|
shrink: 0
|
|
14553
15178
|
},
|
|
15179
|
+
{
|
|
15180
|
+
component: this.goalLine,
|
|
15181
|
+
shrink: 0
|
|
15182
|
+
},
|
|
14554
15183
|
{
|
|
14555
15184
|
component: this.queuePane,
|
|
14556
15185
|
shrink: 0
|
|
@@ -14714,6 +15343,23 @@ var TuiApp = class TuiApp {
|
|
|
14714
15343
|
* The global Ctrl+O fold still wins, so keyboard behavior is unchanged.
|
|
14715
15344
|
*/
|
|
14716
15345
|
handleFullscreenClick(x, y) {
|
|
15346
|
+
const question = this.activeQuestions;
|
|
15347
|
+
if (question?.frame !== void 0) {
|
|
15348
|
+
if (this.terminal.rows !== question.frame.termRows) return;
|
|
15349
|
+
const width = this.terminal.columns;
|
|
15350
|
+
const height = this.terminal.rows;
|
|
15351
|
+
const footerHeight = this.footer.render(width).length;
|
|
15352
|
+
const seatHeight = question.frame.rows;
|
|
15353
|
+
const seatBottom = height - footerHeight;
|
|
15354
|
+
const seatTop = seatBottom - seatHeight;
|
|
15355
|
+
if (y >= seatTop && y < seatBottom) {
|
|
15356
|
+
if (x >= 2 && x <= width - 3) {
|
|
15357
|
+
question.flow.clickRow(y - seatTop - 1);
|
|
15358
|
+
this.requestRender();
|
|
15359
|
+
}
|
|
15360
|
+
return;
|
|
15361
|
+
}
|
|
15362
|
+
}
|
|
14717
15363
|
const width = this.terminal.columns;
|
|
14718
15364
|
const rowInScroll = y - this.header.render(width).length;
|
|
14719
15365
|
if (rowInScroll < 0) return;
|
|
@@ -14972,9 +15618,12 @@ var TuiApp = class TuiApp {
|
|
|
14972
15618
|
} else {
|
|
14973
15619
|
const rows = [];
|
|
14974
15620
|
const callPreview = parseCallPreview(message.name, message.args);
|
|
15621
|
+
const foldedCall = header.summary === "" ? foldedCallPreview(message.name, message.args) : "";
|
|
14975
15622
|
const resultPreview = message.name === "read" ? readFoldedPreview(message.result) : message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
|
|
15623
|
+
const callHead = foldedCall === "" ? "" : foldedCall;
|
|
15624
|
+
const headWithPreview = `${head}${callHead}${resultPreview}`;
|
|
14976
15625
|
if (callPreview?.kind === "bash" && callPreview.command !== "") {
|
|
14977
|
-
rows.push(truncateToWidth(head
|
|
15626
|
+
rows.push(truncateToWidth(`${head}${callHead}`, this.terminal.columns, "…"));
|
|
14978
15627
|
const commandLines = callPreview.command.split("\n");
|
|
14979
15628
|
const shown = commandLines.slice(0, 3);
|
|
14980
15629
|
const prompt = color.shellMode("$ ");
|
|
@@ -14985,12 +15634,12 @@ var TuiApp = class TuiApp {
|
|
|
14985
15634
|
if (commandLines.length > 3) rows.push(color.textDim(`${indent}… ${commandLines.length - 3} more command lines (ctrl+o to expand)`));
|
|
14986
15635
|
if (resultPreview !== "") rows.push(color.textDim(` ${resultPreview}`));
|
|
14987
15636
|
} else if (callPreview?.kind === "diff" && callPreview.diffs.length > 0) {
|
|
14988
|
-
rows.push(truncateToWidth(`${
|
|
15637
|
+
rows.push(truncateToWidth(`${headWithPreview}`, this.terminal.columns, "…"));
|
|
14989
15638
|
for (const line of renderDiffView(callPreview.diffs, this.workspaceRoot, {
|
|
14990
15639
|
maxLines: 4,
|
|
14991
15640
|
expandHint: "ctrl+o to expand"
|
|
14992
15641
|
})) rows.push(` ${line}`);
|
|
14993
|
-
} else rows.push(truncateToWidth(
|
|
15642
|
+
} else rows.push(truncateToWidth(headWithPreview, this.terminal.columns, "…"));
|
|
14994
15643
|
card.addChild(new Text(rows.join("\n"), 0, 0));
|
|
14995
15644
|
}
|
|
14996
15645
|
return card;
|
|
@@ -15068,10 +15717,11 @@ var TuiApp = class TuiApp {
|
|
|
15068
15717
|
this.addTerminalCommandRow(card, callView.title, this.shellPrompt(message.name));
|
|
15069
15718
|
return;
|
|
15070
15719
|
}
|
|
15071
|
-
if (callView.card === "generic" && callView.rawInput !== void 0) {
|
|
15720
|
+
if (callView.card === "generic" && (callView.rawInput !== void 0 || (callView.content?.length ?? 0) > 0)) {
|
|
15072
15721
|
this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
|
|
15073
|
-
|
|
15074
|
-
card.addChild(new Text(color.textDim(
|
|
15722
|
+
if (callView.rawInput !== void 0) for (const line of genericRawInputLines(message.name, callView.rawInput)) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
15723
|
+
for (const block of callView.content ?? []) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
|
|
15724
|
+
else card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
|
|
15075
15725
|
return;
|
|
15076
15726
|
}
|
|
15077
15727
|
}
|
|
@@ -15112,6 +15762,18 @@ var TuiApp = class TuiApp {
|
|
|
15112
15762
|
case "diff":
|
|
15113
15763
|
this.renderDiffBody(card, resultView.diffs, explicitlyExpanded);
|
|
15114
15764
|
return;
|
|
15765
|
+
case "web":
|
|
15766
|
+
for (const line of webCardLines(resultView)) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
15767
|
+
return;
|
|
15768
|
+
case "generic": {
|
|
15769
|
+
const content = resultView.content ?? [];
|
|
15770
|
+
if (content.length > 0) {
|
|
15771
|
+
for (const block of content) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
|
|
15772
|
+
else card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
|
|
15773
|
+
return;
|
|
15774
|
+
}
|
|
15775
|
+
break;
|
|
15776
|
+
}
|
|
15115
15777
|
}
|
|
15116
15778
|
if (resultView === void 0) {
|
|
15117
15779
|
const callView = this.present?.call(message.name, message.args);
|
|
@@ -15139,7 +15801,12 @@ var TuiApp = class TuiApp {
|
|
|
15139
15801
|
if (isDiffResult(message.name, message.result)) for (const line of renderDiffLines(message.result)) card.addChild(new Text(line, 0, 0));
|
|
15140
15802
|
else {
|
|
15141
15803
|
this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
|
|
15142
|
-
|
|
15804
|
+
const blocks = message.resultBlocks ?? [];
|
|
15805
|
+
const lines = blocks.length > 0 ? resultTextLines(blocks, message.status === "error" ? {
|
|
15806
|
+
name: "error",
|
|
15807
|
+
code: "tool"
|
|
15808
|
+
} : void 0) : [message.result];
|
|
15809
|
+
for (const line of lines) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
15143
15810
|
}
|
|
15144
15811
|
}
|
|
15145
15812
|
/**
|
|
@@ -15161,21 +15828,12 @@ var TuiApp = class TuiApp {
|
|
|
15161
15828
|
(this.fullscreen ?? this.tui).requestRender(force);
|
|
15162
15829
|
}
|
|
15163
15830
|
/**
|
|
15164
|
-
* Reflect the todo list in the
|
|
15165
|
-
* and, when the list is non-empty, the first active item's text.
|
|
15831
|
+
* Reflect the todo list in the dock summary line: active (non-completed)
|
|
15832
|
+
* count and, when the list is non-empty, the first active item's text.
|
|
15166
15833
|
* @param todos - the latest todo/write snapshot.
|
|
15167
15834
|
*/
|
|
15168
15835
|
setTodoSummary(todos) {
|
|
15169
15836
|
this.todoItems = todos;
|
|
15170
|
-
const active = todos.filter((todo) => todo.status !== "completed");
|
|
15171
|
-
const done = todos.length - active.length;
|
|
15172
|
-
if (active.length === 0) this.todoText = done > 0 ? ` · ${done} todo done` : "";
|
|
15173
|
-
else {
|
|
15174
|
-
const first = active[0];
|
|
15175
|
-
const label = first === void 0 ? "" : first.content.length > 30 ? `${first.content.slice(0, 30)}…` : first.content;
|
|
15176
|
-
this.todoText = ` · ${active.length} active · ${label}`;
|
|
15177
|
-
}
|
|
15178
|
-
this.renderHeader();
|
|
15179
15837
|
this.renderDock();
|
|
15180
15838
|
if (this.todoPanelVisible) this.renderTodoPanel();
|
|
15181
15839
|
}
|
|
@@ -15183,6 +15841,7 @@ var TuiApp = class TuiApp {
|
|
|
15183
15841
|
toggleTodoPanel() {
|
|
15184
15842
|
this.todoPanelVisible = !this.todoPanelVisible;
|
|
15185
15843
|
this.renderTodoPanel();
|
|
15844
|
+
this.renderDock();
|
|
15186
15845
|
this.requestRender();
|
|
15187
15846
|
return this.todoPanelVisible;
|
|
15188
15847
|
}
|
|
@@ -15191,8 +15850,9 @@ var TuiApp = class TuiApp {
|
|
|
15191
15850
|
return this.todoPanelVisible;
|
|
15192
15851
|
}
|
|
15193
15852
|
/**
|
|
15194
|
-
* Rebuild the todo panel text: a
|
|
15195
|
-
* in_progress first, then pending, then
|
|
15853
|
+
* Rebuild the todo panel text: a border rule + `Todo` title (both indented
|
|
15854
|
+
* one cell) plus up to five rows, in_progress first, then pending, then
|
|
15855
|
+
* completed (strikethrough).
|
|
15196
15856
|
*/
|
|
15197
15857
|
renderTodoPanel() {
|
|
15198
15858
|
if (!this.todoPanelVisible) {
|
|
@@ -15205,15 +15865,22 @@ var TuiApp = class TuiApp {
|
|
|
15205
15865
|
...this.todoItems.filter((todo) => todo.status === "pending"),
|
|
15206
15866
|
...this.todoItems.filter((todo) => todo.status === "completed")
|
|
15207
15867
|
].slice(0, 5);
|
|
15868
|
+
const width = Math.max(1, this.terminal.columns);
|
|
15869
|
+
const border = color.border(` ${"─".repeat(Math.max(0, width - 2))} `);
|
|
15870
|
+
const title = color.textStrong(" Todo");
|
|
15208
15871
|
if (ordered.length === 0) {
|
|
15209
|
-
this.todoPanel.setText(
|
|
15872
|
+
this.todoPanel.setText([border, title].join("\n"));
|
|
15210
15873
|
return;
|
|
15211
15874
|
}
|
|
15212
15875
|
const lines = ordered.map((todo) => {
|
|
15213
15876
|
const body = todo.status === "completed" ? `\x1b[9m${todo.content}\x1b[29m` : todo.content;
|
|
15214
15877
|
return `${mark(todo)} ${body}`;
|
|
15215
15878
|
});
|
|
15216
|
-
this.todoPanel.setText([
|
|
15879
|
+
this.todoPanel.setText([
|
|
15880
|
+
border,
|
|
15881
|
+
title,
|
|
15882
|
+
...lines
|
|
15883
|
+
].join("\n"));
|
|
15217
15884
|
}
|
|
15218
15885
|
/** Hide/show thinking entries; the fold state is untouched. */
|
|
15219
15886
|
toggleThinkingHidden() {
|
|
@@ -15225,15 +15892,15 @@ var TuiApp = class TuiApp {
|
|
|
15225
15892
|
isThinkingHidden() {
|
|
15226
15893
|
return this.hideThinking;
|
|
15227
15894
|
}
|
|
15228
|
-
/** Rebuild the header from base + session title +
|
|
15895
|
+
/** Rebuild the header from base + session title + plan badge.
|
|
15229
15896
|
* Colours are applied AT RENDER TIME from the live palette — the semantic
|
|
15230
|
-
* state (plan mode, title
|
|
15231
|
-
*
|
|
15897
|
+
* state (plan mode, title) is stored separately, so a theme switch only
|
|
15898
|
+
* has to re-run this. */
|
|
15232
15899
|
renderHeader() {
|
|
15233
15900
|
const badge = this.planMode ? ` ${color.warning("[plan]")}` : "";
|
|
15234
15901
|
const viewerBadge = this.viewerMode === void 0 ? "" : ` ${color.accent("[viewing subagent]")}`;
|
|
15235
15902
|
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}${
|
|
15903
|
+
this.header.setText(`🐋 dsh-pi-tui${title}${badge}${viewerBadge}`);
|
|
15237
15904
|
this.requestRender();
|
|
15238
15905
|
}
|
|
15239
15906
|
/**
|
|
@@ -15249,29 +15916,27 @@ var TuiApp = class TuiApp {
|
|
|
15249
15916
|
};
|
|
15250
15917
|
this.renderFooter();
|
|
15251
15918
|
this.renderDock();
|
|
15919
|
+
this.renderGoalLine();
|
|
15252
15920
|
}
|
|
15253
15921
|
/**
|
|
15254
|
-
* Replace the active background-task list for the
|
|
15255
|
-
*
|
|
15922
|
+
* Replace the active background-task list for the footer badge. Non-empty
|
|
15923
|
+
* sets arm the ↓/Ctrl+J task-browser trigger.
|
|
15256
15924
|
* @param tasks - active jobs (id + label + lifecycle status), empty to hide.
|
|
15257
15925
|
*/
|
|
15258
15926
|
setTasks(tasks) {
|
|
15259
15927
|
this.dockTasks = tasks;
|
|
15260
15928
|
this.tasksActive = tasks.length > 0 || this.dockAgents.length > 0;
|
|
15261
|
-
this.renderDock();
|
|
15262
15929
|
this.renderFooter();
|
|
15263
15930
|
}
|
|
15264
15931
|
/**
|
|
15265
|
-
* Replace the live child-subagent list for the
|
|
15266
|
-
*
|
|
15267
|
-
*
|
|
15268
|
-
* trigger through this channel.
|
|
15932
|
+
* Replace the live child-subagent list for the footer badge. Continuable
|
|
15933
|
+
* children and foreground one-shot children never register jobs records
|
|
15934
|
+
* (AGENTS.md), so they arm the ↓/Ctrl+J trigger through this channel.
|
|
15269
15935
|
* @param agents - live children (id + label + activity), empty to hide.
|
|
15270
15936
|
*/
|
|
15271
15937
|
setAgents(agents) {
|
|
15272
15938
|
this.dockAgents = agents;
|
|
15273
15939
|
this.tasksActive = this.dockTasks.length > 0 || agents.length > 0;
|
|
15274
|
-
this.renderDock();
|
|
15275
15940
|
this.renderFooter();
|
|
15276
15941
|
}
|
|
15277
15942
|
/** Whether background tasks are active (drives the ↓/Ctrl+J trigger). */
|
|
@@ -15279,9 +15944,9 @@ var TuiApp = class TuiApp {
|
|
|
15279
15944
|
return this.tasksActive;
|
|
15280
15945
|
}
|
|
15281
15946
|
/**
|
|
15282
|
-
* Replace the pending inbox rows for the queue pane
|
|
15283
|
-
*
|
|
15284
|
-
*
|
|
15947
|
+
* Replace the pending inbox rows for the queue pane: a border rule, one
|
|
15948
|
+
* `❯ text` row per message, and a dim hint. An empty queue renders
|
|
15949
|
+
* nothing at all.
|
|
15285
15950
|
* @param items - pending followups/steers, in delivery order.
|
|
15286
15951
|
*/
|
|
15287
15952
|
setQueueItems(items) {
|
|
@@ -15297,7 +15962,7 @@ var TuiApp = class TuiApp {
|
|
|
15297
15962
|
return;
|
|
15298
15963
|
}
|
|
15299
15964
|
const width = Math.max(1, this.terminal.columns);
|
|
15300
|
-
const lines = [color.border("─".repeat(width))];
|
|
15965
|
+
const lines = [color.border(` ${"─".repeat(Math.max(0, width - 2))} `)];
|
|
15301
15966
|
for (const item of items) {
|
|
15302
15967
|
const prefix = item.notice === true ? `${color.textDim("⏳")} ` : `${color.accent("❯")} `;
|
|
15303
15968
|
const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth(prefix)), "…");
|
|
@@ -15341,45 +16006,37 @@ var TuiApp = class TuiApp {
|
|
|
15341
16006
|
};
|
|
15342
16007
|
}
|
|
15343
16008
|
/**
|
|
15344
|
-
* Rebuild the persistent dock strip above the todo panel: todo summary
|
|
15345
|
-
*
|
|
15346
|
-
*
|
|
15347
|
-
*
|
|
15348
|
-
*
|
|
15349
|
-
*
|
|
15350
|
-
* every fact has exactly one home.
|
|
16009
|
+
* Rebuild the persistent dock strip above the todo panel: the todo summary
|
|
16010
|
+
* as a single dim info line, only while non-empty. No border rule — a full
|
|
16011
|
+
* ─ line here visually collides with the editor's own full-width border
|
|
16012
|
+
* (user feedback); the summary reads as an info line, and only the
|
|
16013
|
+
* EXPANDED todo panel (Ctrl+T) carries a panel border. Background-task and
|
|
16014
|
+
* subagent details live in the footer badge and the ↓/Ctrl+J browser ONLY
|
|
16015
|
+
* — every fact has exactly one home.
|
|
15351
16016
|
*/
|
|
15352
16017
|
renderDock() {
|
|
15353
|
-
|
|
15354
|
-
|
|
15355
|
-
|
|
15356
|
-
|
|
15357
|
-
|
|
15358
|
-
|
|
15359
|
-
|
|
15360
|
-
|
|
15361
|
-
|
|
15362
|
-
|
|
15363
|
-
|
|
15364
|
-
|
|
15365
|
-
|
|
15366
|
-
|
|
15367
|
-
|
|
15368
|
-
|
|
15369
|
-
|
|
15370
|
-
|
|
15371
|
-
|
|
15372
|
-
|
|
15373
|
-
|
|
15374
|
-
|
|
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
|
-
}
|
|
15382
|
-
this.dock.setText(lines.join("\n"));
|
|
16018
|
+
if (this.todoPanelVisible || this.todoItems.length === 0) {
|
|
16019
|
+
this.dock.setText("");
|
|
16020
|
+
this.requestRender();
|
|
16021
|
+
return;
|
|
16022
|
+
}
|
|
16023
|
+
const active = this.todoItems.filter((todo) => todo.status !== "completed");
|
|
16024
|
+
const done = this.todoItems.length - active.length;
|
|
16025
|
+
const first = active[0];
|
|
16026
|
+
const label = first === void 0 ? "" : first.content.length > 40 ? `${first.content.slice(0, 40)}…` : first.content;
|
|
16027
|
+
const summary = [
|
|
16028
|
+
done > 0 ? `${done} todo done` : "",
|
|
16029
|
+
active.length > 0 ? `${active.length} active` : "",
|
|
16030
|
+
label
|
|
16031
|
+
].filter((part) => part !== "").join(" · ");
|
|
16032
|
+
this.dock.setText(color.textDim(`☑ ${summary}`));
|
|
16033
|
+
this.requestRender();
|
|
16034
|
+
}
|
|
16035
|
+
/** Rebuild the goal line: `goal ● <objective>` while a goal is set, hidden
|
|
16036
|
+
* otherwise (display-only — no verbs yet). */
|
|
16037
|
+
renderGoalLine() {
|
|
16038
|
+
const goal = this.status.goal;
|
|
16039
|
+
this.goalLine.setText(goal === void 0 || goal === "" ? "" : color.primary(goal));
|
|
15383
16040
|
this.requestRender();
|
|
15384
16041
|
}
|
|
15385
16042
|
/** Footer density presets: full keeps the stats line, compact drops it. */
|
|
@@ -15404,7 +16061,6 @@ var TuiApp = class TuiApp {
|
|
|
15404
16061
|
const line1 = [
|
|
15405
16062
|
permissionBadge,
|
|
15406
16063
|
this.planMode ? color.warning("[plan]") : "",
|
|
15407
|
-
this.status.goal === void 0 || this.status.goal === "" ? "" : color.primary(this.status.goal),
|
|
15408
16064
|
this.status.model === "" ? "" : `[${this.status.model}]`,
|
|
15409
16065
|
taskBadge,
|
|
15410
16066
|
this.status.cwd,
|
|
@@ -15463,23 +16119,67 @@ var TuiApp = class TuiApp {
|
|
|
15463
16119
|
};
|
|
15464
16120
|
}
|
|
15465
16121
|
/**
|
|
15466
|
-
* Open the
|
|
15467
|
-
*
|
|
15468
|
-
*
|
|
15469
|
-
*
|
|
15470
|
-
*
|
|
15471
|
-
*
|
|
15472
|
-
* @param
|
|
15473
|
-
* @param
|
|
15474
|
-
* @
|
|
16122
|
+
* Open the task browser overlay (the ↓ / Ctrl+J trigger with an empty
|
|
16123
|
+
* editor, and /tasks). Unlike the generic {@link openPicker}, rows carry a
|
|
16124
|
+
* status word + start timestamp so the panel can render status dots,
|
|
16125
|
+
* right-aligned status/elapsed columns, live counts, and a 1s elapsed
|
|
16126
|
+
* tick. Selection calls `onSelect` with the row value and closes; Esc
|
|
16127
|
+
* calls `onCancel`.
|
|
16128
|
+
* @param items - task rows (see TaskPanelItem).
|
|
16129
|
+
* @param onSelect - confirmed row value.
|
|
16130
|
+
* @param onCancel - dismissed without a choice.
|
|
16131
|
+
* @param options - header/search/sizing configuration.
|
|
16132
|
+
* @returns a handle to close the browser or replace its rows (e.g. when
|
|
16133
|
+
* jobs change while it is open).
|
|
15475
16134
|
*/
|
|
15476
|
-
|
|
15477
|
-
|
|
15478
|
-
|
|
15479
|
-
|
|
15480
|
-
|
|
15481
|
-
|
|
15482
|
-
|
|
16135
|
+
openTaskBrowser(items, onSelect, onCancel, options = {}) {
|
|
16136
|
+
const panel = new TaskBrowserPanel(items.map((item) => ({ ...item })), options.maxVisible ?? 10, {
|
|
16137
|
+
header: options.header,
|
|
16138
|
+
noMatchText: options.noMatchText,
|
|
16139
|
+
enableSearch: options.enableSearch,
|
|
16140
|
+
initialQuery: options.initialQuery
|
|
16141
|
+
}, (value) => {
|
|
16142
|
+
close();
|
|
16143
|
+
onSelect(value);
|
|
16144
|
+
}, () => {
|
|
16145
|
+
close();
|
|
16146
|
+
onCancel();
|
|
16147
|
+
}, () => this.requestRender());
|
|
16148
|
+
const handle = this.showOverlayOnHost(new Frame(panel), {
|
|
16149
|
+
width: options.width ?? 72,
|
|
16150
|
+
maxHeight: options.maxHeight ?? 24
|
|
16151
|
+
});
|
|
16152
|
+
let close = () => {};
|
|
16153
|
+
close = () => {
|
|
16154
|
+
panel.dispose();
|
|
16155
|
+
handle.hide();
|
|
16156
|
+
};
|
|
16157
|
+
return {
|
|
16158
|
+
close,
|
|
16159
|
+
setItems: (next) => {
|
|
16160
|
+
panel.setItems(next.map((item) => ({ ...item })));
|
|
16161
|
+
this.requestRender();
|
|
16162
|
+
}
|
|
16163
|
+
};
|
|
16164
|
+
}
|
|
16165
|
+
/**
|
|
16166
|
+
* Open the settings overlay as a SettingsList. The runner supplies the
|
|
16167
|
+
* items and reacts to changes/cancellation. Returns a CLOSER so an
|
|
16168
|
+
* action-style list (e.g. /subagents' View transcript / Interrupt) can
|
|
16169
|
+
* dismiss itself after the action — without it the list stays mounted as
|
|
16170
|
+
* a ghost overlay that eats every later key (the /subagents trap).
|
|
16171
|
+
* @param items - setting rows.
|
|
16172
|
+
* @param onChange - called with (id, newValue) on confirm.
|
|
16173
|
+
* @param onCancel - called when the user closes without applying.
|
|
16174
|
+
* @returns a function that closes the overlay.
|
|
16175
|
+
*/
|
|
16176
|
+
openSettings(items, onChange, onCancel) {
|
|
16177
|
+
let handle;
|
|
16178
|
+
const settings = new SettingsList(items, 6, settingsListTheme(), onChange, () => {
|
|
16179
|
+
handle?.hide();
|
|
16180
|
+
onCancel();
|
|
16181
|
+
}, { enableSearch: true });
|
|
16182
|
+
handle = this.showOverlayOnHost(new Frame(settings), {
|
|
15483
16183
|
width: 72,
|
|
15484
16184
|
maxHeight: 28
|
|
15485
16185
|
});
|
|
@@ -15542,6 +16242,7 @@ var TuiApp = class TuiApp {
|
|
|
15542
16242
|
this.renderFooter();
|
|
15543
16243
|
this.renderDock();
|
|
15544
16244
|
this.renderTodoPanel();
|
|
16245
|
+
this.renderGoalLine();
|
|
15545
16246
|
this.renderQueuePane();
|
|
15546
16247
|
this.working.refresh();
|
|
15547
16248
|
this.editor.invalidate();
|
|
@@ -15951,14 +16652,6 @@ function taskRowLabel(row) {
|
|
|
15951
16652
|
function rowGroup(row) {
|
|
15952
16653
|
return row.kind === "job" ? JOB_GROUP : SUBAGENT_GROUP;
|
|
15953
16654
|
}
|
|
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
16655
|
//#endregion
|
|
15963
16656
|
//#region src/model-menu.ts
|
|
15964
16657
|
/**
|
|
@@ -16240,6 +16933,262 @@ async function loadSessionTitles(query, persistence, ids, signal) {
|
|
|
16240
16933
|
return titles;
|
|
16241
16934
|
}
|
|
16242
16935
|
//#endregion
|
|
16936
|
+
//#region src/skill-catalog.ts
|
|
16937
|
+
/**
|
|
16938
|
+
* The standing-scope skill catalog adapter: the SINGLE narrow seam between
|
|
16939
|
+
* the TUI and the dsh skill services (the non-public-API isolation
|
|
16940
|
+
* contract, plan appendix B).
|
|
16941
|
+
*
|
|
16942
|
+
* Everything the TUI knows about dsh-skill and agent-presets lives here:
|
|
16943
|
+
* - structural service types (never upstream classes) with OPTIONAL
|
|
16944
|
+
* capability members — `snapshot` and `standingKeyFor` are detected at
|
|
16945
|
+
* call time, so an upstream change degrades instead of breaking;
|
|
16946
|
+
* - `readHumanSkillCatalog()`: snapshot-first collector (list() fallback),
|
|
16947
|
+
* official `isUserInvocable` filter, field validation, stable sort,
|
|
16948
|
+
* deep freeze, distinct abort propagation;
|
|
16949
|
+
* - `resolveColdSkillTarget()`: capability-gated cold target selection
|
|
16950
|
+
* (standing scope → rosterless global → degraded global with a notice);
|
|
16951
|
+
* - `resolveLiveSkillTarget()`: the agent's own registry + agent scope.
|
|
16952
|
+
*
|
|
16953
|
+
* Failure contract: ordinary provider/composition failures surface as
|
|
16954
|
+
* degraded targets or rethrown errors for the coordinator to classify;
|
|
16955
|
+
* aborts ALWAYS propagate as aborts. No failure may hang or crash the TUI —
|
|
16956
|
+
* the worst outcome is "the corresponding commands are missing".
|
|
16957
|
+
* @module @xmoon76/dsh-pi-tui/skill-catalog
|
|
16958
|
+
*/
|
|
16959
|
+
/**
|
|
16960
|
+
* Subscribe to the dsh-skill invalidation notification. The event carries
|
|
16961
|
+
* no scope or cwd — consumers refetch for their own cwd and scope — and the
|
|
16962
|
+
* subscription itself is optional: an unavailable `on` or a throwing
|
|
16963
|
+
* subscribe degrades to NO subscription (owner switches and `/reload` still
|
|
16964
|
+
* refresh). The CALLER owns the listener's failure totality (route the
|
|
16965
|
+
* refresh through `runOwned`).
|
|
16966
|
+
* @param ctx - the context surface exposing the event bus.
|
|
16967
|
+
* @param listener - the notification listener (must be synchronous and
|
|
16968
|
+
* never throw).
|
|
16969
|
+
*/
|
|
16970
|
+
function subscribeSkillsChange(ctx, listener) {
|
|
16971
|
+
ctx.on?.("skills/change", listener);
|
|
16972
|
+
}
|
|
16973
|
+
/**
|
|
16974
|
+
* Whether one summary passes the OFFICIAL user-invocation policy — the
|
|
16975
|
+
* adapter's exported guard, used by every human entry point (direct
|
|
16976
|
+
* wrappers, `/skill`, the picker, and final body loads). A malformed
|
|
16977
|
+
* `invocation` (missing, non-object, non-boolean flag) is treated as NOT
|
|
16978
|
+
* user-invocable: the policy defaults are never reinterpreted, and a
|
|
16979
|
+
* hostile entry can neither throw nor sneak into a human surface.
|
|
16980
|
+
*/
|
|
16981
|
+
function isUserInvocableSkill(skill) {
|
|
16982
|
+
const invocation = skill.invocation;
|
|
16983
|
+
if (typeof invocation !== "object" || invocation === null) return false;
|
|
16984
|
+
if (typeof invocation.userInvocable !== "boolean") return false;
|
|
16985
|
+
return isUserInvocable(skill);
|
|
16986
|
+
}
|
|
16987
|
+
/** Copy one summary, keeping ONLY the supported display fields. An entry
|
|
16988
|
+
* with a non-string name or description is rejected (never copied, never
|
|
16989
|
+
* trusted); a non-string `whenToUse` is dropped. */
|
|
16990
|
+
function toHumanSummary(skill) {
|
|
16991
|
+
if (typeof skill.name !== "string" || skill.name === "") return void 0;
|
|
16992
|
+
if (typeof skill.description !== "string") return void 0;
|
|
16993
|
+
return Object.freeze({
|
|
16994
|
+
name: skill.name,
|
|
16995
|
+
description: skill.description,
|
|
16996
|
+
...typeof skill.whenToUse === "string" && skill.whenToUse !== "" ? { whenToUse: skill.whenToUse } : {}
|
|
16997
|
+
});
|
|
16998
|
+
}
|
|
16999
|
+
/** Whether one summary passes the OFFICIAL user-invocation policy. A
|
|
17000
|
+
* malformed `invocation` (missing, non-object, non-boolean flag) is treated
|
|
17001
|
+
* as NOT user-invocable — the policy defaults are never reinterpreted, and
|
|
17002
|
+
* a hostile entry can neither throw nor sneak into the human catalog. */
|
|
17003
|
+
function userInvocable(skill) {
|
|
17004
|
+
return isUserInvocableSkill(skill);
|
|
17005
|
+
}
|
|
17006
|
+
/**
|
|
17007
|
+
* Read one human skill catalog from a registry: snapshot-first, official
|
|
17008
|
+
* policy filter, validated detached copies, stable name sort, deep freeze.
|
|
17009
|
+
* @param registry - the registry to read (the adapter's structural type).
|
|
17010
|
+
* @param options - cwd/scope/signal for the read.
|
|
17011
|
+
* @returns the detached catalog.
|
|
17012
|
+
* @throws on cancellation (ALWAYS, distinctly) and on non-abort read
|
|
17013
|
+
* failures (the coordinator classifies them: retain last-good + diag).
|
|
17014
|
+
*/
|
|
17015
|
+
async function readHumanSkillCatalog(registry, options) {
|
|
17016
|
+
options.signal?.throwIfAborted();
|
|
17017
|
+
const observed = typeof registry.snapshot === "function" ? await registry.snapshot(options) : {
|
|
17018
|
+
skills: await registry.list?.(options) ?? [],
|
|
17019
|
+
complete: true
|
|
17020
|
+
};
|
|
17021
|
+
options.signal?.throwIfAborted();
|
|
17022
|
+
const skills = observed.skills.filter(userInvocable).map(toHumanSummary).filter((summary) => summary !== void 0).sort((left, right) => left.name < right.name ? -1 : 1);
|
|
17023
|
+
return Object.freeze({
|
|
17024
|
+
skills: Object.freeze(skills),
|
|
17025
|
+
complete: observed.complete === true
|
|
17026
|
+
});
|
|
17027
|
+
}
|
|
17028
|
+
/**
|
|
17029
|
+
* Resolve the COLD (sessionless) skill target with capability detection:
|
|
17030
|
+
* - a preset roster with `standingKeyFor` → the standing scope of the
|
|
17031
|
+
* effective preset;
|
|
17032
|
+
* - no roster / no `standingKeyFor` / a standing resolution failure → the
|
|
17033
|
+
* global layer (with a one-shot degradation notice on failure);
|
|
17034
|
+
* - no skill registry at all → no target (nothing to read, no notice).
|
|
17035
|
+
* @param ctx - the context surface.
|
|
17036
|
+
* @param presetId - the effective preset id, or undefined for the default.
|
|
17037
|
+
* @param cwd - the workspace cwd for provider selection.
|
|
17038
|
+
*/
|
|
17039
|
+
async function resolveColdSkillTarget(ctx, presetId, cwd) {
|
|
17040
|
+
const registry = ctx.get("skills");
|
|
17041
|
+
if (registry === void 0) return {};
|
|
17042
|
+
const presets = ctx.get("agentPresets");
|
|
17043
|
+
if (presets === void 0 || typeof presets.standingKeyFor !== "function") return { target: {
|
|
17044
|
+
kind: "cold-global",
|
|
17045
|
+
registry,
|
|
17046
|
+
cwd,
|
|
17047
|
+
scope: void 0
|
|
17048
|
+
} };
|
|
17049
|
+
try {
|
|
17050
|
+
return { target: {
|
|
17051
|
+
kind: "cold-standing",
|
|
17052
|
+
registry,
|
|
17053
|
+
cwd,
|
|
17054
|
+
scope: await presets.standingKeyFor(presetId)
|
|
17055
|
+
} };
|
|
17056
|
+
} catch (error) {
|
|
17057
|
+
const message = safeErrorMessage(error);
|
|
17058
|
+
return {
|
|
17059
|
+
target: {
|
|
17060
|
+
kind: "cold-global",
|
|
17061
|
+
registry,
|
|
17062
|
+
cwd,
|
|
17063
|
+
scope: void 0
|
|
17064
|
+
},
|
|
17065
|
+
degraded: `skill catalog unavailable for preset "${presetId ?? "default"}": ${message}`
|
|
17066
|
+
};
|
|
17067
|
+
}
|
|
17068
|
+
}
|
|
17069
|
+
/**
|
|
17070
|
+
* Resolve the LIVE skill target: the registry the agent actually sees
|
|
17071
|
+
* (its preset's scoped instance when the preset mounts one, else the host
|
|
17072
|
+
* registry), with the AGENT OBJECT as the view scope.
|
|
17073
|
+
* @param ctx - the context surface.
|
|
17074
|
+
* @param agent - the live agent.
|
|
17075
|
+
* @param cwd - the session workspace cwd.
|
|
17076
|
+
* @returns the live target, or undefined when no registry is reachable.
|
|
17077
|
+
*/
|
|
17078
|
+
function resolveLiveSkillTarget(ctx, agent, cwd) {
|
|
17079
|
+
const registry = ctx.get("agentPresets")?.serviceFor?.(agent, "skills") ?? ctx.get("skills");
|
|
17080
|
+
if (registry === void 0) return void 0;
|
|
17081
|
+
return {
|
|
17082
|
+
kind: "live",
|
|
17083
|
+
registry,
|
|
17084
|
+
cwd,
|
|
17085
|
+
scope: agent
|
|
17086
|
+
};
|
|
17087
|
+
}
|
|
17088
|
+
//#endregion
|
|
17089
|
+
//#region src/surface-catalog.ts
|
|
17090
|
+
/**
|
|
17091
|
+
* The in-process global-layer command view. The public type requires an
|
|
17092
|
+
* `Agent`, but `commands.list(undefined)` resolves the global layer only
|
|
17093
|
+
* (ScopedLayers merges no overlays for an undefined key); the current TUI
|
|
17094
|
+
* already depends on this in-process behavior. The cast is isolated HERE so
|
|
17095
|
+
* the undefined key never reaches a remote RPC path, and the helper is the
|
|
17096
|
+
* single seam to replace if upstream ever ships a typed global-list API.
|
|
17097
|
+
* @param commands - the commands service.
|
|
17098
|
+
* @returns the global-layer descriptors (name-sorted by the registry).
|
|
17099
|
+
*/
|
|
17100
|
+
function listGlobalCommands(commands) {
|
|
17101
|
+
return commands.list(void 0);
|
|
17102
|
+
}
|
|
17103
|
+
/** Copy one descriptor into a fresh frozen summary (never borrowed). */
|
|
17104
|
+
function commandSummaryOf(descriptor) {
|
|
17105
|
+
return Object.freeze({
|
|
17106
|
+
name: descriptor.name,
|
|
17107
|
+
description: descriptor.description,
|
|
17108
|
+
...descriptor.input === void 0 ? {} : { input: Object.freeze({ hint: descriptor.input.hint }) }
|
|
17109
|
+
});
|
|
17110
|
+
}
|
|
17111
|
+
/** Whether two descriptors expose identical display fields (origin-blind:
|
|
17112
|
+
* an identical scoped entry needs no override because the visible result and
|
|
17113
|
+
* the real-agent execution are the same either way). */
|
|
17114
|
+
function sameCommand(left, right) {
|
|
17115
|
+
return left.name === right.name && left.description === right.description && left.input?.hint === right.input?.hint;
|
|
17116
|
+
}
|
|
17117
|
+
/**
|
|
17118
|
+
* Read one agent's effective surface catalog: global + scoped commands and
|
|
17119
|
+
* human-invocable skills, fully detached and frozen.
|
|
17120
|
+
*
|
|
17121
|
+
* Provider isolation (per the probe contract):
|
|
17122
|
+
* - lifecycle/refresh cancellation terminates the WHOLE read and propagates;
|
|
17123
|
+
* - an ordinary provider failure empties only that field and records a
|
|
17124
|
+
* detached issue; other providers continue;
|
|
17125
|
+
* - a missing service is that provider's successful empty result (no issue).
|
|
17126
|
+
*
|
|
17127
|
+
* This function only READS: it registers nothing, mutates nothing, and never
|
|
17128
|
+
* touches a runner's live agent. Probe and live agent share this collector
|
|
17129
|
+
* so the two surfaces cannot drift apart.
|
|
17130
|
+
* @param agent - the agent whose effective view to read.
|
|
17131
|
+
* @param signal - lifecycle/refresh cancellation.
|
|
17132
|
+
* @param ctx - the context surface resolving the services.
|
|
17133
|
+
* @returns a frozen, detached snapshot.
|
|
17134
|
+
*/
|
|
17135
|
+
async function readSurfaceCatalog(agent, signal, ctx) {
|
|
17136
|
+
signal.throwIfAborted();
|
|
17137
|
+
const issues = [];
|
|
17138
|
+
let commands = [];
|
|
17139
|
+
let scopedCommands = [];
|
|
17140
|
+
const commandsService = ctx.get("commands");
|
|
17141
|
+
if (commandsService !== void 0) try {
|
|
17142
|
+
const global = listGlobalCommands(commandsService);
|
|
17143
|
+
const globalBy = new Map(global.map((descriptor) => [descriptor.name, descriptor]));
|
|
17144
|
+
const effective = commandsService.list(agent);
|
|
17145
|
+
const scoped = [];
|
|
17146
|
+
for (const descriptor of effective) {
|
|
17147
|
+
const summary = commandSummaryOf(descriptor);
|
|
17148
|
+
const baseline = globalBy.get(descriptor.name);
|
|
17149
|
+
if (baseline === void 0 || !sameCommand(descriptor, baseline)) scoped.push(summary);
|
|
17150
|
+
}
|
|
17151
|
+
commands = sortCommands(effective.map(commandSummaryOf));
|
|
17152
|
+
scopedCommands = sortCommands(scoped);
|
|
17153
|
+
} catch (error) {
|
|
17154
|
+
issues.push({
|
|
17155
|
+
provider: "commands",
|
|
17156
|
+
message: safeErrorMessage(error)
|
|
17157
|
+
});
|
|
17158
|
+
}
|
|
17159
|
+
let skills = [];
|
|
17160
|
+
const skillTarget = resolveLiveSkillTarget(ctx, agent, agent.session.header.cwd ?? process.cwd());
|
|
17161
|
+
if (skillTarget !== void 0) try {
|
|
17162
|
+
signal.throwIfAborted();
|
|
17163
|
+
const catalog = await readHumanSkillCatalog(skillTarget.registry, {
|
|
17164
|
+
cwd: skillTarget.cwd,
|
|
17165
|
+
scope: skillTarget.scope,
|
|
17166
|
+
signal
|
|
17167
|
+
});
|
|
17168
|
+
skills = catalog.skills;
|
|
17169
|
+
if (catalog.complete !== true) issues.push({
|
|
17170
|
+
provider: "skills",
|
|
17171
|
+
message: "incomplete skill observation"
|
|
17172
|
+
});
|
|
17173
|
+
} catch (error) {
|
|
17174
|
+
if (signal.aborted) throw error;
|
|
17175
|
+
issues.push({
|
|
17176
|
+
provider: "skills",
|
|
17177
|
+
message: safeErrorMessage(error)
|
|
17178
|
+
});
|
|
17179
|
+
}
|
|
17180
|
+
return Object.freeze({
|
|
17181
|
+
commands,
|
|
17182
|
+
scopedCommands,
|
|
17183
|
+
skills,
|
|
17184
|
+
issues: Object.freeze(issues.map((issue) => Object.freeze({ ...issue })))
|
|
17185
|
+
});
|
|
17186
|
+
}
|
|
17187
|
+
/** Name-stable sort for command summaries (copies, never mutates input). */
|
|
17188
|
+
function sortCommands(commands) {
|
|
17189
|
+
return Object.freeze([...commands].sort((left, right) => left.name < right.name ? -1 : 1));
|
|
17190
|
+
}
|
|
17191
|
+
//#endregion
|
|
16243
17192
|
//#region src/commands.ts
|
|
16244
17193
|
/**
|
|
16245
17194
|
* The TUI-owned slash commands (/exit /settings /sessions /skill /model
|
|
@@ -16311,17 +17260,71 @@ function presetDisplayText(preset) {
|
|
|
16311
17260
|
...preset.description === void 0 ? {} : { description: preset.description }
|
|
16312
17261
|
};
|
|
16313
17262
|
}
|
|
17263
|
+
/** The credential-target options for /login and /logout: the deepseek
|
|
17264
|
+
* official ref always first, then every llm-pi-ai provider route's
|
|
17265
|
+
* apiKeyEnv (deduped by ref). `providers` is the llm-pi-ai settings
|
|
17266
|
+
* section's `providers` dict, or undefined when the adapter or the settings
|
|
17267
|
+
* service is absent — which degrades /login to the official target only.
|
|
17268
|
+
*/
|
|
17269
|
+
function credentialOptionsFor(providers) {
|
|
17270
|
+
const options = [{
|
|
17271
|
+
label: "deepseek official",
|
|
17272
|
+
ref: "DEEPSEEK_API_KEY"
|
|
17273
|
+
}];
|
|
17274
|
+
if (providers === void 0) return options;
|
|
17275
|
+
const seen = new Set(options.map((option) => option.ref));
|
|
17276
|
+
for (const [route, profile] of Object.entries(providers)) {
|
|
17277
|
+
const ref = profile?.apiKeyEnv;
|
|
17278
|
+
if (ref === void 0 || ref === "" || seen.has(ref)) continue;
|
|
17279
|
+
seen.add(ref);
|
|
17280
|
+
options.push({
|
|
17281
|
+
label: route,
|
|
17282
|
+
ref
|
|
17283
|
+
});
|
|
17284
|
+
}
|
|
17285
|
+
return options;
|
|
17286
|
+
}
|
|
17287
|
+
/** Resolve a /login or /logout argument to a credential ref. A
|
|
17288
|
+
* case-insensitive llm-pi-ai route name wins, then a literal env-var-looking
|
|
17289
|
+
* name (any casing, uppercased like the old behavior — `/login my_key` sets
|
|
17290
|
+
* `MY_KEY`); anything else is unknown and returns undefined so the caller
|
|
17291
|
+
* can list the valid options.
|
|
17292
|
+
*/
|
|
17293
|
+
function resolveCredentialArg(arg, options) {
|
|
17294
|
+
const trimmed = arg.trim();
|
|
17295
|
+
if (trimmed === "") return void 0;
|
|
17296
|
+
const needle = trimmed.toLowerCase();
|
|
17297
|
+
const route = options.find((option) => option.label.toLowerCase() === needle || option.label.split(" ")[0].toLowerCase() === needle);
|
|
17298
|
+
if (route !== void 0) return route.ref;
|
|
17299
|
+
if (/^[a-zA-Z][a-zA-Z0-9_]*$/.test(trimmed)) return trimmed.toUpperCase();
|
|
17300
|
+
}
|
|
17301
|
+
/** Read the llm-pi-ai adapter's `providers` dict from its settings section,
|
|
17302
|
+
* or undefined when the settings service or the section is absent. */
|
|
17303
|
+
function readLlmpiAiProviders(ctx) {
|
|
17304
|
+
const settings = ctx.get("settings");
|
|
17305
|
+
if (settings === void 0) return void 0;
|
|
17306
|
+
try {
|
|
17307
|
+
return settings.get(settingsNamespace("llm-pi-ai"))?.providers;
|
|
17308
|
+
} catch {
|
|
17309
|
+
return;
|
|
17310
|
+
}
|
|
17311
|
+
}
|
|
16314
17312
|
/**
|
|
16315
17313
|
* Register the TUI-owned slash commands on the commands service. The
|
|
16316
17314
|
* completion list is refreshed after every registration so TUI-owned
|
|
16317
17315
|
* commands appear in the editor's tab list. Registration is sessionless:
|
|
16318
17316
|
* the commands service's global layer needs no agent, so the whole surface
|
|
16319
17317
|
* is available before the first session exists (deferred start).
|
|
17318
|
+
*
|
|
17319
|
+
* When an `initial` catalog was prefetched (resume snapshot or cold
|
|
17320
|
+
* standing-scope skills), it installs SYNCHRONOUSLY at the end of
|
|
17321
|
+
* registration — direct skill wrappers plus the completion merge — so the
|
|
17322
|
+
* first input is served by the complete catalog with zero async I/O in
|
|
17323
|
+
* between.
|
|
16320
17324
|
* @param runner - the live runner surface.
|
|
16321
|
-
* @
|
|
16322
|
-
* the first session exists, since the skill catalog scope is the agent).
|
|
17325
|
+
* @param initial - optional prefetched catalogs installed synchronously.
|
|
16323
17326
|
*/
|
|
16324
|
-
function registerTuiCommands(runner) {
|
|
17327
|
+
function registerTuiCommands(runner, initial) {
|
|
16325
17328
|
const { ctx, app } = runner;
|
|
16326
17329
|
const cwd = runner.cwd;
|
|
16327
17330
|
const signal = runner.signal;
|
|
@@ -16365,12 +17368,60 @@ function registerTuiCommands(runner) {
|
|
|
16365
17368
|
if (agent === void 0) throw new Error("session could not be created");
|
|
16366
17369
|
return agent;
|
|
16367
17370
|
};
|
|
16368
|
-
|
|
16369
|
-
|
|
17371
|
+
/** The advertised names of the currently installed completion list. */
|
|
17372
|
+
let claims = /* @__PURE__ */ new Set();
|
|
17373
|
+
/**
|
|
17374
|
+
* Install one completion list (sorted, claims refreshed). The single
|
|
17375
|
+
* synchronous seam every catalog commit funnels through.
|
|
17376
|
+
*/
|
|
17377
|
+
const installCompletions = (entries) => {
|
|
17378
|
+
const sorted = [...entries].sort((left, right) => left.name < right.name ? -1 : 1);
|
|
17379
|
+
app.setCommandCompletions(sorted.map((command) => ({
|
|
16370
17380
|
name: command.name,
|
|
16371
17381
|
description: command.description,
|
|
16372
17382
|
argumentHint: command.input?.hint
|
|
16373
17383
|
})), runner.sessionCwd(), fdPath);
|
|
17384
|
+
claims = new Set(sorted.map((command) => command.name));
|
|
17385
|
+
};
|
|
17386
|
+
/** The saved probed scoped overrides (see installSurfaceSnapshot). */
|
|
17387
|
+
let savedScopedCommands = [];
|
|
17388
|
+
/**
|
|
17389
|
+
* The sessionless completion view: the CURRENT global layer (fresh read —
|
|
17390
|
+
* TUI built-ins, global plugins and installed skill wrappers all flow in)
|
|
17391
|
+
* overlaid with the saved scoped overrides from the latest snapshot.
|
|
17392
|
+
*/
|
|
17393
|
+
const mergeGlobalAndSavedScoped = () => {
|
|
17394
|
+
const byName = /* @__PURE__ */ new Map();
|
|
17395
|
+
for (const descriptor of listGlobalCommands(commands)) byName.set(descriptor.name, commandSummaryOf(descriptor));
|
|
17396
|
+
for (const scoped of savedScopedCommands) byName.set(scoped.name, scoped);
|
|
17397
|
+
return [...byName.values()];
|
|
17398
|
+
};
|
|
17399
|
+
/**
|
|
17400
|
+
* Refresh completions from the registry: the LIVE agent's effective view
|
|
17401
|
+
* when one exists (global + its scoped shadows), else the global layer
|
|
17402
|
+
* overlaid with the saved scoped overrides (sessionless merge). The agent
|
|
17403
|
+
* may be undefined before the first session exists: in-process
|
|
17404
|
+
* `commands.list(undefined)` safely returns the global layer only (the
|
|
17405
|
+
* remote RPC path's lookup guard does not apply in-process).
|
|
17406
|
+
*/
|
|
17407
|
+
const refreshCompletions = () => {
|
|
17408
|
+
const liveAgent = runner.liveAgent;
|
|
17409
|
+
installCompletions(liveAgent === void 0 ? mergeGlobalAndSavedScoped() : commands.list(liveAgent).map(commandSummaryOf));
|
|
17410
|
+
};
|
|
17411
|
+
let commandCommitDepth = 0;
|
|
17412
|
+
let commandCommitDirty = false;
|
|
17413
|
+
/** Run one bulk command commit; registry changes inside are coalesced. */
|
|
17414
|
+
const withCommandCommit = (phase) => {
|
|
17415
|
+
commandCommitDepth += 1;
|
|
17416
|
+
try {
|
|
17417
|
+
phase();
|
|
17418
|
+
} finally {
|
|
17419
|
+
commandCommitDepth -= 1;
|
|
17420
|
+
if (commandCommitDepth === 0 && commandCommitDirty) {
|
|
17421
|
+
commandCommitDirty = false;
|
|
17422
|
+
refreshCompletions();
|
|
17423
|
+
}
|
|
17424
|
+
}
|
|
16374
17425
|
};
|
|
16375
17426
|
refreshCompletions();
|
|
16376
17427
|
const exitHandler = () => {
|
|
@@ -16633,29 +17684,44 @@ function registerTuiCommands(runner) {
|
|
|
16633
17684
|
return openSessionPicker(invocation, "resume");
|
|
16634
17685
|
}
|
|
16635
17686
|
});
|
|
16636
|
-
const skillService = (agent) => {
|
|
16637
|
-
return ctx.get("agentPresets")?.serviceFor(agent, "skills") ?? ctx.get("skills");
|
|
16638
|
-
};
|
|
16639
17687
|
/** The workspace the live session runs in; fallback to the TUI's cwd. */
|
|
16640
17688
|
const sessionCwd = (agent) => agent.session.header.cwd ?? cwd;
|
|
17689
|
+
const skillTarget = (agent) => resolveLiveSkillTarget(ctx, agent, sessionCwd(agent));
|
|
17690
|
+
/**
|
|
17691
|
+
* The execution boundary for loading one skill into the live session;
|
|
17692
|
+
* shared by /skill and the per-skill slash commands. The skill is fetched
|
|
17693
|
+
* from the CURRENT agent's registry and its invocation policy is RE-CHECKED
|
|
17694
|
+
* here — a summary that passed the cold/live filter is never execution
|
|
17695
|
+
* authorization. A model-only skill is refused with an explicit error and
|
|
17696
|
+
* never injected.
|
|
17697
|
+
*/
|
|
16641
17698
|
const loadSkill = async (agent, name) => {
|
|
16642
|
-
const
|
|
16643
|
-
if (
|
|
17699
|
+
const target = skillTarget(agent);
|
|
17700
|
+
if (target === void 0) return {
|
|
16644
17701
|
kind: "error",
|
|
16645
17702
|
text: "skill service unavailable"
|
|
16646
17703
|
};
|
|
16647
|
-
const skill = await
|
|
16648
|
-
cwd:
|
|
16649
|
-
scope:
|
|
17704
|
+
const skill = await target.registry.get?.(name, {
|
|
17705
|
+
cwd: target.cwd,
|
|
17706
|
+
scope: target.scope
|
|
16650
17707
|
});
|
|
16651
17708
|
if (skill === void 0) return {
|
|
16652
17709
|
kind: "error",
|
|
16653
17710
|
text: "unknown skill \"" + name + "\""
|
|
16654
17711
|
};
|
|
17712
|
+
if (!isUserInvocableSkill(skill)) return {
|
|
17713
|
+
kind: "error",
|
|
17714
|
+
text: `skill "${name}" is not invocable by the user`
|
|
17715
|
+
};
|
|
17716
|
+
if (typeof skill.name !== "string" || skill.name === "" || typeof skill.description !== "string") return {
|
|
17717
|
+
kind: "error",
|
|
17718
|
+
text: `skill "${name}" returned a malformed definition`
|
|
17719
|
+
};
|
|
17720
|
+
const body = typeof skill.content === "string" && skill.content !== "" ? skill.content : skill.description;
|
|
16655
17721
|
agent.inject(createUserMessage({
|
|
16656
17722
|
content: [{
|
|
16657
17723
|
type: "text",
|
|
16658
|
-
text: "Skill loaded by the user: **" + skill.name + "**\n\n" +
|
|
17724
|
+
text: "Skill loaded by the user: **" + skill.name + "**\n\n" + body
|
|
16659
17725
|
}],
|
|
16660
17726
|
source: {
|
|
16661
17727
|
kind: "plugin",
|
|
@@ -16668,21 +17734,30 @@ function registerTuiCommands(runner) {
|
|
|
16668
17734
|
};
|
|
16669
17735
|
};
|
|
16670
17736
|
const skillDisposers = /* @__PURE__ */ new Map();
|
|
16671
|
-
|
|
16672
|
-
|
|
17737
|
+
/**
|
|
17738
|
+
* Synchronously replace every direct skill wrapper from a snapshot. Pure
|
|
17739
|
+
* install: no catalog fetch, no await — the callers (the initial snapshot
|
|
17740
|
+
* commit, the live refresh, the coordinator) provide the data. The target
|
|
17741
|
+
* set is computed FIRST (current global/effective view minus the wrappers
|
|
17742
|
+
* this surface owns, plus the snapshot's scoped overrides), then the old
|
|
17743
|
+
* wrappers are disposed and the new ones registered in one synchronous
|
|
17744
|
+
* commit.
|
|
17745
|
+
* @param skills - the human-invocable summaries to install.
|
|
17746
|
+
* @param scopedNames - the snapshot's scoped command names: a preset-scoped
|
|
17747
|
+
* command must block a same-name wrapper even though the global view
|
|
17748
|
+
* cannot see it yet (sessionless install).
|
|
17749
|
+
* @returns the number of wrappers installed.
|
|
17750
|
+
*/
|
|
17751
|
+
const replaceSkillCommands = (skills, scopedNames) => {
|
|
17752
|
+
const owned = new Set(skillDisposers.keys());
|
|
17753
|
+
const taken = /* @__PURE__ */ new Set();
|
|
17754
|
+
const view = commands.list(runner.liveAgent);
|
|
17755
|
+
for (const command of view) if (!owned.has(command.name)) taken.add(command.name);
|
|
17756
|
+
for (const name of scopedNames) taken.add(name);
|
|
16673
17757
|
for (const dispose of skillDisposers.values()) dispose();
|
|
16674
17758
|
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) {
|
|
17759
|
+
let count = 0;
|
|
17760
|
+
for (const skill of skills) {
|
|
16686
17761
|
if (taken.has(skill.name)) continue;
|
|
16687
17762
|
try {
|
|
16688
17763
|
const dispose = commands.register({
|
|
@@ -16694,34 +17769,83 @@ function registerTuiCommands(runner) {
|
|
|
16694
17769
|
}
|
|
16695
17770
|
});
|
|
16696
17771
|
skillDisposers.set(skill.name, dispose);
|
|
17772
|
+
count += 1;
|
|
16697
17773
|
} catch {}
|
|
16698
17774
|
}
|
|
16699
|
-
|
|
16700
|
-
|
|
17775
|
+
return count;
|
|
17776
|
+
};
|
|
17777
|
+
/**
|
|
17778
|
+
* Install one whole snapshot on the surface in ONE synchronous commit:
|
|
17779
|
+
* replace the direct skill wrappers, save the scoped overrides, and merge
|
|
17780
|
+
* the completions (fresh global view + saved overrides). No await between
|
|
17781
|
+
* the pieces, so a first input can never observe a half-installed catalog.
|
|
17782
|
+
* A FAILED skills provider keeps the current wrappers (transitions or the
|
|
17783
|
+
* previous catalog): they re-validate against the current agent at
|
|
17784
|
+
* execution time, so a submitted skill name can never fall through to a
|
|
17785
|
+
* plain model message while the catalog is unavailable.
|
|
17786
|
+
*/
|
|
17787
|
+
const installSurfaceSnapshot = (snapshot) => {
|
|
17788
|
+
const scopedNames = new Set(snapshot.scopedCommands.map((command) => command.name));
|
|
17789
|
+
const skillsFailed = snapshot.issues.some((issue) => issue.provider === "skills");
|
|
17790
|
+
withCommandCommit(() => {
|
|
17791
|
+
if (!skillsFailed) replaceSkillCommands(snapshot.skills, scopedNames);
|
|
17792
|
+
savedScopedCommands = snapshot.scopedCommands;
|
|
17793
|
+
installCompletions(mergeGlobalAndSavedScoped());
|
|
17794
|
+
});
|
|
17795
|
+
};
|
|
17796
|
+
/**
|
|
17797
|
+
* The revalidating transition (target/owner change): scoped previews clear
|
|
17798
|
+
* so new inputs complete against the current global view only, and every
|
|
17799
|
+
* old skill wrapper becomes a revalidating transition command whose
|
|
17800
|
+
* handler re-fetches from the CURRENT live agent and re-checks the policy
|
|
17801
|
+
* at execution time. The names survive so an already-submitted skill
|
|
17802
|
+
* command can never become a plain model message mid-switch.
|
|
17803
|
+
*/
|
|
17804
|
+
const enterCatalogTransition = () => {
|
|
17805
|
+
const names = [...skillDisposers.keys()];
|
|
17806
|
+
withCommandCommit(() => {
|
|
17807
|
+
for (const dispose of skillDisposers.values()) dispose();
|
|
17808
|
+
skillDisposers.clear();
|
|
17809
|
+
savedScopedCommands = [];
|
|
17810
|
+
for (const name of names) try {
|
|
17811
|
+
const dispose = commands.register({
|
|
17812
|
+
name,
|
|
17813
|
+
description: `[skill: revalidating] ${name}`,
|
|
17814
|
+
handler: async () => {
|
|
17815
|
+
const agent = await requireAgent();
|
|
17816
|
+
return loadSkill(agent, name);
|
|
17817
|
+
}
|
|
17818
|
+
});
|
|
17819
|
+
skillDisposers.set(name, dispose);
|
|
17820
|
+
} catch {}
|
|
17821
|
+
installCompletions(mergeGlobalAndSavedScoped());
|
|
17822
|
+
});
|
|
16701
17823
|
};
|
|
16702
|
-
|
|
17824
|
+
/** Whether one command name is advertised by the CURRENT completion list
|
|
17825
|
+
* (the claim captured at submit time, before any session creation). */
|
|
17826
|
+
const wasAdvertised = (name) => claims.has(name);
|
|
16703
17827
|
commands.register({
|
|
16704
17828
|
name: "skill",
|
|
16705
17829
|
description: "Load a skill into the session context",
|
|
16706
17830
|
input: { hint: "<name>" },
|
|
16707
17831
|
handler: async (invocation) => {
|
|
16708
17832
|
const liveAgent = await requireAgent();
|
|
16709
|
-
const
|
|
16710
|
-
if (
|
|
17833
|
+
const target = skillTarget(liveAgent);
|
|
17834
|
+
if (target === void 0) return {
|
|
16711
17835
|
kind: "error",
|
|
16712
17836
|
text: "skill service unavailable"
|
|
16713
17837
|
};
|
|
16714
17838
|
const name = invocation.rawInput.trim();
|
|
16715
17839
|
if (name !== "") return loadSkill(liveAgent, name);
|
|
16716
|
-
const catalog = await
|
|
16717
|
-
cwd:
|
|
16718
|
-
scope:
|
|
17840
|
+
const catalog = await readHumanSkillCatalog(target.registry, {
|
|
17841
|
+
cwd: target.cwd,
|
|
17842
|
+
scope: target.scope
|
|
16719
17843
|
});
|
|
16720
|
-
if (catalog.length === 0) return {
|
|
17844
|
+
if (catalog.skills.length === 0) return {
|
|
16721
17845
|
kind: "error",
|
|
16722
17846
|
text: "no skills available"
|
|
16723
17847
|
};
|
|
16724
|
-
app.openSettings(catalog.map((skill) => ({
|
|
17848
|
+
app.openSettings(catalog.skills.map((skill) => ({
|
|
16725
17849
|
id: skill.name,
|
|
16726
17850
|
label: skill.name,
|
|
16727
17851
|
description: skill.description,
|
|
@@ -16737,12 +17861,38 @@ function registerTuiCommands(runner) {
|
|
|
16737
17861
|
});
|
|
16738
17862
|
commands.register({
|
|
16739
17863
|
name: "reload",
|
|
16740
|
-
description: "Reload TUI settings and refresh skill
|
|
17864
|
+
description: "Reload TUI settings and refresh the live command/skill catalog",
|
|
16741
17865
|
handler: async () => {
|
|
16742
|
-
let
|
|
16743
|
-
|
|
16744
|
-
|
|
16745
|
-
|
|
17866
|
+
let catalogText = "";
|
|
17867
|
+
const liveAgent = runner.liveAgent;
|
|
17868
|
+
if (liveAgent !== void 0) {
|
|
17869
|
+
const outcome = await runner.refreshCatalog({
|
|
17870
|
+
source: "reload",
|
|
17871
|
+
target: {
|
|
17872
|
+
kind: "agent",
|
|
17873
|
+
key: runner.sessionGeneration
|
|
17874
|
+
},
|
|
17875
|
+
agent: liveAgent
|
|
17876
|
+
});
|
|
17877
|
+
if (outcome.kind === "applied") {
|
|
17878
|
+
catalogText = `${outcome.snapshot.commands.length} commands \u00b7 ${outcome.snapshot.skills.length} skills`;
|
|
17879
|
+
if (outcome.snapshot.issues.length > 0) catalogText += ` \u00b7 partial: ${outcome.snapshot.issues.map((issue) => issue.provider).join(", ")} unavailable`;
|
|
17880
|
+
} else if (outcome.kind === "failed") catalogText = `catalog refresh failed: ${outcome.error}`;
|
|
17881
|
+
else catalogText = "catalog refresh superseded";
|
|
17882
|
+
} else {
|
|
17883
|
+
const outcome = await runner.refreshCatalog({
|
|
17884
|
+
source: "reload",
|
|
17885
|
+
target: {
|
|
17886
|
+
kind: "preset",
|
|
17887
|
+
presetId: runner.effectivePresetId
|
|
17888
|
+
}
|
|
17889
|
+
});
|
|
17890
|
+
if (outcome.kind === "applied") {
|
|
17891
|
+
catalogText = `${outcome.snapshot.skills.length} human skills`;
|
|
17892
|
+
if (outcome.notice !== void 0) catalogText += ` \u00b7 ${outcome.notice}`;
|
|
17893
|
+
} else if (outcome.kind === "failed") catalogText = `catalog refresh failed: ${outcome.error}`;
|
|
17894
|
+
else catalogText = "catalog refresh superseded";
|
|
17895
|
+
}
|
|
16746
17896
|
const settings = runner.tuiSettings;
|
|
16747
17897
|
if (settings !== void 0) {
|
|
16748
17898
|
const doc = settings.get();
|
|
@@ -16761,7 +17911,7 @@ function registerTuiCommands(runner) {
|
|
|
16761
17911
|
app.setFooterPreset(doc.footer === "compact" ? "compact" : "full");
|
|
16762
17912
|
app.setFullscreen(doc.fullscreen === "on");
|
|
16763
17913
|
}
|
|
16764
|
-
app.notify(`reloaded — ${
|
|
17914
|
+
app.notify(`reloaded — ${catalogText} \u00b7 settings reapplied`, "info");
|
|
16765
17915
|
return { kind: "success" };
|
|
16766
17916
|
}
|
|
16767
17917
|
});
|
|
@@ -16953,12 +18103,17 @@ function registerTuiCommands(runner) {
|
|
|
16953
18103
|
kind: "error",
|
|
16954
18104
|
text: "no background jobs"
|
|
16955
18105
|
};
|
|
16956
|
-
|
|
16957
|
-
app.openPicker(snapshots.map((job) => ({
|
|
18106
|
+
app.openTaskBrowser(snapshots.map((job) => ({
|
|
16958
18107
|
value: job.id,
|
|
16959
18108
|
label: `${job.kind} · ${job.label}`,
|
|
16960
|
-
|
|
16961
|
-
|
|
18109
|
+
status: job.status,
|
|
18110
|
+
detail: job.detail,
|
|
18111
|
+
startedAt: job.startedAt,
|
|
18112
|
+
group: "jobs"
|
|
18113
|
+
})), (jobId) => runner.openJobView(jobId), () => {}, {
|
|
18114
|
+
header: "tasks",
|
|
18115
|
+
enableSearch: true
|
|
18116
|
+
});
|
|
16962
18117
|
return { kind: "success" };
|
|
16963
18118
|
}
|
|
16964
18119
|
});
|
|
@@ -17012,11 +18167,28 @@ function registerTuiCommands(runner) {
|
|
|
17012
18167
|
kind: "success",
|
|
17013
18168
|
text: `default preset: ${settings.get(ns)?.default ?? presets.defaultId}`
|
|
17014
18169
|
};
|
|
17015
|
-
|
|
17016
|
-
|
|
17017
|
-
|
|
17018
|
-
|
|
17019
|
-
|
|
18170
|
+
try {
|
|
18171
|
+
await settings.mutate(ns, [{
|
|
18172
|
+
op: "set",
|
|
18173
|
+
path: ["default"],
|
|
18174
|
+
value: rest
|
|
18175
|
+
}]);
|
|
18176
|
+
} catch (error) {
|
|
18177
|
+
return {
|
|
18178
|
+
kind: "error",
|
|
18179
|
+
text: safeErrorMessage(error)
|
|
18180
|
+
};
|
|
18181
|
+
}
|
|
18182
|
+
if (runner.effectivePresetId === void 0) {
|
|
18183
|
+
const outcome = await runner.refreshCatalog({
|
|
18184
|
+
source: "preset",
|
|
18185
|
+
target: {
|
|
18186
|
+
kind: "preset",
|
|
18187
|
+
presetId: rest
|
|
18188
|
+
}
|
|
18189
|
+
});
|
|
18190
|
+
if (outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
|
|
18191
|
+
}
|
|
17020
18192
|
return {
|
|
17021
18193
|
kind: "success",
|
|
17022
18194
|
text: `default preset set: ${rest}`
|
|
@@ -17026,6 +18198,14 @@ function registerTuiCommands(runner) {
|
|
|
17026
18198
|
if (liveAgent === void 0) {
|
|
17027
18199
|
const resolved = await presets.resolve(id);
|
|
17028
18200
|
runner.pendingPreset = resolved.id;
|
|
18201
|
+
const outcome = await runner.refreshCatalog({
|
|
18202
|
+
source: "preset",
|
|
18203
|
+
target: {
|
|
18204
|
+
kind: "preset",
|
|
18205
|
+
presetId: resolved.id
|
|
18206
|
+
}
|
|
18207
|
+
});
|
|
18208
|
+
if (outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
|
|
17029
18209
|
return {
|
|
17030
18210
|
kind: "pending",
|
|
17031
18211
|
preset: resolved.id
|
|
@@ -17036,7 +18216,14 @@ function registerTuiCommands(runner) {
|
|
|
17036
18216
|
kind: "locked",
|
|
17037
18217
|
sessionId: liveAgent.session.id
|
|
17038
18218
|
};
|
|
17039
|
-
|
|
18219
|
+
await runner.refreshCatalog({
|
|
18220
|
+
source: "preset",
|
|
18221
|
+
target: {
|
|
18222
|
+
kind: "agent",
|
|
18223
|
+
key: runner.sessionGeneration
|
|
18224
|
+
},
|
|
18225
|
+
agent: runner.liveAgent
|
|
18226
|
+
});
|
|
17040
18227
|
runner.updateWelcomeCard();
|
|
17041
18228
|
return {
|
|
17042
18229
|
kind: "switched",
|
|
@@ -17109,7 +18296,11 @@ function registerTuiCommands(runner) {
|
|
|
17109
18296
|
};
|
|
17110
18297
|
}), (id) => {
|
|
17111
18298
|
close();
|
|
17112
|
-
|
|
18299
|
+
runOwned("preset pick", () => pickPreset(id), {
|
|
18300
|
+
diag: runner.diag,
|
|
18301
|
+
sessionId: () => runner.liveAgent?.session.id,
|
|
18302
|
+
onError: (error) => app.notify(`preset selection failed: ${safeErrorMessage(error)}`, "error")
|
|
18303
|
+
});
|
|
17113
18304
|
}, () => {});
|
|
17114
18305
|
return { kind: "success" };
|
|
17115
18306
|
}
|
|
@@ -17403,15 +18594,37 @@ function registerTuiCommands(runner) {
|
|
|
17403
18594
|
});
|
|
17404
18595
|
commands.register({
|
|
17405
18596
|
name: "login",
|
|
17406
|
-
description: "Set an API key
|
|
17407
|
-
input: { hint: "[<env-var>]" },
|
|
18597
|
+
description: "Set an API key — deepseek official or an llm-pi-ai provider route",
|
|
18598
|
+
input: { hint: "[<route|env-var>]" },
|
|
17408
18599
|
handler: async (invocation) => {
|
|
17409
18600
|
const credentials = ctx.get("credentials");
|
|
17410
18601
|
if (credentials === void 0) return {
|
|
17411
18602
|
kind: "error",
|
|
17412
18603
|
text: "credentials service unavailable"
|
|
17413
18604
|
};
|
|
17414
|
-
const
|
|
18605
|
+
const options = credentialOptionsFor(readLlmpiAiProviders(ctx));
|
|
18606
|
+
const arg = invocation.rawInput.trim();
|
|
18607
|
+
let ref;
|
|
18608
|
+
if (arg !== "") {
|
|
18609
|
+
const resolved = resolveCredentialArg(arg, options);
|
|
18610
|
+
if (resolved === void 0) return {
|
|
18611
|
+
kind: "error",
|
|
18612
|
+
text: `unknown credential target "${arg}" — ${options.map((option) => `${option.label} (${option.ref})`).join(", ")}`
|
|
18613
|
+
};
|
|
18614
|
+
ref = resolved;
|
|
18615
|
+
} else if (options.length > 1) {
|
|
18616
|
+
const picked = await new Promise((resolve) => {
|
|
18617
|
+
app.openPicker(options.map((option) => ({
|
|
18618
|
+
value: option.ref,
|
|
18619
|
+
label: `${option.label} (${option.ref})`
|
|
18620
|
+
})), (value) => resolve(value), () => resolve(void 0));
|
|
18621
|
+
});
|
|
18622
|
+
if (picked === void 0) return {
|
|
18623
|
+
kind: "error",
|
|
18624
|
+
text: "login cancelled"
|
|
18625
|
+
};
|
|
18626
|
+
ref = picked;
|
|
18627
|
+
} else ref = options[0].ref;
|
|
17415
18628
|
try {
|
|
17416
18629
|
const key = (await app.askQuestions([{
|
|
17417
18630
|
id: "key",
|
|
@@ -17436,19 +18649,32 @@ function registerTuiCommands(runner) {
|
|
|
17436
18649
|
});
|
|
17437
18650
|
commands.register({
|
|
17438
18651
|
name: "logout",
|
|
17439
|
-
description: "Clear a stored API key
|
|
17440
|
-
input: { hint: "[<env-var>]" },
|
|
18652
|
+
description: "Clear a stored API key — deepseek official or an llm-pi-ai provider route",
|
|
18653
|
+
input: { hint: "[<route|env-var>]" },
|
|
17441
18654
|
handler: async (invocation) => {
|
|
17442
18655
|
const credentials = ctx.get("credentials");
|
|
17443
18656
|
if (credentials === void 0) return {
|
|
17444
18657
|
kind: "error",
|
|
17445
18658
|
text: "credentials service unavailable"
|
|
17446
18659
|
};
|
|
17447
|
-
const
|
|
17448
|
-
|
|
18660
|
+
const options = credentialOptionsFor(readLlmpiAiProviders(ctx));
|
|
18661
|
+
const arg = invocation.rawInput.trim();
|
|
18662
|
+
if (arg !== "") {
|
|
18663
|
+
const resolved = resolveCredentialArg(arg, options);
|
|
18664
|
+
if (resolved === void 0) return {
|
|
18665
|
+
kind: "error",
|
|
18666
|
+
text: `unknown credential target "${arg}" — ${options.map((option) => `${option.label} (${option.ref})`).join(", ")}`
|
|
18667
|
+
};
|
|
18668
|
+
await credentials.unset(resolved);
|
|
18669
|
+
return {
|
|
18670
|
+
kind: "success",
|
|
18671
|
+
text: `${resolved} cleared`
|
|
18672
|
+
};
|
|
18673
|
+
}
|
|
18674
|
+
await credentials.unset(options[0].ref);
|
|
17449
18675
|
return {
|
|
17450
18676
|
kind: "success",
|
|
17451
|
-
text: `${ref} cleared`
|
|
18677
|
+
text: `${options[0].ref} cleared`
|
|
17452
18678
|
};
|
|
17453
18679
|
}
|
|
17454
18680
|
});
|
|
@@ -17551,8 +18777,28 @@ function registerTuiCommands(runner) {
|
|
|
17551
18777
|
return { kind: "success" };
|
|
17552
18778
|
}
|
|
17553
18779
|
});
|
|
17554
|
-
|
|
17555
|
-
|
|
18780
|
+
if (initial?.snapshot !== void 0) installSurfaceSnapshot(initial.snapshot);
|
|
18781
|
+
else if (initial?.skills !== void 0) withCommandCommit(() => {
|
|
18782
|
+
replaceSkillCommands(initial.skills.skills, /* @__PURE__ */ new Set());
|
|
18783
|
+
savedScopedCommands = [];
|
|
18784
|
+
installCompletions(mergeGlobalAndSavedScoped());
|
|
18785
|
+
});
|
|
18786
|
+
else refreshCompletions();
|
|
18787
|
+
ctx.on("commands/change", () => {
|
|
18788
|
+
if (commandCommitDepth > 0) {
|
|
18789
|
+
commandCommitDirty = true;
|
|
18790
|
+
return;
|
|
18791
|
+
}
|
|
18792
|
+
refreshCompletions();
|
|
18793
|
+
});
|
|
18794
|
+
return {
|
|
18795
|
+
/** The claim test for the dispatch: is /name advertised right now? */
|
|
18796
|
+
wasAdvertised,
|
|
18797
|
+
/** One synchronous catalog commit (the coordinator's install hook). */
|
|
18798
|
+
installSnapshot: (snapshot) => installSurfaceSnapshot(snapshot),
|
|
18799
|
+
/** The revalidating transition (the coordinator's target-change hook). */
|
|
18800
|
+
enterTransition: () => enterCatalogTransition()
|
|
18801
|
+
};
|
|
17556
18802
|
}
|
|
17557
18803
|
//#endregion
|
|
17558
18804
|
//#region src/diag.ts
|
|
@@ -17673,6 +18919,110 @@ function createDiag(options = {}) {
|
|
|
17673
18919
|
};
|
|
17674
18920
|
}
|
|
17675
18921
|
//#endregion
|
|
18922
|
+
//#region src/history.ts
|
|
18923
|
+
/**
|
|
18924
|
+
* Per-cwd input-history persistence — JSONL files under `$DSH_HOME/user-history/`,
|
|
18925
|
+
* one `{"content": "..."}` line per submitted prompt (kimi-code's
|
|
18926
|
+
* input-history pattern: a dedicated data file, never the settings
|
|
18927
|
+
* document). Append-only writes make concurrent windows in one directory
|
|
18928
|
+
* safe (O_APPEND lines cannot clobber each other), which whole-file settings
|
|
18929
|
+
* replaces could not guarantee.
|
|
18930
|
+
*
|
|
18931
|
+
* Semantics:
|
|
18932
|
+
* - File order is submission order (oldest first); the editor seeds
|
|
18933
|
+
* oldest→newest so its recall list is newest-first.
|
|
18934
|
+
* - Empty submissions and consecutive repeats are skipped (shell-history
|
|
18935
|
+
* behavior); non-consecutive repeats are legal history and survive.
|
|
18936
|
+
* - Corrupt lines are skipped on load, never fatal.
|
|
18937
|
+
* - The file is capped at HISTORY_LIMIT entries; the cap trims on load.
|
|
18938
|
+
* - `!` shell commands are stored verbatim (with the leading `!`), so ↑
|
|
18939
|
+
* recall restores them as text and Enter re-runs the shell branch.
|
|
18940
|
+
*
|
|
18941
|
+
* Pure and injectable so pathing, parsing, and append rules are
|
|
18942
|
+
* unit-testable without touching the terminal or dsh services.
|
|
18943
|
+
* @module @xmoon76/dsh-pi-tui/history
|
|
18944
|
+
*/
|
|
18945
|
+
/** Directory name under `$DSH_HOME` holding the per-cwd history files. */
|
|
18946
|
+
const HISTORY_DIR_NAME = "user-history";
|
|
18947
|
+
/**
|
|
18948
|
+
* The history file for one working directory: `$DSH_HOME/user-history/<md5(cwd)>.jsonl`.
|
|
18949
|
+
* The cwd hash keeps the file list flat and free of path separators; the
|
|
18950
|
+
* hash is an identity, never shown to the user.
|
|
18951
|
+
* @param home - the dsh home directory (`$DSH_HOME`, default `~/.dsh`).
|
|
18952
|
+
* @param cwd - the working directory whose submissions the file records.
|
|
18953
|
+
*/
|
|
18954
|
+
function historyFilePath(home, cwd) {
|
|
18955
|
+
const hash = createHash("md5").update(cwd, "utf-8").digest("hex");
|
|
18956
|
+
return join(home, HISTORY_DIR_NAME, `${hash}.jsonl`);
|
|
18957
|
+
}
|
|
18958
|
+
/**
|
|
18959
|
+
* Parse one history file's text into entries, in file order (oldest first).
|
|
18960
|
+
* Blank lines and corrupt lines (unparsable JSON or a non-string `content`)
|
|
18961
|
+
* are skipped — a torn write or an older format must never abort recall.
|
|
18962
|
+
* @param text - the raw file content.
|
|
18963
|
+
*/
|
|
18964
|
+
function parseHistoryLines(text) {
|
|
18965
|
+
const entries = [];
|
|
18966
|
+
for (const line of text.split("\n")) {
|
|
18967
|
+
const trimmed = line.trim();
|
|
18968
|
+
if (trimmed === "") continue;
|
|
18969
|
+
try {
|
|
18970
|
+
const parsed = JSON.parse(trimmed);
|
|
18971
|
+
if (typeof parsed.content === "string" && parsed.content !== "") entries.push(parsed.content);
|
|
18972
|
+
} catch {}
|
|
18973
|
+
}
|
|
18974
|
+
return entries;
|
|
18975
|
+
}
|
|
18976
|
+
/**
|
|
18977
|
+
* Load a history file, trimming it to the last HISTORY_LIMIT entries when it
|
|
18978
|
+
* outgrew the cap (the trim rewrites the file best-effort so it stops
|
|
18979
|
+
* growing). Never throws: an unreadable or absent file is empty history.
|
|
18980
|
+
* @param file - the JSONL history file path.
|
|
18981
|
+
* @returns entries in file order (oldest first).
|
|
18982
|
+
*/
|
|
18983
|
+
function loadHistoryFile(file) {
|
|
18984
|
+
try {
|
|
18985
|
+
if (!existsSync(file)) return [];
|
|
18986
|
+
const entries = parseHistoryLines(readFileSync(file, "utf8"));
|
|
18987
|
+
if (entries.length <= 100) return entries;
|
|
18988
|
+
const kept = entries.slice(-100);
|
|
18989
|
+
try {
|
|
18990
|
+
const temp = `${file}.tmp`;
|
|
18991
|
+
writeFileSync(temp, kept.map((entry) => JSON.stringify({ content: entry })).join("\n") + "\n", { mode: 384 });
|
|
18992
|
+
renameSync(temp, file);
|
|
18993
|
+
} catch {}
|
|
18994
|
+
return kept;
|
|
18995
|
+
} catch {
|
|
18996
|
+
return [];
|
|
18997
|
+
}
|
|
18998
|
+
}
|
|
18999
|
+
/**
|
|
19000
|
+
* Append one submitted line to the history file. Empty lines and a repeat of
|
|
19001
|
+
* `lastContent` (the newest known entry) are skipped, like shell history.
|
|
19002
|
+
* Multi-line submissions are stored as one JSON line (newlines escaped), so
|
|
19003
|
+
* the JSONL layout survives pastes.
|
|
19004
|
+
*
|
|
19005
|
+
* Throws on I/O failure (the caller decides how to surface it); the
|
|
19006
|
+
* skip verdicts return false without touching the file.
|
|
19007
|
+
* @param file - the JSONL history file path.
|
|
19008
|
+
* @param text - the submitted input.
|
|
19009
|
+
* @param lastContent - the newest entry known to the caller, or undefined.
|
|
19010
|
+
* @returns whether an entry was written.
|
|
19011
|
+
*/
|
|
19012
|
+
function appendHistoryLine(file, text, lastContent) {
|
|
19013
|
+
const content = text.trim();
|
|
19014
|
+
if (content === "" || content === lastContent) return false;
|
|
19015
|
+
mkdirSync(dirname(file), {
|
|
19016
|
+
recursive: true,
|
|
19017
|
+
mode: 448
|
|
19018
|
+
});
|
|
19019
|
+
appendFileSync(file, JSON.stringify({ content }) + "\n", {
|
|
19020
|
+
encoding: "utf8",
|
|
19021
|
+
mode: 384
|
|
19022
|
+
});
|
|
19023
|
+
return true;
|
|
19024
|
+
}
|
|
19025
|
+
//#endregion
|
|
17676
19026
|
//#region src/exit.ts
|
|
17677
19027
|
/**
|
|
17678
19028
|
* The TUI exit contract: flush the live session with a HARD timeout, record
|
|
@@ -18509,6 +19859,471 @@ function parseShellWords(line) {
|
|
|
18509
19859
|
return words;
|
|
18510
19860
|
}
|
|
18511
19861
|
//#endregion
|
|
19862
|
+
//#region src/skill-catalog-refresh.ts
|
|
19863
|
+
/** Whether two targets are the same owner (no transition needed). The
|
|
19864
|
+
* preset identity is the caller-provided presetId compared EXACTLY —
|
|
19865
|
+
* `undefined` (the deployment default) never conflates with a user preset
|
|
19866
|
+
* literally named `'default'`, and a resolved id never conflates with the
|
|
19867
|
+
* raw id it came from (the worst case is a benign extra transition). */
|
|
19868
|
+
function sameTarget(left, right) {
|
|
19869
|
+
if (left === void 0) return false;
|
|
19870
|
+
if (left.kind !== right.kind) return false;
|
|
19871
|
+
return left.kind === "agent" ? left.key === right.key : left.presetId === right.presetId;
|
|
19872
|
+
}
|
|
19873
|
+
/**
|
|
19874
|
+
* The coordinator. One instance per runner; `dispose()` aborts the active
|
|
19875
|
+
* refresh on teardown.
|
|
19876
|
+
*/
|
|
19877
|
+
var CatalogRefreshCoordinator = class {
|
|
19878
|
+
epoch = 0;
|
|
19879
|
+
active;
|
|
19880
|
+
appliedTarget;
|
|
19881
|
+
snapshot;
|
|
19882
|
+
hooks;
|
|
19883
|
+
lifecycleSignal;
|
|
19884
|
+
diag;
|
|
19885
|
+
constructor(hooks, lifecycleSignal, diag) {
|
|
19886
|
+
this.hooks = hooks;
|
|
19887
|
+
this.lifecycleSignal = lifecycleSignal;
|
|
19888
|
+
this.diag = diag;
|
|
19889
|
+
}
|
|
19890
|
+
/** The last applied snapshot (for same-target partial merges and the
|
|
19891
|
+
* `/reload` report). */
|
|
19892
|
+
get currentSnapshot() {
|
|
19893
|
+
return this.snapshot;
|
|
19894
|
+
}
|
|
19895
|
+
/**
|
|
19896
|
+
* Run one refresh request. A new request aborts the active one; only the
|
|
19897
|
+
* LATEST epoch may commit. Returns the settled outcome; never rejects
|
|
19898
|
+
* (failures and supersessions are outcomes, not exceptions).
|
|
19899
|
+
*/
|
|
19900
|
+
async refresh(request) {
|
|
19901
|
+
this.active?.controller.abort();
|
|
19902
|
+
const epoch = ++this.epoch;
|
|
19903
|
+
const controller = new AbortController();
|
|
19904
|
+
const signal = AbortSignal.any([this.lifecycleSignal, controller.signal]);
|
|
19905
|
+
this.active = {
|
|
19906
|
+
epoch,
|
|
19907
|
+
controller
|
|
19908
|
+
};
|
|
19909
|
+
const targetChanged = !sameTarget(this.appliedTarget, request.target);
|
|
19910
|
+
try {
|
|
19911
|
+
if (targetChanged) this.hooks.enterCatalogTransition();
|
|
19912
|
+
const committed = request.target.kind === "agent" ? { snapshot: await this.hooks.readAgent(request.agent, signal) } : await this.readStandingSnapshot(request.target.presetId, signal);
|
|
19913
|
+
if (signal.aborted || epoch !== this.epoch) {
|
|
19914
|
+
this.diag.debug("catalog refresh superseded", {
|
|
19915
|
+
epoch,
|
|
19916
|
+
source: request.source
|
|
19917
|
+
});
|
|
19918
|
+
return { kind: "superseded" };
|
|
19919
|
+
}
|
|
19920
|
+
const merged = targetChanged ? committed.snapshot : this.mergePartial(committed.snapshot);
|
|
19921
|
+
this.hooks.installSnapshot(merged);
|
|
19922
|
+
this.snapshot = merged;
|
|
19923
|
+
this.appliedTarget = request.target;
|
|
19924
|
+
this.diag.info("catalog applied", {
|
|
19925
|
+
epoch,
|
|
19926
|
+
source: request.source,
|
|
19927
|
+
commands: merged.commands.length,
|
|
19928
|
+
scopedCommands: merged.scopedCommands.length,
|
|
19929
|
+
skills: merged.skills.length,
|
|
19930
|
+
issues: merged.issues.length
|
|
19931
|
+
});
|
|
19932
|
+
return {
|
|
19933
|
+
kind: "applied",
|
|
19934
|
+
snapshot: merged,
|
|
19935
|
+
...committed.notice === void 0 ? {} : { notice: committed.notice }
|
|
19936
|
+
};
|
|
19937
|
+
} catch (error) {
|
|
19938
|
+
if (signal.aborted || epoch !== this.epoch) {
|
|
19939
|
+
this.diag.debug("catalog refresh superseded", {
|
|
19940
|
+
epoch,
|
|
19941
|
+
source: request.source
|
|
19942
|
+
});
|
|
19943
|
+
return { kind: "superseded" };
|
|
19944
|
+
}
|
|
19945
|
+
const message = safeErrorMessage(error);
|
|
19946
|
+
this.diag.warn("catalog unavailable", {
|
|
19947
|
+
epoch,
|
|
19948
|
+
source: request.source,
|
|
19949
|
+
error: message
|
|
19950
|
+
});
|
|
19951
|
+
return {
|
|
19952
|
+
kind: "failed",
|
|
19953
|
+
error: message
|
|
19954
|
+
};
|
|
19955
|
+
} finally {
|
|
19956
|
+
if (this.active?.epoch === epoch) this.active = void 0;
|
|
19957
|
+
}
|
|
19958
|
+
}
|
|
19959
|
+
/** Abort the active refresh (app teardown). Late results are dropped. */
|
|
19960
|
+
dispose() {
|
|
19961
|
+
this.active?.controller.abort();
|
|
19962
|
+
}
|
|
19963
|
+
/** Same-target partial failure: a provider issue keeps the OLD field for
|
|
19964
|
+
* that provider (the old snapshot is still more useful than a blank
|
|
19965
|
+
* catalog); the successful providers update as usual. */
|
|
19966
|
+
mergePartial(snapshot) {
|
|
19967
|
+
const old = this.snapshot;
|
|
19968
|
+
if (old === void 0) return snapshot;
|
|
19969
|
+
const commandsFailed = snapshot.issues.some((issue) => issue.provider === "commands");
|
|
19970
|
+
const skillsFailed = snapshot.issues.some((issue) => issue.provider === "skills");
|
|
19971
|
+
if (!commandsFailed && !skillsFailed) return snapshot;
|
|
19972
|
+
return Object.freeze({
|
|
19973
|
+
commands: commandsFailed ? old.commands : snapshot.commands,
|
|
19974
|
+
scopedCommands: commandsFailed ? old.scopedCommands : snapshot.scopedCommands,
|
|
19975
|
+
skills: skillsFailed ? old.skills : snapshot.skills,
|
|
19976
|
+
issues: snapshot.issues
|
|
19977
|
+
});
|
|
19978
|
+
}
|
|
19979
|
+
/** One standing read wrapped into a skills-only surface snapshot: the
|
|
19980
|
+
* sessionless view has no agent-scoped commands, and the global commands
|
|
19981
|
+
* layer is already registered — only the skill wrappers are installed.
|
|
19982
|
+
* An INCOMPLETE observation (`complete !== true`) is never an
|
|
19983
|
+
* authoritative blank catalog: it carries a detached skills issue, so a
|
|
19984
|
+
* same-target reload keeps the last-good skills (mergePartial) and a
|
|
19985
|
+
* target change keeps the revalidating transition wrappers
|
|
19986
|
+
* (installSurfaceSnapshot's skillsFailed guard). */
|
|
19987
|
+
async readStandingSnapshot(presetId, signal) {
|
|
19988
|
+
const read = await this.hooks.readStanding(presetId, signal);
|
|
19989
|
+
const incomplete = read.catalog.complete !== true;
|
|
19990
|
+
return {
|
|
19991
|
+
snapshot: Object.freeze({
|
|
19992
|
+
commands: Object.freeze([]),
|
|
19993
|
+
scopedCommands: Object.freeze([]),
|
|
19994
|
+
skills: read.catalog.skills,
|
|
19995
|
+
issues: Object.freeze(incomplete ? [Object.freeze({
|
|
19996
|
+
provider: "skills",
|
|
19997
|
+
message: "incomplete skill observation"
|
|
19998
|
+
})] : [])
|
|
19999
|
+
}),
|
|
20000
|
+
...read.notice === void 0 ? {} : { notice: read.notice }
|
|
20001
|
+
};
|
|
20002
|
+
}
|
|
20003
|
+
};
|
|
20004
|
+
/**
|
|
20005
|
+
* The deterministic, timer-free coalescer for invalidation notifications
|
|
20006
|
+
* (`skills/change`): while one refresh is in flight, further notifications
|
|
20007
|
+
* mark the gate DIRTY; when the in-flight refresh settles, ONE more refresh
|
|
20008
|
+
* runs if the gate is dirty. A burst therefore costs at most two reads, and
|
|
20009
|
+
* the re-run always observes the CURRENT ownership because the runner's
|
|
20010
|
+
* `start` callback reads live state at run time.
|
|
20011
|
+
*/
|
|
20012
|
+
var CoalescingRefreshGate = class {
|
|
20013
|
+
inFlight = false;
|
|
20014
|
+
dirty = false;
|
|
20015
|
+
start;
|
|
20016
|
+
/** @param start - invoked synchronously to START one owned refresh. Must
|
|
20017
|
+
* never throw and must not itself be re-entrant (the gate owns the
|
|
20018
|
+
* in-flight bookkeeping; `start` only launches the owned task). */
|
|
20019
|
+
constructor(start) {
|
|
20020
|
+
this.start = start;
|
|
20021
|
+
}
|
|
20022
|
+
/** One invalidation notification arrived. */
|
|
20023
|
+
notify() {
|
|
20024
|
+
if (this.inFlight) {
|
|
20025
|
+
this.dirty = true;
|
|
20026
|
+
return;
|
|
20027
|
+
}
|
|
20028
|
+
this.inFlight = true;
|
|
20029
|
+
this.start();
|
|
20030
|
+
}
|
|
20031
|
+
/** Terminal settle of the in-flight refresh (called from EVERY terminal
|
|
20032
|
+
* runOwned callback: onResult / onCancel / onError). Clears the in-flight
|
|
20033
|
+
* flag; a dirty gate starts exactly one follow-up refresh. IDEMPOTENT: a
|
|
20034
|
+
* second settle (e.g. onResult throws and runOwned routes to onError)
|
|
20035
|
+
* must not clear the in-flight flag of a follow-up refresh it did not
|
|
20036
|
+
* start. */
|
|
20037
|
+
settled() {
|
|
20038
|
+
if (!this.inFlight) return;
|
|
20039
|
+
this.inFlight = false;
|
|
20040
|
+
if (this.dirty) {
|
|
20041
|
+
this.dirty = false;
|
|
20042
|
+
this.notify();
|
|
20043
|
+
}
|
|
20044
|
+
}
|
|
20045
|
+
};
|
|
20046
|
+
//#endregion
|
|
20047
|
+
//#region src/session-lock.ts
|
|
20048
|
+
/** The lock file name, next to `session.jsonl[.zstd]` in the session directory. */
|
|
20049
|
+
const LOCK_FILE_NAME = "owner.lock";
|
|
20050
|
+
/**
|
|
20051
|
+
* Decide the lock repair after an INTERNAL swap failure (flush/dispose/
|
|
20052
|
+
* whenIdle throwing inside swapTo), given the single-slot tracker state and
|
|
20053
|
+
* the outgoing session id. Pure so the three-shape matrix is headless-testable:
|
|
20054
|
+
*
|
|
20055
|
+
* - tracker holds `from` itself (/new, /fork — the current lock was never
|
|
20056
|
+
* pre-released): nothing to repair — `from` is still locked by us.
|
|
20057
|
+
* - tracker holds the TARGET (switchSession pre-released `from` and acquired
|
|
20058
|
+
* the target before calling swapTo): release the target (we never entered
|
|
20059
|
+
* it) and re-acquire `from` (the current session stays live).
|
|
20060
|
+
* - tracker is empty (switchSession's target acquire was `unavailable`):
|
|
20061
|
+
* `from`'s lock was released and never re-taken — re-acquire it.
|
|
20062
|
+
*/
|
|
20063
|
+
function swapFailureLockRepair(tracker, from) {
|
|
20064
|
+
if (from === void 0) return tracker === void 0 ? {
|
|
20065
|
+
release: void 0,
|
|
20066
|
+
reacquire: void 0
|
|
20067
|
+
} : {
|
|
20068
|
+
release: tracker.sessionId,
|
|
20069
|
+
reacquire: void 0
|
|
20070
|
+
};
|
|
20071
|
+
if (tracker === void 0 || tracker.sessionId !== from) return {
|
|
20072
|
+
release: tracker?.sessionId,
|
|
20073
|
+
reacquire: from
|
|
20074
|
+
};
|
|
20075
|
+
return {
|
|
20076
|
+
release: void 0,
|
|
20077
|
+
reacquire: void 0
|
|
20078
|
+
};
|
|
20079
|
+
}
|
|
20080
|
+
/** A tiny deterministic serializer for the lock record (single-line JSON). */
|
|
20081
|
+
function serializeLockInfo(info) {
|
|
20082
|
+
return JSON.stringify(info);
|
|
20083
|
+
}
|
|
20084
|
+
/** Parse a lock file's content; malformed content yields undefined. */
|
|
20085
|
+
function parseLockInfo(content) {
|
|
20086
|
+
try {
|
|
20087
|
+
const parsed = JSON.parse(content);
|
|
20088
|
+
if (typeof parsed.pid !== "number" || !Number.isSafeInteger(parsed.pid)) return void 0;
|
|
20089
|
+
if (typeof parsed.starttime !== "number" || !Number.isSafeInteger(parsed.starttime)) return void 0;
|
|
20090
|
+
if (typeof parsed.startedAt !== "number" || !Number.isSafeInteger(parsed.startedAt)) return void 0;
|
|
20091
|
+
return {
|
|
20092
|
+
pid: parsed.pid,
|
|
20093
|
+
starttime: parsed.starttime,
|
|
20094
|
+
startedAt: parsed.startedAt,
|
|
20095
|
+
...typeof parsed.profile === "string" ? { profile: parsed.profile } : {},
|
|
20096
|
+
...typeof parsed.tty === "string" ? { tty: parsed.tty } : {}
|
|
20097
|
+
};
|
|
20098
|
+
} catch {
|
|
20099
|
+
return;
|
|
20100
|
+
}
|
|
20101
|
+
}
|
|
20102
|
+
/** Derive the lock file path from the session artifact path. */
|
|
20103
|
+
function lockPathOf(artifactPath) {
|
|
20104
|
+
return `${artifactPath}.${LOCK_FILE_NAME}`;
|
|
20105
|
+
}
|
|
20106
|
+
/**
|
|
20107
|
+
* Try to acquire the lock with an atomic create. Returns the path when this
|
|
20108
|
+
* process owns it, undefined when another process holds it or the create
|
|
20109
|
+
* cannot happen. Never throws.
|
|
20110
|
+
*/
|
|
20111
|
+
function tryCreate(fs, lockPath, self) {
|
|
20112
|
+
try {
|
|
20113
|
+
fs.writeFileSync(lockPath, serializeLockInfo(self), {
|
|
20114
|
+
flag: "wx",
|
|
20115
|
+
mode: 384
|
|
20116
|
+
});
|
|
20117
|
+
return { created: true };
|
|
20118
|
+
} catch (error) {
|
|
20119
|
+
if (error?.code === "EEXIST") return {
|
|
20120
|
+
created: false,
|
|
20121
|
+
reason: "exists"
|
|
20122
|
+
};
|
|
20123
|
+
return {
|
|
20124
|
+
created: false,
|
|
20125
|
+
reason: "error",
|
|
20126
|
+
error
|
|
20127
|
+
};
|
|
20128
|
+
}
|
|
20129
|
+
}
|
|
20130
|
+
/**
|
|
20131
|
+
* Acquire the open-time lock for one session.
|
|
20132
|
+
*
|
|
20133
|
+
* Returns `acquired` (with an idempotent `release`), `held` (another live dsh
|
|
20134
|
+
* process owns it), `taken-over-stale` (a dead owner's lock was replaced),
|
|
20135
|
+
* `unverifiable` (the owner could not be probed — refuse), or `unavailable`
|
|
20136
|
+
* (this deployment cannot lock; callers should proceed as before, the guard
|
|
20137
|
+
* still protects the write path). Never throws.
|
|
20138
|
+
*/
|
|
20139
|
+
function acquireSessionLock(deps, session, self) {
|
|
20140
|
+
const { persistence, fs, proc } = deps;
|
|
20141
|
+
if (persistence === void 0) return {
|
|
20142
|
+
kind: "unavailable",
|
|
20143
|
+
reason: "no-persistence"
|
|
20144
|
+
};
|
|
20145
|
+
const artifact = persistence.locate({
|
|
20146
|
+
id: session.id,
|
|
20147
|
+
cwd: session.header?.cwd
|
|
20148
|
+
});
|
|
20149
|
+
if (artifact === void 0) return {
|
|
20150
|
+
kind: "unavailable",
|
|
20151
|
+
reason: "no-artifact"
|
|
20152
|
+
};
|
|
20153
|
+
const lockPath = lockPathOf(artifact.path);
|
|
20154
|
+
let tookOver = false;
|
|
20155
|
+
for (let attempt = 0;; attempt += 1) {
|
|
20156
|
+
const created = tryCreate(fs, lockPath, self);
|
|
20157
|
+
if (created.created) return tookOver ? {
|
|
20158
|
+
kind: "taken-over-stale",
|
|
20159
|
+
release: makeRelease(fs, lockPath, self)
|
|
20160
|
+
} : {
|
|
20161
|
+
kind: "acquired",
|
|
20162
|
+
release: makeRelease(fs, lockPath, self)
|
|
20163
|
+
};
|
|
20164
|
+
if (created.reason === "error") return {
|
|
20165
|
+
kind: "unavailable",
|
|
20166
|
+
reason: created.error?.code === "ENOENT" ? "no-lock-dir" : "write-error"
|
|
20167
|
+
};
|
|
20168
|
+
let owner;
|
|
20169
|
+
try {
|
|
20170
|
+
owner = parseLockInfo(fs.readFileSync(lockPath));
|
|
20171
|
+
} catch (error) {
|
|
20172
|
+
if (error?.code === "ENOENT") {
|
|
20173
|
+
if (attempt >= 2) return {
|
|
20174
|
+
kind: "unverifiable",
|
|
20175
|
+
owner
|
|
20176
|
+
};
|
|
20177
|
+
continue;
|
|
20178
|
+
}
|
|
20179
|
+
return {
|
|
20180
|
+
kind: "unverifiable",
|
|
20181
|
+
owner
|
|
20182
|
+
};
|
|
20183
|
+
}
|
|
20184
|
+
if (owner === void 0) return {
|
|
20185
|
+
kind: "unverifiable",
|
|
20186
|
+
owner
|
|
20187
|
+
};
|
|
20188
|
+
if (owner.pid === self.pid && owner.starttime === self.starttime) return {
|
|
20189
|
+
kind: "acquired",
|
|
20190
|
+
release: makeRelease(fs, lockPath, self)
|
|
20191
|
+
};
|
|
20192
|
+
const verdict = proc.probe(owner, self);
|
|
20193
|
+
if (verdict.kind === "alive") return {
|
|
20194
|
+
kind: "held",
|
|
20195
|
+
owner
|
|
20196
|
+
};
|
|
20197
|
+
if (verdict.kind === "unknown") return {
|
|
20198
|
+
kind: "unverifiable",
|
|
20199
|
+
owner
|
|
20200
|
+
};
|
|
20201
|
+
let removed = false;
|
|
20202
|
+
try {
|
|
20203
|
+
const current = parseLockInfo(fs.readFileSync(lockPath));
|
|
20204
|
+
if (current !== void 0 && current.pid === owner.pid && current.starttime === owner.starttime) {
|
|
20205
|
+
fs.unlinkSync(lockPath);
|
|
20206
|
+
removed = true;
|
|
20207
|
+
}
|
|
20208
|
+
} catch {}
|
|
20209
|
+
if (removed) tookOver = true;
|
|
20210
|
+
else if (attempt >= 2) return {
|
|
20211
|
+
kind: "held",
|
|
20212
|
+
owner
|
|
20213
|
+
};
|
|
20214
|
+
}
|
|
20215
|
+
}
|
|
20216
|
+
/**
|
|
20217
|
+
* An idempotent release that only removes the lock if it is still OURS.
|
|
20218
|
+
* Compare-and-delete: after ownership is lost (a concurrent takeover, manual
|
|
20219
|
+
* removal and recreation by another process), the exit-time release must not
|
|
20220
|
+
* delete the new owner's lock file.
|
|
20221
|
+
*/
|
|
20222
|
+
function makeRelease(fs, lockPath, self) {
|
|
20223
|
+
let released = false;
|
|
20224
|
+
return () => {
|
|
20225
|
+
if (released) return;
|
|
20226
|
+
released = true;
|
|
20227
|
+
try {
|
|
20228
|
+
const owner = parseLockInfo(fs.readFileSync(lockPath));
|
|
20229
|
+
if (owner !== void 0 && owner.pid === self.pid && owner.starttime === self.starttime) fs.unlinkSync(lockPath);
|
|
20230
|
+
} catch {}
|
|
20231
|
+
};
|
|
20232
|
+
}
|
|
20233
|
+
//#endregion
|
|
20234
|
+
//#region src/session-lock-proc.ts
|
|
20235
|
+
/**
|
|
20236
|
+
* Real `/proc`-based owner probe for the open-time session lock.
|
|
20237
|
+
*
|
|
20238
|
+
* Verifies that a recorded lock owner is the SAME live dsh invocation:
|
|
20239
|
+
* - the process exists (`/proc/<pid>/stat` readable),
|
|
20240
|
+
* - it is not a zombie (state `Z`),
|
|
20241
|
+
* - its starttime (stat field 22) matches the lock record — a reused pid
|
|
20242
|
+
* would carry a different starttime,
|
|
20243
|
+
* - its command line identifies a dsh process (the same family of tools the
|
|
20244
|
+
* lock speaks for).
|
|
20245
|
+
*
|
|
20246
|
+
* The probe never takes a lock over on doubt: any inability to verify
|
|
20247
|
+
* (missing /proc, permission errors, unparsable stat) reports `unknown` and
|
|
20248
|
+
* the caller refuses the open. An ENOENT stat, a zombie state, a starttime
|
|
20249
|
+
* mismatch, or a non-dsh command line is a definitive `stale`.
|
|
20250
|
+
*
|
|
20251
|
+
* Non-Linux platforms degrade: `process.kill(pid, 0)` checks existence, then
|
|
20252
|
+
* the command line is compared when `ps` is available; when it is not, the
|
|
20253
|
+
* probe reports `unknown` (conservative refusal) instead of guessing.
|
|
20254
|
+
* @module @xmoon76/dsh-pi-tui/session-lock-proc
|
|
20255
|
+
*/
|
|
20256
|
+
/** Parse `/proc/<pid>/stat`: state (field 3) and starttime (field 22). */
|
|
20257
|
+
function parseProcStat(content) {
|
|
20258
|
+
const close = content.lastIndexOf(")");
|
|
20259
|
+
if (close < 0) return void 0;
|
|
20260
|
+
const fields = content.slice(close + 2).trim().split(" ");
|
|
20261
|
+
if (fields.length < 20) return void 0;
|
|
20262
|
+
const state = fields[0] ?? "";
|
|
20263
|
+
const starttime = Number(fields[19]);
|
|
20264
|
+
if (!Number.isSafeInteger(starttime)) return void 0;
|
|
20265
|
+
return {
|
|
20266
|
+
state,
|
|
20267
|
+
starttime
|
|
20268
|
+
};
|
|
20269
|
+
}
|
|
20270
|
+
/** The set of command names this lock family recognizes as "our own tool". */
|
|
20271
|
+
function looksLikeDsh(cmdline) {
|
|
20272
|
+
if (cmdline === void 0) return false;
|
|
20273
|
+
return cmdline.split(/\s+/).some((arg) => {
|
|
20274
|
+
if (arg === "dsh") return true;
|
|
20275
|
+
const base = arg.split("/").pop() ?? "";
|
|
20276
|
+
return base === "dsh" || base === "dsh.exe";
|
|
20277
|
+
});
|
|
20278
|
+
}
|
|
20279
|
+
/** The real probe: Linux /proc, with a conservative non-Linux degradation. */
|
|
20280
|
+
function createProcProbe(options) {
|
|
20281
|
+
const platform = options?.platform ?? process.platform;
|
|
20282
|
+
const execFile = options?.execFile ?? execFileSync;
|
|
20283
|
+
const readFile = options?.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
20284
|
+
return { probe(owner, self) {
|
|
20285
|
+
if (owner.pid === self.pid && owner.starttime === self.starttime) return { kind: "alive" };
|
|
20286
|
+
if (platform === "linux" || platform === "android") {
|
|
20287
|
+
let statContent;
|
|
20288
|
+
try {
|
|
20289
|
+
statContent = readFile(`/proc/${owner.pid}/stat`);
|
|
20290
|
+
} catch (error) {
|
|
20291
|
+
if (error?.code === "ENOENT") return { kind: "stale" };
|
|
20292
|
+
return { kind: "unknown" };
|
|
20293
|
+
}
|
|
20294
|
+
const parsed = parseProcStat(statContent);
|
|
20295
|
+
if (parsed === void 0) return { kind: "unknown" };
|
|
20296
|
+
if (parsed.state === "Z") return { kind: "stale" };
|
|
20297
|
+
if (parsed.starttime !== owner.starttime) return { kind: "stale" };
|
|
20298
|
+
let cmdline;
|
|
20299
|
+
try {
|
|
20300
|
+
cmdline = readFile(`/proc/${owner.pid}/cmdline`).split("\0").filter((arg) => arg !== "").join(" ");
|
|
20301
|
+
} catch {
|
|
20302
|
+
return { kind: "unknown" };
|
|
20303
|
+
}
|
|
20304
|
+
if (cmdline !== void 0 && !looksLikeDsh(cmdline)) return { kind: "stale" };
|
|
20305
|
+
return { kind: "alive" };
|
|
20306
|
+
}
|
|
20307
|
+
try {
|
|
20308
|
+
process.kill(owner.pid, 0);
|
|
20309
|
+
} catch (error) {
|
|
20310
|
+
if (error?.code === "ESRCH") return { kind: "stale" };
|
|
20311
|
+
}
|
|
20312
|
+
try {
|
|
20313
|
+
const command = execFile("ps", [
|
|
20314
|
+
"-p",
|
|
20315
|
+
String(owner.pid),
|
|
20316
|
+
"-o",
|
|
20317
|
+
"command="
|
|
20318
|
+
], { encoding: "utf8" }).trim();
|
|
20319
|
+
if (command === "") return { kind: "stale" };
|
|
20320
|
+
return looksLikeDsh(command) ? { kind: "alive" } : { kind: "stale" };
|
|
20321
|
+
} catch {
|
|
20322
|
+
return { kind: "unknown" };
|
|
20323
|
+
}
|
|
20324
|
+
} };
|
|
20325
|
+
}
|
|
20326
|
+
//#endregion
|
|
18512
20327
|
//#region src/index.ts
|
|
18513
20328
|
/**
|
|
18514
20329
|
* @xmoon76/dsh-pi-tui — the bundle's runner plugin. Waits for the startup
|
|
@@ -18615,11 +20430,65 @@ function shouldSteerOnEnter(parsed, running, busyEnter, forceQueue) {
|
|
|
18615
20430
|
return running && busyEnter === "steer";
|
|
18616
20431
|
}
|
|
18617
20432
|
/**
|
|
18618
|
-
*
|
|
18619
|
-
*
|
|
18620
|
-
*
|
|
18621
|
-
*
|
|
20433
|
+
* The advertised-claim miss decision (pure, exported for the headless
|
|
20434
|
+
* suite): a slash input whose name was advertised by the completion list at
|
|
20435
|
+
* submit time but that the REAL session's catalog lacks is CONSUMED with an
|
|
20436
|
+
* explicit error — never sent to the model as a plain user message. An
|
|
20437
|
+
* unadvertised miss keeps the existing plain-input fallback (the user may
|
|
20438
|
+
* deliberately send slash text to the model).
|
|
20439
|
+
* @param execution - the settled `commands.execute` outcome.
|
|
20440
|
+
* @param wasAdvertised - the claim captured BEFORE session creation.
|
|
20441
|
+
* @returns whether the miss must be consumed as an advertised miss.
|
|
18622
20442
|
*/
|
|
20443
|
+
function shouldConsumeAdvertisedMiss(execution, wasAdvertised) {
|
|
20444
|
+
return execution === void 0 && wasAdvertised;
|
|
20445
|
+
}
|
|
20446
|
+
async function resolveInitialCatalog(options) {
|
|
20447
|
+
const { liveAgent, presetId, signal, ctx, diag } = options;
|
|
20448
|
+
if (liveAgent !== void 0) try {
|
|
20449
|
+
const snapshot = await readSurfaceCatalog(liveAgent, signal, ctx);
|
|
20450
|
+
diag.info("surface catalog prefetched", {
|
|
20451
|
+
commands: snapshot.commands.length,
|
|
20452
|
+
scopedCommands: snapshot.scopedCommands.length,
|
|
20453
|
+
skills: snapshot.skills.length
|
|
20454
|
+
});
|
|
20455
|
+
return { snapshot };
|
|
20456
|
+
} catch (error) {
|
|
20457
|
+
if (isCancellation(error)) return {};
|
|
20458
|
+
const message = safeErrorMessage(error);
|
|
20459
|
+
diag.warn("surface catalog unavailable", {
|
|
20460
|
+
phase: "resume",
|
|
20461
|
+
error: message
|
|
20462
|
+
});
|
|
20463
|
+
return { notice: `surface catalog unavailable: ${message}` };
|
|
20464
|
+
}
|
|
20465
|
+
const target = await resolveColdSkillTarget(ctx, presetId, process.cwd());
|
|
20466
|
+
if (target.target === void 0) return {};
|
|
20467
|
+
try {
|
|
20468
|
+
const catalog = await readHumanSkillCatalog(target.target.registry, {
|
|
20469
|
+
cwd: target.target.cwd,
|
|
20470
|
+
scope: target.target.scope,
|
|
20471
|
+
signal
|
|
20472
|
+
});
|
|
20473
|
+
diag.info("skill catalog standing ready", {
|
|
20474
|
+
preset: presetId ?? "default",
|
|
20475
|
+
skills: catalog.skills.length,
|
|
20476
|
+
complete: catalog.complete
|
|
20477
|
+
});
|
|
20478
|
+
return {
|
|
20479
|
+
skills: catalog,
|
|
20480
|
+
...target.degraded === void 0 ? {} : { notice: target.degraded }
|
|
20481
|
+
};
|
|
20482
|
+
} catch (error) {
|
|
20483
|
+
if (isCancellation(error)) return {};
|
|
20484
|
+
const message = safeErrorMessage(error);
|
|
20485
|
+
diag.warn("skill catalog unavailable", {
|
|
20486
|
+
phase: "cold",
|
|
20487
|
+
error: message
|
|
20488
|
+
});
|
|
20489
|
+
return { notice: `skill catalog unavailable: ${message}` };
|
|
20490
|
+
}
|
|
20491
|
+
}
|
|
18623
20492
|
function subagentJobTranscriptId(snapshot) {
|
|
18624
20493
|
if (typeof snapshot !== "object" || snapshot === null) return void 0;
|
|
18625
20494
|
const childSessionId = snapshot.childSessionId;
|
|
@@ -18637,6 +20506,86 @@ function subagentJobViewHint(status, detail) {
|
|
|
18637
20506
|
].join("\n");
|
|
18638
20507
|
}
|
|
18639
20508
|
/**
|
|
20509
|
+
* Whether a plain submitted draft is the quit word: exactly `exit` (trimmed,
|
|
20510
|
+
* lowercase). The runner intercepts this BEFORE any session creation or
|
|
20511
|
+
* submission (shell muscle memory); anything else — `exit!`, `Exit`, or a
|
|
20512
|
+
* draft with a recalled entry still in it — is an ordinary message.
|
|
20513
|
+
* @param text - the submitted draft.
|
|
20514
|
+
*/
|
|
20515
|
+
function isPlainExitPrompt(text) {
|
|
20516
|
+
return text.trim() === "exit";
|
|
20517
|
+
}
|
|
20518
|
+
/**
|
|
20519
|
+
* Whether an inbox message is a BACKGROUND-SUBAGENT settlement notice — the
|
|
20520
|
+
* runtime's account of a child ending, not steerable user input. Two dsh
|
|
20521
|
+
* producers push these into the parent's inbox:
|
|
20522
|
+
* - continuable children: `source.kind === 'subagent-settled'` (the
|
|
20523
|
+
* continuation manager's settlement notice);
|
|
20524
|
+
* - one-shot background subagent jobs: tool-jobs completion notices whose
|
|
20525
|
+
* summary starts with the job kind (`subagent <label> [status: …]`).
|
|
20526
|
+
* The queue pane mirrors the inbox, but these belong to the task browser
|
|
20527
|
+
* (terminal job rows / inactive child rows), so the mirror drops them and
|
|
20528
|
+
* only failures surface as a transient error notify.
|
|
20529
|
+
* @param source - the message source projection, or undefined for a plain row.
|
|
20530
|
+
*/
|
|
20531
|
+
function isSubagentSettlementNotice(source) {
|
|
20532
|
+
if (source === void 0 || source.form !== "notice") return false;
|
|
20533
|
+
if (source.kind === "subagent-settled") return true;
|
|
20534
|
+
return source.kind === "plugin" && typeof source.summary === "string" && source.summary.startsWith("subagent ");
|
|
20535
|
+
}
|
|
20536
|
+
/**
|
|
20537
|
+
* Whether a subagent settlement notice reports FAILURE, classified on the
|
|
20538
|
+
* producers' own deterministic wording:
|
|
20539
|
+
* - `subagent-settled` summaries: "finished and will do no further work"
|
|
20540
|
+
* is the only success wording; aborted / max-tokens / refusal / error /
|
|
20541
|
+
* unknown endings all fail;
|
|
20542
|
+
* - tool-jobs subagent summaries carry the terminal status line, whose
|
|
20543
|
+
* failure statuses are `failed` and `killed` (dsh JobStatus).
|
|
20544
|
+
* A notice that cannot be classified is treated as success (silent).
|
|
20545
|
+
* @param source - the message source projection.
|
|
20546
|
+
*/
|
|
20547
|
+
function subagentNoticeIsFailure(source) {
|
|
20548
|
+
if (source === void 0 || source.form !== "notice") return false;
|
|
20549
|
+
if (source.kind === "subagent-settled") return typeof source.summary === "string" && !source.summary.includes("finished and");
|
|
20550
|
+
if (source.kind === "plugin" && typeof source.summary === "string" && source.summary.startsWith("subagent ")) return /\[status: (failed|killed)[,\]]/.test(source.summary);
|
|
20551
|
+
return false;
|
|
20552
|
+
}
|
|
20553
|
+
/**
|
|
20554
|
+
* Build the queue-pane rows for one inbox batch, dropping background-subagent
|
|
20555
|
+
* settlement notices (the task browser is their surface) and reporting which
|
|
20556
|
+
* FAILED settlements should notify. Pure and injectable so the filter +
|
|
20557
|
+
* once-notify semantics are testable without the agent.
|
|
20558
|
+
* @param messages - one inbox batch (next-turn or next-step), in order.
|
|
20559
|
+
* @param mode - the delivery mode for surviving rows.
|
|
20560
|
+
* @param notified - the notify-once guard; failed notices already in it are
|
|
20561
|
+
* skipped, and a newly-reported id is ADDED here so a re-render can never
|
|
20562
|
+
* double-notify.
|
|
20563
|
+
*/
|
|
20564
|
+
function foldQueueRows(messages, mode, notified) {
|
|
20565
|
+
const rows = [];
|
|
20566
|
+
const failures = [];
|
|
20567
|
+
for (const message of messages) {
|
|
20568
|
+
const source = message.source;
|
|
20569
|
+
if (isSubagentSettlementNotice(source)) {
|
|
20570
|
+
if (source?.summary !== void 0 && subagentNoticeIsFailure(source) && !notified.has(message.id)) {
|
|
20571
|
+
notified.add(message.id);
|
|
20572
|
+
failures.push(source.summary);
|
|
20573
|
+
}
|
|
20574
|
+
continue;
|
|
20575
|
+
}
|
|
20576
|
+
rows.push({
|
|
20577
|
+
id: message.id,
|
|
20578
|
+
text: textOf(message.content),
|
|
20579
|
+
mode,
|
|
20580
|
+
notice: source?.form === "notice"
|
|
20581
|
+
});
|
|
20582
|
+
}
|
|
20583
|
+
return {
|
|
20584
|
+
rows,
|
|
20585
|
+
failures
|
|
20586
|
+
};
|
|
20587
|
+
}
|
|
20588
|
+
/**
|
|
18640
20589
|
* The installed dsh version (e.g. `0.1.0-rc.6`), resolved from the launcher's
|
|
18641
20590
|
* real path: `process.argv[1]` is the `dsh` bin, whose realpath walks up to
|
|
18642
20591
|
* the `@deepseek-ai/dsh/package.json` that owns it. The version the welcome
|
|
@@ -18762,6 +20711,51 @@ function resumeCommand(profile, sessionId) {
|
|
|
18762
20711
|
if (id === "") return void 0;
|
|
18763
20712
|
return `dsh --profile ${profile} --session ${id}`;
|
|
18764
20713
|
}
|
|
20714
|
+
/** The lock identity of THIS process: pid + boot-relative starttime. */
|
|
20715
|
+
let cachedSelfLockInfo;
|
|
20716
|
+
function selfLockInfo() {
|
|
20717
|
+
if (cachedSelfLockInfo !== void 0) return cachedSelfLockInfo;
|
|
20718
|
+
const info = {
|
|
20719
|
+
pid: process.pid,
|
|
20720
|
+
starttime: 0,
|
|
20721
|
+
startedAt: Date.now(),
|
|
20722
|
+
profile: runningProfile()
|
|
20723
|
+
};
|
|
20724
|
+
try {
|
|
20725
|
+
info.starttime = parseProcStat(readFileSync(`/proc/self/stat`, "utf8"))?.starttime ?? 0;
|
|
20726
|
+
} catch {}
|
|
20727
|
+
cachedSelfLockInfo = info;
|
|
20728
|
+
return info;
|
|
20729
|
+
}
|
|
20730
|
+
/** Human-readable lock owner description for the refusal notice. */
|
|
20731
|
+
function lockOwnerText(owner) {
|
|
20732
|
+
const parts = [`pid ${owner.pid}`];
|
|
20733
|
+
if (owner.profile !== void 0) parts.push(`profile ${owner.profile}`);
|
|
20734
|
+
if (owner.startedAt > 0) {
|
|
20735
|
+
const started = new Date(owner.startedAt);
|
|
20736
|
+
parts.push(`started ${started.toLocaleTimeString()}`);
|
|
20737
|
+
}
|
|
20738
|
+
if (owner.tty !== void 0) parts.push(`tty ${owner.tty}`);
|
|
20739
|
+
return parts.join(", ");
|
|
20740
|
+
}
|
|
20741
|
+
/** The refusal notice for a session held by another live dsh process. */
|
|
20742
|
+
function lockHeldNotice(sessionId, owner) {
|
|
20743
|
+
return `Session ${sessionId} is open in another dsh process (${lockOwnerText(owner)}); opening it here could corrupt the log. Close it there first, or restart that process.`;
|
|
20744
|
+
}
|
|
20745
|
+
/**
|
|
20746
|
+
* A launch-time open-lock refusal: the requested session is held by another
|
|
20747
|
+
* live dsh process (or its lock cannot be verified). Unlike a stale/missing
|
|
20748
|
+
* session id, this is NOT recoverable by falling back to a fresh session —
|
|
20749
|
+
* the user asked for a specific session and must resolve the holder first.
|
|
20750
|
+
* Thrown from the resume path and re-thrown past the resume catch so the
|
|
20751
|
+
* runner exits with the refusal as the error message.
|
|
20752
|
+
*/
|
|
20753
|
+
var SessionLockRefusedError = class extends Error {
|
|
20754
|
+
constructor(message) {
|
|
20755
|
+
super(message);
|
|
20756
|
+
this.name = "SessionLockRefusedError";
|
|
20757
|
+
}
|
|
20758
|
+
};
|
|
18765
20759
|
/**
|
|
18766
20760
|
* The active goal badge text from the session log, or undefined. The latest
|
|
18767
20761
|
* `goal/change` wins; a clear or completed goal hides the badge.
|
|
@@ -18951,9 +20945,81 @@ function apply(ctx, config) {
|
|
|
18951
20945
|
};
|
|
18952
20946
|
}
|
|
18953
20947
|
};
|
|
20948
|
+
let heldLock;
|
|
20949
|
+
const lockProc = createProcProbe();
|
|
20950
|
+
/** Try to take the open-time lock for a session. Returns the refusal text
|
|
20951
|
+
* when the session is held by another live dsh process (or unverifiable);
|
|
20952
|
+
* undefined when the lock is now ours (or the deployment cannot lock —
|
|
20953
|
+
* proceed as before). Never throws. */
|
|
20954
|
+
const acquireOpenLock = (sessionId, header) => {
|
|
20955
|
+
const outcome = acquireSessionLock({
|
|
20956
|
+
persistence: ctx.get("sessionPersistence"),
|
|
20957
|
+
fs: {
|
|
20958
|
+
readFileSync: (path) => readFileSync(path, "utf8"),
|
|
20959
|
+
writeFileSync: (path, content, options) => writeFileSync(path, content, options),
|
|
20960
|
+
unlinkSync
|
|
20961
|
+
},
|
|
20962
|
+
proc: lockProc
|
|
20963
|
+
}, {
|
|
20964
|
+
id: sessionId,
|
|
20965
|
+
header
|
|
20966
|
+
}, selfLockInfo());
|
|
20967
|
+
switch (outcome.kind) {
|
|
20968
|
+
case "acquired":
|
|
20969
|
+
case "taken-over-stale":
|
|
20970
|
+
heldLock = {
|
|
20971
|
+
sessionId,
|
|
20972
|
+
release: outcome.release
|
|
20973
|
+
};
|
|
20974
|
+
if (outcome.kind === "taken-over-stale") diag.warn("session lock stale taken over", { session: sessionId });
|
|
20975
|
+
return;
|
|
20976
|
+
case "held":
|
|
20977
|
+
diag.warn("session lock held", {
|
|
20978
|
+
session: sessionId,
|
|
20979
|
+
pid: outcome.owner.pid
|
|
20980
|
+
});
|
|
20981
|
+
return lockHeldNotice(sessionId, outcome.owner);
|
|
20982
|
+
case "unverifiable":
|
|
20983
|
+
diag.warn("session lock unverifiable", {
|
|
20984
|
+
session: sessionId,
|
|
20985
|
+
pid: outcome.owner?.pid
|
|
20986
|
+
});
|
|
20987
|
+
return outcome.owner === void 0 ? `Session ${sessionId} has an unreadable or malformed lock file; refusing to open it here. If no other dsh process is using it, delete the owner.lock file next to the session log and retry.` : `Session ${sessionId} has a lock file whose owner (pid ${outcome.owner.pid}) cannot be verified; refusing to open it here. If that process is gone, delete the owner.lock file next to the session log and retry.`;
|
|
20988
|
+
case "unavailable": return;
|
|
20989
|
+
}
|
|
20990
|
+
};
|
|
20991
|
+
/** Release the open-time lock for a session (idempotent). */
|
|
20992
|
+
const releaseOpenLock = (sessionId) => {
|
|
20993
|
+
if (heldLock === void 0 || heldLock.sessionId !== sessionId) return;
|
|
20994
|
+
heldLock.release();
|
|
20995
|
+
heldLock = void 0;
|
|
20996
|
+
};
|
|
20997
|
+
/** Re-take the lock for the still-live current session after a failed
|
|
20998
|
+
* switch (refusal, resume failure, or an internal swap failure). The
|
|
20999
|
+
* lock was released before the target acquire, so another process may
|
|
21000
|
+
* have taken it in the window — the re-take result is checked and a
|
|
21001
|
+
* failed re-take surfaces as a notice (the write-path guard remains the
|
|
21002
|
+
* backstop, but the user must know the session is no longer locked by
|
|
21003
|
+
* this TUI). */
|
|
21004
|
+
const reacquireCurrentLock = (from, fromHeader) => {
|
|
21005
|
+
if (from === void 0) return;
|
|
21006
|
+
const refusal = acquireOpenLock(from, fromHeader ?? liveAgent?.session.header);
|
|
21007
|
+
if (refusal !== void 0) {
|
|
21008
|
+
const message = `the current session ${from} is no longer locked by this TUI (${refusal})`;
|
|
21009
|
+
ctx.logger.warn(`tui-runner: ${message}`);
|
|
21010
|
+
diag.warn("session lock lost on failed switch", { session: from });
|
|
21011
|
+
app?.notify(message, "error");
|
|
21012
|
+
}
|
|
21013
|
+
};
|
|
18954
21014
|
let resumeFailure;
|
|
18955
21015
|
let handle;
|
|
18956
21016
|
if (sessionId !== void 0) try {
|
|
21017
|
+
let lockHeader;
|
|
21018
|
+
try {
|
|
21019
|
+
lockHeader = (await ctx.get("sessionPersistence")?.list())?.find((candidate) => candidate.id === sessionId);
|
|
21020
|
+
} catch {}
|
|
21021
|
+
const lockRefusal = acquireOpenLock(sessionId, lockHeader);
|
|
21022
|
+
if (lockRefusal !== void 0) throw new SessionLockRefusedError(lockRefusal);
|
|
18957
21023
|
const recorded = await recordedPreset(ctx, sessionId);
|
|
18958
21024
|
const composition = await compose(recorded);
|
|
18959
21025
|
handle = await agents.resume({
|
|
@@ -18985,6 +21051,7 @@ function apply(ctx, config) {
|
|
|
18985
21051
|
});
|
|
18986
21052
|
}
|
|
18987
21053
|
} catch (error) {
|
|
21054
|
+
if (error instanceof SessionLockRefusedError) throw error;
|
|
18988
21055
|
const message = safeErrorMessage(error);
|
|
18989
21056
|
ctx.logger.warn(`tui-runner: resume ${sessionId} failed: ${message}`);
|
|
18990
21057
|
diag.error("resume failed", {
|
|
@@ -18992,6 +21059,7 @@ function apply(ctx, config) {
|
|
|
18992
21059
|
error: message
|
|
18993
21060
|
});
|
|
18994
21061
|
resumeFailure = `session ${sessionId} could not be resumed; started a fresh session`;
|
|
21062
|
+
releaseOpenLock(sessionId);
|
|
18995
21063
|
const launched = await launchComposition();
|
|
18996
21064
|
if (launched.failure !== void 0) resumeFailure = launched.failure;
|
|
18997
21065
|
handle = await agents.create({
|
|
@@ -19003,10 +21071,34 @@ function apply(ctx, config) {
|
|
|
19003
21071
|
agentOptions,
|
|
19004
21072
|
setup: launched.composition.setup
|
|
19005
21073
|
});
|
|
21074
|
+
acquireOpenLock(handle.agent.session.id, handle.agent.session.header);
|
|
19006
21075
|
}
|
|
19007
21076
|
let liveHandle = handle;
|
|
19008
21077
|
let liveAgent = handle?.agent;
|
|
19009
21078
|
if (liveAgent !== void 0) await liveAgent.whenIdle();
|
|
21079
|
+
let initialSnapshot;
|
|
21080
|
+
let initialSkills;
|
|
21081
|
+
let surfaceNotice;
|
|
21082
|
+
{
|
|
21083
|
+
let effectivePresetId;
|
|
21084
|
+
try {
|
|
21085
|
+
const launched = await launchComposition();
|
|
21086
|
+
if (launched.failure !== void 0) resumeFailure = launched.failure;
|
|
21087
|
+
effectivePresetId = launched.composition.agentPreset;
|
|
21088
|
+
} catch (error) {
|
|
21089
|
+
diag.warn("preset resolution failed at startup", { error: safeErrorMessage(error) });
|
|
21090
|
+
}
|
|
21091
|
+
const resolution = await resolveInitialCatalog({
|
|
21092
|
+
liveAgent,
|
|
21093
|
+
presetId: effectivePresetId,
|
|
21094
|
+
signal: lifecycleController.signal,
|
|
21095
|
+
ctx,
|
|
21096
|
+
diag
|
|
21097
|
+
});
|
|
21098
|
+
initialSnapshot = resolution.snapshot;
|
|
21099
|
+
initialSkills = resolution.skills;
|
|
21100
|
+
surfaceNotice = resolution.notice;
|
|
21101
|
+
}
|
|
19010
21102
|
let guardState = freshGuardState();
|
|
19011
21103
|
let guardToken;
|
|
19012
21104
|
/**
|
|
@@ -19123,6 +21215,7 @@ function apply(ctx, config) {
|
|
|
19123
21215
|
/** Swap the live agent to a new handle, repainting for its session. */
|
|
19124
21216
|
const swapTo = async (next) => {
|
|
19125
21217
|
const from = liveAgent?.session.id;
|
|
21218
|
+
const fromHeader = liveAgent?.session.header;
|
|
19126
21219
|
try {
|
|
19127
21220
|
if (liveAgent !== void 0) {
|
|
19128
21221
|
await sessions.flush(liveAgent.session);
|
|
@@ -19137,12 +21230,22 @@ function apply(ctx, config) {
|
|
|
19137
21230
|
from,
|
|
19138
21231
|
error: message
|
|
19139
21232
|
});
|
|
21233
|
+
const repair = swapFailureLockRepair(heldLock, from);
|
|
21234
|
+
if (repair.release !== void 0) releaseOpenLock(repair.release);
|
|
21235
|
+
if (repair.reacquire !== void 0) reacquireCurrentLock(repair.reacquire, fromHeader);
|
|
19140
21236
|
return `swap failed: ${message}`;
|
|
19141
21237
|
}
|
|
21238
|
+
if (from !== void 0) releaseOpenLock(from);
|
|
21239
|
+
const swapLockRefusal = acquireOpenLock(next.agent.session.id, next.agent.session.header);
|
|
21240
|
+
if (swapLockRefusal !== void 0) {
|
|
21241
|
+
ctx.logger.warn(`tui-runner: lock refused on swap to ${next.agent.session.id}: ${swapLockRefusal}`);
|
|
21242
|
+
diag.warn("session lock refused on swap", { session: next.agent.session.id });
|
|
21243
|
+
}
|
|
19142
21244
|
guardState = freshGuardState();
|
|
19143
21245
|
guardToken = void 0;
|
|
19144
21246
|
bumpSessionGeneration();
|
|
19145
21247
|
await initLiveSession(next.agent);
|
|
21248
|
+
await refreshLiveCatalog(next.agent);
|
|
19146
21249
|
diag.info("switch ok", {
|
|
19147
21250
|
from: from ?? "(none)",
|
|
19148
21251
|
to: next.agent.session.id,
|
|
@@ -19154,6 +21257,19 @@ function apply(ctx, config) {
|
|
|
19154
21257
|
* string so callers' `.then(error => ...)` need no rejection path. */
|
|
19155
21258
|
const switchSession = async (sessionId) => {
|
|
19156
21259
|
try {
|
|
21260
|
+
const from = liveAgent?.session.id;
|
|
21261
|
+
if (from !== void 0) releaseOpenLock(from);
|
|
21262
|
+
let lockHeader;
|
|
21263
|
+
try {
|
|
21264
|
+
lockHeader = (await ctx.get("sessionPersistence")?.list())?.find((candidate) => candidate.id === sessionId);
|
|
21265
|
+
} catch {}
|
|
21266
|
+
const lockRefusal = acquireOpenLock(sessionId, lockHeader);
|
|
21267
|
+
if (lockRefusal !== void 0) {
|
|
21268
|
+
ctx.logger.warn(`tui-runner: switch to ${sessionId} refused: ${lockRefusal}`);
|
|
21269
|
+
diag.warn("session lock held on switch", { session: sessionId });
|
|
21270
|
+
reacquireCurrentLock(from);
|
|
21271
|
+
return lockRefusal;
|
|
21272
|
+
}
|
|
19157
21273
|
const composition = await compose(await recordedPreset(ctx, sessionId));
|
|
19158
21274
|
const next = await agents.resume({
|
|
19159
21275
|
resumeSessionId: SessionId(sessionId),
|
|
@@ -19171,6 +21287,8 @@ function apply(ctx, config) {
|
|
|
19171
21287
|
session: sessionId,
|
|
19172
21288
|
error: message
|
|
19173
21289
|
});
|
|
21290
|
+
releaseOpenLock(sessionId);
|
|
21291
|
+
reacquireCurrentLock(liveAgent?.session.id);
|
|
19174
21292
|
return `switch failed: ${message}`;
|
|
19175
21293
|
}
|
|
19176
21294
|
};
|
|
@@ -19228,7 +21346,12 @@ function apply(ctx, config) {
|
|
|
19228
21346
|
if (cleanedUp) return;
|
|
19229
21347
|
cleanedUp = true;
|
|
19230
21348
|
guardToken = void 0;
|
|
21349
|
+
if (heldLock !== void 0) {
|
|
21350
|
+
heldLock.release();
|
|
21351
|
+
heldLock = void 0;
|
|
21352
|
+
}
|
|
19231
21353
|
lifecycleController.abort();
|
|
21354
|
+
catalogCoordinator?.dispose();
|
|
19232
21355
|
localShellController?.abort();
|
|
19233
21356
|
for (const file of shellTempFiles) try {
|
|
19234
21357
|
rmSync(file, { force: true });
|
|
@@ -19528,6 +21651,8 @@ function apply(ctx, config) {
|
|
|
19528
21651
|
* user input is the deferred trigger), guard against cross-process
|
|
19529
21652
|
* divergence, then execute a registered slash command or follow up. */
|
|
19530
21653
|
const dispatchViaSession = (text) => {
|
|
21654
|
+
const parsedAtSubmit = parseCommand(text);
|
|
21655
|
+
const wasAdvertised = parsedAtSubmit !== void 0 && wasAdvertisedClaim?.(parsedAtSubmit.name) === true;
|
|
19531
21656
|
runOwned("submit", () => ensureSession().then(async () => {
|
|
19532
21657
|
const agent = liveAgent;
|
|
19533
21658
|
if (agent === void 0) return;
|
|
@@ -19557,6 +21682,10 @@ function apply(ctx, config) {
|
|
|
19557
21682
|
diag,
|
|
19558
21683
|
sessionId: () => agent.session.id,
|
|
19559
21684
|
onResult: (execution) => {
|
|
21685
|
+
if (shouldConsumeAdvertisedMiss(execution, wasAdvertised)) {
|
|
21686
|
+
app.notify(`/${parsedAtSubmit?.name ?? "?"} is not available in the created session`, "error");
|
|
21687
|
+
return;
|
|
21688
|
+
}
|
|
19560
21689
|
if (execution === void 0) {
|
|
19561
21690
|
if (sessionUnchanged({
|
|
19562
21691
|
agent,
|
|
@@ -19693,6 +21822,12 @@ function apply(ctx, config) {
|
|
|
19693
21822
|
});
|
|
19694
21823
|
};
|
|
19695
21824
|
/**
|
|
21825
|
+
* The newest input-history entry this process persisted (kimi's
|
|
21826
|
+
* `lastHistoryContent` analogue): consecutive repeats are skipped per
|
|
21827
|
+
* window, exactly like shell history.
|
|
21828
|
+
*/
|
|
21829
|
+
let lastHistoryContent;
|
|
21830
|
+
/**
|
|
19696
21831
|
* Dispatch one user submission end to end: the viewer guard, the input-
|
|
19697
21832
|
* history persistence, `!` local shells, sessionless commands, the
|
|
19698
21833
|
* busy-Enter preference, and the session dispatch. The Ctrl+Enter
|
|
@@ -19702,22 +21837,23 @@ function apply(ctx, config) {
|
|
|
19702
21837
|
* @param forceQueue - the chord: never steer, queue instead.
|
|
19703
21838
|
*/
|
|
19704
21839
|
const dispatchUserInput = (text, forceQueue = false) => {
|
|
21840
|
+
if (isPlainExitPrompt(text)) {
|
|
21841
|
+
requestExit();
|
|
21842
|
+
return;
|
|
21843
|
+
}
|
|
19705
21844
|
if (viewing !== void 0) {
|
|
19706
21845
|
app.setEditorText(mergeDraft(app.getDraft(), text));
|
|
19707
21846
|
app.notify("viewing a subagent — Esc returns before submitting", "info");
|
|
19708
21847
|
return;
|
|
19709
21848
|
}
|
|
19710
21849
|
app.clearSettledLocalMessages();
|
|
19711
|
-
const
|
|
19712
|
-
if (
|
|
19713
|
-
const
|
|
19714
|
-
|
|
19715
|
-
|
|
19716
|
-
|
|
19717
|
-
|
|
19718
|
-
[sessionCwd()]: history
|
|
19719
|
-
}
|
|
19720
|
-
}), {
|
|
21850
|
+
const trimmed = text.trim();
|
|
21851
|
+
if (trimmed !== "" && trimmed !== lastHistoryContent) {
|
|
21852
|
+
const file = historyFilePath(dshHome(process.env), sessionCwd());
|
|
21853
|
+
runDetached("input history write", () => {
|
|
21854
|
+
appendHistoryLine(file, trimmed, lastHistoryContent);
|
|
21855
|
+
lastHistoryContent = trimmed;
|
|
21856
|
+
}, {
|
|
19721
21857
|
diag,
|
|
19722
21858
|
notify: (message) => app.notify(message, "error"),
|
|
19723
21859
|
recoverable: () => true
|
|
@@ -19855,7 +21991,6 @@ function apply(ctx, config) {
|
|
|
19855
21991
|
if (jobs !== void 0) try {
|
|
19856
21992
|
jobSnapshots = jobs.list(liveAgent);
|
|
19857
21993
|
} catch {}
|
|
19858
|
-
const now = Date.now();
|
|
19859
21994
|
let rows = buildTaskRows(jobSnapshots, []);
|
|
19860
21995
|
const selectRow = (value) => {
|
|
19861
21996
|
const row = rows.find((candidate) => candidate.value === value);
|
|
@@ -19870,16 +22005,23 @@ function apply(ctx, config) {
|
|
|
19870
22005
|
}
|
|
19871
22006
|
openJobView(row.jobId);
|
|
19872
22007
|
};
|
|
19873
|
-
const
|
|
22008
|
+
const taskPanelItems = (target) => target.map((row) => row.kind === "job" ? {
|
|
19874
22009
|
value: row.value,
|
|
19875
22010
|
label: taskRowLabel(row),
|
|
19876
|
-
|
|
22011
|
+
status: row.status,
|
|
22012
|
+
detail: row.detail,
|
|
22013
|
+
startedAt: row.startedAt,
|
|
19877
22014
|
group: rowGroup(row)
|
|
19878
|
-
}
|
|
19879
|
-
|
|
22015
|
+
} : {
|
|
22016
|
+
value: row.value,
|
|
22017
|
+
label: taskRowLabel(row),
|
|
22018
|
+
status: row.activity,
|
|
22019
|
+
detail: row.hasChildren ? "has children" : void 0,
|
|
22020
|
+
group: rowGroup(row)
|
|
22021
|
+
});
|
|
22022
|
+
const handle = app.openTaskBrowser(taskPanelItems(rows), selectRow, () => {}, {
|
|
19880
22023
|
header: "tasks · subagents",
|
|
19881
|
-
enableSearch: true
|
|
19882
|
-
showHint: true
|
|
22024
|
+
enableSearch: true
|
|
19883
22025
|
});
|
|
19884
22026
|
if (subagents !== void 0) {
|
|
19885
22027
|
const sessionId = liveAgent.session.id;
|
|
@@ -19890,7 +22032,7 @@ function apply(ctx, config) {
|
|
|
19890
22032
|
onResult: (entries) => {
|
|
19891
22033
|
if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
|
|
19892
22034
|
rows = buildTaskRows(jobSnapshots, entries);
|
|
19893
|
-
handle.setItems(
|
|
22035
|
+
handle.setItems(taskPanelItems(rows));
|
|
19894
22036
|
}
|
|
19895
22037
|
});
|
|
19896
22038
|
}
|
|
@@ -19903,14 +22045,12 @@ function apply(ctx, config) {
|
|
|
19903
22045
|
theme: z.string(),
|
|
19904
22046
|
footer: z.string(),
|
|
19905
22047
|
fullscreen: z.string(),
|
|
19906
|
-
busyEnter: z.string()
|
|
19907
|
-
history: z.dict(z.array(z.string()))
|
|
22048
|
+
busyEnter: z.string()
|
|
19908
22049
|
}), { base: {
|
|
19909
22050
|
theme: "auto",
|
|
19910
22051
|
footer: "full",
|
|
19911
22052
|
fullscreen: "on",
|
|
19912
|
-
busyEnter: "queue"
|
|
19913
|
-
history: {}
|
|
22053
|
+
busyEnter: "queue"
|
|
19914
22054
|
} });
|
|
19915
22055
|
if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
|
|
19916
22056
|
const storedTheme = tuiSettings?.get().theme;
|
|
@@ -19929,6 +22069,39 @@ function apply(ctx, config) {
|
|
|
19929
22069
|
app.trackTerminalTheme(false);
|
|
19930
22070
|
}
|
|
19931
22071
|
if (tuiSettings?.get().footer === "compact") app.setFooterPreset("compact");
|
|
22072
|
+
let legacyHistory;
|
|
22073
|
+
try {
|
|
22074
|
+
const value = ((ctx.get("settings")?.describe().find((d) => d.ns === settingsNamespace("dsh-pi-tui")))?.user)?.history;
|
|
22075
|
+
if (typeof value === "object" && value !== null) legacyHistory = value;
|
|
22076
|
+
} catch {}
|
|
22077
|
+
if (legacyHistory !== void 0) {
|
|
22078
|
+
const home = dshHome(process.env);
|
|
22079
|
+
for (const [cwd, entries] of Object.entries(legacyHistory)) {
|
|
22080
|
+
if (!Array.isArray(entries) || entries.length === 0) continue;
|
|
22081
|
+
const file = historyFilePath(home, cwd);
|
|
22082
|
+
if (existsSync(file)) continue;
|
|
22083
|
+
for (const entry of entries.slice().reverse()) try {
|
|
22084
|
+
appendHistoryLine(file, entry, void 0);
|
|
22085
|
+
} catch {}
|
|
22086
|
+
}
|
|
22087
|
+
if (tuiSettings !== void 0) {
|
|
22088
|
+
if (Object.entries(legacyHistory).every(([cwd, entries]) => {
|
|
22089
|
+
if (!Array.isArray(entries) || entries.length === 0) return true;
|
|
22090
|
+
return existsSync(historyFilePath(home, cwd));
|
|
22091
|
+
})) runDetached("settings history cleanup", () => {
|
|
22092
|
+
const doc = { ...tuiSettings.get() };
|
|
22093
|
+
delete doc.history;
|
|
22094
|
+
tuiSettings.replace(doc);
|
|
22095
|
+
}, {
|
|
22096
|
+
diag,
|
|
22097
|
+
notify: (message) => app.notify(message, "error"),
|
|
22098
|
+
recoverable: () => true
|
|
22099
|
+
});
|
|
22100
|
+
}
|
|
22101
|
+
}
|
|
22102
|
+
const bootHistoryEntries = loadHistoryFile(historyFilePath(dshHome(process.env), cwd));
|
|
22103
|
+
lastHistoryContent = bootHistoryEntries.at(-1);
|
|
22104
|
+
app.resetInputHistory([...bootHistoryEntries].reverse());
|
|
19932
22105
|
refreshStatus();
|
|
19933
22106
|
let refreshTasks = () => {};
|
|
19934
22107
|
let refreshAgents = () => {};
|
|
@@ -20044,26 +22217,24 @@ function apply(ctx, config) {
|
|
|
20044
22217
|
const jobStatusHint = (status, detail) => {
|
|
20045
22218
|
return `${status}${status === "running" || status === "stopping" ? " — output is delivered to the agent via job_output; viewing never consumes the job’s read cursor" : ` — final output: ask the agent to run job_output in the conversation${detail === void 0 ? "" : ` (${detail})`}`}`;
|
|
20046
22219
|
};
|
|
22220
|
+
const notifiedSubagentNotices = /* @__PURE__ */ new Set();
|
|
20047
22221
|
const refreshQueue = () => {
|
|
20048
22222
|
if (liveAgent === void 0) {
|
|
20049
22223
|
app.setQueueItems([]);
|
|
20050
22224
|
return;
|
|
20051
22225
|
}
|
|
20052
|
-
const
|
|
20053
|
-
|
|
20054
|
-
|
|
20055
|
-
|
|
20056
|
-
notice: message.source?.form === "notice"
|
|
20057
|
-
})), ...liveAgent.inbox.nextStep.map((message) => ({
|
|
20058
|
-
id: message.id,
|
|
20059
|
-
text: textOf(message.content),
|
|
20060
|
-
mode: "steer",
|
|
20061
|
-
notice: message.source?.form === "notice"
|
|
20062
|
-
}))];
|
|
20063
|
-
app.setQueueItems(queue);
|
|
22226
|
+
const turn = foldQueueRows(liveAgent.inbox.nextTurn, "followup", notifiedSubagentNotices);
|
|
22227
|
+
const step = foldQueueRows(liveAgent.inbox.nextStep, "steer", notifiedSubagentNotices);
|
|
22228
|
+
for (const summary of [...turn.failures, ...step.failures]) app.notify(summary, "error");
|
|
22229
|
+
app.setQueueItems([...turn.rows, ...step.rows]);
|
|
20064
22230
|
};
|
|
20065
22231
|
refreshQueue();
|
|
20066
|
-
/**
|
|
22232
|
+
/**
|
|
22233
|
+
* Rebuild every live-session surface after resume, create, or swap.
|
|
22234
|
+
* The surface catalog is NOT touched here: the initial owner's catalog
|
|
22235
|
+
* came from the pre-mount prefetch/probe, and the first deferred create
|
|
22236
|
+
* plus every switch await the coordinator refresh themselves.
|
|
22237
|
+
*/
|
|
20067
22238
|
const initLiveSession = async (agent) => {
|
|
20068
22239
|
folder = new TranscriptFolder();
|
|
20069
22240
|
folder.apply(agent.session.events);
|
|
@@ -20074,16 +22245,22 @@ function apply(ctx, config) {
|
|
|
20074
22245
|
app.setSessionTitle(foldSessionTitle(agent.session.events)?.title);
|
|
20075
22246
|
app.clearLocalMessages();
|
|
20076
22247
|
app.clearNotify();
|
|
22248
|
+
notifiedSubagentNotices.clear();
|
|
20077
22249
|
repaint(app, folder);
|
|
20078
22250
|
refreshStatus();
|
|
20079
22251
|
refreshQueue();
|
|
20080
22252
|
refreshTasks();
|
|
20081
22253
|
refreshAgents();
|
|
20082
|
-
|
|
22254
|
+
const historyCwd = sessionCwd();
|
|
22255
|
+
const historyEntries = loadHistoryFile(historyFilePath(dshHome(process.env), historyCwd));
|
|
22256
|
+
lastHistoryContent = historyEntries.at(-1);
|
|
22257
|
+
app.resetInputHistory([...historyEntries].reverse());
|
|
20083
22258
|
setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())} · ${agent.session.id}`);
|
|
20084
22259
|
updateWelcomeCard();
|
|
20085
|
-
registerCommands(
|
|
20086
|
-
|
|
22260
|
+
registerCommands({
|
|
22261
|
+
snapshot: initialSnapshot,
|
|
22262
|
+
skills: initialSkills
|
|
22263
|
+
});
|
|
20087
22264
|
};
|
|
20088
22265
|
/**
|
|
20089
22266
|
* Create the first session lazily — the FIRST user message triggers it
|
|
@@ -20109,9 +22286,11 @@ function apply(ctx, config) {
|
|
|
20109
22286
|
});
|
|
20110
22287
|
liveHandle = created;
|
|
20111
22288
|
liveAgent = created.agent;
|
|
22289
|
+
acquireOpenLock(liveAgent.session.id, liveAgent.session.header);
|
|
20112
22290
|
await liveAgent.whenIdle();
|
|
20113
22291
|
bumpSessionGeneration();
|
|
20114
22292
|
await initLiveSession(liveAgent);
|
|
22293
|
+
await refreshLiveCatalog(liveAgent);
|
|
20115
22294
|
} catch (error) {
|
|
20116
22295
|
const message = safeErrorMessage(error);
|
|
20117
22296
|
ctx.logger.warn(`tui-runner: failed to start with preset "${launchPreset ?? "default"}": ${message}`);
|
|
@@ -20132,9 +22311,11 @@ function apply(ctx, config) {
|
|
|
20132
22311
|
});
|
|
20133
22312
|
liveHandle = created;
|
|
20134
22313
|
liveAgent = created.agent;
|
|
22314
|
+
acquireOpenLock(liveAgent.session.id, liveAgent.session.header);
|
|
20135
22315
|
await liveAgent.whenIdle();
|
|
20136
22316
|
bumpSessionGeneration();
|
|
20137
22317
|
await initLiveSession(liveAgent);
|
|
22318
|
+
await refreshLiveCatalog(liveAgent);
|
|
20138
22319
|
}
|
|
20139
22320
|
if (resumeFailure !== void 0) {
|
|
20140
22321
|
app.notify(resumeFailure, "error");
|
|
@@ -20146,7 +22327,67 @@ function apply(ctx, config) {
|
|
|
20146
22327
|
return creating;
|
|
20147
22328
|
};
|
|
20148
22329
|
let commandsRegistered = false;
|
|
20149
|
-
|
|
22330
|
+
/** The claim test installed by registerTuiCommands: is a slash name
|
|
22331
|
+
* advertised by the CURRENT completion list? The dispatch captures it
|
|
22332
|
+
* BEFORE any session creation (see dispatchViaSession). */
|
|
22333
|
+
let wasAdvertisedClaim;
|
|
22334
|
+
/** The catalog refresh coordinator: the ONE post-mount refresh owner
|
|
22335
|
+
* (first session, switches, /preset, /reload). Built inside
|
|
22336
|
+
* registerCommands once the surface hooks exist. */
|
|
22337
|
+
let catalogRefreshRequest;
|
|
22338
|
+
let catalogCoordinator;
|
|
22339
|
+
/** `skills/change` coalescing: bursts of invalidation notifications cost
|
|
22340
|
+
* at most two reads, and the follow-up re-read observes the CURRENT
|
|
22341
|
+
* ownership (live agent vs standing preset). */
|
|
22342
|
+
let skillsChangeSubscribed = false;
|
|
22343
|
+
const skillsChangeGate = new CoalescingRefreshGate(() => {
|
|
22344
|
+
runOwned("skills/change refresh", async () => {
|
|
22345
|
+
const refresh = catalogRefreshRequest;
|
|
22346
|
+
if (refresh === void 0) return void 0;
|
|
22347
|
+
const target = liveAgent === void 0 ? {
|
|
22348
|
+
kind: "preset",
|
|
22349
|
+
presetId: pendingPreset ?? launchPreset
|
|
22350
|
+
} : {
|
|
22351
|
+
kind: "agent",
|
|
22352
|
+
key: sessionGeneration
|
|
22353
|
+
};
|
|
22354
|
+
return refresh({
|
|
22355
|
+
source: "invalidation",
|
|
22356
|
+
target,
|
|
22357
|
+
...target.kind === "agent" ? { agent: liveAgent } : {}
|
|
22358
|
+
});
|
|
22359
|
+
}, {
|
|
22360
|
+
diag,
|
|
22361
|
+
sessionId: () => liveAgent?.session.id,
|
|
22362
|
+
onResult: (outcome) => {
|
|
22363
|
+
if (outcome !== void 0 && outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
|
|
22364
|
+
skillsChangeGate.settled();
|
|
22365
|
+
},
|
|
22366
|
+
onCancel: () => {
|
|
22367
|
+
skillsChangeGate.settled();
|
|
22368
|
+
},
|
|
22369
|
+
onError: (error) => {
|
|
22370
|
+
skillsChangeGate.settled();
|
|
22371
|
+
app.notify(`skill catalog refresh failed: ${safeErrorMessage(error)}`, "error");
|
|
22372
|
+
}
|
|
22373
|
+
});
|
|
22374
|
+
});
|
|
22375
|
+
/** Subscribe to the dsh-skill invalidation notification once, through
|
|
22376
|
+
* the single-point adapter (plan appendix B.1). The event carries no
|
|
22377
|
+
* scope or cwd, so the refresh target follows the CURRENT ownership; an
|
|
22378
|
+
* unavailable or throwing subscription degrades to no subscription —
|
|
22379
|
+
* owner switches and /reload still refresh. The flag is set only after
|
|
22380
|
+
* a successful subscribe, so a throwing subscribe can retry on a later
|
|
22381
|
+
* registration attempt. */
|
|
22382
|
+
const subscribeSkillsChangeEvents = () => {
|
|
22383
|
+
if (skillsChangeSubscribed) return;
|
|
22384
|
+
try {
|
|
22385
|
+
subscribeSkillsChange(ctx, () => skillsChangeGate.notify());
|
|
22386
|
+
skillsChangeSubscribed = true;
|
|
22387
|
+
} catch (error) {
|
|
22388
|
+
diag.warn("skills/change subscription unavailable", { error: safeErrorMessage(error) });
|
|
22389
|
+
}
|
|
22390
|
+
};
|
|
20150
22391
|
const runner = {
|
|
20151
22392
|
ctx,
|
|
20152
22393
|
app,
|
|
@@ -20180,6 +22421,20 @@ function apply(ctx, config) {
|
|
|
20180
22421
|
set pendingPreset(id) {
|
|
20181
22422
|
pendingPreset = id;
|
|
20182
22423
|
},
|
|
22424
|
+
/** The effective preset id for COLD (sessionless) reads: the run-local
|
|
22425
|
+
* pending override ahead of the launch-time --preset (the SAME
|
|
22426
|
+
* precedence ensureSession uses); undefined = the saved/default
|
|
22427
|
+
* preset applies. */
|
|
22428
|
+
get effectivePresetId() {
|
|
22429
|
+
return pendingPreset ?? launchPreset;
|
|
22430
|
+
},
|
|
22431
|
+
refreshCatalog: (request) => {
|
|
22432
|
+
const refresh = catalogRefreshRequest;
|
|
22433
|
+
return refresh === void 0 ? Promise.resolve({
|
|
22434
|
+
kind: "failed",
|
|
22435
|
+
error: "catalog refresh unavailable"
|
|
22436
|
+
}) : refresh(request);
|
|
22437
|
+
},
|
|
20183
22438
|
switchSession,
|
|
20184
22439
|
swapTo: (next) => swapTo(next),
|
|
20185
22440
|
currentPreset,
|
|
@@ -20191,12 +22446,32 @@ function apply(ctx, config) {
|
|
|
20191
22446
|
requestExit,
|
|
20192
22447
|
exit
|
|
20193
22448
|
};
|
|
20194
|
-
const registerCommands = () => {
|
|
22449
|
+
const registerCommands = (initial) => {
|
|
20195
22450
|
if (commandsRegistered) return;
|
|
20196
22451
|
if (ctx.get("commands") === void 0) return;
|
|
20197
22452
|
commandsRegistered = true;
|
|
20198
22453
|
try {
|
|
20199
|
-
|
|
22454
|
+
const installed = registerTuiCommands(runner, initial);
|
|
22455
|
+
wasAdvertisedClaim = installed.wasAdvertised;
|
|
22456
|
+
catalogCoordinator = new CatalogRefreshCoordinator({
|
|
22457
|
+
readAgent: (agent, readSignal) => readSurfaceCatalog(agent, readSignal, ctx),
|
|
22458
|
+
readStanding: async (presetId, readSignal) => {
|
|
22459
|
+
const target = await resolveColdSkillTarget(ctx, presetId, process.cwd());
|
|
22460
|
+
if (target.target === void 0) throw new Error("skill service unavailable");
|
|
22461
|
+
return {
|
|
22462
|
+
catalog: await readHumanSkillCatalog(target.target.registry, {
|
|
22463
|
+
cwd: target.target.cwd,
|
|
22464
|
+
scope: target.target.scope,
|
|
22465
|
+
signal: readSignal
|
|
22466
|
+
}),
|
|
22467
|
+
...target.degraded === void 0 ? {} : { notice: target.degraded }
|
|
22468
|
+
};
|
|
22469
|
+
},
|
|
22470
|
+
installSnapshot: (next) => installed.installSnapshot(next),
|
|
22471
|
+
enterCatalogTransition: () => installed.enterTransition()
|
|
22472
|
+
}, lifecycleController.signal, diag);
|
|
22473
|
+
catalogRefreshRequest = (request) => catalogCoordinator.refresh(request);
|
|
22474
|
+
subscribeSkillsChangeEvents();
|
|
20200
22475
|
} catch (error) {
|
|
20201
22476
|
commandsRegistered = false;
|
|
20202
22477
|
const message = safeErrorMessage(error);
|
|
@@ -20205,13 +22480,21 @@ function apply(ctx, config) {
|
|
|
20205
22480
|
app.notify(`command registration failed: ${message}`, "error");
|
|
20206
22481
|
}
|
|
20207
22482
|
};
|
|
20208
|
-
/**
|
|
20209
|
-
|
|
20210
|
-
|
|
22483
|
+
/** Await one live-owner catalog refresh through the coordinator (the
|
|
22484
|
+
* first deferred create and every session switch): the refresh attempt
|
|
22485
|
+
* settles before the caller continues, and its outcome is an outcome —
|
|
22486
|
+
* provider issues degrade fields, failures warn, the submission or the
|
|
22487
|
+
* switch proceeds either way. */
|
|
22488
|
+
const refreshLiveCatalog = async (agent) => {
|
|
22489
|
+
const refresh = catalogRefreshRequest;
|
|
20211
22490
|
if (refresh === void 0) return;
|
|
20212
|
-
|
|
20213
|
-
|
|
20214
|
-
|
|
22491
|
+
await refresh({
|
|
22492
|
+
source: "live-session",
|
|
22493
|
+
target: {
|
|
22494
|
+
kind: "agent",
|
|
22495
|
+
key: sessionGeneration
|
|
22496
|
+
},
|
|
22497
|
+
agent
|
|
20215
22498
|
});
|
|
20216
22499
|
};
|
|
20217
22500
|
if (liveAgent !== void 0) await initLiveSession(liveAgent);
|
|
@@ -20220,7 +22503,14 @@ function apply(ctx, config) {
|
|
|
20220
22503
|
refreshStatus();
|
|
20221
22504
|
setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())}`);
|
|
20222
22505
|
}
|
|
20223
|
-
registerCommands(
|
|
22506
|
+
registerCommands({
|
|
22507
|
+
snapshot: initialSnapshot,
|
|
22508
|
+
skills: initialSkills
|
|
22509
|
+
});
|
|
22510
|
+
if (surfaceNotice !== void 0) {
|
|
22511
|
+
app.notify(surfaceNotice, "error");
|
|
22512
|
+
surfaceNotice = void 0;
|
|
22513
|
+
}
|
|
20224
22514
|
if (resumeFailure !== void 0) {
|
|
20225
22515
|
app.notify(resumeFailure, "error");
|
|
20226
22516
|
resumeFailure = void 0;
|
|
@@ -20330,4 +22620,4 @@ function apply(ctx, config) {
|
|
|
20330
22620
|
});
|
|
20331
22621
|
}
|
|
20332
22622
|
//#endregion
|
|
20333
|
-
export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resumeCommand, runningProfile, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };
|
|
22623
|
+
export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, SessionLockRefusedError, apply, composeAgent, dangerCommand, foldQueueRows, forkSeed, inject, isPlainExitPrompt, isSubagentSettlementNotice, name, recomposeBlank, recordedPreset, resolveInitialCatalog, resumeCommand, runningProfile, shouldConsumeAdvertisedMiss, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint, subagentNoticeIsFailure };
|