@xmoon76/dsh-pi-tui 0.1.5 → 0.1.7
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 +112 -1
- package/dist/index.mjs +1463 -159
- package/package.json +1 -2
- 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";
|
|
@@ -148,6 +148,37 @@ function deriveSummary(variant, argsRaw) {
|
|
|
148
148
|
for (const value of Object.values(args)) if (typeof value === "string" && value !== "") return firstLine(value);
|
|
149
149
|
return firstLine(argsRaw);
|
|
150
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
|
+
}
|
|
151
182
|
/** Classify a tool name into its row variant; unknown names are `others`. */
|
|
152
183
|
function classifyTool(name) {
|
|
153
184
|
return TOOL_VARIANTS[name] ?? "others";
|
|
@@ -295,7 +326,8 @@ function readFoldedPreview(result) {
|
|
|
295
326
|
function toolCardHeader(name, argsRaw, cwd) {
|
|
296
327
|
const variant = classifyTool(name);
|
|
297
328
|
const toolTitle = TOOL_TITLES[name] ?? TUI_TOOL_TITLES[name];
|
|
298
|
-
const
|
|
329
|
+
const toolSummary = summarizeToolArgs(name, argsRaw);
|
|
330
|
+
const base = argsRaw === "" ? "" : toolSummary ?? relativizeToCwd(deriveSummary(variant, argsRaw), cwd);
|
|
299
331
|
const summary = variant === "others" && toolTitle === void 0 ? base === "" ? "" : name + " · " + base : base;
|
|
300
332
|
return {
|
|
301
333
|
title: toolTitle ?? (name.startsWith("/") ? name.slice(1) : VARIANT_TITLES[variant]),
|
|
@@ -303,6 +335,123 @@ function toolCardHeader(name, argsRaw, cwd) {
|
|
|
303
335
|
};
|
|
304
336
|
}
|
|
305
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
|
+
/**
|
|
306
455
|
* Build the presentation bridge over a tool-definition lookup (the runner
|
|
307
456
|
* passes a scoped registry read, e.g. `ctx.get('tools')?.get(name, scope)`).
|
|
308
457
|
* Mirrors the host apiproxy's presenter invocations: args are JSON-parsed and
|
|
@@ -1531,6 +1680,24 @@ const selectListTheme = {
|
|
|
1531
1680
|
groupHeader: (text) => chalk.bold.hex(currentPalette.textMuted)(text)
|
|
1532
1681
|
};
|
|
1533
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
|
+
/**
|
|
1534
1701
|
* SettingsList palette from the semantic tokens. A FUNCTION, not a
|
|
1535
1702
|
* module-level constant: `cursor` is a pre-rendered ANSI string, so a
|
|
1536
1703
|
* constant would freeze the cursor colour at module load and never follow
|
|
@@ -6079,6 +6246,7 @@ var Editor = class {
|
|
|
6079
6246
|
isInPaste = false;
|
|
6080
6247
|
pasteBurst = new PasteBurst();
|
|
6081
6248
|
disablePasteBurst = false;
|
|
6249
|
+
inlineSlashTrigger = false;
|
|
6082
6250
|
history = [];
|
|
6083
6251
|
historyIndex = -1;
|
|
6084
6252
|
historyDraft = null;
|
|
@@ -6118,6 +6286,7 @@ var Editor = class {
|
|
|
6118
6286
|
const maxVisible = options.autocompleteMaxVisible ?? 5;
|
|
6119
6287
|
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
|
6120
6288
|
this.disablePasteBurst = options.disablePasteBurst ?? false;
|
|
6289
|
+
this.inlineSlashTrigger = options.inlineSlashTrigger ?? false;
|
|
6121
6290
|
}
|
|
6122
6291
|
/** Set of currently valid paste IDs, for marker-aware segmentation. */
|
|
6123
6292
|
validPasteIds() {
|
|
@@ -6405,7 +6574,7 @@ var Editor = class {
|
|
|
6405
6574
|
this.state.lines = result.lines;
|
|
6406
6575
|
this.state.cursorLine = result.cursorLine;
|
|
6407
6576
|
this.setCursorCol(result.cursorCol);
|
|
6408
|
-
if (this.autocompletePrefix.startsWith("/")) this.cancelAutocomplete();
|
|
6577
|
+
if (this.autocompletePrefix.startsWith("/") && selected.data?.["inlineSkill"] !== true) this.cancelAutocomplete();
|
|
6409
6578
|
else {
|
|
6410
6579
|
this.cancelAutocomplete();
|
|
6411
6580
|
if (this.onChange) this.onChange(this.getText());
|
|
@@ -6711,15 +6880,16 @@ var Editor = class {
|
|
|
6711
6880
|
this.setCursorCol(this.state.cursorCol + char.length);
|
|
6712
6881
|
if (this.onChange) this.onChange(this.getText());
|
|
6713
6882
|
if (!this.autocompleteState) {
|
|
6714
|
-
if (char === "/" && this.isAtStartOfMessage()) this.tryTriggerAutocomplete();
|
|
6883
|
+
if (char === "/" && (this.isAtStartOfMessage() || this.inlineSlashTrigger && this.isAtInlineSlashTrigger())) this.tryTriggerAutocomplete();
|
|
6715
6884
|
else if (this.autocompleteTriggerCharacters.includes(char)) {
|
|
6716
6885
|
const textBeforeCursor = (this.state.lines[this.state.cursorLine] || "").slice(0, this.state.cursorCol);
|
|
6717
6886
|
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
|
6718
6887
|
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === " ") this.tryTriggerAutocomplete();
|
|
6719
|
-
} else if (/[a-zA-Z0-9
|
|
6888
|
+
} else if (/[a-zA-Z0-9.:\-_]/.test(char)) {
|
|
6720
6889
|
const textBeforeCursor = (this.state.lines[this.state.cursorLine] || "").slice(0, this.state.cursorCol);
|
|
6721
6890
|
if (this.isInSlashCommandContext(textBeforeCursor)) this.tryTriggerAutocomplete();
|
|
6722
6891
|
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) this.tryTriggerAutocomplete();
|
|
6892
|
+
else if (this.isInInlineSlashContext(textBeforeCursor)) this.tryTriggerAutocomplete();
|
|
6723
6893
|
}
|
|
6724
6894
|
} else this.updateAutocomplete();
|
|
6725
6895
|
}
|
|
@@ -7343,6 +7513,20 @@ var Editor = class {
|
|
|
7343
7513
|
const beforeCursor = (this.state.lines[this.state.cursorLine] || "").slice(0, this.state.cursorCol);
|
|
7344
7514
|
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
|
|
7345
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
|
+
}
|
|
7346
7530
|
isInSlashCommandContext(textBeforeCursor) {
|
|
7347
7531
|
return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
|
|
7348
7532
|
}
|
|
@@ -12555,6 +12739,249 @@ function renderDiffView(diffs, cwd, options = {}) {
|
|
|
12555
12739
|
return out;
|
|
12556
12740
|
}
|
|
12557
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
|
|
12558
12985
|
//#region src/search.ts
|
|
12559
12986
|
/**
|
|
12560
12987
|
* The transcript-search overlay component: a one-line query input with a
|
|
@@ -13150,12 +13577,24 @@ var QuestionFlow = class {
|
|
|
13150
13577
|
return;
|
|
13151
13578
|
}
|
|
13152
13579
|
if (data === "\x1B[A" || data === "k") {
|
|
13153
|
-
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;
|
|
13154
13586
|
this.pendingCursorScroll = true;
|
|
13155
13587
|
return;
|
|
13156
13588
|
}
|
|
13157
13589
|
if (data === "\x1B[B" || data === "j") {
|
|
13158
|
-
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;
|
|
13159
13598
|
this.pendingCursorScroll = true;
|
|
13160
13599
|
return;
|
|
13161
13600
|
}
|
|
@@ -14179,19 +14618,23 @@ var TuiApp = class TuiApp {
|
|
|
14179
14618
|
/** The todo panel Text; empty when hidden. */
|
|
14180
14619
|
todoPanel;
|
|
14181
14620
|
/**
|
|
14182
|
-
* The persistent dock strip directly above the todo panel:
|
|
14183
|
-
*
|
|
14184
|
-
* 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.
|
|
14185
14623
|
*/
|
|
14186
14624
|
dock;
|
|
14187
|
-
/**
|
|
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). */
|
|
14188
14631
|
dockTasks = [];
|
|
14189
|
-
/** 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). */
|
|
14190
14633
|
dockAgents = [];
|
|
14191
14634
|
/**
|
|
14192
|
-
* The queued-input pane below the todo panel
|
|
14193
|
-
*
|
|
14194
|
-
*
|
|
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.
|
|
14195
14638
|
*/
|
|
14196
14639
|
queuePane;
|
|
14197
14640
|
/** The pending inbox messages (next-turn followups and next-step steers). */
|
|
@@ -14244,8 +14687,6 @@ var TuiApp = class TuiApp {
|
|
|
14244
14687
|
planMode = false;
|
|
14245
14688
|
/** The editor's normal border style, restored when plan mode ends. */
|
|
14246
14689
|
editorBorder;
|
|
14247
|
-
/** Todo summary segment of the header (without the base or badges). */
|
|
14248
|
-
todoText = "";
|
|
14249
14690
|
/** Welcome card shown above the transcript; renders nothing without facts. */
|
|
14250
14691
|
welcomeCard = new WelcomeCard();
|
|
14251
14692
|
/** Transient error line shown under the transcript; cleared by the next
|
|
@@ -14315,6 +14756,7 @@ var TuiApp = class TuiApp {
|
|
|
14315
14756
|
this.messagesView = new Container();
|
|
14316
14757
|
this.dock = new Text("", 0, 0);
|
|
14317
14758
|
this.todoPanel = new Text("", 0, 0);
|
|
14759
|
+
this.goalLine = new Text("", 0, 0);
|
|
14318
14760
|
this.queuePane = new Text("", 0, 0);
|
|
14319
14761
|
this.working = new WorkingIndicator(() => this.requestRender(), options.workingIntervalMs === void 0 ? {} : { intervalMs: options.workingIntervalMs });
|
|
14320
14762
|
this.footer = new Text("", 0, 0);
|
|
@@ -14324,6 +14766,7 @@ var TuiApp = class TuiApp {
|
|
|
14324
14766
|
this.tui.addChild(this.messagesView);
|
|
14325
14767
|
this.tui.addChild(this.dock);
|
|
14326
14768
|
this.tui.addChild(this.todoPanel);
|
|
14769
|
+
this.tui.addChild(this.goalLine);
|
|
14327
14770
|
this.tui.addChild(this.queuePane);
|
|
14328
14771
|
this.tui.addChild(this.working);
|
|
14329
14772
|
this.tui.addChild(this.editorSeat);
|
|
@@ -14733,6 +15176,10 @@ var TuiApp = class TuiApp {
|
|
|
14733
15176
|
component: this.todoPanel,
|
|
14734
15177
|
shrink: 0
|
|
14735
15178
|
},
|
|
15179
|
+
{
|
|
15180
|
+
component: this.goalLine,
|
|
15181
|
+
shrink: 0
|
|
15182
|
+
},
|
|
14736
15183
|
{
|
|
14737
15184
|
component: this.queuePane,
|
|
14738
15185
|
shrink: 0
|
|
@@ -14882,6 +15329,17 @@ var TuiApp = class TuiApp {
|
|
|
14882
15329
|
this.requestRender();
|
|
14883
15330
|
}
|
|
14884
15331
|
/**
|
|
15332
|
+
* Anchor the transcript view to its END (the latest content). Fullscreen
|
|
15333
|
+
* scrolls its ScrollView; the regular surface has no scroll view (it draws
|
|
15334
|
+
* into the terminal scrollback), so a forced full repaint re-renders from
|
|
15335
|
+
* row 0 and the main screen's viewport tracking lands on the bottom — the
|
|
15336
|
+
* same mechanism the fullscreen-toggle path uses to redraw cleanly.
|
|
15337
|
+
*/
|
|
15338
|
+
scrollToBottom() {
|
|
15339
|
+
if (this.fullscreenScroll !== void 0) this.fullscreenScroll.scrollToEnd();
|
|
15340
|
+
else this.tui.requestRender(true);
|
|
15341
|
+
}
|
|
15342
|
+
/**
|
|
14885
15343
|
* Set the live session's auto-generated title (from the session/title
|
|
14886
15344
|
* log) for the header; undefined clears it.
|
|
14887
15345
|
*/
|
|
@@ -15171,9 +15629,12 @@ var TuiApp = class TuiApp {
|
|
|
15171
15629
|
} else {
|
|
15172
15630
|
const rows = [];
|
|
15173
15631
|
const callPreview = parseCallPreview(message.name, message.args);
|
|
15632
|
+
const foldedCall = header.summary === "" ? foldedCallPreview(message.name, message.args) : "";
|
|
15174
15633
|
const resultPreview = message.name === "read" ? readFoldedPreview(message.result) : message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
|
|
15634
|
+
const callHead = foldedCall === "" ? "" : foldedCall;
|
|
15635
|
+
const headWithPreview = `${head}${callHead}${resultPreview}`;
|
|
15175
15636
|
if (callPreview?.kind === "bash" && callPreview.command !== "") {
|
|
15176
|
-
rows.push(truncateToWidth(head
|
|
15637
|
+
rows.push(truncateToWidth(`${head}${callHead}`, this.terminal.columns, "…"));
|
|
15177
15638
|
const commandLines = callPreview.command.split("\n");
|
|
15178
15639
|
const shown = commandLines.slice(0, 3);
|
|
15179
15640
|
const prompt = color.shellMode("$ ");
|
|
@@ -15184,12 +15645,12 @@ var TuiApp = class TuiApp {
|
|
|
15184
15645
|
if (commandLines.length > 3) rows.push(color.textDim(`${indent}… ${commandLines.length - 3} more command lines (ctrl+o to expand)`));
|
|
15185
15646
|
if (resultPreview !== "") rows.push(color.textDim(` ${resultPreview}`));
|
|
15186
15647
|
} else if (callPreview?.kind === "diff" && callPreview.diffs.length > 0) {
|
|
15187
|
-
rows.push(truncateToWidth(`${
|
|
15648
|
+
rows.push(truncateToWidth(`${headWithPreview}`, this.terminal.columns, "…"));
|
|
15188
15649
|
for (const line of renderDiffView(callPreview.diffs, this.workspaceRoot, {
|
|
15189
15650
|
maxLines: 4,
|
|
15190
15651
|
expandHint: "ctrl+o to expand"
|
|
15191
15652
|
})) rows.push(` ${line}`);
|
|
15192
|
-
} else rows.push(truncateToWidth(
|
|
15653
|
+
} else rows.push(truncateToWidth(headWithPreview, this.terminal.columns, "…"));
|
|
15193
15654
|
card.addChild(new Text(rows.join("\n"), 0, 0));
|
|
15194
15655
|
}
|
|
15195
15656
|
return card;
|
|
@@ -15267,10 +15728,11 @@ var TuiApp = class TuiApp {
|
|
|
15267
15728
|
this.addTerminalCommandRow(card, callView.title, this.shellPrompt(message.name));
|
|
15268
15729
|
return;
|
|
15269
15730
|
}
|
|
15270
|
-
if (callView.card === "generic" && callView.rawInput !== void 0) {
|
|
15731
|
+
if (callView.card === "generic" && (callView.rawInput !== void 0 || (callView.content?.length ?? 0) > 0)) {
|
|
15271
15732
|
this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
|
|
15272
|
-
|
|
15273
|
-
card.addChild(new Text(color.textDim(
|
|
15733
|
+
if (callView.rawInput !== void 0) for (const line of genericRawInputLines(message.name, callView.rawInput)) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
15734
|
+
for (const block of callView.content ?? []) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
|
|
15735
|
+
else card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
|
|
15274
15736
|
return;
|
|
15275
15737
|
}
|
|
15276
15738
|
}
|
|
@@ -15311,6 +15773,18 @@ var TuiApp = class TuiApp {
|
|
|
15311
15773
|
case "diff":
|
|
15312
15774
|
this.renderDiffBody(card, resultView.diffs, explicitlyExpanded);
|
|
15313
15775
|
return;
|
|
15776
|
+
case "web":
|
|
15777
|
+
for (const line of webCardLines(resultView)) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
15778
|
+
return;
|
|
15779
|
+
case "generic": {
|
|
15780
|
+
const content = resultView.content ?? [];
|
|
15781
|
+
if (content.length > 0) {
|
|
15782
|
+
for (const block of content) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
|
|
15783
|
+
else card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
|
|
15784
|
+
return;
|
|
15785
|
+
}
|
|
15786
|
+
break;
|
|
15787
|
+
}
|
|
15314
15788
|
}
|
|
15315
15789
|
if (resultView === void 0) {
|
|
15316
15790
|
const callView = this.present?.call(message.name, message.args);
|
|
@@ -15338,7 +15812,12 @@ var TuiApp = class TuiApp {
|
|
|
15338
15812
|
if (isDiffResult(message.name, message.result)) for (const line of renderDiffLines(message.result)) card.addChild(new Text(line, 0, 0));
|
|
15339
15813
|
else {
|
|
15340
15814
|
this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
|
|
15341
|
-
|
|
15815
|
+
const blocks = message.resultBlocks ?? [];
|
|
15816
|
+
const lines = blocks.length > 0 ? resultTextLines(blocks, message.status === "error" ? {
|
|
15817
|
+
name: "error",
|
|
15818
|
+
code: "tool"
|
|
15819
|
+
} : void 0) : [message.result];
|
|
15820
|
+
for (const line of lines) card.addChild(new Text(color.textDim(line), 0, 0));
|
|
15342
15821
|
}
|
|
15343
15822
|
}
|
|
15344
15823
|
/**
|
|
@@ -15360,21 +15839,12 @@ var TuiApp = class TuiApp {
|
|
|
15360
15839
|
(this.fullscreen ?? this.tui).requestRender(force);
|
|
15361
15840
|
}
|
|
15362
15841
|
/**
|
|
15363
|
-
* Reflect the todo list in the
|
|
15364
|
-
* and, when the list is non-empty, the first active item's text.
|
|
15842
|
+
* Reflect the todo list in the dock summary line: active (non-completed)
|
|
15843
|
+
* count and, when the list is non-empty, the first active item's text.
|
|
15365
15844
|
* @param todos - the latest todo/write snapshot.
|
|
15366
15845
|
*/
|
|
15367
15846
|
setTodoSummary(todos) {
|
|
15368
15847
|
this.todoItems = todos;
|
|
15369
|
-
const active = todos.filter((todo) => todo.status !== "completed");
|
|
15370
|
-
const done = todos.length - active.length;
|
|
15371
|
-
if (active.length === 0) this.todoText = done > 0 ? ` · ${done} todo done` : "";
|
|
15372
|
-
else {
|
|
15373
|
-
const first = active[0];
|
|
15374
|
-
const label = first === void 0 ? "" : first.content.length > 30 ? `${first.content.slice(0, 30)}…` : first.content;
|
|
15375
|
-
this.todoText = ` · ${active.length} active · ${label}`;
|
|
15376
|
-
}
|
|
15377
|
-
this.renderHeader();
|
|
15378
15848
|
this.renderDock();
|
|
15379
15849
|
if (this.todoPanelVisible) this.renderTodoPanel();
|
|
15380
15850
|
}
|
|
@@ -15382,6 +15852,7 @@ var TuiApp = class TuiApp {
|
|
|
15382
15852
|
toggleTodoPanel() {
|
|
15383
15853
|
this.todoPanelVisible = !this.todoPanelVisible;
|
|
15384
15854
|
this.renderTodoPanel();
|
|
15855
|
+
this.renderDock();
|
|
15385
15856
|
this.requestRender();
|
|
15386
15857
|
return this.todoPanelVisible;
|
|
15387
15858
|
}
|
|
@@ -15390,8 +15861,9 @@ var TuiApp = class TuiApp {
|
|
|
15390
15861
|
return this.todoPanelVisible;
|
|
15391
15862
|
}
|
|
15392
15863
|
/**
|
|
15393
|
-
* Rebuild the todo panel text: a
|
|
15394
|
-
* in_progress first, then pending, then
|
|
15864
|
+
* Rebuild the todo panel text: a border rule + `Todo` title (both indented
|
|
15865
|
+
* one cell) plus up to five rows, in_progress first, then pending, then
|
|
15866
|
+
* completed (strikethrough).
|
|
15395
15867
|
*/
|
|
15396
15868
|
renderTodoPanel() {
|
|
15397
15869
|
if (!this.todoPanelVisible) {
|
|
@@ -15404,15 +15876,22 @@ var TuiApp = class TuiApp {
|
|
|
15404
15876
|
...this.todoItems.filter((todo) => todo.status === "pending"),
|
|
15405
15877
|
...this.todoItems.filter((todo) => todo.status === "completed")
|
|
15406
15878
|
].slice(0, 5);
|
|
15879
|
+
const width = Math.max(1, this.terminal.columns);
|
|
15880
|
+
const border = color.border(` ${"─".repeat(Math.max(0, width - 2))} `);
|
|
15881
|
+
const title = color.textStrong(" Todo");
|
|
15407
15882
|
if (ordered.length === 0) {
|
|
15408
|
-
this.todoPanel.setText(
|
|
15883
|
+
this.todoPanel.setText([border, title].join("\n"));
|
|
15409
15884
|
return;
|
|
15410
15885
|
}
|
|
15411
15886
|
const lines = ordered.map((todo) => {
|
|
15412
15887
|
const body = todo.status === "completed" ? `\x1b[9m${todo.content}\x1b[29m` : todo.content;
|
|
15413
15888
|
return `${mark(todo)} ${body}`;
|
|
15414
15889
|
});
|
|
15415
|
-
this.todoPanel.setText([
|
|
15890
|
+
this.todoPanel.setText([
|
|
15891
|
+
border,
|
|
15892
|
+
title,
|
|
15893
|
+
...lines
|
|
15894
|
+
].join("\n"));
|
|
15416
15895
|
}
|
|
15417
15896
|
/** Hide/show thinking entries; the fold state is untouched. */
|
|
15418
15897
|
toggleThinkingHidden() {
|
|
@@ -15424,15 +15903,15 @@ var TuiApp = class TuiApp {
|
|
|
15424
15903
|
isThinkingHidden() {
|
|
15425
15904
|
return this.hideThinking;
|
|
15426
15905
|
}
|
|
15427
|
-
/** Rebuild the header from base + session title +
|
|
15906
|
+
/** Rebuild the header from base + session title + plan badge.
|
|
15428
15907
|
* Colours are applied AT RENDER TIME from the live palette — the semantic
|
|
15429
|
-
* state (plan mode, title
|
|
15430
|
-
*
|
|
15908
|
+
* state (plan mode, title) is stored separately, so a theme switch only
|
|
15909
|
+
* has to re-run this. */
|
|
15431
15910
|
renderHeader() {
|
|
15432
15911
|
const badge = this.planMode ? ` ${color.warning("[plan]")}` : "";
|
|
15433
15912
|
const viewerBadge = this.viewerMode === void 0 ? "" : ` ${color.accent("[viewing subagent]")}`;
|
|
15434
15913
|
const title = this.viewerMode !== void 0 ? ` · ${color.textMuted(this.viewerMode.label)}` : this.sessionTitleText === "" ? "" : ` · ${color.textMuted(this.sessionTitleText)}`;
|
|
15435
|
-
this.header.setText(`🐋 dsh-pi-tui${title}${
|
|
15914
|
+
this.header.setText(`🐋 dsh-pi-tui${title}${badge}${viewerBadge}`);
|
|
15436
15915
|
this.requestRender();
|
|
15437
15916
|
}
|
|
15438
15917
|
/**
|
|
@@ -15448,29 +15927,27 @@ var TuiApp = class TuiApp {
|
|
|
15448
15927
|
};
|
|
15449
15928
|
this.renderFooter();
|
|
15450
15929
|
this.renderDock();
|
|
15930
|
+
this.renderGoalLine();
|
|
15451
15931
|
}
|
|
15452
15932
|
/**
|
|
15453
|
-
* Replace the active background-task list for the
|
|
15454
|
-
*
|
|
15933
|
+
* Replace the active background-task list for the footer badge. Non-empty
|
|
15934
|
+
* sets arm the ↓/Ctrl+J task-browser trigger.
|
|
15455
15935
|
* @param tasks - active jobs (id + label + lifecycle status), empty to hide.
|
|
15456
15936
|
*/
|
|
15457
15937
|
setTasks(tasks) {
|
|
15458
15938
|
this.dockTasks = tasks;
|
|
15459
15939
|
this.tasksActive = tasks.length > 0 || this.dockAgents.length > 0;
|
|
15460
|
-
this.renderDock();
|
|
15461
15940
|
this.renderFooter();
|
|
15462
15941
|
}
|
|
15463
15942
|
/**
|
|
15464
|
-
* Replace the live child-subagent list for the
|
|
15465
|
-
*
|
|
15466
|
-
*
|
|
15467
|
-
* trigger through this channel.
|
|
15943
|
+
* Replace the live child-subagent list for the footer badge. Continuable
|
|
15944
|
+
* children and foreground one-shot children never register jobs records
|
|
15945
|
+
* (AGENTS.md), so they arm the ↓/Ctrl+J trigger through this channel.
|
|
15468
15946
|
* @param agents - live children (id + label + activity), empty to hide.
|
|
15469
15947
|
*/
|
|
15470
15948
|
setAgents(agents) {
|
|
15471
15949
|
this.dockAgents = agents;
|
|
15472
15950
|
this.tasksActive = this.dockTasks.length > 0 || agents.length > 0;
|
|
15473
|
-
this.renderDock();
|
|
15474
15951
|
this.renderFooter();
|
|
15475
15952
|
}
|
|
15476
15953
|
/** Whether background tasks are active (drives the ↓/Ctrl+J trigger). */
|
|
@@ -15478,9 +15955,9 @@ var TuiApp = class TuiApp {
|
|
|
15478
15955
|
return this.tasksActive;
|
|
15479
15956
|
}
|
|
15480
15957
|
/**
|
|
15481
|
-
* Replace the pending inbox rows for the queue pane
|
|
15482
|
-
*
|
|
15483
|
-
*
|
|
15958
|
+
* Replace the pending inbox rows for the queue pane: a border rule, one
|
|
15959
|
+
* `❯ text` row per message, and a dim hint. An empty queue renders
|
|
15960
|
+
* nothing at all.
|
|
15484
15961
|
* @param items - pending followups/steers, in delivery order.
|
|
15485
15962
|
*/
|
|
15486
15963
|
setQueueItems(items) {
|
|
@@ -15496,7 +15973,7 @@ var TuiApp = class TuiApp {
|
|
|
15496
15973
|
return;
|
|
15497
15974
|
}
|
|
15498
15975
|
const width = Math.max(1, this.terminal.columns);
|
|
15499
|
-
const lines = [color.border("─".repeat(width))];
|
|
15976
|
+
const lines = [color.border(` ${"─".repeat(Math.max(0, width - 2))} `)];
|
|
15500
15977
|
for (const item of items) {
|
|
15501
15978
|
const prefix = item.notice === true ? `${color.textDim("⏳")} ` : `${color.accent("❯")} `;
|
|
15502
15979
|
const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth(prefix)), "…");
|
|
@@ -15540,45 +16017,37 @@ var TuiApp = class TuiApp {
|
|
|
15540
16017
|
};
|
|
15541
16018
|
}
|
|
15542
16019
|
/**
|
|
15543
|
-
* Rebuild the persistent dock strip above the todo panel: todo summary
|
|
15544
|
-
*
|
|
15545
|
-
*
|
|
15546
|
-
*
|
|
15547
|
-
*
|
|
15548
|
-
*
|
|
15549
|
-
* every fact has exactly one home.
|
|
16020
|
+
* Rebuild the persistent dock strip above the todo panel: the todo summary
|
|
16021
|
+
* as a single dim info line, only while non-empty. No border rule — a full
|
|
16022
|
+
* ─ line here visually collides with the editor's own full-width border
|
|
16023
|
+
* (user feedback); the summary reads as an info line, and only the
|
|
16024
|
+
* EXPANDED todo panel (Ctrl+T) carries a panel border. Background-task and
|
|
16025
|
+
* subagent details live in the footer badge and the ↓/Ctrl+J browser ONLY
|
|
16026
|
+
* — every fact has exactly one home.
|
|
15550
16027
|
*/
|
|
15551
16028
|
renderDock() {
|
|
15552
|
-
|
|
15553
|
-
|
|
15554
|
-
|
|
15555
|
-
|
|
15556
|
-
|
|
15557
|
-
|
|
15558
|
-
|
|
15559
|
-
|
|
15560
|
-
|
|
15561
|
-
|
|
15562
|
-
|
|
15563
|
-
|
|
15564
|
-
|
|
15565
|
-
|
|
15566
|
-
|
|
15567
|
-
|
|
15568
|
-
|
|
15569
|
-
|
|
15570
|
-
|
|
15571
|
-
|
|
15572
|
-
|
|
15573
|
-
|
|
15574
|
-
const shown = this.dockAgents.slice(0, 3);
|
|
15575
|
-
for (const agent of shown) {
|
|
15576
|
-
const label = agent.label.length > 40 ? `${agent.label.slice(0, 40)}…` : agent.label;
|
|
15577
|
-
lines.push(color.textDim(`🤖 ${agent.id} · ${label}`));
|
|
15578
|
-
}
|
|
15579
|
-
if (this.dockAgents.length > 3) lines.push(color.textDim(` … ${this.dockAgents.length - 3} more`));
|
|
15580
|
-
}
|
|
15581
|
-
this.dock.setText(lines.join("\n"));
|
|
16029
|
+
if (this.todoPanelVisible || this.todoItems.length === 0) {
|
|
16030
|
+
this.dock.setText("");
|
|
16031
|
+
this.requestRender();
|
|
16032
|
+
return;
|
|
16033
|
+
}
|
|
16034
|
+
const active = this.todoItems.filter((todo) => todo.status !== "completed");
|
|
16035
|
+
const done = this.todoItems.length - active.length;
|
|
16036
|
+
const first = active[0];
|
|
16037
|
+
const label = first === void 0 ? "" : first.content.length > 40 ? `${first.content.slice(0, 40)}…` : first.content;
|
|
16038
|
+
const summary = [
|
|
16039
|
+
done > 0 ? `${done} todo done` : "",
|
|
16040
|
+
active.length > 0 ? `${active.length} active` : "",
|
|
16041
|
+
label
|
|
16042
|
+
].filter((part) => part !== "").join(" · ");
|
|
16043
|
+
this.dock.setText(color.textDim(`☑ ${summary}`));
|
|
16044
|
+
this.requestRender();
|
|
16045
|
+
}
|
|
16046
|
+
/** Rebuild the goal line: `goal ● <objective>` while a goal is set, hidden
|
|
16047
|
+
* otherwise (display-only — no verbs yet). */
|
|
16048
|
+
renderGoalLine() {
|
|
16049
|
+
const goal = this.status.goal;
|
|
16050
|
+
this.goalLine.setText(goal === void 0 || goal === "" ? "" : color.primary(goal));
|
|
15582
16051
|
this.requestRender();
|
|
15583
16052
|
}
|
|
15584
16053
|
/** Footer density presets: full keeps the stats line, compact drops it. */
|
|
@@ -15603,7 +16072,6 @@ var TuiApp = class TuiApp {
|
|
|
15603
16072
|
const line1 = [
|
|
15604
16073
|
permissionBadge,
|
|
15605
16074
|
this.planMode ? color.warning("[plan]") : "",
|
|
15606
|
-
this.status.goal === void 0 || this.status.goal === "" ? "" : color.primary(this.status.goal),
|
|
15607
16075
|
this.status.model === "" ? "" : `[${this.status.model}]`,
|
|
15608
16076
|
taskBadge,
|
|
15609
16077
|
this.status.cwd,
|
|
@@ -15662,6 +16130,50 @@ var TuiApp = class TuiApp {
|
|
|
15662
16130
|
};
|
|
15663
16131
|
}
|
|
15664
16132
|
/**
|
|
16133
|
+
* Open the task browser overlay (the ↓ / Ctrl+J trigger with an empty
|
|
16134
|
+
* editor, and /tasks). Unlike the generic {@link openPicker}, rows carry a
|
|
16135
|
+
* status word + start timestamp so the panel can render status dots,
|
|
16136
|
+
* right-aligned status/elapsed columns, live counts, and a 1s elapsed
|
|
16137
|
+
* tick. Selection calls `onSelect` with the row value and closes; Esc
|
|
16138
|
+
* calls `onCancel`.
|
|
16139
|
+
* @param items - task rows (see TaskPanelItem).
|
|
16140
|
+
* @param onSelect - confirmed row value.
|
|
16141
|
+
* @param onCancel - dismissed without a choice.
|
|
16142
|
+
* @param options - header/search/sizing configuration.
|
|
16143
|
+
* @returns a handle to close the browser or replace its rows (e.g. when
|
|
16144
|
+
* jobs change while it is open).
|
|
16145
|
+
*/
|
|
16146
|
+
openTaskBrowser(items, onSelect, onCancel, options = {}) {
|
|
16147
|
+
const panel = new TaskBrowserPanel(items.map((item) => ({ ...item })), options.maxVisible ?? 10, {
|
|
16148
|
+
header: options.header,
|
|
16149
|
+
noMatchText: options.noMatchText,
|
|
16150
|
+
enableSearch: options.enableSearch,
|
|
16151
|
+
initialQuery: options.initialQuery
|
|
16152
|
+
}, (value) => {
|
|
16153
|
+
close();
|
|
16154
|
+
onSelect(value);
|
|
16155
|
+
}, () => {
|
|
16156
|
+
close();
|
|
16157
|
+
onCancel();
|
|
16158
|
+
}, () => this.requestRender());
|
|
16159
|
+
const handle = this.showOverlayOnHost(new Frame(panel), {
|
|
16160
|
+
width: options.width ?? 72,
|
|
16161
|
+
maxHeight: options.maxHeight ?? 24
|
|
16162
|
+
});
|
|
16163
|
+
let close = () => {};
|
|
16164
|
+
close = () => {
|
|
16165
|
+
panel.dispose();
|
|
16166
|
+
handle.hide();
|
|
16167
|
+
};
|
|
16168
|
+
return {
|
|
16169
|
+
close,
|
|
16170
|
+
setItems: (next) => {
|
|
16171
|
+
panel.setItems(next.map((item) => ({ ...item })));
|
|
16172
|
+
this.requestRender();
|
|
16173
|
+
}
|
|
16174
|
+
};
|
|
16175
|
+
}
|
|
16176
|
+
/**
|
|
15665
16177
|
* Open the settings overlay as a SettingsList. The runner supplies the
|
|
15666
16178
|
* items and reacts to changes/cancellation. Returns a CLOSER so an
|
|
15667
16179
|
* action-style list (e.g. /subagents' View transcript / Interrupt) can
|
|
@@ -15741,6 +16253,7 @@ var TuiApp = class TuiApp {
|
|
|
15741
16253
|
this.renderFooter();
|
|
15742
16254
|
this.renderDock();
|
|
15743
16255
|
this.renderTodoPanel();
|
|
16256
|
+
this.renderGoalLine();
|
|
15744
16257
|
this.renderQueuePane();
|
|
15745
16258
|
this.working.refresh();
|
|
15746
16259
|
this.editor.invalidate();
|
|
@@ -16150,14 +16663,6 @@ function taskRowLabel(row) {
|
|
|
16150
16663
|
function rowGroup(row) {
|
|
16151
16664
|
return row.kind === "job" ? JOB_GROUP : SUBAGENT_GROUP;
|
|
16152
16665
|
}
|
|
16153
|
-
/** The picker description line for a row. */
|
|
16154
|
-
function describeTaskRow(row, now) {
|
|
16155
|
-
if (row.kind === "job") {
|
|
16156
|
-
const elapsed = Math.max(0, Math.floor((now - row.startedAt) / 1e3));
|
|
16157
|
-
return `${row.status}${row.detail === void 0 ? "" : ` — ${row.detail}`} · ${elapsed}s`;
|
|
16158
|
-
}
|
|
16159
|
-
return `${row.activity}${row.hasChildren ? " · has children" : ""}`;
|
|
16160
|
-
}
|
|
16161
16666
|
//#endregion
|
|
16162
16667
|
//#region src/model-menu.ts
|
|
16163
16668
|
/**
|
|
@@ -16766,6 +17271,55 @@ function presetDisplayText(preset) {
|
|
|
16766
17271
|
...preset.description === void 0 ? {} : { description: preset.description }
|
|
16767
17272
|
};
|
|
16768
17273
|
}
|
|
17274
|
+
/** The credential-target options for /login and /logout: the deepseek
|
|
17275
|
+
* official ref always first, then every llm-pi-ai provider route's
|
|
17276
|
+
* apiKeyEnv (deduped by ref). `providers` is the llm-pi-ai settings
|
|
17277
|
+
* section's `providers` dict, or undefined when the adapter or the settings
|
|
17278
|
+
* service is absent — which degrades /login to the official target only.
|
|
17279
|
+
*/
|
|
17280
|
+
function credentialOptionsFor(providers) {
|
|
17281
|
+
const options = [{
|
|
17282
|
+
label: "deepseek official",
|
|
17283
|
+
ref: "DEEPSEEK_API_KEY"
|
|
17284
|
+
}];
|
|
17285
|
+
if (providers === void 0) return options;
|
|
17286
|
+
const seen = new Set(options.map((option) => option.ref));
|
|
17287
|
+
for (const [route, profile] of Object.entries(providers)) {
|
|
17288
|
+
const ref = profile?.apiKeyEnv;
|
|
17289
|
+
if (ref === void 0 || ref === "" || seen.has(ref)) continue;
|
|
17290
|
+
seen.add(ref);
|
|
17291
|
+
options.push({
|
|
17292
|
+
label: route,
|
|
17293
|
+
ref
|
|
17294
|
+
});
|
|
17295
|
+
}
|
|
17296
|
+
return options;
|
|
17297
|
+
}
|
|
17298
|
+
/** Resolve a /login or /logout argument to a credential ref. A
|
|
17299
|
+
* case-insensitive llm-pi-ai route name wins, then a literal env-var-looking
|
|
17300
|
+
* name (any casing, uppercased like the old behavior — `/login my_key` sets
|
|
17301
|
+
* `MY_KEY`); anything else is unknown and returns undefined so the caller
|
|
17302
|
+
* can list the valid options.
|
|
17303
|
+
*/
|
|
17304
|
+
function resolveCredentialArg(arg, options) {
|
|
17305
|
+
const trimmed = arg.trim();
|
|
17306
|
+
if (trimmed === "") return void 0;
|
|
17307
|
+
const needle = trimmed.toLowerCase();
|
|
17308
|
+
const route = options.find((option) => option.label.toLowerCase() === needle || option.label.split(" ")[0].toLowerCase() === needle);
|
|
17309
|
+
if (route !== void 0) return route.ref;
|
|
17310
|
+
if (/^[a-zA-Z][a-zA-Z0-9_]*$/.test(trimmed)) return trimmed.toUpperCase();
|
|
17311
|
+
}
|
|
17312
|
+
/** Read the llm-pi-ai adapter's `providers` dict from its settings section,
|
|
17313
|
+
* or undefined when the settings service or the section is absent. */
|
|
17314
|
+
function readLlmpiAiProviders(ctx) {
|
|
17315
|
+
const settings = ctx.get("settings");
|
|
17316
|
+
if (settings === void 0) return void 0;
|
|
17317
|
+
try {
|
|
17318
|
+
return settings.get(settingsNamespace("llm-pi-ai"))?.providers;
|
|
17319
|
+
} catch {
|
|
17320
|
+
return;
|
|
17321
|
+
}
|
|
17322
|
+
}
|
|
16769
17323
|
/**
|
|
16770
17324
|
* Register the TUI-owned slash commands on the commands service. The
|
|
16771
17325
|
* completion list is refreshed after every registration so TUI-owned
|
|
@@ -17175,7 +17729,7 @@ function registerTuiCommands(runner, initial) {
|
|
|
17175
17729
|
text: `skill "${name}" returned a malformed definition`
|
|
17176
17730
|
};
|
|
17177
17731
|
const body = typeof skill.content === "string" && skill.content !== "" ? skill.content : skill.description;
|
|
17178
|
-
|
|
17732
|
+
const message = createUserMessage({
|
|
17179
17733
|
content: [{
|
|
17180
17734
|
type: "text",
|
|
17181
17735
|
text: "Skill loaded by the user: **" + skill.name + "**\n\n" + body
|
|
@@ -17184,7 +17738,9 @@ function registerTuiCommands(runner, initial) {
|
|
|
17184
17738
|
kind: "plugin",
|
|
17185
17739
|
plugin: "tui-skill"
|
|
17186
17740
|
}
|
|
17187
|
-
})
|
|
17741
|
+
});
|
|
17742
|
+
if (agent.status === "running") agent.steer(message);
|
|
17743
|
+
else agent.followup(message);
|
|
17188
17744
|
return {
|
|
17189
17745
|
kind: "success",
|
|
17190
17746
|
text: "skill " + name + " loaded"
|
|
@@ -17560,12 +18116,17 @@ function registerTuiCommands(runner, initial) {
|
|
|
17560
18116
|
kind: "error",
|
|
17561
18117
|
text: "no background jobs"
|
|
17562
18118
|
};
|
|
17563
|
-
|
|
17564
|
-
app.openPicker(snapshots.map((job) => ({
|
|
18119
|
+
app.openTaskBrowser(snapshots.map((job) => ({
|
|
17565
18120
|
value: job.id,
|
|
17566
18121
|
label: `${job.kind} · ${job.label}`,
|
|
17567
|
-
|
|
17568
|
-
|
|
18122
|
+
status: job.status,
|
|
18123
|
+
detail: job.detail,
|
|
18124
|
+
startedAt: job.startedAt,
|
|
18125
|
+
group: "jobs"
|
|
18126
|
+
})), (jobId) => runner.openJobView(jobId), () => {}, {
|
|
18127
|
+
header: "tasks",
|
|
18128
|
+
enableSearch: true
|
|
18129
|
+
});
|
|
17569
18130
|
return { kind: "success" };
|
|
17570
18131
|
}
|
|
17571
18132
|
});
|
|
@@ -18046,15 +18607,37 @@ function registerTuiCommands(runner, initial) {
|
|
|
18046
18607
|
});
|
|
18047
18608
|
commands.register({
|
|
18048
18609
|
name: "login",
|
|
18049
|
-
description: "Set an API key
|
|
18050
|
-
input: { hint: "[<env-var>]" },
|
|
18610
|
+
description: "Set an API key — deepseek official or an llm-pi-ai provider route",
|
|
18611
|
+
input: { hint: "[<route|env-var>]" },
|
|
18051
18612
|
handler: async (invocation) => {
|
|
18052
18613
|
const credentials = ctx.get("credentials");
|
|
18053
18614
|
if (credentials === void 0) return {
|
|
18054
18615
|
kind: "error",
|
|
18055
18616
|
text: "credentials service unavailable"
|
|
18056
18617
|
};
|
|
18057
|
-
const
|
|
18618
|
+
const options = credentialOptionsFor(readLlmpiAiProviders(ctx));
|
|
18619
|
+
const arg = invocation.rawInput.trim();
|
|
18620
|
+
let ref;
|
|
18621
|
+
if (arg !== "") {
|
|
18622
|
+
const resolved = resolveCredentialArg(arg, options);
|
|
18623
|
+
if (resolved === void 0) return {
|
|
18624
|
+
kind: "error",
|
|
18625
|
+
text: `unknown credential target "${arg}" — ${options.map((option) => `${option.label} (${option.ref})`).join(", ")}`
|
|
18626
|
+
};
|
|
18627
|
+
ref = resolved;
|
|
18628
|
+
} else if (options.length > 1) {
|
|
18629
|
+
const picked = await new Promise((resolve) => {
|
|
18630
|
+
app.openPicker(options.map((option) => ({
|
|
18631
|
+
value: option.ref,
|
|
18632
|
+
label: `${option.label} (${option.ref})`
|
|
18633
|
+
})), (value) => resolve(value), () => resolve(void 0));
|
|
18634
|
+
});
|
|
18635
|
+
if (picked === void 0) return {
|
|
18636
|
+
kind: "error",
|
|
18637
|
+
text: "login cancelled"
|
|
18638
|
+
};
|
|
18639
|
+
ref = picked;
|
|
18640
|
+
} else ref = options[0].ref;
|
|
18058
18641
|
try {
|
|
18059
18642
|
const key = (await app.askQuestions([{
|
|
18060
18643
|
id: "key",
|
|
@@ -18079,19 +18662,32 @@ function registerTuiCommands(runner, initial) {
|
|
|
18079
18662
|
});
|
|
18080
18663
|
commands.register({
|
|
18081
18664
|
name: "logout",
|
|
18082
|
-
description: "Clear a stored API key
|
|
18083
|
-
input: { hint: "[<env-var>]" },
|
|
18665
|
+
description: "Clear a stored API key — deepseek official or an llm-pi-ai provider route",
|
|
18666
|
+
input: { hint: "[<route|env-var>]" },
|
|
18084
18667
|
handler: async (invocation) => {
|
|
18085
18668
|
const credentials = ctx.get("credentials");
|
|
18086
18669
|
if (credentials === void 0) return {
|
|
18087
18670
|
kind: "error",
|
|
18088
18671
|
text: "credentials service unavailable"
|
|
18089
18672
|
};
|
|
18090
|
-
const
|
|
18091
|
-
|
|
18673
|
+
const options = credentialOptionsFor(readLlmpiAiProviders(ctx));
|
|
18674
|
+
const arg = invocation.rawInput.trim();
|
|
18675
|
+
if (arg !== "") {
|
|
18676
|
+
const resolved = resolveCredentialArg(arg, options);
|
|
18677
|
+
if (resolved === void 0) return {
|
|
18678
|
+
kind: "error",
|
|
18679
|
+
text: `unknown credential target "${arg}" — ${options.map((option) => `${option.label} (${option.ref})`).join(", ")}`
|
|
18680
|
+
};
|
|
18681
|
+
await credentials.unset(resolved);
|
|
18682
|
+
return {
|
|
18683
|
+
kind: "success",
|
|
18684
|
+
text: `${resolved} cleared`
|
|
18685
|
+
};
|
|
18686
|
+
}
|
|
18687
|
+
await credentials.unset(options[0].ref);
|
|
18092
18688
|
return {
|
|
18093
18689
|
kind: "success",
|
|
18094
|
-
text: `${ref} cleared`
|
|
18690
|
+
text: `${options[0].ref} cleared`
|
|
18095
18691
|
};
|
|
18096
18692
|
}
|
|
18097
18693
|
});
|
|
@@ -18336,6 +18932,110 @@ function createDiag(options = {}) {
|
|
|
18336
18932
|
};
|
|
18337
18933
|
}
|
|
18338
18934
|
//#endregion
|
|
18935
|
+
//#region src/history.ts
|
|
18936
|
+
/**
|
|
18937
|
+
* Per-cwd input-history persistence — JSONL files under `$DSH_HOME/user-history/`,
|
|
18938
|
+
* one `{"content": "..."}` line per submitted prompt (kimi-code's
|
|
18939
|
+
* input-history pattern: a dedicated data file, never the settings
|
|
18940
|
+
* document). Append-only writes make concurrent windows in one directory
|
|
18941
|
+
* safe (O_APPEND lines cannot clobber each other), which whole-file settings
|
|
18942
|
+
* replaces could not guarantee.
|
|
18943
|
+
*
|
|
18944
|
+
* Semantics:
|
|
18945
|
+
* - File order is submission order (oldest first); the editor seeds
|
|
18946
|
+
* oldest→newest so its recall list is newest-first.
|
|
18947
|
+
* - Empty submissions and consecutive repeats are skipped (shell-history
|
|
18948
|
+
* behavior); non-consecutive repeats are legal history and survive.
|
|
18949
|
+
* - Corrupt lines are skipped on load, never fatal.
|
|
18950
|
+
* - The file is capped at HISTORY_LIMIT entries; the cap trims on load.
|
|
18951
|
+
* - `!` shell commands are stored verbatim (with the leading `!`), so ↑
|
|
18952
|
+
* recall restores them as text and Enter re-runs the shell branch.
|
|
18953
|
+
*
|
|
18954
|
+
* Pure and injectable so pathing, parsing, and append rules are
|
|
18955
|
+
* unit-testable without touching the terminal or dsh services.
|
|
18956
|
+
* @module @xmoon76/dsh-pi-tui/history
|
|
18957
|
+
*/
|
|
18958
|
+
/** Directory name under `$DSH_HOME` holding the per-cwd history files. */
|
|
18959
|
+
const HISTORY_DIR_NAME = "user-history";
|
|
18960
|
+
/**
|
|
18961
|
+
* The history file for one working directory: `$DSH_HOME/user-history/<md5(cwd)>.jsonl`.
|
|
18962
|
+
* The cwd hash keeps the file list flat and free of path separators; the
|
|
18963
|
+
* hash is an identity, never shown to the user.
|
|
18964
|
+
* @param home - the dsh home directory (`$DSH_HOME`, default `~/.dsh`).
|
|
18965
|
+
* @param cwd - the working directory whose submissions the file records.
|
|
18966
|
+
*/
|
|
18967
|
+
function historyFilePath(home, cwd) {
|
|
18968
|
+
const hash = createHash("md5").update(cwd, "utf-8").digest("hex");
|
|
18969
|
+
return join(home, HISTORY_DIR_NAME, `${hash}.jsonl`);
|
|
18970
|
+
}
|
|
18971
|
+
/**
|
|
18972
|
+
* Parse one history file's text into entries, in file order (oldest first).
|
|
18973
|
+
* Blank lines and corrupt lines (unparsable JSON or a non-string `content`)
|
|
18974
|
+
* are skipped — a torn write or an older format must never abort recall.
|
|
18975
|
+
* @param text - the raw file content.
|
|
18976
|
+
*/
|
|
18977
|
+
function parseHistoryLines(text) {
|
|
18978
|
+
const entries = [];
|
|
18979
|
+
for (const line of text.split("\n")) {
|
|
18980
|
+
const trimmed = line.trim();
|
|
18981
|
+
if (trimmed === "") continue;
|
|
18982
|
+
try {
|
|
18983
|
+
const parsed = JSON.parse(trimmed);
|
|
18984
|
+
if (typeof parsed.content === "string" && parsed.content !== "") entries.push(parsed.content);
|
|
18985
|
+
} catch {}
|
|
18986
|
+
}
|
|
18987
|
+
return entries;
|
|
18988
|
+
}
|
|
18989
|
+
/**
|
|
18990
|
+
* Load a history file, trimming it to the last HISTORY_LIMIT entries when it
|
|
18991
|
+
* outgrew the cap (the trim rewrites the file best-effort so it stops
|
|
18992
|
+
* growing). Never throws: an unreadable or absent file is empty history.
|
|
18993
|
+
* @param file - the JSONL history file path.
|
|
18994
|
+
* @returns entries in file order (oldest first).
|
|
18995
|
+
*/
|
|
18996
|
+
function loadHistoryFile(file) {
|
|
18997
|
+
try {
|
|
18998
|
+
if (!existsSync(file)) return [];
|
|
18999
|
+
const entries = parseHistoryLines(readFileSync(file, "utf8"));
|
|
19000
|
+
if (entries.length <= 100) return entries;
|
|
19001
|
+
const kept = entries.slice(-100);
|
|
19002
|
+
try {
|
|
19003
|
+
const temp = `${file}.tmp`;
|
|
19004
|
+
writeFileSync(temp, kept.map((entry) => JSON.stringify({ content: entry })).join("\n") + "\n", { mode: 384 });
|
|
19005
|
+
renameSync(temp, file);
|
|
19006
|
+
} catch {}
|
|
19007
|
+
return kept;
|
|
19008
|
+
} catch {
|
|
19009
|
+
return [];
|
|
19010
|
+
}
|
|
19011
|
+
}
|
|
19012
|
+
/**
|
|
19013
|
+
* Append one submitted line to the history file. Empty lines and a repeat of
|
|
19014
|
+
* `lastContent` (the newest known entry) are skipped, like shell history.
|
|
19015
|
+
* Multi-line submissions are stored as one JSON line (newlines escaped), so
|
|
19016
|
+
* the JSONL layout survives pastes.
|
|
19017
|
+
*
|
|
19018
|
+
* Throws on I/O failure (the caller decides how to surface it); the
|
|
19019
|
+
* skip verdicts return false without touching the file.
|
|
19020
|
+
* @param file - the JSONL history file path.
|
|
19021
|
+
* @param text - the submitted input.
|
|
19022
|
+
* @param lastContent - the newest entry known to the caller, or undefined.
|
|
19023
|
+
* @returns whether an entry was written.
|
|
19024
|
+
*/
|
|
19025
|
+
function appendHistoryLine(file, text, lastContent) {
|
|
19026
|
+
const content = text.trim();
|
|
19027
|
+
if (content === "" || content === lastContent) return false;
|
|
19028
|
+
mkdirSync(dirname(file), {
|
|
19029
|
+
recursive: true,
|
|
19030
|
+
mode: 448
|
|
19031
|
+
});
|
|
19032
|
+
appendFileSync(file, JSON.stringify({ content }) + "\n", {
|
|
19033
|
+
encoding: "utf8",
|
|
19034
|
+
mode: 384
|
|
19035
|
+
});
|
|
19036
|
+
return true;
|
|
19037
|
+
}
|
|
19038
|
+
//#endregion
|
|
18339
19039
|
//#region src/exit.ts
|
|
18340
19040
|
/**
|
|
18341
19041
|
* The TUI exit contract: flush the live session with a HARD timeout, record
|
|
@@ -19357,6 +20057,286 @@ var CoalescingRefreshGate = class {
|
|
|
19357
20057
|
}
|
|
19358
20058
|
};
|
|
19359
20059
|
//#endregion
|
|
20060
|
+
//#region src/session-lock.ts
|
|
20061
|
+
/** The lock file name, next to `session.jsonl[.zstd]` in the session directory. */
|
|
20062
|
+
const LOCK_FILE_NAME = "owner.lock";
|
|
20063
|
+
/**
|
|
20064
|
+
* Decide the lock repair after an INTERNAL swap failure (flush/dispose/
|
|
20065
|
+
* whenIdle throwing inside swapTo), given the single-slot tracker state and
|
|
20066
|
+
* the outgoing session id. Pure so the three-shape matrix is headless-testable:
|
|
20067
|
+
*
|
|
20068
|
+
* - tracker holds `from` itself (/new, /fork — the current lock was never
|
|
20069
|
+
* pre-released): nothing to repair — `from` is still locked by us.
|
|
20070
|
+
* - tracker holds the TARGET (switchSession pre-released `from` and acquired
|
|
20071
|
+
* the target before calling swapTo): release the target (we never entered
|
|
20072
|
+
* it) and re-acquire `from` (the current session stays live).
|
|
20073
|
+
* - tracker is empty (switchSession's target acquire was `unavailable`):
|
|
20074
|
+
* `from`'s lock was released and never re-taken — re-acquire it.
|
|
20075
|
+
*/
|
|
20076
|
+
function swapFailureLockRepair(tracker, from) {
|
|
20077
|
+
if (from === void 0) return tracker === void 0 ? {
|
|
20078
|
+
release: void 0,
|
|
20079
|
+
reacquire: void 0
|
|
20080
|
+
} : {
|
|
20081
|
+
release: tracker.sessionId,
|
|
20082
|
+
reacquire: void 0
|
|
20083
|
+
};
|
|
20084
|
+
if (tracker === void 0 || tracker.sessionId !== from) return {
|
|
20085
|
+
release: tracker?.sessionId,
|
|
20086
|
+
reacquire: from
|
|
20087
|
+
};
|
|
20088
|
+
return {
|
|
20089
|
+
release: void 0,
|
|
20090
|
+
reacquire: void 0
|
|
20091
|
+
};
|
|
20092
|
+
}
|
|
20093
|
+
/** A tiny deterministic serializer for the lock record (single-line JSON). */
|
|
20094
|
+
function serializeLockInfo(info) {
|
|
20095
|
+
return JSON.stringify(info);
|
|
20096
|
+
}
|
|
20097
|
+
/** Parse a lock file's content; malformed content yields undefined. */
|
|
20098
|
+
function parseLockInfo(content) {
|
|
20099
|
+
try {
|
|
20100
|
+
const parsed = JSON.parse(content);
|
|
20101
|
+
if (typeof parsed.pid !== "number" || !Number.isSafeInteger(parsed.pid)) return void 0;
|
|
20102
|
+
if (typeof parsed.starttime !== "number" || !Number.isSafeInteger(parsed.starttime)) return void 0;
|
|
20103
|
+
if (typeof parsed.startedAt !== "number" || !Number.isSafeInteger(parsed.startedAt)) return void 0;
|
|
20104
|
+
return {
|
|
20105
|
+
pid: parsed.pid,
|
|
20106
|
+
starttime: parsed.starttime,
|
|
20107
|
+
startedAt: parsed.startedAt,
|
|
20108
|
+
...typeof parsed.profile === "string" ? { profile: parsed.profile } : {},
|
|
20109
|
+
...typeof parsed.tty === "string" ? { tty: parsed.tty } : {}
|
|
20110
|
+
};
|
|
20111
|
+
} catch {
|
|
20112
|
+
return;
|
|
20113
|
+
}
|
|
20114
|
+
}
|
|
20115
|
+
/** Derive the lock file path from the session artifact path. */
|
|
20116
|
+
function lockPathOf(artifactPath) {
|
|
20117
|
+
return `${artifactPath}.${LOCK_FILE_NAME}`;
|
|
20118
|
+
}
|
|
20119
|
+
/**
|
|
20120
|
+
* Try to acquire the lock with an atomic create. Returns the path when this
|
|
20121
|
+
* process owns it, undefined when another process holds it or the create
|
|
20122
|
+
* cannot happen. Never throws.
|
|
20123
|
+
*/
|
|
20124
|
+
function tryCreate(fs, lockPath, self) {
|
|
20125
|
+
try {
|
|
20126
|
+
fs.writeFileSync(lockPath, serializeLockInfo(self), {
|
|
20127
|
+
flag: "wx",
|
|
20128
|
+
mode: 384
|
|
20129
|
+
});
|
|
20130
|
+
return { created: true };
|
|
20131
|
+
} catch (error) {
|
|
20132
|
+
if (error?.code === "EEXIST") return {
|
|
20133
|
+
created: false,
|
|
20134
|
+
reason: "exists"
|
|
20135
|
+
};
|
|
20136
|
+
return {
|
|
20137
|
+
created: false,
|
|
20138
|
+
reason: "error",
|
|
20139
|
+
error
|
|
20140
|
+
};
|
|
20141
|
+
}
|
|
20142
|
+
}
|
|
20143
|
+
/**
|
|
20144
|
+
* Acquire the open-time lock for one session.
|
|
20145
|
+
*
|
|
20146
|
+
* Returns `acquired` (with an idempotent `release`), `held` (another live dsh
|
|
20147
|
+
* process owns it), `taken-over-stale` (a dead owner's lock was replaced),
|
|
20148
|
+
* `unverifiable` (the owner could not be probed — refuse), or `unavailable`
|
|
20149
|
+
* (this deployment cannot lock; callers should proceed as before, the guard
|
|
20150
|
+
* still protects the write path). Never throws.
|
|
20151
|
+
*/
|
|
20152
|
+
function acquireSessionLock(deps, session, self) {
|
|
20153
|
+
const { persistence, fs, proc } = deps;
|
|
20154
|
+
if (persistence === void 0) return {
|
|
20155
|
+
kind: "unavailable",
|
|
20156
|
+
reason: "no-persistence"
|
|
20157
|
+
};
|
|
20158
|
+
const artifact = persistence.locate({
|
|
20159
|
+
id: session.id,
|
|
20160
|
+
cwd: session.header?.cwd
|
|
20161
|
+
});
|
|
20162
|
+
if (artifact === void 0) return {
|
|
20163
|
+
kind: "unavailable",
|
|
20164
|
+
reason: "no-artifact"
|
|
20165
|
+
};
|
|
20166
|
+
const lockPath = lockPathOf(artifact.path);
|
|
20167
|
+
let tookOver = false;
|
|
20168
|
+
for (let attempt = 0;; attempt += 1) {
|
|
20169
|
+
const created = tryCreate(fs, lockPath, self);
|
|
20170
|
+
if (created.created) return tookOver ? {
|
|
20171
|
+
kind: "taken-over-stale",
|
|
20172
|
+
release: makeRelease(fs, lockPath, self)
|
|
20173
|
+
} : {
|
|
20174
|
+
kind: "acquired",
|
|
20175
|
+
release: makeRelease(fs, lockPath, self)
|
|
20176
|
+
};
|
|
20177
|
+
if (created.reason === "error") return {
|
|
20178
|
+
kind: "unavailable",
|
|
20179
|
+
reason: created.error?.code === "ENOENT" ? "no-lock-dir" : "write-error"
|
|
20180
|
+
};
|
|
20181
|
+
let owner;
|
|
20182
|
+
try {
|
|
20183
|
+
owner = parseLockInfo(fs.readFileSync(lockPath));
|
|
20184
|
+
} catch (error) {
|
|
20185
|
+
if (error?.code === "ENOENT") {
|
|
20186
|
+
if (attempt >= 2) return {
|
|
20187
|
+
kind: "unverifiable",
|
|
20188
|
+
owner
|
|
20189
|
+
};
|
|
20190
|
+
continue;
|
|
20191
|
+
}
|
|
20192
|
+
return {
|
|
20193
|
+
kind: "unverifiable",
|
|
20194
|
+
owner
|
|
20195
|
+
};
|
|
20196
|
+
}
|
|
20197
|
+
if (owner === void 0) return {
|
|
20198
|
+
kind: "unverifiable",
|
|
20199
|
+
owner
|
|
20200
|
+
};
|
|
20201
|
+
if (owner.pid === self.pid && owner.starttime === self.starttime) return {
|
|
20202
|
+
kind: "acquired",
|
|
20203
|
+
release: makeRelease(fs, lockPath, self)
|
|
20204
|
+
};
|
|
20205
|
+
const verdict = proc.probe(owner, self);
|
|
20206
|
+
if (verdict.kind === "alive") return {
|
|
20207
|
+
kind: "held",
|
|
20208
|
+
owner
|
|
20209
|
+
};
|
|
20210
|
+
if (verdict.kind === "unknown") return {
|
|
20211
|
+
kind: "unverifiable",
|
|
20212
|
+
owner
|
|
20213
|
+
};
|
|
20214
|
+
let removed = false;
|
|
20215
|
+
try {
|
|
20216
|
+
const current = parseLockInfo(fs.readFileSync(lockPath));
|
|
20217
|
+
if (current !== void 0 && current.pid === owner.pid && current.starttime === owner.starttime) {
|
|
20218
|
+
fs.unlinkSync(lockPath);
|
|
20219
|
+
removed = true;
|
|
20220
|
+
}
|
|
20221
|
+
} catch {}
|
|
20222
|
+
if (removed) tookOver = true;
|
|
20223
|
+
else if (attempt >= 2) return {
|
|
20224
|
+
kind: "held",
|
|
20225
|
+
owner
|
|
20226
|
+
};
|
|
20227
|
+
}
|
|
20228
|
+
}
|
|
20229
|
+
/**
|
|
20230
|
+
* An idempotent release that only removes the lock if it is still OURS.
|
|
20231
|
+
* Compare-and-delete: after ownership is lost (a concurrent takeover, manual
|
|
20232
|
+
* removal and recreation by another process), the exit-time release must not
|
|
20233
|
+
* delete the new owner's lock file.
|
|
20234
|
+
*/
|
|
20235
|
+
function makeRelease(fs, lockPath, self) {
|
|
20236
|
+
let released = false;
|
|
20237
|
+
return () => {
|
|
20238
|
+
if (released) return;
|
|
20239
|
+
released = true;
|
|
20240
|
+
try {
|
|
20241
|
+
const owner = parseLockInfo(fs.readFileSync(lockPath));
|
|
20242
|
+
if (owner !== void 0 && owner.pid === self.pid && owner.starttime === self.starttime) fs.unlinkSync(lockPath);
|
|
20243
|
+
} catch {}
|
|
20244
|
+
};
|
|
20245
|
+
}
|
|
20246
|
+
//#endregion
|
|
20247
|
+
//#region src/session-lock-proc.ts
|
|
20248
|
+
/**
|
|
20249
|
+
* Real `/proc`-based owner probe for the open-time session lock.
|
|
20250
|
+
*
|
|
20251
|
+
* Verifies that a recorded lock owner is the SAME live dsh invocation:
|
|
20252
|
+
* - the process exists (`/proc/<pid>/stat` readable),
|
|
20253
|
+
* - it is not a zombie (state `Z`),
|
|
20254
|
+
* - its starttime (stat field 22) matches the lock record — a reused pid
|
|
20255
|
+
* would carry a different starttime,
|
|
20256
|
+
* - its command line identifies a dsh process (the same family of tools the
|
|
20257
|
+
* lock speaks for).
|
|
20258
|
+
*
|
|
20259
|
+
* The probe never takes a lock over on doubt: any inability to verify
|
|
20260
|
+
* (missing /proc, permission errors, unparsable stat) reports `unknown` and
|
|
20261
|
+
* the caller refuses the open. An ENOENT stat, a zombie state, a starttime
|
|
20262
|
+
* mismatch, or a non-dsh command line is a definitive `stale`.
|
|
20263
|
+
*
|
|
20264
|
+
* Non-Linux platforms degrade: `process.kill(pid, 0)` checks existence, then
|
|
20265
|
+
* the command line is compared when `ps` is available; when it is not, the
|
|
20266
|
+
* probe reports `unknown` (conservative refusal) instead of guessing.
|
|
20267
|
+
* @module @xmoon76/dsh-pi-tui/session-lock-proc
|
|
20268
|
+
*/
|
|
20269
|
+
/** Parse `/proc/<pid>/stat`: state (field 3) and starttime (field 22). */
|
|
20270
|
+
function parseProcStat(content) {
|
|
20271
|
+
const close = content.lastIndexOf(")");
|
|
20272
|
+
if (close < 0) return void 0;
|
|
20273
|
+
const fields = content.slice(close + 2).trim().split(" ");
|
|
20274
|
+
if (fields.length < 20) return void 0;
|
|
20275
|
+
const state = fields[0] ?? "";
|
|
20276
|
+
const starttime = Number(fields[19]);
|
|
20277
|
+
if (!Number.isSafeInteger(starttime)) return void 0;
|
|
20278
|
+
return {
|
|
20279
|
+
state,
|
|
20280
|
+
starttime
|
|
20281
|
+
};
|
|
20282
|
+
}
|
|
20283
|
+
/** The set of command names this lock family recognizes as "our own tool". */
|
|
20284
|
+
function looksLikeDsh(cmdline) {
|
|
20285
|
+
if (cmdline === void 0) return false;
|
|
20286
|
+
return cmdline.split(/\s+/).some((arg) => {
|
|
20287
|
+
if (arg === "dsh") return true;
|
|
20288
|
+
const base = arg.split("/").pop() ?? "";
|
|
20289
|
+
return base === "dsh" || base === "dsh.exe";
|
|
20290
|
+
});
|
|
20291
|
+
}
|
|
20292
|
+
/** The real probe: Linux /proc, with a conservative non-Linux degradation. */
|
|
20293
|
+
function createProcProbe(options) {
|
|
20294
|
+
const platform = options?.platform ?? process.platform;
|
|
20295
|
+
const execFile = options?.execFile ?? execFileSync;
|
|
20296
|
+
const readFile = options?.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
20297
|
+
return { probe(owner, self) {
|
|
20298
|
+
if (owner.pid === self.pid && owner.starttime === self.starttime) return { kind: "alive" };
|
|
20299
|
+
if (platform === "linux" || platform === "android") {
|
|
20300
|
+
let statContent;
|
|
20301
|
+
try {
|
|
20302
|
+
statContent = readFile(`/proc/${owner.pid}/stat`);
|
|
20303
|
+
} catch (error) {
|
|
20304
|
+
if (error?.code === "ENOENT") return { kind: "stale" };
|
|
20305
|
+
return { kind: "unknown" };
|
|
20306
|
+
}
|
|
20307
|
+
const parsed = parseProcStat(statContent);
|
|
20308
|
+
if (parsed === void 0) return { kind: "unknown" };
|
|
20309
|
+
if (parsed.state === "Z") return { kind: "stale" };
|
|
20310
|
+
if (parsed.starttime !== owner.starttime) return { kind: "stale" };
|
|
20311
|
+
let cmdline;
|
|
20312
|
+
try {
|
|
20313
|
+
cmdline = readFile(`/proc/${owner.pid}/cmdline`).split("\0").filter((arg) => arg !== "").join(" ");
|
|
20314
|
+
} catch {
|
|
20315
|
+
return { kind: "unknown" };
|
|
20316
|
+
}
|
|
20317
|
+
if (cmdline !== void 0 && !looksLikeDsh(cmdline)) return { kind: "stale" };
|
|
20318
|
+
return { kind: "alive" };
|
|
20319
|
+
}
|
|
20320
|
+
try {
|
|
20321
|
+
process.kill(owner.pid, 0);
|
|
20322
|
+
} catch (error) {
|
|
20323
|
+
if (error?.code === "ESRCH") return { kind: "stale" };
|
|
20324
|
+
}
|
|
20325
|
+
try {
|
|
20326
|
+
const command = execFile("ps", [
|
|
20327
|
+
"-p",
|
|
20328
|
+
String(owner.pid),
|
|
20329
|
+
"-o",
|
|
20330
|
+
"command="
|
|
20331
|
+
], { encoding: "utf8" }).trim();
|
|
20332
|
+
if (command === "") return { kind: "stale" };
|
|
20333
|
+
return looksLikeDsh(command) ? { kind: "alive" } : { kind: "stale" };
|
|
20334
|
+
} catch {
|
|
20335
|
+
return { kind: "unknown" };
|
|
20336
|
+
}
|
|
20337
|
+
} };
|
|
20338
|
+
}
|
|
20339
|
+
//#endregion
|
|
19360
20340
|
//#region src/index.ts
|
|
19361
20341
|
/**
|
|
19362
20342
|
* @xmoon76/dsh-pi-tui — the bundle's runner plugin. Waits for the startup
|
|
@@ -19476,6 +20456,26 @@ function shouldSteerOnEnter(parsed, running, busyEnter, forceQueue) {
|
|
|
19476
20456
|
function shouldConsumeAdvertisedMiss(execution, wasAdvertised) {
|
|
19477
20457
|
return execution === void 0 && wasAdvertised;
|
|
19478
20458
|
}
|
|
20459
|
+
/**
|
|
20460
|
+
* Match the child a user is about to view against the unsettled subagent
|
|
20461
|
+
* calls (pure, exported for the headless suite). The child's durable label
|
|
20462
|
+
* is the delegation's `description`; duplicate descriptions take the MOST
|
|
20463
|
+
* RECENT call (the one the user is most likely watching), an empty/absent
|
|
20464
|
+
* label falls back to a LONE pending call, and no match disables the
|
|
20465
|
+
* auto-pop (the user exits the viewer with Esc as before — never a wrong
|
|
20466
|
+
* pop). Mutates `pending` by removing the matched call.
|
|
20467
|
+
* @param pending - the unsettled calls, in order (oldest first).
|
|
20468
|
+
* @param label - the child's durable label; '' or undefined = no description.
|
|
20469
|
+
* @returns the matched call, or undefined when nothing matches.
|
|
20470
|
+
*/
|
|
20471
|
+
function matchPendingSubagentCall(pending, label) {
|
|
20472
|
+
if (label !== void 0 && label !== "") {
|
|
20473
|
+
for (let index = pending.length - 1; index >= 0; index -= 1) if (pending[index].description === label) return pending.splice(index, 1)[0];
|
|
20474
|
+
if (pending.length === 1) return pending.splice(0, 1)[0];
|
|
20475
|
+
return;
|
|
20476
|
+
}
|
|
20477
|
+
if (pending.length === 1) return pending.splice(0, 1)[0];
|
|
20478
|
+
}
|
|
19479
20479
|
async function resolveInitialCatalog(options) {
|
|
19480
20480
|
const { liveAgent, presetId, signal, ctx, diag } = options;
|
|
19481
20481
|
if (liveAgent !== void 0) try {
|
|
@@ -19539,6 +20539,86 @@ function subagentJobViewHint(status, detail) {
|
|
|
19539
20539
|
].join("\n");
|
|
19540
20540
|
}
|
|
19541
20541
|
/**
|
|
20542
|
+
* Whether a plain submitted draft is the quit word: exactly `exit` (trimmed,
|
|
20543
|
+
* lowercase). The runner intercepts this BEFORE any session creation or
|
|
20544
|
+
* submission (shell muscle memory); anything else — `exit!`, `Exit`, or a
|
|
20545
|
+
* draft with a recalled entry still in it — is an ordinary message.
|
|
20546
|
+
* @param text - the submitted draft.
|
|
20547
|
+
*/
|
|
20548
|
+
function isPlainExitPrompt(text) {
|
|
20549
|
+
return text.trim() === "exit";
|
|
20550
|
+
}
|
|
20551
|
+
/**
|
|
20552
|
+
* Whether an inbox message is a BACKGROUND-SUBAGENT settlement notice — the
|
|
20553
|
+
* runtime's account of a child ending, not steerable user input. Two dsh
|
|
20554
|
+
* producers push these into the parent's inbox:
|
|
20555
|
+
* - continuable children: `source.kind === 'subagent-settled'` (the
|
|
20556
|
+
* continuation manager's settlement notice);
|
|
20557
|
+
* - one-shot background subagent jobs: tool-jobs completion notices whose
|
|
20558
|
+
* summary starts with the job kind (`subagent <label> [status: …]`).
|
|
20559
|
+
* The queue pane mirrors the inbox, but these belong to the task browser
|
|
20560
|
+
* (terminal job rows / inactive child rows), so the mirror drops them and
|
|
20561
|
+
* only failures surface as a transient error notify.
|
|
20562
|
+
* @param source - the message source projection, or undefined for a plain row.
|
|
20563
|
+
*/
|
|
20564
|
+
function isSubagentSettlementNotice(source) {
|
|
20565
|
+
if (source === void 0 || source.form !== "notice") return false;
|
|
20566
|
+
if (source.kind === "subagent-settled") return true;
|
|
20567
|
+
return source.kind === "plugin" && typeof source.summary === "string" && source.summary.startsWith("subagent ");
|
|
20568
|
+
}
|
|
20569
|
+
/**
|
|
20570
|
+
* Whether a subagent settlement notice reports FAILURE, classified on the
|
|
20571
|
+
* producers' own deterministic wording:
|
|
20572
|
+
* - `subagent-settled` summaries: "finished and will do no further work"
|
|
20573
|
+
* is the only success wording; aborted / max-tokens / refusal / error /
|
|
20574
|
+
* unknown endings all fail;
|
|
20575
|
+
* - tool-jobs subagent summaries carry the terminal status line, whose
|
|
20576
|
+
* failure statuses are `failed` and `killed` (dsh JobStatus).
|
|
20577
|
+
* A notice that cannot be classified is treated as success (silent).
|
|
20578
|
+
* @param source - the message source projection.
|
|
20579
|
+
*/
|
|
20580
|
+
function subagentNoticeIsFailure(source) {
|
|
20581
|
+
if (source === void 0 || source.form !== "notice") return false;
|
|
20582
|
+
if (source.kind === "subagent-settled") return typeof source.summary === "string" && !source.summary.includes("finished and");
|
|
20583
|
+
if (source.kind === "plugin" && typeof source.summary === "string" && source.summary.startsWith("subagent ")) return /\[status: (failed|killed)[,\]]/.test(source.summary);
|
|
20584
|
+
return false;
|
|
20585
|
+
}
|
|
20586
|
+
/**
|
|
20587
|
+
* Build the queue-pane rows for one inbox batch, dropping background-subagent
|
|
20588
|
+
* settlement notices (the task browser is their surface) and reporting which
|
|
20589
|
+
* FAILED settlements should notify. Pure and injectable so the filter +
|
|
20590
|
+
* once-notify semantics are testable without the agent.
|
|
20591
|
+
* @param messages - one inbox batch (next-turn or next-step), in order.
|
|
20592
|
+
* @param mode - the delivery mode for surviving rows.
|
|
20593
|
+
* @param notified - the notify-once guard; failed notices already in it are
|
|
20594
|
+
* skipped, and a newly-reported id is ADDED here so a re-render can never
|
|
20595
|
+
* double-notify.
|
|
20596
|
+
*/
|
|
20597
|
+
function foldQueueRows(messages, mode, notified) {
|
|
20598
|
+
const rows = [];
|
|
20599
|
+
const failures = [];
|
|
20600
|
+
for (const message of messages) {
|
|
20601
|
+
const source = message.source;
|
|
20602
|
+
if (isSubagentSettlementNotice(source)) {
|
|
20603
|
+
if (source?.summary !== void 0 && subagentNoticeIsFailure(source) && !notified.has(message.id)) {
|
|
20604
|
+
notified.add(message.id);
|
|
20605
|
+
failures.push(source.summary);
|
|
20606
|
+
}
|
|
20607
|
+
continue;
|
|
20608
|
+
}
|
|
20609
|
+
rows.push({
|
|
20610
|
+
id: message.id,
|
|
20611
|
+
text: textOf(message.content),
|
|
20612
|
+
mode,
|
|
20613
|
+
notice: source?.form === "notice"
|
|
20614
|
+
});
|
|
20615
|
+
}
|
|
20616
|
+
return {
|
|
20617
|
+
rows,
|
|
20618
|
+
failures
|
|
20619
|
+
};
|
|
20620
|
+
}
|
|
20621
|
+
/**
|
|
19542
20622
|
* The installed dsh version (e.g. `0.1.0-rc.6`), resolved from the launcher's
|
|
19543
20623
|
* real path: `process.argv[1]` is the `dsh` bin, whose realpath walks up to
|
|
19544
20624
|
* the `@deepseek-ai/dsh/package.json` that owns it. The version the welcome
|
|
@@ -19664,6 +20744,51 @@ function resumeCommand(profile, sessionId) {
|
|
|
19664
20744
|
if (id === "") return void 0;
|
|
19665
20745
|
return `dsh --profile ${profile} --session ${id}`;
|
|
19666
20746
|
}
|
|
20747
|
+
/** The lock identity of THIS process: pid + boot-relative starttime. */
|
|
20748
|
+
let cachedSelfLockInfo;
|
|
20749
|
+
function selfLockInfo() {
|
|
20750
|
+
if (cachedSelfLockInfo !== void 0) return cachedSelfLockInfo;
|
|
20751
|
+
const info = {
|
|
20752
|
+
pid: process.pid,
|
|
20753
|
+
starttime: 0,
|
|
20754
|
+
startedAt: Date.now(),
|
|
20755
|
+
profile: runningProfile()
|
|
20756
|
+
};
|
|
20757
|
+
try {
|
|
20758
|
+
info.starttime = parseProcStat(readFileSync(`/proc/self/stat`, "utf8"))?.starttime ?? 0;
|
|
20759
|
+
} catch {}
|
|
20760
|
+
cachedSelfLockInfo = info;
|
|
20761
|
+
return info;
|
|
20762
|
+
}
|
|
20763
|
+
/** Human-readable lock owner description for the refusal notice. */
|
|
20764
|
+
function lockOwnerText(owner) {
|
|
20765
|
+
const parts = [`pid ${owner.pid}`];
|
|
20766
|
+
if (owner.profile !== void 0) parts.push(`profile ${owner.profile}`);
|
|
20767
|
+
if (owner.startedAt > 0) {
|
|
20768
|
+
const started = new Date(owner.startedAt);
|
|
20769
|
+
parts.push(`started ${started.toLocaleTimeString()}`);
|
|
20770
|
+
}
|
|
20771
|
+
if (owner.tty !== void 0) parts.push(`tty ${owner.tty}`);
|
|
20772
|
+
return parts.join(", ");
|
|
20773
|
+
}
|
|
20774
|
+
/** The refusal notice for a session held by another live dsh process. */
|
|
20775
|
+
function lockHeldNotice(sessionId, owner) {
|
|
20776
|
+
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.`;
|
|
20777
|
+
}
|
|
20778
|
+
/**
|
|
20779
|
+
* A launch-time open-lock refusal: the requested session is held by another
|
|
20780
|
+
* live dsh process (or its lock cannot be verified). Unlike a stale/missing
|
|
20781
|
+
* session id, this is NOT recoverable by falling back to a fresh session —
|
|
20782
|
+
* the user asked for a specific session and must resolve the holder first.
|
|
20783
|
+
* Thrown from the resume path and re-thrown past the resume catch so the
|
|
20784
|
+
* runner exits with the refusal as the error message.
|
|
20785
|
+
*/
|
|
20786
|
+
var SessionLockRefusedError = class extends Error {
|
|
20787
|
+
constructor(message) {
|
|
20788
|
+
super(message);
|
|
20789
|
+
this.name = "SessionLockRefusedError";
|
|
20790
|
+
}
|
|
20791
|
+
};
|
|
19667
20792
|
/**
|
|
19668
20793
|
* The active goal badge text from the session log, or undefined. The latest
|
|
19669
20794
|
* `goal/change` wins; a clear or completed goal hides the badge.
|
|
@@ -19853,9 +20978,81 @@ function apply(ctx, config) {
|
|
|
19853
20978
|
};
|
|
19854
20979
|
}
|
|
19855
20980
|
};
|
|
20981
|
+
let heldLock;
|
|
20982
|
+
const lockProc = createProcProbe();
|
|
20983
|
+
/** Try to take the open-time lock for a session. Returns the refusal text
|
|
20984
|
+
* when the session is held by another live dsh process (or unverifiable);
|
|
20985
|
+
* undefined when the lock is now ours (or the deployment cannot lock —
|
|
20986
|
+
* proceed as before). Never throws. */
|
|
20987
|
+
const acquireOpenLock = (sessionId, header) => {
|
|
20988
|
+
const outcome = acquireSessionLock({
|
|
20989
|
+
persistence: ctx.get("sessionPersistence"),
|
|
20990
|
+
fs: {
|
|
20991
|
+
readFileSync: (path) => readFileSync(path, "utf8"),
|
|
20992
|
+
writeFileSync: (path, content, options) => writeFileSync(path, content, options),
|
|
20993
|
+
unlinkSync
|
|
20994
|
+
},
|
|
20995
|
+
proc: lockProc
|
|
20996
|
+
}, {
|
|
20997
|
+
id: sessionId,
|
|
20998
|
+
header
|
|
20999
|
+
}, selfLockInfo());
|
|
21000
|
+
switch (outcome.kind) {
|
|
21001
|
+
case "acquired":
|
|
21002
|
+
case "taken-over-stale":
|
|
21003
|
+
heldLock = {
|
|
21004
|
+
sessionId,
|
|
21005
|
+
release: outcome.release
|
|
21006
|
+
};
|
|
21007
|
+
if (outcome.kind === "taken-over-stale") diag.warn("session lock stale taken over", { session: sessionId });
|
|
21008
|
+
return;
|
|
21009
|
+
case "held":
|
|
21010
|
+
diag.warn("session lock held", {
|
|
21011
|
+
session: sessionId,
|
|
21012
|
+
pid: outcome.owner.pid
|
|
21013
|
+
});
|
|
21014
|
+
return lockHeldNotice(sessionId, outcome.owner);
|
|
21015
|
+
case "unverifiable":
|
|
21016
|
+
diag.warn("session lock unverifiable", {
|
|
21017
|
+
session: sessionId,
|
|
21018
|
+
pid: outcome.owner?.pid
|
|
21019
|
+
});
|
|
21020
|
+
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.`;
|
|
21021
|
+
case "unavailable": return;
|
|
21022
|
+
}
|
|
21023
|
+
};
|
|
21024
|
+
/** Release the open-time lock for a session (idempotent). */
|
|
21025
|
+
const releaseOpenLock = (sessionId) => {
|
|
21026
|
+
if (heldLock === void 0 || heldLock.sessionId !== sessionId) return;
|
|
21027
|
+
heldLock.release();
|
|
21028
|
+
heldLock = void 0;
|
|
21029
|
+
};
|
|
21030
|
+
/** Re-take the lock for the still-live current session after a failed
|
|
21031
|
+
* switch (refusal, resume failure, or an internal swap failure). The
|
|
21032
|
+
* lock was released before the target acquire, so another process may
|
|
21033
|
+
* have taken it in the window — the re-take result is checked and a
|
|
21034
|
+
* failed re-take surfaces as a notice (the write-path guard remains the
|
|
21035
|
+
* backstop, but the user must know the session is no longer locked by
|
|
21036
|
+
* this TUI). */
|
|
21037
|
+
const reacquireCurrentLock = (from, fromHeader) => {
|
|
21038
|
+
if (from === void 0) return;
|
|
21039
|
+
const refusal = acquireOpenLock(from, fromHeader ?? liveAgent?.session.header);
|
|
21040
|
+
if (refusal !== void 0) {
|
|
21041
|
+
const message = `the current session ${from} is no longer locked by this TUI (${refusal})`;
|
|
21042
|
+
ctx.logger.warn(`tui-runner: ${message}`);
|
|
21043
|
+
diag.warn("session lock lost on failed switch", { session: from });
|
|
21044
|
+
app?.notify(message, "error");
|
|
21045
|
+
}
|
|
21046
|
+
};
|
|
19856
21047
|
let resumeFailure;
|
|
19857
21048
|
let handle;
|
|
19858
21049
|
if (sessionId !== void 0) try {
|
|
21050
|
+
let lockHeader;
|
|
21051
|
+
try {
|
|
21052
|
+
lockHeader = (await ctx.get("sessionPersistence")?.list())?.find((candidate) => candidate.id === sessionId);
|
|
21053
|
+
} catch {}
|
|
21054
|
+
const lockRefusal = acquireOpenLock(sessionId, lockHeader);
|
|
21055
|
+
if (lockRefusal !== void 0) throw new SessionLockRefusedError(lockRefusal);
|
|
19859
21056
|
const recorded = await recordedPreset(ctx, sessionId);
|
|
19860
21057
|
const composition = await compose(recorded);
|
|
19861
21058
|
handle = await agents.resume({
|
|
@@ -19887,6 +21084,7 @@ function apply(ctx, config) {
|
|
|
19887
21084
|
});
|
|
19888
21085
|
}
|
|
19889
21086
|
} catch (error) {
|
|
21087
|
+
if (error instanceof SessionLockRefusedError) throw error;
|
|
19890
21088
|
const message = safeErrorMessage(error);
|
|
19891
21089
|
ctx.logger.warn(`tui-runner: resume ${sessionId} failed: ${message}`);
|
|
19892
21090
|
diag.error("resume failed", {
|
|
@@ -19894,6 +21092,7 @@ function apply(ctx, config) {
|
|
|
19894
21092
|
error: message
|
|
19895
21093
|
});
|
|
19896
21094
|
resumeFailure = `session ${sessionId} could not be resumed; started a fresh session`;
|
|
21095
|
+
releaseOpenLock(sessionId);
|
|
19897
21096
|
const launched = await launchComposition();
|
|
19898
21097
|
if (launched.failure !== void 0) resumeFailure = launched.failure;
|
|
19899
21098
|
handle = await agents.create({
|
|
@@ -19905,6 +21104,7 @@ function apply(ctx, config) {
|
|
|
19905
21104
|
agentOptions,
|
|
19906
21105
|
setup: launched.composition.setup
|
|
19907
21106
|
});
|
|
21107
|
+
acquireOpenLock(handle.agent.session.id, handle.agent.session.header);
|
|
19908
21108
|
}
|
|
19909
21109
|
let liveHandle = handle;
|
|
19910
21110
|
let liveAgent = handle?.agent;
|
|
@@ -20048,6 +21248,7 @@ function apply(ctx, config) {
|
|
|
20048
21248
|
/** Swap the live agent to a new handle, repainting for its session. */
|
|
20049
21249
|
const swapTo = async (next) => {
|
|
20050
21250
|
const from = liveAgent?.session.id;
|
|
21251
|
+
const fromHeader = liveAgent?.session.header;
|
|
20051
21252
|
try {
|
|
20052
21253
|
if (liveAgent !== void 0) {
|
|
20053
21254
|
await sessions.flush(liveAgent.session);
|
|
@@ -20062,8 +21263,17 @@ function apply(ctx, config) {
|
|
|
20062
21263
|
from,
|
|
20063
21264
|
error: message
|
|
20064
21265
|
});
|
|
21266
|
+
const repair = swapFailureLockRepair(heldLock, from);
|
|
21267
|
+
if (repair.release !== void 0) releaseOpenLock(repair.release);
|
|
21268
|
+
if (repair.reacquire !== void 0) reacquireCurrentLock(repair.reacquire, fromHeader);
|
|
20065
21269
|
return `swap failed: ${message}`;
|
|
20066
21270
|
}
|
|
21271
|
+
if (from !== void 0) releaseOpenLock(from);
|
|
21272
|
+
const swapLockRefusal = acquireOpenLock(next.agent.session.id, next.agent.session.header);
|
|
21273
|
+
if (swapLockRefusal !== void 0) {
|
|
21274
|
+
ctx.logger.warn(`tui-runner: lock refused on swap to ${next.agent.session.id}: ${swapLockRefusal}`);
|
|
21275
|
+
diag.warn("session lock refused on swap", { session: next.agent.session.id });
|
|
21276
|
+
}
|
|
20067
21277
|
guardState = freshGuardState();
|
|
20068
21278
|
guardToken = void 0;
|
|
20069
21279
|
bumpSessionGeneration();
|
|
@@ -20080,6 +21290,19 @@ function apply(ctx, config) {
|
|
|
20080
21290
|
* string so callers' `.then(error => ...)` need no rejection path. */
|
|
20081
21291
|
const switchSession = async (sessionId) => {
|
|
20082
21292
|
try {
|
|
21293
|
+
const from = liveAgent?.session.id;
|
|
21294
|
+
if (from !== void 0) releaseOpenLock(from);
|
|
21295
|
+
let lockHeader;
|
|
21296
|
+
try {
|
|
21297
|
+
lockHeader = (await ctx.get("sessionPersistence")?.list())?.find((candidate) => candidate.id === sessionId);
|
|
21298
|
+
} catch {}
|
|
21299
|
+
const lockRefusal = acquireOpenLock(sessionId, lockHeader);
|
|
21300
|
+
if (lockRefusal !== void 0) {
|
|
21301
|
+
ctx.logger.warn(`tui-runner: switch to ${sessionId} refused: ${lockRefusal}`);
|
|
21302
|
+
diag.warn("session lock held on switch", { session: sessionId });
|
|
21303
|
+
reacquireCurrentLock(from);
|
|
21304
|
+
return lockRefusal;
|
|
21305
|
+
}
|
|
20083
21306
|
const composition = await compose(await recordedPreset(ctx, sessionId));
|
|
20084
21307
|
const next = await agents.resume({
|
|
20085
21308
|
resumeSessionId: SessionId(sessionId),
|
|
@@ -20097,6 +21320,8 @@ function apply(ctx, config) {
|
|
|
20097
21320
|
session: sessionId,
|
|
20098
21321
|
error: message
|
|
20099
21322
|
});
|
|
21323
|
+
releaseOpenLock(sessionId);
|
|
21324
|
+
reacquireCurrentLock(liveAgent?.session.id);
|
|
20100
21325
|
return `switch failed: ${message}`;
|
|
20101
21326
|
}
|
|
20102
21327
|
};
|
|
@@ -20154,6 +21379,10 @@ function apply(ctx, config) {
|
|
|
20154
21379
|
if (cleanedUp) return;
|
|
20155
21380
|
cleanedUp = true;
|
|
20156
21381
|
guardToken = void 0;
|
|
21382
|
+
if (heldLock !== void 0) {
|
|
21383
|
+
heldLock.release();
|
|
21384
|
+
heldLock = void 0;
|
|
21385
|
+
}
|
|
20157
21386
|
lifecycleController.abort();
|
|
20158
21387
|
catalogCoordinator?.dispose();
|
|
20159
21388
|
localShellController?.abort();
|
|
@@ -20368,6 +21597,8 @@ function apply(ctx, config) {
|
|
|
20368
21597
|
};
|
|
20369
21598
|
let repaintTimer;
|
|
20370
21599
|
let viewing;
|
|
21600
|
+
const pendingSubagentCalls = [];
|
|
21601
|
+
const viewCallToChild = /* @__PURE__ */ new Map();
|
|
20371
21602
|
const activeFolder = () => viewing?.folder ?? folder;
|
|
20372
21603
|
const paintNow = () => {
|
|
20373
21604
|
if (repaintTimer !== void 0) {
|
|
@@ -20390,6 +21621,8 @@ function apply(ctx, config) {
|
|
|
20390
21621
|
const bumpSessionGeneration = () => {
|
|
20391
21622
|
sessionGeneration += 1;
|
|
20392
21623
|
callArgs.clear();
|
|
21624
|
+
pendingSubagentCalls.length = 0;
|
|
21625
|
+
viewCallToChild.clear();
|
|
20393
21626
|
searchMatches = [];
|
|
20394
21627
|
searchCurrent = -1;
|
|
20395
21628
|
app.setSearchResult(0, 0);
|
|
@@ -20417,6 +21650,8 @@ function apply(ctx, config) {
|
|
|
20417
21650
|
childFolder.apply((await persistence.inspect(childId)).events);
|
|
20418
21651
|
} catch {}
|
|
20419
21652
|
}
|
|
21653
|
+
const matched = matchPendingSubagentCall(pendingSubagentCalls, label);
|
|
21654
|
+
if (matched !== void 0) viewCallToChild.set(matched.callId, childId);
|
|
20420
21655
|
viewing = {
|
|
20421
21656
|
id: childId,
|
|
20422
21657
|
folder: childFolder
|
|
@@ -20435,6 +21670,7 @@ function apply(ctx, config) {
|
|
|
20435
21670
|
app.clearNotify();
|
|
20436
21671
|
app.setViewerMode(void 0);
|
|
20437
21672
|
repaint(app, folder);
|
|
21673
|
+
app.scrollToBottom();
|
|
20438
21674
|
refreshStatus();
|
|
20439
21675
|
return true;
|
|
20440
21676
|
};
|
|
@@ -20626,6 +21862,12 @@ function apply(ctx, config) {
|
|
|
20626
21862
|
});
|
|
20627
21863
|
};
|
|
20628
21864
|
/**
|
|
21865
|
+
* The newest input-history entry this process persisted (kimi's
|
|
21866
|
+
* `lastHistoryContent` analogue): consecutive repeats are skipped per
|
|
21867
|
+
* window, exactly like shell history.
|
|
21868
|
+
*/
|
|
21869
|
+
let lastHistoryContent;
|
|
21870
|
+
/**
|
|
20629
21871
|
* Dispatch one user submission end to end: the viewer guard, the input-
|
|
20630
21872
|
* history persistence, `!` local shells, sessionless commands, the
|
|
20631
21873
|
* busy-Enter preference, and the session dispatch. The Ctrl+Enter
|
|
@@ -20635,22 +21877,23 @@ function apply(ctx, config) {
|
|
|
20635
21877
|
* @param forceQueue - the chord: never steer, queue instead.
|
|
20636
21878
|
*/
|
|
20637
21879
|
const dispatchUserInput = (text, forceQueue = false) => {
|
|
21880
|
+
if (isPlainExitPrompt(text)) {
|
|
21881
|
+
requestExit();
|
|
21882
|
+
return;
|
|
21883
|
+
}
|
|
20638
21884
|
if (viewing !== void 0) {
|
|
20639
21885
|
app.setEditorText(mergeDraft(app.getDraft(), text));
|
|
20640
21886
|
app.notify("viewing a subagent — Esc returns before submitting", "info");
|
|
20641
21887
|
return;
|
|
20642
21888
|
}
|
|
20643
21889
|
app.clearSettledLocalMessages();
|
|
20644
|
-
const
|
|
20645
|
-
if (
|
|
20646
|
-
const
|
|
20647
|
-
|
|
20648
|
-
|
|
20649
|
-
|
|
20650
|
-
|
|
20651
|
-
[sessionCwd()]: history
|
|
20652
|
-
}
|
|
20653
|
-
}), {
|
|
21890
|
+
const trimmed = text.trim();
|
|
21891
|
+
if (trimmed !== "" && trimmed !== lastHistoryContent) {
|
|
21892
|
+
const file = historyFilePath(dshHome(process.env), sessionCwd());
|
|
21893
|
+
runDetached("input history write", () => {
|
|
21894
|
+
appendHistoryLine(file, trimmed, lastHistoryContent);
|
|
21895
|
+
lastHistoryContent = trimmed;
|
|
21896
|
+
}, {
|
|
20654
21897
|
diag,
|
|
20655
21898
|
notify: (message) => app.notify(message, "error"),
|
|
20656
21899
|
recoverable: () => true
|
|
@@ -20788,7 +22031,6 @@ function apply(ctx, config) {
|
|
|
20788
22031
|
if (jobs !== void 0) try {
|
|
20789
22032
|
jobSnapshots = jobs.list(liveAgent);
|
|
20790
22033
|
} catch {}
|
|
20791
|
-
const now = Date.now();
|
|
20792
22034
|
let rows = buildTaskRows(jobSnapshots, []);
|
|
20793
22035
|
const selectRow = (value) => {
|
|
20794
22036
|
const row = rows.find((candidate) => candidate.value === value);
|
|
@@ -20803,16 +22045,23 @@ function apply(ctx, config) {
|
|
|
20803
22045
|
}
|
|
20804
22046
|
openJobView(row.jobId);
|
|
20805
22047
|
};
|
|
20806
|
-
const
|
|
22048
|
+
const taskPanelItems = (target) => target.map((row) => row.kind === "job" ? {
|
|
20807
22049
|
value: row.value,
|
|
20808
22050
|
label: taskRowLabel(row),
|
|
20809
|
-
|
|
22051
|
+
status: row.status,
|
|
22052
|
+
detail: row.detail,
|
|
22053
|
+
startedAt: row.startedAt,
|
|
20810
22054
|
group: rowGroup(row)
|
|
20811
|
-
}
|
|
20812
|
-
|
|
22055
|
+
} : {
|
|
22056
|
+
value: row.value,
|
|
22057
|
+
label: taskRowLabel(row),
|
|
22058
|
+
status: row.activity,
|
|
22059
|
+
detail: row.hasChildren ? "has children" : void 0,
|
|
22060
|
+
group: rowGroup(row)
|
|
22061
|
+
});
|
|
22062
|
+
const handle = app.openTaskBrowser(taskPanelItems(rows), selectRow, () => {}, {
|
|
20813
22063
|
header: "tasks · subagents",
|
|
20814
|
-
enableSearch: true
|
|
20815
|
-
showHint: true
|
|
22064
|
+
enableSearch: true
|
|
20816
22065
|
});
|
|
20817
22066
|
if (subagents !== void 0) {
|
|
20818
22067
|
const sessionId = liveAgent.session.id;
|
|
@@ -20823,7 +22072,7 @@ function apply(ctx, config) {
|
|
|
20823
22072
|
onResult: (entries) => {
|
|
20824
22073
|
if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
|
|
20825
22074
|
rows = buildTaskRows(jobSnapshots, entries);
|
|
20826
|
-
handle.setItems(
|
|
22075
|
+
handle.setItems(taskPanelItems(rows));
|
|
20827
22076
|
}
|
|
20828
22077
|
});
|
|
20829
22078
|
}
|
|
@@ -20836,14 +22085,12 @@ function apply(ctx, config) {
|
|
|
20836
22085
|
theme: z.string(),
|
|
20837
22086
|
footer: z.string(),
|
|
20838
22087
|
fullscreen: z.string(),
|
|
20839
|
-
busyEnter: z.string()
|
|
20840
|
-
history: z.dict(z.array(z.string()))
|
|
22088
|
+
busyEnter: z.string()
|
|
20841
22089
|
}), { base: {
|
|
20842
22090
|
theme: "auto",
|
|
20843
22091
|
footer: "full",
|
|
20844
22092
|
fullscreen: "on",
|
|
20845
|
-
busyEnter: "queue"
|
|
20846
|
-
history: {}
|
|
22093
|
+
busyEnter: "queue"
|
|
20847
22094
|
} });
|
|
20848
22095
|
if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
|
|
20849
22096
|
const storedTheme = tuiSettings?.get().theme;
|
|
@@ -20862,6 +22109,39 @@ function apply(ctx, config) {
|
|
|
20862
22109
|
app.trackTerminalTheme(false);
|
|
20863
22110
|
}
|
|
20864
22111
|
if (tuiSettings?.get().footer === "compact") app.setFooterPreset("compact");
|
|
22112
|
+
let legacyHistory;
|
|
22113
|
+
try {
|
|
22114
|
+
const value = ((ctx.get("settings")?.describe().find((d) => d.ns === settingsNamespace("dsh-pi-tui")))?.user)?.history;
|
|
22115
|
+
if (typeof value === "object" && value !== null) legacyHistory = value;
|
|
22116
|
+
} catch {}
|
|
22117
|
+
if (legacyHistory !== void 0) {
|
|
22118
|
+
const home = dshHome(process.env);
|
|
22119
|
+
for (const [cwd, entries] of Object.entries(legacyHistory)) {
|
|
22120
|
+
if (!Array.isArray(entries) || entries.length === 0) continue;
|
|
22121
|
+
const file = historyFilePath(home, cwd);
|
|
22122
|
+
if (existsSync(file)) continue;
|
|
22123
|
+
for (const entry of entries.slice().reverse()) try {
|
|
22124
|
+
appendHistoryLine(file, entry, void 0);
|
|
22125
|
+
} catch {}
|
|
22126
|
+
}
|
|
22127
|
+
if (tuiSettings !== void 0) {
|
|
22128
|
+
if (Object.entries(legacyHistory).every(([cwd, entries]) => {
|
|
22129
|
+
if (!Array.isArray(entries) || entries.length === 0) return true;
|
|
22130
|
+
return existsSync(historyFilePath(home, cwd));
|
|
22131
|
+
})) runDetached("settings history cleanup", () => {
|
|
22132
|
+
const doc = { ...tuiSettings.get() };
|
|
22133
|
+
delete doc.history;
|
|
22134
|
+
tuiSettings.replace(doc);
|
|
22135
|
+
}, {
|
|
22136
|
+
diag,
|
|
22137
|
+
notify: (message) => app.notify(message, "error"),
|
|
22138
|
+
recoverable: () => true
|
|
22139
|
+
});
|
|
22140
|
+
}
|
|
22141
|
+
}
|
|
22142
|
+
const bootHistoryEntries = loadHistoryFile(historyFilePath(dshHome(process.env), cwd));
|
|
22143
|
+
lastHistoryContent = bootHistoryEntries.at(-1);
|
|
22144
|
+
app.resetInputHistory([...bootHistoryEntries].reverse());
|
|
20865
22145
|
refreshStatus();
|
|
20866
22146
|
let refreshTasks = () => {};
|
|
20867
22147
|
let refreshAgents = () => {};
|
|
@@ -20977,23 +22257,16 @@ function apply(ctx, config) {
|
|
|
20977
22257
|
const jobStatusHint = (status, detail) => {
|
|
20978
22258
|
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})`}`}`;
|
|
20979
22259
|
};
|
|
22260
|
+
const notifiedSubagentNotices = /* @__PURE__ */ new Set();
|
|
20980
22261
|
const refreshQueue = () => {
|
|
20981
22262
|
if (liveAgent === void 0) {
|
|
20982
22263
|
app.setQueueItems([]);
|
|
20983
22264
|
return;
|
|
20984
22265
|
}
|
|
20985
|
-
const
|
|
20986
|
-
|
|
20987
|
-
|
|
20988
|
-
|
|
20989
|
-
notice: message.source?.form === "notice"
|
|
20990
|
-
})), ...liveAgent.inbox.nextStep.map((message) => ({
|
|
20991
|
-
id: message.id,
|
|
20992
|
-
text: textOf(message.content),
|
|
20993
|
-
mode: "steer",
|
|
20994
|
-
notice: message.source?.form === "notice"
|
|
20995
|
-
}))];
|
|
20996
|
-
app.setQueueItems(queue);
|
|
22266
|
+
const turn = foldQueueRows(liveAgent.inbox.nextTurn, "followup", notifiedSubagentNotices);
|
|
22267
|
+
const step = foldQueueRows(liveAgent.inbox.nextStep, "steer", notifiedSubagentNotices);
|
|
22268
|
+
for (const summary of [...turn.failures, ...step.failures]) app.notify(summary, "error");
|
|
22269
|
+
app.setQueueItems([...turn.rows, ...step.rows]);
|
|
20997
22270
|
};
|
|
20998
22271
|
refreshQueue();
|
|
20999
22272
|
/**
|
|
@@ -21012,12 +22285,16 @@ function apply(ctx, config) {
|
|
|
21012
22285
|
app.setSessionTitle(foldSessionTitle(agent.session.events)?.title);
|
|
21013
22286
|
app.clearLocalMessages();
|
|
21014
22287
|
app.clearNotify();
|
|
22288
|
+
notifiedSubagentNotices.clear();
|
|
21015
22289
|
repaint(app, folder);
|
|
21016
22290
|
refreshStatus();
|
|
21017
22291
|
refreshQueue();
|
|
21018
22292
|
refreshTasks();
|
|
21019
22293
|
refreshAgents();
|
|
21020
|
-
|
|
22294
|
+
const historyCwd = sessionCwd();
|
|
22295
|
+
const historyEntries = loadHistoryFile(historyFilePath(dshHome(process.env), historyCwd));
|
|
22296
|
+
lastHistoryContent = historyEntries.at(-1);
|
|
22297
|
+
app.resetInputHistory([...historyEntries].reverse());
|
|
21021
22298
|
setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())} · ${agent.session.id}`);
|
|
21022
22299
|
updateWelcomeCard();
|
|
21023
22300
|
registerCommands({
|
|
@@ -21049,6 +22326,7 @@ function apply(ctx, config) {
|
|
|
21049
22326
|
});
|
|
21050
22327
|
liveHandle = created;
|
|
21051
22328
|
liveAgent = created.agent;
|
|
22329
|
+
acquireOpenLock(liveAgent.session.id, liveAgent.session.header);
|
|
21052
22330
|
await liveAgent.whenIdle();
|
|
21053
22331
|
bumpSessionGeneration();
|
|
21054
22332
|
await initLiveSession(liveAgent);
|
|
@@ -21073,6 +22351,7 @@ function apply(ctx, config) {
|
|
|
21073
22351
|
});
|
|
21074
22352
|
liveHandle = created;
|
|
21075
22353
|
liveAgent = created.agent;
|
|
22354
|
+
acquireOpenLock(liveAgent.session.id, liveAgent.session.header);
|
|
21076
22355
|
await liveAgent.whenIdle();
|
|
21077
22356
|
bumpSessionGeneration();
|
|
21078
22357
|
await initLiveSession(liveAgent);
|
|
@@ -21279,17 +22558,42 @@ function apply(ctx, config) {
|
|
|
21279
22558
|
ctx.on("session/event", (session, event) => {
|
|
21280
22559
|
if (liveAgent === void 0) return;
|
|
21281
22560
|
if (viewing !== void 0) {
|
|
21282
|
-
if (session.id
|
|
21283
|
-
|
|
21284
|
-
|
|
21285
|
-
|
|
21286
|
-
|
|
22561
|
+
if (session.id === viewing.id) {
|
|
22562
|
+
viewing.folder.apply([event]);
|
|
22563
|
+
schedulePaint();
|
|
22564
|
+
if (event.type === "turn/end") paintNow();
|
|
22565
|
+
return;
|
|
22566
|
+
}
|
|
21287
22567
|
}
|
|
21288
22568
|
if (session.id !== liveAgent.session.id) return;
|
|
21289
22569
|
if (event.type === "tool/call") {
|
|
21290
22570
|
callArgs.set(event.data.callId, typeof event.data.arguments === "string" ? event.data.arguments : JSON.stringify(event.data.arguments));
|
|
21291
|
-
if (typeof event.data.name === "string" && event.data.name.startsWith("subagent"))
|
|
21292
|
-
|
|
22571
|
+
if (typeof event.data.name === "string" && event.data.name.startsWith("subagent")) {
|
|
22572
|
+
refreshAgents();
|
|
22573
|
+
let description = "";
|
|
22574
|
+
try {
|
|
22575
|
+
const parsed = JSON.parse(event.data.arguments);
|
|
22576
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.description === "string") description = parsed.description;
|
|
22577
|
+
} catch {}
|
|
22578
|
+
pendingSubagentCalls.push({
|
|
22579
|
+
callId: event.data.callId,
|
|
22580
|
+
description
|
|
22581
|
+
});
|
|
22582
|
+
}
|
|
22583
|
+
} else if (event.type === "tool/result") {
|
|
22584
|
+
const callId = event.data.message.content[0]?.toolCallId;
|
|
22585
|
+
callArgs.delete(callId ?? "");
|
|
22586
|
+
const callIndex = pendingSubagentCalls.findIndex((call) => call.callId === callId);
|
|
22587
|
+
if (callIndex !== -1) pendingSubagentCalls.splice(callIndex, 1);
|
|
22588
|
+
const childId = callId === void 0 ? void 0 : viewCallToChild.get(callId);
|
|
22589
|
+
if (childId !== void 0) viewCallToChild.delete(callId);
|
|
22590
|
+
if (childId !== void 0 && viewing !== void 0 && viewing.id === childId) {
|
|
22591
|
+
folder.apply([event]);
|
|
22592
|
+
statsFolder.apply([event]);
|
|
22593
|
+
exitView();
|
|
22594
|
+
return;
|
|
22595
|
+
}
|
|
22596
|
+
}
|
|
21293
22597
|
folder.apply([event]);
|
|
21294
22598
|
statsFolder.apply([event]);
|
|
21295
22599
|
if (event.type === "goal/change") goalText = foldGoal([event]);
|
|
@@ -21381,4 +22685,4 @@ function apply(ctx, config) {
|
|
|
21381
22685
|
});
|
|
21382
22686
|
}
|
|
21383
22687
|
//#endregion
|
|
21384
|
-
export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resolveInitialCatalog, resumeCommand, runningProfile, shouldConsumeAdvertisedMiss, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };
|
|
22688
|
+
export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, SessionLockRefusedError, apply, composeAgent, dangerCommand, foldQueueRows, forkSeed, inject, isPlainExitPrompt, isSubagentSettlementNotice, matchPendingSubagentCall, name, recomposeBlank, recordedPreset, resolveInitialCatalog, resumeCommand, runningProfile, shouldConsumeAdvertisedMiss, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint, subagentNoticeIsFailure };
|