@xmoon76/dsh-pi-tui 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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 base = argsRaw === "" ? "" : relativizeToCwd(deriveSummary(variant, argsRaw), cwd);
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.\-_]/.test(char)) {
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 > 0) this.cursor = (this.cursor - 1 + rows.length) % 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 > 0) this.cursor = (this.cursor + 1) % 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: one line each
14183
- * for the current permission, the live goal, the todo summary, and running
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
- /** Active background tasks for the dock/footer lines (label + status). */
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 dock/footer lines (never jobs records). */
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 (kimi QueuePane parity):
14193
- * a border rule plus one `❯ text` row per pending message and a dim hint.
14194
- * Renders nothing while the queue is empty.
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
@@ -15171,9 +15618,12 @@ var TuiApp = class TuiApp {
15171
15618
  } else {
15172
15619
  const rows = [];
15173
15620
  const callPreview = parseCallPreview(message.name, message.args);
15621
+ const foldedCall = header.summary === "" ? foldedCallPreview(message.name, message.args) : "";
15174
15622
  const resultPreview = message.name === "read" ? readFoldedPreview(message.result) : message.result === "" ? "" : ` — ${preview(message.result, 3)}`;
15623
+ const callHead = foldedCall === "" ? "" : foldedCall;
15624
+ const headWithPreview = `${head}${callHead}${resultPreview}`;
15175
15625
  if (callPreview?.kind === "bash" && callPreview.command !== "") {
15176
- rows.push(truncateToWidth(head, this.terminal.columns, "…"));
15626
+ rows.push(truncateToWidth(`${head}${callHead}`, this.terminal.columns, "…"));
15177
15627
  const commandLines = callPreview.command.split("\n");
15178
15628
  const shown = commandLines.slice(0, 3);
15179
15629
  const prompt = color.shellMode("$ ");
@@ -15184,12 +15634,12 @@ var TuiApp = class TuiApp {
15184
15634
  if (commandLines.length > 3) rows.push(color.textDim(`${indent}… ${commandLines.length - 3} more command lines (ctrl+o to expand)`));
15185
15635
  if (resultPreview !== "") rows.push(color.textDim(` ${resultPreview}`));
15186
15636
  } else if (callPreview?.kind === "diff" && callPreview.diffs.length > 0) {
15187
- rows.push(truncateToWidth(`${head}${resultPreview}`, this.terminal.columns, "…"));
15637
+ rows.push(truncateToWidth(`${headWithPreview}`, this.terminal.columns, "…"));
15188
15638
  for (const line of renderDiffView(callPreview.diffs, this.workspaceRoot, {
15189
15639
  maxLines: 4,
15190
15640
  expandHint: "ctrl+o to expand"
15191
15641
  })) rows.push(` ${line}`);
15192
- } else rows.push(truncateToWidth(`${head}${resultPreview}`, this.terminal.columns, "…"));
15642
+ } else rows.push(truncateToWidth(headWithPreview, this.terminal.columns, "…"));
15193
15643
  card.addChild(new Text(rows.join("\n"), 0, 0));
15194
15644
  }
15195
15645
  return card;
@@ -15267,10 +15717,11 @@ var TuiApp = class TuiApp {
15267
15717
  this.addTerminalCommandRow(card, callView.title, this.shellPrompt(message.name));
15268
15718
  return;
15269
15719
  }
15270
- if (callView.card === "generic" && callView.rawInput !== void 0) {
15720
+ if (callView.card === "generic" && (callView.rawInput !== void 0 || (callView.content?.length ?? 0) > 0)) {
15271
15721
  this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
15272
- const raw = typeof callView.rawInput === "string" ? callView.rawInput : JSON.stringify(callView.rawInput, null, 2);
15273
- card.addChild(new Text(color.textDim(raw), 0, 0));
15722
+ if (callView.rawInput !== void 0) for (const line of genericRawInputLines(message.name, callView.rawInput)) card.addChild(new Text(color.textDim(line), 0, 0));
15723
+ for (const block of callView.content ?? []) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
15724
+ else card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
15274
15725
  return;
15275
15726
  }
15276
15727
  }
@@ -15311,6 +15762,18 @@ var TuiApp = class TuiApp {
15311
15762
  case "diff":
15312
15763
  this.renderDiffBody(card, resultView.diffs, explicitlyExpanded);
15313
15764
  return;
15765
+ case "web":
15766
+ for (const line of webCardLines(resultView)) card.addChild(new Text(color.textDim(line), 0, 0));
15767
+ return;
15768
+ case "generic": {
15769
+ const content = resultView.content ?? [];
15770
+ if (content.length > 0) {
15771
+ for (const block of content) if (block.type === "text") card.addChild(new Text(color.textDim(block.text), 0, 0));
15772
+ else card.addChild(new Text(color.textDim(JSON.stringify(block, null, 2)), 0, 0));
15773
+ return;
15774
+ }
15775
+ break;
15776
+ }
15314
15777
  }
15315
15778
  if (resultView === void 0) {
15316
15779
  const callView = this.present?.call(message.name, message.args);
@@ -15338,7 +15801,12 @@ var TuiApp = class TuiApp {
15338
15801
  if (isDiffResult(message.name, message.result)) for (const line of renderDiffLines(message.result)) card.addChild(new Text(line, 0, 0));
15339
15802
  else {
15340
15803
  this.addTerminalCommandRow(card, this.terminalCommand(message.name, message.args), this.shellPrompt(message.name));
15341
- card.addChild(new Text(color.textDim(message.result), 0, 0));
15804
+ const blocks = message.resultBlocks ?? [];
15805
+ const lines = blocks.length > 0 ? resultTextLines(blocks, message.status === "error" ? {
15806
+ name: "error",
15807
+ code: "tool"
15808
+ } : void 0) : [message.result];
15809
+ for (const line of lines) card.addChild(new Text(color.textDim(line), 0, 0));
15342
15810
  }
15343
15811
  }
15344
15812
  /**
@@ -15360,21 +15828,12 @@ var TuiApp = class TuiApp {
15360
15828
  (this.fullscreen ?? this.tui).requestRender(force);
15361
15829
  }
15362
15830
  /**
15363
- * Reflect the todo list in the header line: active (non-completed) count
15364
- * and, when the list is non-empty, the first active item's text.
15831
+ * Reflect the todo list in the dock summary line: active (non-completed)
15832
+ * count and, when the list is non-empty, the first active item's text.
15365
15833
  * @param todos - the latest todo/write snapshot.
15366
15834
  */
15367
15835
  setTodoSummary(todos) {
15368
15836
  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
15837
  this.renderDock();
15379
15838
  if (this.todoPanelVisible) this.renderTodoPanel();
15380
15839
  }
@@ -15382,6 +15841,7 @@ var TuiApp = class TuiApp {
15382
15841
  toggleTodoPanel() {
15383
15842
  this.todoPanelVisible = !this.todoPanelVisible;
15384
15843
  this.renderTodoPanel();
15844
+ this.renderDock();
15385
15845
  this.requestRender();
15386
15846
  return this.todoPanelVisible;
15387
15847
  }
@@ -15390,8 +15850,9 @@ var TuiApp = class TuiApp {
15390
15850
  return this.todoPanelVisible;
15391
15851
  }
15392
15852
  /**
15393
- * Rebuild the todo panel text: a header line plus up to five rows,
15394
- * in_progress first, then pending, then completed (strikethrough).
15853
+ * Rebuild the todo panel text: a border rule + `Todo` title (both indented
15854
+ * one cell) plus up to five rows, in_progress first, then pending, then
15855
+ * completed (strikethrough).
15395
15856
  */
15396
15857
  renderTodoPanel() {
15397
15858
  if (!this.todoPanelVisible) {
@@ -15404,15 +15865,22 @@ var TuiApp = class TuiApp {
15404
15865
  ...this.todoItems.filter((todo) => todo.status === "pending"),
15405
15866
  ...this.todoItems.filter((todo) => todo.status === "completed")
15406
15867
  ].slice(0, 5);
15868
+ const width = Math.max(1, this.terminal.columns);
15869
+ const border = color.border(` ${"─".repeat(Math.max(0, width - 2))} `);
15870
+ const title = color.textStrong(" Todo");
15407
15871
  if (ordered.length === 0) {
15408
- this.todoPanel.setText(color.border("─ todo ─"));
15872
+ this.todoPanel.setText([border, title].join("\n"));
15409
15873
  return;
15410
15874
  }
15411
15875
  const lines = ordered.map((todo) => {
15412
15876
  const body = todo.status === "completed" ? `\x1b[9m${todo.content}\x1b[29m` : todo.content;
15413
15877
  return `${mark(todo)} ${body}`;
15414
15878
  });
15415
- this.todoPanel.setText([color.border("─ todo ─"), ...lines].join("\n"));
15879
+ this.todoPanel.setText([
15880
+ border,
15881
+ title,
15882
+ ...lines
15883
+ ].join("\n"));
15416
15884
  }
15417
15885
  /** Hide/show thinking entries; the fold state is untouched. */
15418
15886
  toggleThinkingHidden() {
@@ -15424,15 +15892,15 @@ var TuiApp = class TuiApp {
15424
15892
  isThinkingHidden() {
15425
15893
  return this.hideThinking;
15426
15894
  }
15427
- /** Rebuild the header from base + session title + todo summary + plan badge.
15895
+ /** Rebuild the header from base + session title + plan badge.
15428
15896
  * Colours are applied AT RENDER TIME from the live palette — the semantic
15429
- * state (plan mode, title, todo text) is stored separately, so a theme
15430
- * switch only has to re-run this. */
15897
+ * state (plan mode, title) is stored separately, so a theme switch only
15898
+ * has to re-run this. */
15431
15899
  renderHeader() {
15432
15900
  const badge = this.planMode ? ` ${color.warning("[plan]")}` : "";
15433
15901
  const viewerBadge = this.viewerMode === void 0 ? "" : ` ${color.accent("[viewing subagent]")}`;
15434
15902
  const title = this.viewerMode !== void 0 ? ` · ${color.textMuted(this.viewerMode.label)}` : this.sessionTitleText === "" ? "" : ` · ${color.textMuted(this.sessionTitleText)}`;
15435
- this.header.setText(`🐋 dsh-pi-tui${title}${this.todoText}${badge}${viewerBadge}`);
15903
+ this.header.setText(`🐋 dsh-pi-tui${title}${badge}${viewerBadge}`);
15436
15904
  this.requestRender();
15437
15905
  }
15438
15906
  /**
@@ -15448,29 +15916,27 @@ var TuiApp = class TuiApp {
15448
15916
  };
15449
15917
  this.renderFooter();
15450
15918
  this.renderDock();
15919
+ this.renderGoalLine();
15451
15920
  }
15452
15921
  /**
15453
- * Replace the active background-task list for the dock lines and the
15454
- * footer badge. Non-empty sets arm the ↓/Ctrl+J task-browser trigger.
15922
+ * Replace the active background-task list for the footer badge. Non-empty
15923
+ * sets arm the ↓/Ctrl+J task-browser trigger.
15455
15924
  * @param tasks - active jobs (id + label + lifecycle status), empty to hide.
15456
15925
  */
15457
15926
  setTasks(tasks) {
15458
15927
  this.dockTasks = tasks;
15459
15928
  this.tasksActive = tasks.length > 0 || this.dockAgents.length > 0;
15460
- this.renderDock();
15461
15929
  this.renderFooter();
15462
15930
  }
15463
15931
  /**
15464
- * Replace the live child-subagent list for the dock lines and the
15465
- * footer badge. Continuable children and foreground one-shot children
15466
- * never register jobs records (AGENTS.md), so they arm the ↓/Ctrl+J
15467
- * trigger through this channel.
15932
+ * Replace the live child-subagent list for the footer badge. Continuable
15933
+ * children and foreground one-shot children never register jobs records
15934
+ * (AGENTS.md), so they arm the ↓/Ctrl+J trigger through this channel.
15468
15935
  * @param agents - live children (id + label + activity), empty to hide.
15469
15936
  */
15470
15937
  setAgents(agents) {
15471
15938
  this.dockAgents = agents;
15472
15939
  this.tasksActive = this.dockTasks.length > 0 || agents.length > 0;
15473
- this.renderDock();
15474
15940
  this.renderFooter();
15475
15941
  }
15476
15942
  /** Whether background tasks are active (drives the ↓/Ctrl+J trigger). */
@@ -15478,9 +15944,9 @@ var TuiApp = class TuiApp {
15478
15944
  return this.tasksActive;
15479
15945
  }
15480
15946
  /**
15481
- * Replace the pending inbox rows for the queue pane (kimi QueuePane
15482
- * parity): a border rule, one `❯ text` row per message, and a dim hint.
15483
- * An empty queue renders nothing at all.
15947
+ * Replace the pending inbox rows for the queue pane: a border rule, one
15948
+ * `❯ text` row per message, and a dim hint. An empty queue renders
15949
+ * nothing at all.
15484
15950
  * @param items - pending followups/steers, in delivery order.
15485
15951
  */
15486
15952
  setQueueItems(items) {
@@ -15496,7 +15962,7 @@ var TuiApp = class TuiApp {
15496
15962
  return;
15497
15963
  }
15498
15964
  const width = Math.max(1, this.terminal.columns);
15499
- const lines = [color.border("─".repeat(width))];
15965
+ const lines = [color.border(` ${"─".repeat(Math.max(0, width - 2))} `)];
15500
15966
  for (const item of items) {
15501
15967
  const prefix = item.notice === true ? `${color.textDim("⏳")} ` : `${color.accent("❯")} `;
15502
15968
  const truncated = truncateToWidth(item.text.replace(/\s+/g, " ").trim(), Math.max(1, width - visibleWidth(prefix)), "…");
@@ -15540,45 +16006,37 @@ var TuiApp = class TuiApp {
15540
16006
  };
15541
16007
  }
15542
16008
  /**
15543
- * Rebuild the persistent dock strip above the todo panel: todo summary and
15544
- * background tasks — one truncated line each, only while non-empty (kimi
15545
- * chrome parity). The dock is the "at a glance" surface under the
15546
- * transcript; the full todo list stays on Ctrl+T. The goal badge lives in
15547
- * the FOOTER ONLY (its one home — the dock previously duplicated it above
15548
- * the editor), and the permission preset badges in the footer only too, so
15549
- * every fact has exactly one home.
16009
+ * Rebuild the persistent dock strip above the todo panel: the todo summary
16010
+ * as a single dim info line, only while non-empty. No border rule — a full
16011
+ * ─ line here visually collides with the editor's own full-width border
16012
+ * (user feedback); the summary reads as an info line, and only the
16013
+ * EXPANDED todo panel (Ctrl+T) carries a panel border. Background-task and
16014
+ * subagent details live in the footer badge and the ↓/Ctrl+J browser ONLY
16015
+ * — every fact has exactly one home.
15550
16016
  */
15551
16017
  renderDock() {
15552
- const lines = [];
15553
- if (this.todoItems.length > 0) {
15554
- const active = this.todoItems.filter((todo) => todo.status !== "completed");
15555
- const done = this.todoItems.length - active.length;
15556
- const first = active[0];
15557
- const label = first === void 0 ? "" : first.content.length > 40 ? `${first.content.slice(0, 40)}…` : first.content;
15558
- const summary = [
15559
- done > 0 ? `${done} done` : "",
15560
- active.length > 0 ? `${active.length} active` : "",
15561
- label
15562
- ].filter((part) => part !== "").join(" · ");
15563
- lines.push(color.textDim(`☑ ${summary}`));
15564
- }
15565
- if (this.dockTasks.length > 0) {
15566
- const shown = this.dockTasks.slice(0, 3);
15567
- for (const task of shown) {
15568
- const label = task.label.length > 40 ? `${task.label.slice(0, 40)}…` : task.label;
15569
- lines.push(color.textDim(`⏳ ${task.id} · ${label}`));
15570
- }
15571
- if (this.dockTasks.length > 3) lines.push(color.textDim(` … ${this.dockTasks.length - 3} more`));
15572
- }
15573
- if (this.dockAgents.length > 0) {
15574
- const shown = this.dockAgents.slice(0, 3);
15575
- for (const agent of shown) {
15576
- const label = agent.label.length > 40 ? `${agent.label.slice(0, 40)}…` : agent.label;
15577
- lines.push(color.textDim(`🤖 ${agent.id} · ${label}`));
15578
- }
15579
- if (this.dockAgents.length > 3) lines.push(color.textDim(` … ${this.dockAgents.length - 3} more`));
15580
- }
15581
- this.dock.setText(lines.join("\n"));
16018
+ if (this.todoPanelVisible || this.todoItems.length === 0) {
16019
+ this.dock.setText("");
16020
+ this.requestRender();
16021
+ return;
16022
+ }
16023
+ const active = this.todoItems.filter((todo) => todo.status !== "completed");
16024
+ const done = this.todoItems.length - active.length;
16025
+ const first = active[0];
16026
+ const label = first === void 0 ? "" : first.content.length > 40 ? `${first.content.slice(0, 40)}…` : first.content;
16027
+ const summary = [
16028
+ done > 0 ? `${done} todo done` : "",
16029
+ active.length > 0 ? `${active.length} active` : "",
16030
+ label
16031
+ ].filter((part) => part !== "").join(" · ");
16032
+ this.dock.setText(color.textDim(`☑ ${summary}`));
16033
+ this.requestRender();
16034
+ }
16035
+ /** Rebuild the goal line: `goal ● <objective>` while a goal is set, hidden
16036
+ * otherwise (display-only — no verbs yet). */
16037
+ renderGoalLine() {
16038
+ const goal = this.status.goal;
16039
+ this.goalLine.setText(goal === void 0 || goal === "" ? "" : color.primary(goal));
15582
16040
  this.requestRender();
15583
16041
  }
15584
16042
  /** Footer density presets: full keeps the stats line, compact drops it. */
@@ -15603,7 +16061,6 @@ var TuiApp = class TuiApp {
15603
16061
  const line1 = [
15604
16062
  permissionBadge,
15605
16063
  this.planMode ? color.warning("[plan]") : "",
15606
- this.status.goal === void 0 || this.status.goal === "" ? "" : color.primary(this.status.goal),
15607
16064
  this.status.model === "" ? "" : `[${this.status.model}]`,
15608
16065
  taskBadge,
15609
16066
  this.status.cwd,
@@ -15662,6 +16119,50 @@ var TuiApp = class TuiApp {
15662
16119
  };
15663
16120
  }
15664
16121
  /**
16122
+ * Open the task browser overlay (the ↓ / Ctrl+J trigger with an empty
16123
+ * editor, and /tasks). Unlike the generic {@link openPicker}, rows carry a
16124
+ * status word + start timestamp so the panel can render status dots,
16125
+ * right-aligned status/elapsed columns, live counts, and a 1s elapsed
16126
+ * tick. Selection calls `onSelect` with the row value and closes; Esc
16127
+ * calls `onCancel`.
16128
+ * @param items - task rows (see TaskPanelItem).
16129
+ * @param onSelect - confirmed row value.
16130
+ * @param onCancel - dismissed without a choice.
16131
+ * @param options - header/search/sizing configuration.
16132
+ * @returns a handle to close the browser or replace its rows (e.g. when
16133
+ * jobs change while it is open).
16134
+ */
16135
+ openTaskBrowser(items, onSelect, onCancel, options = {}) {
16136
+ const panel = new TaskBrowserPanel(items.map((item) => ({ ...item })), options.maxVisible ?? 10, {
16137
+ header: options.header,
16138
+ noMatchText: options.noMatchText,
16139
+ enableSearch: options.enableSearch,
16140
+ initialQuery: options.initialQuery
16141
+ }, (value) => {
16142
+ close();
16143
+ onSelect(value);
16144
+ }, () => {
16145
+ close();
16146
+ onCancel();
16147
+ }, () => this.requestRender());
16148
+ const handle = this.showOverlayOnHost(new Frame(panel), {
16149
+ width: options.width ?? 72,
16150
+ maxHeight: options.maxHeight ?? 24
16151
+ });
16152
+ let close = () => {};
16153
+ close = () => {
16154
+ panel.dispose();
16155
+ handle.hide();
16156
+ };
16157
+ return {
16158
+ close,
16159
+ setItems: (next) => {
16160
+ panel.setItems(next.map((item) => ({ ...item })));
16161
+ this.requestRender();
16162
+ }
16163
+ };
16164
+ }
16165
+ /**
15665
16166
  * Open the settings overlay as a SettingsList. The runner supplies the
15666
16167
  * items and reacts to changes/cancellation. Returns a CLOSER so an
15667
16168
  * action-style list (e.g. /subagents' View transcript / Interrupt) can
@@ -15741,6 +16242,7 @@ var TuiApp = class TuiApp {
15741
16242
  this.renderFooter();
15742
16243
  this.renderDock();
15743
16244
  this.renderTodoPanel();
16245
+ this.renderGoalLine();
15744
16246
  this.renderQueuePane();
15745
16247
  this.working.refresh();
15746
16248
  this.editor.invalidate();
@@ -16150,14 +16652,6 @@ function taskRowLabel(row) {
16150
16652
  function rowGroup(row) {
16151
16653
  return row.kind === "job" ? JOB_GROUP : SUBAGENT_GROUP;
16152
16654
  }
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
16655
  //#endregion
16162
16656
  //#region src/model-menu.ts
16163
16657
  /**
@@ -16766,6 +17260,55 @@ function presetDisplayText(preset) {
16766
17260
  ...preset.description === void 0 ? {} : { description: preset.description }
16767
17261
  };
16768
17262
  }
17263
+ /** The credential-target options for /login and /logout: the deepseek
17264
+ * official ref always first, then every llm-pi-ai provider route's
17265
+ * apiKeyEnv (deduped by ref). `providers` is the llm-pi-ai settings
17266
+ * section's `providers` dict, or undefined when the adapter or the settings
17267
+ * service is absent — which degrades /login to the official target only.
17268
+ */
17269
+ function credentialOptionsFor(providers) {
17270
+ const options = [{
17271
+ label: "deepseek official",
17272
+ ref: "DEEPSEEK_API_KEY"
17273
+ }];
17274
+ if (providers === void 0) return options;
17275
+ const seen = new Set(options.map((option) => option.ref));
17276
+ for (const [route, profile] of Object.entries(providers)) {
17277
+ const ref = profile?.apiKeyEnv;
17278
+ if (ref === void 0 || ref === "" || seen.has(ref)) continue;
17279
+ seen.add(ref);
17280
+ options.push({
17281
+ label: route,
17282
+ ref
17283
+ });
17284
+ }
17285
+ return options;
17286
+ }
17287
+ /** Resolve a /login or /logout argument to a credential ref. A
17288
+ * case-insensitive llm-pi-ai route name wins, then a literal env-var-looking
17289
+ * name (any casing, uppercased like the old behavior — `/login my_key` sets
17290
+ * `MY_KEY`); anything else is unknown and returns undefined so the caller
17291
+ * can list the valid options.
17292
+ */
17293
+ function resolveCredentialArg(arg, options) {
17294
+ const trimmed = arg.trim();
17295
+ if (trimmed === "") return void 0;
17296
+ const needle = trimmed.toLowerCase();
17297
+ const route = options.find((option) => option.label.toLowerCase() === needle || option.label.split(" ")[0].toLowerCase() === needle);
17298
+ if (route !== void 0) return route.ref;
17299
+ if (/^[a-zA-Z][a-zA-Z0-9_]*$/.test(trimmed)) return trimmed.toUpperCase();
17300
+ }
17301
+ /** Read the llm-pi-ai adapter's `providers` dict from its settings section,
17302
+ * or undefined when the settings service or the section is absent. */
17303
+ function readLlmpiAiProviders(ctx) {
17304
+ const settings = ctx.get("settings");
17305
+ if (settings === void 0) return void 0;
17306
+ try {
17307
+ return settings.get(settingsNamespace("llm-pi-ai"))?.providers;
17308
+ } catch {
17309
+ return;
17310
+ }
17311
+ }
16769
17312
  /**
16770
17313
  * Register the TUI-owned slash commands on the commands service. The
16771
17314
  * completion list is refreshed after every registration so TUI-owned
@@ -17560,12 +18103,17 @@ function registerTuiCommands(runner, initial) {
17560
18103
  kind: "error",
17561
18104
  text: "no background jobs"
17562
18105
  };
17563
- const now = Date.now();
17564
- app.openPicker(snapshots.map((job) => ({
18106
+ app.openTaskBrowser(snapshots.map((job) => ({
17565
18107
  value: job.id,
17566
18108
  label: `${job.kind} · ${job.label}`,
17567
- description: `${job.status}${job.detail === void 0 ? "" : ` — ${job.detail}`} · ${Math.max(0, Math.floor((now - job.startedAt) / 1e3))}s`
17568
- })), (jobId) => runner.openJobView(jobId), () => {});
18109
+ status: job.status,
18110
+ detail: job.detail,
18111
+ startedAt: job.startedAt,
18112
+ group: "jobs"
18113
+ })), (jobId) => runner.openJobView(jobId), () => {}, {
18114
+ header: "tasks",
18115
+ enableSearch: true
18116
+ });
17569
18117
  return { kind: "success" };
17570
18118
  }
17571
18119
  });
@@ -18046,15 +18594,37 @@ function registerTuiCommands(runner, initial) {
18046
18594
  });
18047
18595
  commands.register({
18048
18596
  name: "login",
18049
- description: "Set an API key credential for a provider (default DEEPSEEK_API_KEY)",
18050
- input: { hint: "[<env-var>]" },
18597
+ description: "Set an API key — deepseek official or an llm-pi-ai provider route",
18598
+ input: { hint: "[<route|env-var>]" },
18051
18599
  handler: async (invocation) => {
18052
18600
  const credentials = ctx.get("credentials");
18053
18601
  if (credentials === void 0) return {
18054
18602
  kind: "error",
18055
18603
  text: "credentials service unavailable"
18056
18604
  };
18057
- const ref = (invocation.rawInput.trim() || "DEEPSEEK_API_KEY").toUpperCase();
18605
+ const options = credentialOptionsFor(readLlmpiAiProviders(ctx));
18606
+ const arg = invocation.rawInput.trim();
18607
+ let ref;
18608
+ if (arg !== "") {
18609
+ const resolved = resolveCredentialArg(arg, options);
18610
+ if (resolved === void 0) return {
18611
+ kind: "error",
18612
+ text: `unknown credential target "${arg}" — ${options.map((option) => `${option.label} (${option.ref})`).join(", ")}`
18613
+ };
18614
+ ref = resolved;
18615
+ } else if (options.length > 1) {
18616
+ const picked = await new Promise((resolve) => {
18617
+ app.openPicker(options.map((option) => ({
18618
+ value: option.ref,
18619
+ label: `${option.label} (${option.ref})`
18620
+ })), (value) => resolve(value), () => resolve(void 0));
18621
+ });
18622
+ if (picked === void 0) return {
18623
+ kind: "error",
18624
+ text: "login cancelled"
18625
+ };
18626
+ ref = picked;
18627
+ } else ref = options[0].ref;
18058
18628
  try {
18059
18629
  const key = (await app.askQuestions([{
18060
18630
  id: "key",
@@ -18079,19 +18649,32 @@ function registerTuiCommands(runner, initial) {
18079
18649
  });
18080
18650
  commands.register({
18081
18651
  name: "logout",
18082
- description: "Clear a stored API key credential (default DEEPSEEK_API_KEY)",
18083
- input: { hint: "[<env-var>]" },
18652
+ description: "Clear a stored API key — deepseek official or an llm-pi-ai provider route",
18653
+ input: { hint: "[<route|env-var>]" },
18084
18654
  handler: async (invocation) => {
18085
18655
  const credentials = ctx.get("credentials");
18086
18656
  if (credentials === void 0) return {
18087
18657
  kind: "error",
18088
18658
  text: "credentials service unavailable"
18089
18659
  };
18090
- const ref = (invocation.rawInput.trim() || "DEEPSEEK_API_KEY").toUpperCase();
18091
- await credentials.unset(ref);
18660
+ const options = credentialOptionsFor(readLlmpiAiProviders(ctx));
18661
+ const arg = invocation.rawInput.trim();
18662
+ if (arg !== "") {
18663
+ const resolved = resolveCredentialArg(arg, options);
18664
+ if (resolved === void 0) return {
18665
+ kind: "error",
18666
+ text: `unknown credential target "${arg}" — ${options.map((option) => `${option.label} (${option.ref})`).join(", ")}`
18667
+ };
18668
+ await credentials.unset(resolved);
18669
+ return {
18670
+ kind: "success",
18671
+ text: `${resolved} cleared`
18672
+ };
18673
+ }
18674
+ await credentials.unset(options[0].ref);
18092
18675
  return {
18093
18676
  kind: "success",
18094
- text: `${ref} cleared`
18677
+ text: `${options[0].ref} cleared`
18095
18678
  };
18096
18679
  }
18097
18680
  });
@@ -18336,6 +18919,110 @@ function createDiag(options = {}) {
18336
18919
  };
18337
18920
  }
18338
18921
  //#endregion
18922
+ //#region src/history.ts
18923
+ /**
18924
+ * Per-cwd input-history persistence — JSONL files under `$DSH_HOME/user-history/`,
18925
+ * one `{"content": "..."}` line per submitted prompt (kimi-code's
18926
+ * input-history pattern: a dedicated data file, never the settings
18927
+ * document). Append-only writes make concurrent windows in one directory
18928
+ * safe (O_APPEND lines cannot clobber each other), which whole-file settings
18929
+ * replaces could not guarantee.
18930
+ *
18931
+ * Semantics:
18932
+ * - File order is submission order (oldest first); the editor seeds
18933
+ * oldest→newest so its recall list is newest-first.
18934
+ * - Empty submissions and consecutive repeats are skipped (shell-history
18935
+ * behavior); non-consecutive repeats are legal history and survive.
18936
+ * - Corrupt lines are skipped on load, never fatal.
18937
+ * - The file is capped at HISTORY_LIMIT entries; the cap trims on load.
18938
+ * - `!` shell commands are stored verbatim (with the leading `!`), so ↑
18939
+ * recall restores them as text and Enter re-runs the shell branch.
18940
+ *
18941
+ * Pure and injectable so pathing, parsing, and append rules are
18942
+ * unit-testable without touching the terminal or dsh services.
18943
+ * @module @xmoon76/dsh-pi-tui/history
18944
+ */
18945
+ /** Directory name under `$DSH_HOME` holding the per-cwd history files. */
18946
+ const HISTORY_DIR_NAME = "user-history";
18947
+ /**
18948
+ * The history file for one working directory: `$DSH_HOME/user-history/<md5(cwd)>.jsonl`.
18949
+ * The cwd hash keeps the file list flat and free of path separators; the
18950
+ * hash is an identity, never shown to the user.
18951
+ * @param home - the dsh home directory (`$DSH_HOME`, default `~/.dsh`).
18952
+ * @param cwd - the working directory whose submissions the file records.
18953
+ */
18954
+ function historyFilePath(home, cwd) {
18955
+ const hash = createHash("md5").update(cwd, "utf-8").digest("hex");
18956
+ return join(home, HISTORY_DIR_NAME, `${hash}.jsonl`);
18957
+ }
18958
+ /**
18959
+ * Parse one history file's text into entries, in file order (oldest first).
18960
+ * Blank lines and corrupt lines (unparsable JSON or a non-string `content`)
18961
+ * are skipped — a torn write or an older format must never abort recall.
18962
+ * @param text - the raw file content.
18963
+ */
18964
+ function parseHistoryLines(text) {
18965
+ const entries = [];
18966
+ for (const line of text.split("\n")) {
18967
+ const trimmed = line.trim();
18968
+ if (trimmed === "") continue;
18969
+ try {
18970
+ const parsed = JSON.parse(trimmed);
18971
+ if (typeof parsed.content === "string" && parsed.content !== "") entries.push(parsed.content);
18972
+ } catch {}
18973
+ }
18974
+ return entries;
18975
+ }
18976
+ /**
18977
+ * Load a history file, trimming it to the last HISTORY_LIMIT entries when it
18978
+ * outgrew the cap (the trim rewrites the file best-effort so it stops
18979
+ * growing). Never throws: an unreadable or absent file is empty history.
18980
+ * @param file - the JSONL history file path.
18981
+ * @returns entries in file order (oldest first).
18982
+ */
18983
+ function loadHistoryFile(file) {
18984
+ try {
18985
+ if (!existsSync(file)) return [];
18986
+ const entries = parseHistoryLines(readFileSync(file, "utf8"));
18987
+ if (entries.length <= 100) return entries;
18988
+ const kept = entries.slice(-100);
18989
+ try {
18990
+ const temp = `${file}.tmp`;
18991
+ writeFileSync(temp, kept.map((entry) => JSON.stringify({ content: entry })).join("\n") + "\n", { mode: 384 });
18992
+ renameSync(temp, file);
18993
+ } catch {}
18994
+ return kept;
18995
+ } catch {
18996
+ return [];
18997
+ }
18998
+ }
18999
+ /**
19000
+ * Append one submitted line to the history file. Empty lines and a repeat of
19001
+ * `lastContent` (the newest known entry) are skipped, like shell history.
19002
+ * Multi-line submissions are stored as one JSON line (newlines escaped), so
19003
+ * the JSONL layout survives pastes.
19004
+ *
19005
+ * Throws on I/O failure (the caller decides how to surface it); the
19006
+ * skip verdicts return false without touching the file.
19007
+ * @param file - the JSONL history file path.
19008
+ * @param text - the submitted input.
19009
+ * @param lastContent - the newest entry known to the caller, or undefined.
19010
+ * @returns whether an entry was written.
19011
+ */
19012
+ function appendHistoryLine(file, text, lastContent) {
19013
+ const content = text.trim();
19014
+ if (content === "" || content === lastContent) return false;
19015
+ mkdirSync(dirname(file), {
19016
+ recursive: true,
19017
+ mode: 448
19018
+ });
19019
+ appendFileSync(file, JSON.stringify({ content }) + "\n", {
19020
+ encoding: "utf8",
19021
+ mode: 384
19022
+ });
19023
+ return true;
19024
+ }
19025
+ //#endregion
18339
19026
  //#region src/exit.ts
18340
19027
  /**
18341
19028
  * The TUI exit contract: flush the live session with a HARD timeout, record
@@ -19357,6 +20044,286 @@ var CoalescingRefreshGate = class {
19357
20044
  }
19358
20045
  };
19359
20046
  //#endregion
20047
+ //#region src/session-lock.ts
20048
+ /** The lock file name, next to `session.jsonl[.zstd]` in the session directory. */
20049
+ const LOCK_FILE_NAME = "owner.lock";
20050
+ /**
20051
+ * Decide the lock repair after an INTERNAL swap failure (flush/dispose/
20052
+ * whenIdle throwing inside swapTo), given the single-slot tracker state and
20053
+ * the outgoing session id. Pure so the three-shape matrix is headless-testable:
20054
+ *
20055
+ * - tracker holds `from` itself (/new, /fork — the current lock was never
20056
+ * pre-released): nothing to repair — `from` is still locked by us.
20057
+ * - tracker holds the TARGET (switchSession pre-released `from` and acquired
20058
+ * the target before calling swapTo): release the target (we never entered
20059
+ * it) and re-acquire `from` (the current session stays live).
20060
+ * - tracker is empty (switchSession's target acquire was `unavailable`):
20061
+ * `from`'s lock was released and never re-taken — re-acquire it.
20062
+ */
20063
+ function swapFailureLockRepair(tracker, from) {
20064
+ if (from === void 0) return tracker === void 0 ? {
20065
+ release: void 0,
20066
+ reacquire: void 0
20067
+ } : {
20068
+ release: tracker.sessionId,
20069
+ reacquire: void 0
20070
+ };
20071
+ if (tracker === void 0 || tracker.sessionId !== from) return {
20072
+ release: tracker?.sessionId,
20073
+ reacquire: from
20074
+ };
20075
+ return {
20076
+ release: void 0,
20077
+ reacquire: void 0
20078
+ };
20079
+ }
20080
+ /** A tiny deterministic serializer for the lock record (single-line JSON). */
20081
+ function serializeLockInfo(info) {
20082
+ return JSON.stringify(info);
20083
+ }
20084
+ /** Parse a lock file's content; malformed content yields undefined. */
20085
+ function parseLockInfo(content) {
20086
+ try {
20087
+ const parsed = JSON.parse(content);
20088
+ if (typeof parsed.pid !== "number" || !Number.isSafeInteger(parsed.pid)) return void 0;
20089
+ if (typeof parsed.starttime !== "number" || !Number.isSafeInteger(parsed.starttime)) return void 0;
20090
+ if (typeof parsed.startedAt !== "number" || !Number.isSafeInteger(parsed.startedAt)) return void 0;
20091
+ return {
20092
+ pid: parsed.pid,
20093
+ starttime: parsed.starttime,
20094
+ startedAt: parsed.startedAt,
20095
+ ...typeof parsed.profile === "string" ? { profile: parsed.profile } : {},
20096
+ ...typeof parsed.tty === "string" ? { tty: parsed.tty } : {}
20097
+ };
20098
+ } catch {
20099
+ return;
20100
+ }
20101
+ }
20102
+ /** Derive the lock file path from the session artifact path. */
20103
+ function lockPathOf(artifactPath) {
20104
+ return `${artifactPath}.${LOCK_FILE_NAME}`;
20105
+ }
20106
+ /**
20107
+ * Try to acquire the lock with an atomic create. Returns the path when this
20108
+ * process owns it, undefined when another process holds it or the create
20109
+ * cannot happen. Never throws.
20110
+ */
20111
+ function tryCreate(fs, lockPath, self) {
20112
+ try {
20113
+ fs.writeFileSync(lockPath, serializeLockInfo(self), {
20114
+ flag: "wx",
20115
+ mode: 384
20116
+ });
20117
+ return { created: true };
20118
+ } catch (error) {
20119
+ if (error?.code === "EEXIST") return {
20120
+ created: false,
20121
+ reason: "exists"
20122
+ };
20123
+ return {
20124
+ created: false,
20125
+ reason: "error",
20126
+ error
20127
+ };
20128
+ }
20129
+ }
20130
+ /**
20131
+ * Acquire the open-time lock for one session.
20132
+ *
20133
+ * Returns `acquired` (with an idempotent `release`), `held` (another live dsh
20134
+ * process owns it), `taken-over-stale` (a dead owner's lock was replaced),
20135
+ * `unverifiable` (the owner could not be probed — refuse), or `unavailable`
20136
+ * (this deployment cannot lock; callers should proceed as before, the guard
20137
+ * still protects the write path). Never throws.
20138
+ */
20139
+ function acquireSessionLock(deps, session, self) {
20140
+ const { persistence, fs, proc } = deps;
20141
+ if (persistence === void 0) return {
20142
+ kind: "unavailable",
20143
+ reason: "no-persistence"
20144
+ };
20145
+ const artifact = persistence.locate({
20146
+ id: session.id,
20147
+ cwd: session.header?.cwd
20148
+ });
20149
+ if (artifact === void 0) return {
20150
+ kind: "unavailable",
20151
+ reason: "no-artifact"
20152
+ };
20153
+ const lockPath = lockPathOf(artifact.path);
20154
+ let tookOver = false;
20155
+ for (let attempt = 0;; attempt += 1) {
20156
+ const created = tryCreate(fs, lockPath, self);
20157
+ if (created.created) return tookOver ? {
20158
+ kind: "taken-over-stale",
20159
+ release: makeRelease(fs, lockPath, self)
20160
+ } : {
20161
+ kind: "acquired",
20162
+ release: makeRelease(fs, lockPath, self)
20163
+ };
20164
+ if (created.reason === "error") return {
20165
+ kind: "unavailable",
20166
+ reason: created.error?.code === "ENOENT" ? "no-lock-dir" : "write-error"
20167
+ };
20168
+ let owner;
20169
+ try {
20170
+ owner = parseLockInfo(fs.readFileSync(lockPath));
20171
+ } catch (error) {
20172
+ if (error?.code === "ENOENT") {
20173
+ if (attempt >= 2) return {
20174
+ kind: "unverifiable",
20175
+ owner
20176
+ };
20177
+ continue;
20178
+ }
20179
+ return {
20180
+ kind: "unverifiable",
20181
+ owner
20182
+ };
20183
+ }
20184
+ if (owner === void 0) return {
20185
+ kind: "unverifiable",
20186
+ owner
20187
+ };
20188
+ if (owner.pid === self.pid && owner.starttime === self.starttime) return {
20189
+ kind: "acquired",
20190
+ release: makeRelease(fs, lockPath, self)
20191
+ };
20192
+ const verdict = proc.probe(owner, self);
20193
+ if (verdict.kind === "alive") return {
20194
+ kind: "held",
20195
+ owner
20196
+ };
20197
+ if (verdict.kind === "unknown") return {
20198
+ kind: "unverifiable",
20199
+ owner
20200
+ };
20201
+ let removed = false;
20202
+ try {
20203
+ const current = parseLockInfo(fs.readFileSync(lockPath));
20204
+ if (current !== void 0 && current.pid === owner.pid && current.starttime === owner.starttime) {
20205
+ fs.unlinkSync(lockPath);
20206
+ removed = true;
20207
+ }
20208
+ } catch {}
20209
+ if (removed) tookOver = true;
20210
+ else if (attempt >= 2) return {
20211
+ kind: "held",
20212
+ owner
20213
+ };
20214
+ }
20215
+ }
20216
+ /**
20217
+ * An idempotent release that only removes the lock if it is still OURS.
20218
+ * Compare-and-delete: after ownership is lost (a concurrent takeover, manual
20219
+ * removal and recreation by another process), the exit-time release must not
20220
+ * delete the new owner's lock file.
20221
+ */
20222
+ function makeRelease(fs, lockPath, self) {
20223
+ let released = false;
20224
+ return () => {
20225
+ if (released) return;
20226
+ released = true;
20227
+ try {
20228
+ const owner = parseLockInfo(fs.readFileSync(lockPath));
20229
+ if (owner !== void 0 && owner.pid === self.pid && owner.starttime === self.starttime) fs.unlinkSync(lockPath);
20230
+ } catch {}
20231
+ };
20232
+ }
20233
+ //#endregion
20234
+ //#region src/session-lock-proc.ts
20235
+ /**
20236
+ * Real `/proc`-based owner probe for the open-time session lock.
20237
+ *
20238
+ * Verifies that a recorded lock owner is the SAME live dsh invocation:
20239
+ * - the process exists (`/proc/<pid>/stat` readable),
20240
+ * - it is not a zombie (state `Z`),
20241
+ * - its starttime (stat field 22) matches the lock record — a reused pid
20242
+ * would carry a different starttime,
20243
+ * - its command line identifies a dsh process (the same family of tools the
20244
+ * lock speaks for).
20245
+ *
20246
+ * The probe never takes a lock over on doubt: any inability to verify
20247
+ * (missing /proc, permission errors, unparsable stat) reports `unknown` and
20248
+ * the caller refuses the open. An ENOENT stat, a zombie state, a starttime
20249
+ * mismatch, or a non-dsh command line is a definitive `stale`.
20250
+ *
20251
+ * Non-Linux platforms degrade: `process.kill(pid, 0)` checks existence, then
20252
+ * the command line is compared when `ps` is available; when it is not, the
20253
+ * probe reports `unknown` (conservative refusal) instead of guessing.
20254
+ * @module @xmoon76/dsh-pi-tui/session-lock-proc
20255
+ */
20256
+ /** Parse `/proc/<pid>/stat`: state (field 3) and starttime (field 22). */
20257
+ function parseProcStat(content) {
20258
+ const close = content.lastIndexOf(")");
20259
+ if (close < 0) return void 0;
20260
+ const fields = content.slice(close + 2).trim().split(" ");
20261
+ if (fields.length < 20) return void 0;
20262
+ const state = fields[0] ?? "";
20263
+ const starttime = Number(fields[19]);
20264
+ if (!Number.isSafeInteger(starttime)) return void 0;
20265
+ return {
20266
+ state,
20267
+ starttime
20268
+ };
20269
+ }
20270
+ /** The set of command names this lock family recognizes as "our own tool". */
20271
+ function looksLikeDsh(cmdline) {
20272
+ if (cmdline === void 0) return false;
20273
+ return cmdline.split(/\s+/).some((arg) => {
20274
+ if (arg === "dsh") return true;
20275
+ const base = arg.split("/").pop() ?? "";
20276
+ return base === "dsh" || base === "dsh.exe";
20277
+ });
20278
+ }
20279
+ /** The real probe: Linux /proc, with a conservative non-Linux degradation. */
20280
+ function createProcProbe(options) {
20281
+ const platform = options?.platform ?? process.platform;
20282
+ const execFile = options?.execFile ?? execFileSync;
20283
+ const readFile = options?.readFile ?? ((path) => readFileSync(path, "utf8"));
20284
+ return { probe(owner, self) {
20285
+ if (owner.pid === self.pid && owner.starttime === self.starttime) return { kind: "alive" };
20286
+ if (platform === "linux" || platform === "android") {
20287
+ let statContent;
20288
+ try {
20289
+ statContent = readFile(`/proc/${owner.pid}/stat`);
20290
+ } catch (error) {
20291
+ if (error?.code === "ENOENT") return { kind: "stale" };
20292
+ return { kind: "unknown" };
20293
+ }
20294
+ const parsed = parseProcStat(statContent);
20295
+ if (parsed === void 0) return { kind: "unknown" };
20296
+ if (parsed.state === "Z") return { kind: "stale" };
20297
+ if (parsed.starttime !== owner.starttime) return { kind: "stale" };
20298
+ let cmdline;
20299
+ try {
20300
+ cmdline = readFile(`/proc/${owner.pid}/cmdline`).split("\0").filter((arg) => arg !== "").join(" ");
20301
+ } catch {
20302
+ return { kind: "unknown" };
20303
+ }
20304
+ if (cmdline !== void 0 && !looksLikeDsh(cmdline)) return { kind: "stale" };
20305
+ return { kind: "alive" };
20306
+ }
20307
+ try {
20308
+ process.kill(owner.pid, 0);
20309
+ } catch (error) {
20310
+ if (error?.code === "ESRCH") return { kind: "stale" };
20311
+ }
20312
+ try {
20313
+ const command = execFile("ps", [
20314
+ "-p",
20315
+ String(owner.pid),
20316
+ "-o",
20317
+ "command="
20318
+ ], { encoding: "utf8" }).trim();
20319
+ if (command === "") return { kind: "stale" };
20320
+ return looksLikeDsh(command) ? { kind: "alive" } : { kind: "stale" };
20321
+ } catch {
20322
+ return { kind: "unknown" };
20323
+ }
20324
+ } };
20325
+ }
20326
+ //#endregion
19360
20327
  //#region src/index.ts
19361
20328
  /**
19362
20329
  * @xmoon76/dsh-pi-tui — the bundle's runner plugin. Waits for the startup
@@ -19539,6 +20506,86 @@ function subagentJobViewHint(status, detail) {
19539
20506
  ].join("\n");
19540
20507
  }
19541
20508
  /**
20509
+ * Whether a plain submitted draft is the quit word: exactly `exit` (trimmed,
20510
+ * lowercase). The runner intercepts this BEFORE any session creation or
20511
+ * submission (shell muscle memory); anything else — `exit!`, `Exit`, or a
20512
+ * draft with a recalled entry still in it — is an ordinary message.
20513
+ * @param text - the submitted draft.
20514
+ */
20515
+ function isPlainExitPrompt(text) {
20516
+ return text.trim() === "exit";
20517
+ }
20518
+ /**
20519
+ * Whether an inbox message is a BACKGROUND-SUBAGENT settlement notice — the
20520
+ * runtime's account of a child ending, not steerable user input. Two dsh
20521
+ * producers push these into the parent's inbox:
20522
+ * - continuable children: `source.kind === 'subagent-settled'` (the
20523
+ * continuation manager's settlement notice);
20524
+ * - one-shot background subagent jobs: tool-jobs completion notices whose
20525
+ * summary starts with the job kind (`subagent <label> [status: …]`).
20526
+ * The queue pane mirrors the inbox, but these belong to the task browser
20527
+ * (terminal job rows / inactive child rows), so the mirror drops them and
20528
+ * only failures surface as a transient error notify.
20529
+ * @param source - the message source projection, or undefined for a plain row.
20530
+ */
20531
+ function isSubagentSettlementNotice(source) {
20532
+ if (source === void 0 || source.form !== "notice") return false;
20533
+ if (source.kind === "subagent-settled") return true;
20534
+ return source.kind === "plugin" && typeof source.summary === "string" && source.summary.startsWith("subagent ");
20535
+ }
20536
+ /**
20537
+ * Whether a subagent settlement notice reports FAILURE, classified on the
20538
+ * producers' own deterministic wording:
20539
+ * - `subagent-settled` summaries: "finished and will do no further work"
20540
+ * is the only success wording; aborted / max-tokens / refusal / error /
20541
+ * unknown endings all fail;
20542
+ * - tool-jobs subagent summaries carry the terminal status line, whose
20543
+ * failure statuses are `failed` and `killed` (dsh JobStatus).
20544
+ * A notice that cannot be classified is treated as success (silent).
20545
+ * @param source - the message source projection.
20546
+ */
20547
+ function subagentNoticeIsFailure(source) {
20548
+ if (source === void 0 || source.form !== "notice") return false;
20549
+ if (source.kind === "subagent-settled") return typeof source.summary === "string" && !source.summary.includes("finished and");
20550
+ if (source.kind === "plugin" && typeof source.summary === "string" && source.summary.startsWith("subagent ")) return /\[status: (failed|killed)[,\]]/.test(source.summary);
20551
+ return false;
20552
+ }
20553
+ /**
20554
+ * Build the queue-pane rows for one inbox batch, dropping background-subagent
20555
+ * settlement notices (the task browser is their surface) and reporting which
20556
+ * FAILED settlements should notify. Pure and injectable so the filter +
20557
+ * once-notify semantics are testable without the agent.
20558
+ * @param messages - one inbox batch (next-turn or next-step), in order.
20559
+ * @param mode - the delivery mode for surviving rows.
20560
+ * @param notified - the notify-once guard; failed notices already in it are
20561
+ * skipped, and a newly-reported id is ADDED here so a re-render can never
20562
+ * double-notify.
20563
+ */
20564
+ function foldQueueRows(messages, mode, notified) {
20565
+ const rows = [];
20566
+ const failures = [];
20567
+ for (const message of messages) {
20568
+ const source = message.source;
20569
+ if (isSubagentSettlementNotice(source)) {
20570
+ if (source?.summary !== void 0 && subagentNoticeIsFailure(source) && !notified.has(message.id)) {
20571
+ notified.add(message.id);
20572
+ failures.push(source.summary);
20573
+ }
20574
+ continue;
20575
+ }
20576
+ rows.push({
20577
+ id: message.id,
20578
+ text: textOf(message.content),
20579
+ mode,
20580
+ notice: source?.form === "notice"
20581
+ });
20582
+ }
20583
+ return {
20584
+ rows,
20585
+ failures
20586
+ };
20587
+ }
20588
+ /**
19542
20589
  * The installed dsh version (e.g. `0.1.0-rc.6`), resolved from the launcher's
19543
20590
  * real path: `process.argv[1]` is the `dsh` bin, whose realpath walks up to
19544
20591
  * the `@deepseek-ai/dsh/package.json` that owns it. The version the welcome
@@ -19664,6 +20711,51 @@ function resumeCommand(profile, sessionId) {
19664
20711
  if (id === "") return void 0;
19665
20712
  return `dsh --profile ${profile} --session ${id}`;
19666
20713
  }
20714
+ /** The lock identity of THIS process: pid + boot-relative starttime. */
20715
+ let cachedSelfLockInfo;
20716
+ function selfLockInfo() {
20717
+ if (cachedSelfLockInfo !== void 0) return cachedSelfLockInfo;
20718
+ const info = {
20719
+ pid: process.pid,
20720
+ starttime: 0,
20721
+ startedAt: Date.now(),
20722
+ profile: runningProfile()
20723
+ };
20724
+ try {
20725
+ info.starttime = parseProcStat(readFileSync(`/proc/self/stat`, "utf8"))?.starttime ?? 0;
20726
+ } catch {}
20727
+ cachedSelfLockInfo = info;
20728
+ return info;
20729
+ }
20730
+ /** Human-readable lock owner description for the refusal notice. */
20731
+ function lockOwnerText(owner) {
20732
+ const parts = [`pid ${owner.pid}`];
20733
+ if (owner.profile !== void 0) parts.push(`profile ${owner.profile}`);
20734
+ if (owner.startedAt > 0) {
20735
+ const started = new Date(owner.startedAt);
20736
+ parts.push(`started ${started.toLocaleTimeString()}`);
20737
+ }
20738
+ if (owner.tty !== void 0) parts.push(`tty ${owner.tty}`);
20739
+ return parts.join(", ");
20740
+ }
20741
+ /** The refusal notice for a session held by another live dsh process. */
20742
+ function lockHeldNotice(sessionId, owner) {
20743
+ return `Session ${sessionId} is open in another dsh process (${lockOwnerText(owner)}); opening it here could corrupt the log. Close it there first, or restart that process.`;
20744
+ }
20745
+ /**
20746
+ * A launch-time open-lock refusal: the requested session is held by another
20747
+ * live dsh process (or its lock cannot be verified). Unlike a stale/missing
20748
+ * session id, this is NOT recoverable by falling back to a fresh session —
20749
+ * the user asked for a specific session and must resolve the holder first.
20750
+ * Thrown from the resume path and re-thrown past the resume catch so the
20751
+ * runner exits with the refusal as the error message.
20752
+ */
20753
+ var SessionLockRefusedError = class extends Error {
20754
+ constructor(message) {
20755
+ super(message);
20756
+ this.name = "SessionLockRefusedError";
20757
+ }
20758
+ };
19667
20759
  /**
19668
20760
  * The active goal badge text from the session log, or undefined. The latest
19669
20761
  * `goal/change` wins; a clear or completed goal hides the badge.
@@ -19853,9 +20945,81 @@ function apply(ctx, config) {
19853
20945
  };
19854
20946
  }
19855
20947
  };
20948
+ let heldLock;
20949
+ const lockProc = createProcProbe();
20950
+ /** Try to take the open-time lock for a session. Returns the refusal text
20951
+ * when the session is held by another live dsh process (or unverifiable);
20952
+ * undefined when the lock is now ours (or the deployment cannot lock —
20953
+ * proceed as before). Never throws. */
20954
+ const acquireOpenLock = (sessionId, header) => {
20955
+ const outcome = acquireSessionLock({
20956
+ persistence: ctx.get("sessionPersistence"),
20957
+ fs: {
20958
+ readFileSync: (path) => readFileSync(path, "utf8"),
20959
+ writeFileSync: (path, content, options) => writeFileSync(path, content, options),
20960
+ unlinkSync
20961
+ },
20962
+ proc: lockProc
20963
+ }, {
20964
+ id: sessionId,
20965
+ header
20966
+ }, selfLockInfo());
20967
+ switch (outcome.kind) {
20968
+ case "acquired":
20969
+ case "taken-over-stale":
20970
+ heldLock = {
20971
+ sessionId,
20972
+ release: outcome.release
20973
+ };
20974
+ if (outcome.kind === "taken-over-stale") diag.warn("session lock stale taken over", { session: sessionId });
20975
+ return;
20976
+ case "held":
20977
+ diag.warn("session lock held", {
20978
+ session: sessionId,
20979
+ pid: outcome.owner.pid
20980
+ });
20981
+ return lockHeldNotice(sessionId, outcome.owner);
20982
+ case "unverifiable":
20983
+ diag.warn("session lock unverifiable", {
20984
+ session: sessionId,
20985
+ pid: outcome.owner?.pid
20986
+ });
20987
+ return outcome.owner === void 0 ? `Session ${sessionId} has an unreadable or malformed lock file; refusing to open it here. If no other dsh process is using it, delete the owner.lock file next to the session log and retry.` : `Session ${sessionId} has a lock file whose owner (pid ${outcome.owner.pid}) cannot be verified; refusing to open it here. If that process is gone, delete the owner.lock file next to the session log and retry.`;
20988
+ case "unavailable": return;
20989
+ }
20990
+ };
20991
+ /** Release the open-time lock for a session (idempotent). */
20992
+ const releaseOpenLock = (sessionId) => {
20993
+ if (heldLock === void 0 || heldLock.sessionId !== sessionId) return;
20994
+ heldLock.release();
20995
+ heldLock = void 0;
20996
+ };
20997
+ /** Re-take the lock for the still-live current session after a failed
20998
+ * switch (refusal, resume failure, or an internal swap failure). The
20999
+ * lock was released before the target acquire, so another process may
21000
+ * have taken it in the window — the re-take result is checked and a
21001
+ * failed re-take surfaces as a notice (the write-path guard remains the
21002
+ * backstop, but the user must know the session is no longer locked by
21003
+ * this TUI). */
21004
+ const reacquireCurrentLock = (from, fromHeader) => {
21005
+ if (from === void 0) return;
21006
+ const refusal = acquireOpenLock(from, fromHeader ?? liveAgent?.session.header);
21007
+ if (refusal !== void 0) {
21008
+ const message = `the current session ${from} is no longer locked by this TUI (${refusal})`;
21009
+ ctx.logger.warn(`tui-runner: ${message}`);
21010
+ diag.warn("session lock lost on failed switch", { session: from });
21011
+ app?.notify(message, "error");
21012
+ }
21013
+ };
19856
21014
  let resumeFailure;
19857
21015
  let handle;
19858
21016
  if (sessionId !== void 0) try {
21017
+ let lockHeader;
21018
+ try {
21019
+ lockHeader = (await ctx.get("sessionPersistence")?.list())?.find((candidate) => candidate.id === sessionId);
21020
+ } catch {}
21021
+ const lockRefusal = acquireOpenLock(sessionId, lockHeader);
21022
+ if (lockRefusal !== void 0) throw new SessionLockRefusedError(lockRefusal);
19859
21023
  const recorded = await recordedPreset(ctx, sessionId);
19860
21024
  const composition = await compose(recorded);
19861
21025
  handle = await agents.resume({
@@ -19887,6 +21051,7 @@ function apply(ctx, config) {
19887
21051
  });
19888
21052
  }
19889
21053
  } catch (error) {
21054
+ if (error instanceof SessionLockRefusedError) throw error;
19890
21055
  const message = safeErrorMessage(error);
19891
21056
  ctx.logger.warn(`tui-runner: resume ${sessionId} failed: ${message}`);
19892
21057
  diag.error("resume failed", {
@@ -19894,6 +21059,7 @@ function apply(ctx, config) {
19894
21059
  error: message
19895
21060
  });
19896
21061
  resumeFailure = `session ${sessionId} could not be resumed; started a fresh session`;
21062
+ releaseOpenLock(sessionId);
19897
21063
  const launched = await launchComposition();
19898
21064
  if (launched.failure !== void 0) resumeFailure = launched.failure;
19899
21065
  handle = await agents.create({
@@ -19905,6 +21071,7 @@ function apply(ctx, config) {
19905
21071
  agentOptions,
19906
21072
  setup: launched.composition.setup
19907
21073
  });
21074
+ acquireOpenLock(handle.agent.session.id, handle.agent.session.header);
19908
21075
  }
19909
21076
  let liveHandle = handle;
19910
21077
  let liveAgent = handle?.agent;
@@ -20048,6 +21215,7 @@ function apply(ctx, config) {
20048
21215
  /** Swap the live agent to a new handle, repainting for its session. */
20049
21216
  const swapTo = async (next) => {
20050
21217
  const from = liveAgent?.session.id;
21218
+ const fromHeader = liveAgent?.session.header;
20051
21219
  try {
20052
21220
  if (liveAgent !== void 0) {
20053
21221
  await sessions.flush(liveAgent.session);
@@ -20062,8 +21230,17 @@ function apply(ctx, config) {
20062
21230
  from,
20063
21231
  error: message
20064
21232
  });
21233
+ const repair = swapFailureLockRepair(heldLock, from);
21234
+ if (repair.release !== void 0) releaseOpenLock(repair.release);
21235
+ if (repair.reacquire !== void 0) reacquireCurrentLock(repair.reacquire, fromHeader);
20065
21236
  return `swap failed: ${message}`;
20066
21237
  }
21238
+ if (from !== void 0) releaseOpenLock(from);
21239
+ const swapLockRefusal = acquireOpenLock(next.agent.session.id, next.agent.session.header);
21240
+ if (swapLockRefusal !== void 0) {
21241
+ ctx.logger.warn(`tui-runner: lock refused on swap to ${next.agent.session.id}: ${swapLockRefusal}`);
21242
+ diag.warn("session lock refused on swap", { session: next.agent.session.id });
21243
+ }
20067
21244
  guardState = freshGuardState();
20068
21245
  guardToken = void 0;
20069
21246
  bumpSessionGeneration();
@@ -20080,6 +21257,19 @@ function apply(ctx, config) {
20080
21257
  * string so callers' `.then(error => ...)` need no rejection path. */
20081
21258
  const switchSession = async (sessionId) => {
20082
21259
  try {
21260
+ const from = liveAgent?.session.id;
21261
+ if (from !== void 0) releaseOpenLock(from);
21262
+ let lockHeader;
21263
+ try {
21264
+ lockHeader = (await ctx.get("sessionPersistence")?.list())?.find((candidate) => candidate.id === sessionId);
21265
+ } catch {}
21266
+ const lockRefusal = acquireOpenLock(sessionId, lockHeader);
21267
+ if (lockRefusal !== void 0) {
21268
+ ctx.logger.warn(`tui-runner: switch to ${sessionId} refused: ${lockRefusal}`);
21269
+ diag.warn("session lock held on switch", { session: sessionId });
21270
+ reacquireCurrentLock(from);
21271
+ return lockRefusal;
21272
+ }
20083
21273
  const composition = await compose(await recordedPreset(ctx, sessionId));
20084
21274
  const next = await agents.resume({
20085
21275
  resumeSessionId: SessionId(sessionId),
@@ -20097,6 +21287,8 @@ function apply(ctx, config) {
20097
21287
  session: sessionId,
20098
21288
  error: message
20099
21289
  });
21290
+ releaseOpenLock(sessionId);
21291
+ reacquireCurrentLock(liveAgent?.session.id);
20100
21292
  return `switch failed: ${message}`;
20101
21293
  }
20102
21294
  };
@@ -20154,6 +21346,10 @@ function apply(ctx, config) {
20154
21346
  if (cleanedUp) return;
20155
21347
  cleanedUp = true;
20156
21348
  guardToken = void 0;
21349
+ if (heldLock !== void 0) {
21350
+ heldLock.release();
21351
+ heldLock = void 0;
21352
+ }
20157
21353
  lifecycleController.abort();
20158
21354
  catalogCoordinator?.dispose();
20159
21355
  localShellController?.abort();
@@ -20626,6 +21822,12 @@ function apply(ctx, config) {
20626
21822
  });
20627
21823
  };
20628
21824
  /**
21825
+ * The newest input-history entry this process persisted (kimi's
21826
+ * `lastHistoryContent` analogue): consecutive repeats are skipped per
21827
+ * window, exactly like shell history.
21828
+ */
21829
+ let lastHistoryContent;
21830
+ /**
20629
21831
  * Dispatch one user submission end to end: the viewer guard, the input-
20630
21832
  * history persistence, `!` local shells, sessionless commands, the
20631
21833
  * busy-Enter preference, and the session dispatch. The Ctrl+Enter
@@ -20635,22 +21837,23 @@ function apply(ctx, config) {
20635
21837
  * @param forceQueue - the chord: never steer, queue instead.
20636
21838
  */
20637
21839
  const dispatchUserInput = (text, forceQueue = false) => {
21840
+ if (isPlainExitPrompt(text)) {
21841
+ requestExit();
21842
+ return;
21843
+ }
20638
21844
  if (viewing !== void 0) {
20639
21845
  app.setEditorText(mergeDraft(app.getDraft(), text));
20640
21846
  app.notify("viewing a subagent — Esc returns before submitting", "info");
20641
21847
  return;
20642
21848
  }
20643
21849
  app.clearSettledLocalMessages();
20644
- const history = app.getInputHistory();
20645
- if (history.length > 0) {
20646
- const settings = tuiSettings;
20647
- if (settings !== void 0) runDetached("settings history write", () => settings.replace({
20648
- ...settings.get(),
20649
- history: {
20650
- ...settings.get().history,
20651
- [sessionCwd()]: history
20652
- }
20653
- }), {
21850
+ const trimmed = text.trim();
21851
+ if (trimmed !== "" && trimmed !== lastHistoryContent) {
21852
+ const file = historyFilePath(dshHome(process.env), sessionCwd());
21853
+ runDetached("input history write", () => {
21854
+ appendHistoryLine(file, trimmed, lastHistoryContent);
21855
+ lastHistoryContent = trimmed;
21856
+ }, {
20654
21857
  diag,
20655
21858
  notify: (message) => app.notify(message, "error"),
20656
21859
  recoverable: () => true
@@ -20788,7 +21991,6 @@ function apply(ctx, config) {
20788
21991
  if (jobs !== void 0) try {
20789
21992
  jobSnapshots = jobs.list(liveAgent);
20790
21993
  } catch {}
20791
- const now = Date.now();
20792
21994
  let rows = buildTaskRows(jobSnapshots, []);
20793
21995
  const selectRow = (value) => {
20794
21996
  const row = rows.find((candidate) => candidate.value === value);
@@ -20803,16 +22005,23 @@ function apply(ctx, config) {
20803
22005
  }
20804
22006
  openJobView(row.jobId);
20805
22007
  };
20806
- const pickerItems = (target) => target.map((row) => ({
22008
+ const taskPanelItems = (target) => target.map((row) => row.kind === "job" ? {
20807
22009
  value: row.value,
20808
22010
  label: taskRowLabel(row),
20809
- description: describeTaskRow(row, now),
22011
+ status: row.status,
22012
+ detail: row.detail,
22013
+ startedAt: row.startedAt,
20810
22014
  group: rowGroup(row)
20811
- }));
20812
- const handle = app.openPicker(pickerItems(rows), selectRow, () => {}, {
22015
+ } : {
22016
+ value: row.value,
22017
+ label: taskRowLabel(row),
22018
+ status: row.activity,
22019
+ detail: row.hasChildren ? "has children" : void 0,
22020
+ group: rowGroup(row)
22021
+ });
22022
+ const handle = app.openTaskBrowser(taskPanelItems(rows), selectRow, () => {}, {
20813
22023
  header: "tasks · subagents",
20814
- enableSearch: true,
20815
- showHint: true
22024
+ enableSearch: true
20816
22025
  });
20817
22026
  if (subagents !== void 0) {
20818
22027
  const sessionId = liveAgent.session.id;
@@ -20823,7 +22032,7 @@ function apply(ctx, config) {
20823
22032
  onResult: (entries) => {
20824
22033
  if (sessionGeneration !== generation || liveAgent?.session.id !== sessionId) return;
20825
22034
  rows = buildTaskRows(jobSnapshots, entries);
20826
- handle.setItems(pickerItems(rows));
22035
+ handle.setItems(taskPanelItems(rows));
20827
22036
  }
20828
22037
  });
20829
22038
  }
@@ -20836,14 +22045,12 @@ function apply(ctx, config) {
20836
22045
  theme: z.string(),
20837
22046
  footer: z.string(),
20838
22047
  fullscreen: z.string(),
20839
- busyEnter: z.string(),
20840
- history: z.dict(z.array(z.string()))
22048
+ busyEnter: z.string()
20841
22049
  }), { base: {
20842
22050
  theme: "auto",
20843
22051
  footer: "full",
20844
22052
  fullscreen: "on",
20845
- busyEnter: "queue",
20846
- history: {}
22053
+ busyEnter: "queue"
20847
22054
  } });
20848
22055
  if (tuiSettings?.get().fullscreen === "on") app.setFullscreen(true);
20849
22056
  const storedTheme = tuiSettings?.get().theme;
@@ -20862,6 +22069,39 @@ function apply(ctx, config) {
20862
22069
  app.trackTerminalTheme(false);
20863
22070
  }
20864
22071
  if (tuiSettings?.get().footer === "compact") app.setFooterPreset("compact");
22072
+ let legacyHistory;
22073
+ try {
22074
+ const value = ((ctx.get("settings")?.describe().find((d) => d.ns === settingsNamespace("dsh-pi-tui")))?.user)?.history;
22075
+ if (typeof value === "object" && value !== null) legacyHistory = value;
22076
+ } catch {}
22077
+ if (legacyHistory !== void 0) {
22078
+ const home = dshHome(process.env);
22079
+ for (const [cwd, entries] of Object.entries(legacyHistory)) {
22080
+ if (!Array.isArray(entries) || entries.length === 0) continue;
22081
+ const file = historyFilePath(home, cwd);
22082
+ if (existsSync(file)) continue;
22083
+ for (const entry of entries.slice().reverse()) try {
22084
+ appendHistoryLine(file, entry, void 0);
22085
+ } catch {}
22086
+ }
22087
+ if (tuiSettings !== void 0) {
22088
+ if (Object.entries(legacyHistory).every(([cwd, entries]) => {
22089
+ if (!Array.isArray(entries) || entries.length === 0) return true;
22090
+ return existsSync(historyFilePath(home, cwd));
22091
+ })) runDetached("settings history cleanup", () => {
22092
+ const doc = { ...tuiSettings.get() };
22093
+ delete doc.history;
22094
+ tuiSettings.replace(doc);
22095
+ }, {
22096
+ diag,
22097
+ notify: (message) => app.notify(message, "error"),
22098
+ recoverable: () => true
22099
+ });
22100
+ }
22101
+ }
22102
+ const bootHistoryEntries = loadHistoryFile(historyFilePath(dshHome(process.env), cwd));
22103
+ lastHistoryContent = bootHistoryEntries.at(-1);
22104
+ app.resetInputHistory([...bootHistoryEntries].reverse());
20865
22105
  refreshStatus();
20866
22106
  let refreshTasks = () => {};
20867
22107
  let refreshAgents = () => {};
@@ -20977,23 +22217,16 @@ function apply(ctx, config) {
20977
22217
  const jobStatusHint = (status, detail) => {
20978
22218
  return `${status}${status === "running" || status === "stopping" ? " — output is delivered to the agent via job_output; viewing never consumes the job’s read cursor" : ` — final output: ask the agent to run job_output in the conversation${detail === void 0 ? "" : ` (${detail})`}`}`;
20979
22219
  };
22220
+ const notifiedSubagentNotices = /* @__PURE__ */ new Set();
20980
22221
  const refreshQueue = () => {
20981
22222
  if (liveAgent === void 0) {
20982
22223
  app.setQueueItems([]);
20983
22224
  return;
20984
22225
  }
20985
- const queue = [...liveAgent.inbox.nextTurn.map((message) => ({
20986
- id: message.id,
20987
- text: textOf(message.content),
20988
- mode: "followup",
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);
22226
+ const turn = foldQueueRows(liveAgent.inbox.nextTurn, "followup", notifiedSubagentNotices);
22227
+ const step = foldQueueRows(liveAgent.inbox.nextStep, "steer", notifiedSubagentNotices);
22228
+ for (const summary of [...turn.failures, ...step.failures]) app.notify(summary, "error");
22229
+ app.setQueueItems([...turn.rows, ...step.rows]);
20997
22230
  };
20998
22231
  refreshQueue();
20999
22232
  /**
@@ -21012,12 +22245,16 @@ function apply(ctx, config) {
21012
22245
  app.setSessionTitle(foldSessionTitle(agent.session.events)?.title);
21013
22246
  app.clearLocalMessages();
21014
22247
  app.clearNotify();
22248
+ notifiedSubagentNotices.clear();
21015
22249
  repaint(app, folder);
21016
22250
  refreshStatus();
21017
22251
  refreshQueue();
21018
22252
  refreshTasks();
21019
22253
  refreshAgents();
21020
- app.resetInputHistory(tuiSettings?.get().history[sessionCwd()] ?? []);
22254
+ const historyCwd = sessionCwd();
22255
+ const historyEntries = loadHistoryFile(historyFilePath(dshHome(process.env), historyCwd));
22256
+ lastHistoryContent = historyEntries.at(-1);
22257
+ app.resetInputHistory([...historyEntries].reverse());
21021
22258
  setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())} · ${agent.session.id}`);
21022
22259
  updateWelcomeCard();
21023
22260
  registerCommands({
@@ -21049,6 +22286,7 @@ function apply(ctx, config) {
21049
22286
  });
21050
22287
  liveHandle = created;
21051
22288
  liveAgent = created.agent;
22289
+ acquireOpenLock(liveAgent.session.id, liveAgent.session.header);
21052
22290
  await liveAgent.whenIdle();
21053
22291
  bumpSessionGeneration();
21054
22292
  await initLiveSession(liveAgent);
@@ -21073,6 +22311,7 @@ function apply(ctx, config) {
21073
22311
  });
21074
22312
  liveHandle = created;
21075
22313
  liveAgent = created.agent;
22314
+ acquireOpenLock(liveAgent.session.id, liveAgent.session.header);
21076
22315
  await liveAgent.whenIdle();
21077
22316
  bumpSessionGeneration();
21078
22317
  await initLiveSession(liveAgent);
@@ -21381,4 +22620,4 @@ function apply(ctx, config) {
21381
22620
  });
21382
22621
  }
21383
22622
  //#endregion
21384
- export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resolveInitialCatalog, resumeCommand, runningProfile, shouldConsumeAdvertisedMiss, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };
22623
+ export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, SessionLockRefusedError, apply, composeAgent, dangerCommand, foldQueueRows, forkSeed, inject, isPlainExitPrompt, isSubagentSettlementNotice, name, recomposeBlank, recordedPreset, resolveInitialCatalog, resumeCommand, runningProfile, shouldConsumeAdvertisedMiss, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint, subagentNoticeIsFailure };