dsh-code 0.7.0 → 0.8.0

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.
Files changed (44) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +2685 -622
  4. package/lib/types/app.d.ts +77 -1
  5. package/lib/types/history.d.ts +15 -4
  6. package/lib/types/index.d.ts +48 -0
  7. package/lib/types/kernel-panels.d.ts +7 -0
  8. package/lib/types/permissions.d.ts +37 -0
  9. package/lib/types/presets.d.ts +2 -0
  10. package/lib/types/provider-settings.d.ts +144 -0
  11. package/lib/types/questions.d.ts +2 -0
  12. package/lib/types/render/animations.d.ts +8 -6
  13. package/lib/types/render/lines.d.ts +6 -0
  14. package/lib/types/render/markdown.d.ts +3 -3
  15. package/lib/types/render/projection.d.ts +95 -3
  16. package/lib/types/render/status.d.ts +26 -36
  17. package/lib/types/render/text.d.ts +14 -7
  18. package/lib/types/render/tool-detail.d.ts +3 -1
  19. package/lib/types/render/tool-preview.d.ts +4 -1
  20. package/lib/types/session-directory.d.ts +15 -0
  21. package/lib/types/store.d.ts +13 -2
  22. package/lib/types/version.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/src/app.ts +847 -150
  25. package/src/approval.ts +11 -2
  26. package/src/history.ts +20 -5
  27. package/src/index.ts +402 -159
  28. package/src/kernel-panels.ts +45 -8
  29. package/src/permissions.ts +85 -0
  30. package/src/presets.ts +12 -0
  31. package/src/provider-settings.ts +520 -0
  32. package/src/questions.ts +15 -5
  33. package/src/render/animations.ts +32 -18
  34. package/src/render/lines.ts +21 -6
  35. package/src/render/markdown.ts +302 -4
  36. package/src/render/projection.ts +665 -10
  37. package/src/render/status.ts +68 -162
  38. package/src/render/text.ts +28 -9
  39. package/src/render/tool-detail.ts +81 -40
  40. package/src/render/tool-preview.ts +18 -2
  41. package/src/session-directory.ts +44 -5
  42. package/src/skills.ts +8 -4
  43. package/src/store.ts +26 -8
  44. package/src/version.ts +16 -0
package/lib/index.mjs CHANGED
@@ -8,7 +8,7 @@ import { mkdir, readdir, writeFile } from "node:fs/promises";
8
8
  import { basename, dirname, join, resolve } from "node:path";
9
9
  import z from "@deepseek-ai/schemastery";
10
10
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
11
- import { MessageId, ReasoningEffortId, assertNever, boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
11
+ import { MessageId, ReasoningEffortId, assertNever, boundContextSummary, createUserMessage, normalizeApiKey } from "@deepseek-ai/dsh-llm";
12
12
  import { SessionId } from "@deepseek-ai/dsh-session";
13
13
  import { PassThrough, Stream } from "node:stream";
14
14
  import process$1, { cwd, env } from "node:process";
@@ -24356,22 +24356,35 @@ function followInspectorCursor(cursor, previousLength, nextLength) {
24356
24356
  * tool payloads, skill descriptions). Control characters — including ANSI
24357
24357
  * CSI/OSC escape sequences — would otherwise pass through Ink into the
24358
24358
  * terminal, letting output rewrite the screen or inject prompts. Newlines
24359
- * and tabs survive; everything else in C0/C1 plus DEL becomes a visible
24360
- * `\xNN` escape.
24359
+ * survive; everything else in C0/C1 plus DEL becomes a visible `\xNN`
24360
+ * escape, and bidi overrides / invisible format controls / Unicode line and
24361
+ * paragraph separators become a visible `\uXXXX` escape (terminal emulators
24362
+ * that render bidirectional text would otherwise reorder the displayed
24363
+ * glyphs and let a command read as something it is not).
24361
24364
  *
24362
24365
  * @module @deepseek-ai/dsh-code/render/text
24363
24366
  */
24364
24367
  /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
24365
24368
  const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
24366
24369
  /**
24367
- * Escape control characters so externally sourced text cannot drive the
24368
- * terminal.
24370
+ * Bidi overrides and isolates (U+202A-202E, U+2066-2069), the Arabic Letter
24371
+ * Mark (U+061C), directional and zero-width format characters (U+200B,
24372
+ * U+200E/200F, U+2060-2064, U+FEFF), and Unicode line/paragraph separators
24373
+ * (U+2028/2029). Terminal emulators with bidi support (Windows Terminal,
24374
+ * iTerm2, kitty, WezTerm) reorder or hide these, so they must never reach
24375
+ * the terminal raw.
24376
+ */
24377
+ const INVISIBLE_ESCAPE = /[\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
24378
+ /**
24379
+ * Escape control and deceptive characters so externally sourced text cannot
24380
+ * drive the terminal. C0/C1/DEL render as a literal `\xNN` escape; bidi,
24381
+ * invisible-format, and separator controls render as a literal `\uXXXX`
24382
+ * escape. Newlines and tabs survive (budgeted callers normalize tabs).
24369
24383
  * @param text - raw text from a session event, tool payload, or catalog.
24370
- * @returns text with every control character (except `\n`, `\t`) rendered
24371
- * as a literal `\xNN` escape.
24384
+ * @returns display-safe text with every injectable character made visible.
24372
24385
  */
24373
24386
  function displayText(text) {
24374
- return text.replace(CONTROL_ESCAPE, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
24387
+ return text.replace(CONTROL_ESCAPE, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`).replace(INVISIBLE_ESCAPE, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
24375
24388
  }
24376
24389
  /** Collapse external text to one terminal-safe logical row. */
24377
24390
  function singleLineText(text) {
@@ -24423,7 +24436,10 @@ function previousCharacter(text, end) {
24423
24436
  * Keep only the newest display-safe text that fits a terminal rectangle.
24424
24437
  * The scan walks backward and stops as soon as the suffix is full, so a long
24425
24438
  * reasoning stream does not rescan its entire accumulated prefix per chunk.
24426
- * Explicit newlines and terminal wrapping both consume rows.
24439
+ * Explicit newlines and terminal wrapping both consume rows; tabs expand to
24440
+ * two spaces so terminal tab stops (which render at contextual column 8
24441
+ * boundaries, not at the budgeted cell count) cannot inflate the physical
24442
+ * row count of the live region.
24427
24443
  * @param text - raw externally sourced text.
24428
24444
  * @param columns - available terminal columns.
24429
24445
  * @param rows - available terminal rows.
@@ -24446,7 +24462,7 @@ function displayTail(text, columns, rows) {
24446
24462
  end = previous.start;
24447
24463
  continue;
24448
24464
  }
24449
- const safe = displayText(previous.char);
24465
+ const safe = previous.char === " " ? " " : displayText(previous.char);
24450
24466
  const width = cellWidth(safe);
24451
24467
  if (used > 0 && used + width > columnLimit) {
24452
24468
  if (row >= rowLimit) break;
@@ -24551,12 +24567,29 @@ const WHALE_GLYPH = [
24551
24567
  " ▀▀███████▀▀ ▀▀▀ "
24552
24568
  ];
24553
24569
  //#endregion
24570
+ //#region src/version.ts
24571
+ /** Installed dsh-code version exposed by the terminal header. */
24572
+ /** Read one package manifest version without making terminal startup depend on it. */
24573
+ function readPackageVersion(manifest = new URL("../package.json", import.meta.url)) {
24574
+ try {
24575
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
24576
+ return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : "0.0.0";
24577
+ } catch {
24578
+ return "0.0.0";
24579
+ }
24580
+ }
24581
+ /** Version of the installed dsh-code package. */
24582
+ const DSH_CODE_VERSION = readPackageVersion();
24583
+ //#endregion
24554
24584
  //#region src/render/tool-preview.ts
24555
24585
  /**
24556
24586
  * Bounded preview line for a tool invocation's raw JSON arguments: the first
24557
24587
  * human-meaningful string among the well-known keys (command, path, query, …)
24558
24588
  * with a fallback to the bounded raw JSON. Shared by the tool card in the
24559
- * transcript and the approval bar's command preview.
24589
+ * transcript and the approval bar's command preview. Arguments longer than
24590
+ * {@link MAX_PARSE_CHARS} are never parsed: the preview is a display concern,
24591
+ * and a synchronous `JSON.parse` plus string copies of an unbounded model
24592
+ * payload must not run on the approval or projection paths.
24560
24593
  *
24561
24594
  * @module @deepseek-ai/dsh-code/render/tool-preview
24562
24595
  */
@@ -24570,6 +24603,16 @@ const PREVIEW_KEYS = [
24570
24603
  "query"
24571
24604
  ];
24572
24605
  /**
24606
+ * Raw arguments longer than this are skipped without parsing and fall back
24607
+ * to the bounded raw preview. Well above any realistic command/path/query
24608
+ * string while keeping the synchronous parse cost negligible.
24609
+ */
24610
+ const MAX_PARSE_CHARS = 4096;
24611
+ /** Bounded raw-arguments fallback shared by the skip-parse and parse-failure paths. */
24612
+ function boundedRawPreview(args) {
24613
+ return args.length > 80 ? `${args.slice(0, 77)}...` : args;
24614
+ }
24615
+ /**
24573
24616
  * Resolve one bounded preview for raw tool arguments.
24574
24617
  * @param args - raw JSON arguments string as the model produced it.
24575
24618
  * @param toolName - the tool the arguments belong to (fallback label).
@@ -24577,6 +24620,7 @@ const PREVIEW_KEYS = [
24577
24620
  */
24578
24621
  function toolArgumentsPreview(args, toolName) {
24579
24622
  if (args === "") return toolName;
24623
+ if (args.length > MAX_PARSE_CHARS) return boundedRawPreview(args);
24580
24624
  try {
24581
24625
  const parsed = JSON.parse(args);
24582
24626
  if (parsed !== null && typeof parsed === "object") {
@@ -24587,7 +24631,7 @@ function toolArgumentsPreview(args, toolName) {
24587
24631
  }
24588
24632
  }
24589
24633
  } catch {}
24590
- return args.length > 80 ? `${args.slice(0, 77)}...` : args;
24634
+ return boundedRawPreview(args);
24591
24635
  }
24592
24636
  //#endregion
24593
24637
  //#region src/render/tool-detail.ts
@@ -24609,6 +24653,9 @@ const MAX_READ_LINES = 120;
24609
24653
  const MAX_SOURCES = 10;
24610
24654
  const MAX_RAW_CHARS = 6e3;
24611
24655
  const MAX_LINE_COLUMNS = 240;
24656
+ /** Hard caps on adversarial `tool/result.meta` before any row is built. */
24657
+ const MAX_DIFFS = 8;
24658
+ const MAX_DIFF_TEXT_CHARS = 24e3;
24612
24659
  /** Truncate one line to the visible-column budget with an ellipsis marker. */
24613
24660
  function clipLine(text) {
24614
24661
  return text.length > MAX_LINE_COLUMNS ? `${text.slice(0, 239)}…` : text;
@@ -24622,35 +24669,51 @@ function toLines(text) {
24622
24669
  * Render one change as removed-then-added rows, hunked by common prefix and
24623
24670
  * suffix. A null before-image (file create) renders as pure additions. The
24624
24671
  * budget caps emitted rows and reports the cut, so a whole-file overwrite
24625
- * never floods the transcript.
24672
+ * never floods the transcript. Inputs are hard-capped before line splitting
24673
+ * and the row list is built incrementally up to the budget — a crafted or
24674
+ * replayed giant diff cannot force a full intermediate rows array.
24626
24675
  * @param oldText - prior content, or null for a create.
24627
24676
  * @param newText - content after the change.
24628
24677
  * @param budget - maximum rows to emit.
24629
24678
  * @returns the bounded rows and whether they were cut.
24630
24679
  */
24631
24680
  function diffRows(oldText, newText, budget) {
24632
- const oldLines = oldText === null ? [] : toLines(oldText);
24633
- const newLines = toLines(newText);
24681
+ const oldRaw = oldText ?? "";
24682
+ const newRaw = newText;
24683
+ let inputTruncated = false;
24684
+ let oldSource = oldRaw;
24685
+ let newSource = newRaw;
24686
+ const combined = oldRaw.length + newRaw.length;
24687
+ if (combined > MAX_DIFF_TEXT_CHARS) {
24688
+ inputTruncated = true;
24689
+ const oldShare = Math.min(oldRaw.length, Math.floor(MAX_DIFF_TEXT_CHARS * oldRaw.length / combined));
24690
+ const newShare = Math.min(newRaw.length, MAX_DIFF_TEXT_CHARS - oldShare);
24691
+ oldSource = oldRaw.slice(0, oldShare);
24692
+ newSource = newRaw.slice(0, newShare);
24693
+ }
24694
+ const oldLines = oldText === null ? [] : toLines(oldSource);
24695
+ const newLines = toLines(newSource);
24634
24696
  let prefix = 0;
24635
24697
  while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1;
24636
24698
  let suffix = 0;
24637
24699
  while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix += 1;
24638
- const removed = oldLines.slice(prefix, oldLines.length - suffix);
24639
- const added = newLines.slice(prefix, newLines.length - suffix);
24640
- const rows = [...removed.map((text) => ({
24700
+ const removedCount = oldLines.length - prefix - suffix;
24701
+ const addedCount = newLines.length - prefix - suffix;
24702
+ const truncated = inputTruncated || removedCount + addedCount > budget;
24703
+ const rows = [];
24704
+ const removedLimit = Math.min(removedCount, Math.max(0, budget));
24705
+ for (let index = 0; index < removedLimit; index += 1) rows.push({
24641
24706
  mark: "-",
24642
- text: clipLine(text)
24643
- })), ...added.map((text) => ({
24707
+ text: clipLine(oldLines[prefix + index] ?? "")
24708
+ });
24709
+ const addedLimit = Math.min(addedCount, Math.max(0, budget - removedLimit));
24710
+ for (let index = 0; index < addedLimit; index += 1) rows.push({
24644
24711
  mark: "+",
24645
- text: clipLine(text)
24646
- }))];
24647
- if (rows.length <= budget) return {
24648
- lines: rows,
24649
- truncated: false
24650
- };
24712
+ text: clipLine(newLines[prefix + index] ?? "")
24713
+ });
24651
24714
  return {
24652
- lines: rows.slice(0, budget),
24653
- truncated: true
24715
+ lines: rows,
24716
+ truncated
24654
24717
  };
24655
24718
  }
24656
24719
  /** Whether `value` is a valid upstream FileDiff (defensive narrowing). */
@@ -24684,44 +24747,60 @@ function toolResultDetail(meta, rawText) {
24684
24747
  if (typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
24685
24748
  const record = meta;
24686
24749
  const diffs = record["diffs"];
24687
- if (Array.isArray(diffs) && diffs.length > 0 && diffs.every(isFileDiff)) {
24688
- const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / diffs.length));
24689
- return {
24690
- kind: "diff",
24691
- diffs: diffs.map((diff) => ({
24692
- path: diff.path,
24693
- ...diffRows(diff.oldText, diff.newText, budget)
24694
- }))
24695
- };
24750
+ if (Array.isArray(diffs) && diffs.length > 0) {
24751
+ const capped = diffs.slice(0, MAX_DIFFS);
24752
+ if (capped.every(isFileDiff)) {
24753
+ const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / capped.length));
24754
+ const dropped = diffs.length > capped.length;
24755
+ return {
24756
+ kind: "diff",
24757
+ diffs: capped.map((diff, index) => {
24758
+ const rows = diffRows(diff.oldText, diff.newText, budget);
24759
+ return dropped && index === capped.length - 1 ? {
24760
+ path: diff.path,
24761
+ ...rows,
24762
+ truncated: true
24763
+ } : {
24764
+ path: diff.path,
24765
+ ...rows
24766
+ };
24767
+ })
24768
+ };
24769
+ }
24696
24770
  }
24697
24771
  const { path, offset, lines, totalLines } = record;
24698
- if (typeof path === "string" && Number.isInteger(offset) && offset >= 1 && Number.isInteger(totalLines) && totalLines >= 0 && Array.isArray(lines) && lines.every(isReadLine)) {
24699
- const window = lines;
24700
- const truncated = window.length > MAX_READ_LINES;
24701
- return {
24702
- kind: "read",
24703
- path,
24704
- offset,
24705
- lines: (truncated ? window.slice(0, MAX_READ_LINES) : window).map((line) => ({
24706
- number: line.number,
24707
- text: clipLine(line.text)
24708
- })),
24709
- totalLines,
24710
- truncated
24711
- };
24772
+ if (typeof path === "string" && Number.isInteger(offset) && offset >= 1 && Number.isInteger(totalLines) && totalLines >= 0 && Array.isArray(lines)) {
24773
+ const window = lines.slice(0, MAX_READ_LINES);
24774
+ if (window.every(isReadLine)) {
24775
+ const truncated = lines.length > MAX_READ_LINES;
24776
+ return {
24777
+ kind: "read",
24778
+ path,
24779
+ offset,
24780
+ lines: window.map((line) => ({
24781
+ number: line.number,
24782
+ text: clipLine(line.text)
24783
+ })),
24784
+ totalLines,
24785
+ truncated
24786
+ };
24787
+ }
24712
24788
  }
24713
24789
  const sources = record["sources"];
24714
- if (Array.isArray(sources) && sources.every(isWebSource)) {
24715
- const truncated = sources.length > MAX_SOURCES;
24716
- return {
24717
- kind: "web-search",
24718
- sources: (truncated ? sources.slice(0, MAX_SOURCES) : sources).map((source) => ({
24719
- url: source.url,
24720
- title: typeof source.title === "string" ? source.title : void 0,
24721
- snippet: typeof source.snippet === "string" ? clipLine(source.snippet) : ""
24722
- })),
24723
- truncated
24724
- };
24790
+ if (Array.isArray(sources)) {
24791
+ const capped = sources.slice(0, MAX_SOURCES);
24792
+ if (capped.every(isWebSource)) {
24793
+ const truncated = sources.length > MAX_SOURCES;
24794
+ return {
24795
+ kind: "web-search",
24796
+ sources: capped.map((source) => ({
24797
+ url: source.url,
24798
+ title: typeof source.title === "string" ? source.title : void 0,
24799
+ snippet: typeof source.snippet === "string" ? clipLine(source.snippet) : ""
24800
+ })),
24801
+ truncated
24802
+ };
24803
+ }
24725
24804
  }
24726
24805
  const { url, statusCode } = record;
24727
24806
  if (typeof url === "string" && typeof statusCode === "number") return {
@@ -24750,6 +24829,15 @@ function toolResultDetail(meta, rawText) {
24750
24829
  */
24751
24830
  /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
24752
24831
  const MAX_STREAMING_CHARS = 65536;
24832
+ /**
24833
+ * Upper bound on remembered `compaction/summary` shadow prices waiting for a
24834
+ * matching `compaction/end`. Compactions are sequential and rare, so a few
24835
+ * slots suffice; an aborted compaction (summary without end) otherwise leaves
24836
+ * an unbounded residue in `anchors.compactionTokens`. An evicted price
24837
+ * degrades to the documented `lastPruneTokens` fallback, exactly like a
24838
+ * missing summary.
24839
+ */
24840
+ const MAX_COMPACTION_SUMMARY_RESIDUE = 16;
24753
24841
  /** Append one delta without retaining an unbounded duplicate of the live reply. */
24754
24842
  function appendStreamingTail(current, delta) {
24755
24843
  const next = current + delta;
@@ -24828,7 +24916,9 @@ function createTranscriptView() {
24828
24916
  firstChunkAt: /* @__PURE__ */ new Map(),
24829
24917
  compactionTokens: /* @__PURE__ */ new Map(),
24830
24918
  lastPruneTokens: 0,
24831
- turnFiles: /* @__PURE__ */ new Map()
24919
+ turnFiles: /* @__PURE__ */ new Map(),
24920
+ turnSteps: /* @__PURE__ */ new Map(),
24921
+ turnTools: /* @__PURE__ */ new Map()
24832
24922
  }
24833
24923
  };
24834
24924
  }
@@ -24954,6 +25044,7 @@ function projectEvent(view, event) {
24954
25044
  view.anchors.stepStart.delete(key);
24955
25045
  const firstChunk = view.anchors.firstChunkAt.get(key);
24956
25046
  view.anchors.firstChunkAt.delete(key);
25047
+ if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn);
24957
25048
  const usage = event.data.usage;
24958
25049
  const totals = view.stats.usage;
24959
25050
  const text = textOf(event.data.message.content);
@@ -24989,6 +25080,9 @@ function projectEvent(view, event) {
24989
25080
  case "tool/call": {
24990
25081
  const data = event.data;
24991
25082
  view.anchors.toolStart.set(data.callId, event.time);
25083
+ const turnTools = view.anchors.turnTools.get(data.turn) ?? /* @__PURE__ */ new Set();
25084
+ turnTools.add(data.callId);
25085
+ view.anchors.turnTools.set(data.turn, turnTools);
24992
25086
  return {
24993
25087
  ...view,
24994
25088
  entries: [...view.entries, {
@@ -25014,6 +25108,11 @@ function projectEvent(view, event) {
25014
25108
  const block = event.data.message.content[0];
25015
25109
  const started = view.anchors.toolStart.get(block.toolCallId);
25016
25110
  view.anchors.toolStart.delete(block.toolCallId);
25111
+ const turnTools = view.anchors.turnTools.get(event.data.turn);
25112
+ if (turnTools !== void 0) {
25113
+ turnTools.delete(block.toolCallId);
25114
+ if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn);
25115
+ }
25017
25116
  const rawText = textOf(block.content);
25018
25117
  const summary = boundContextSummary(rawText);
25019
25118
  const detail = toolResultDetail(event.data.meta, rawText);
@@ -25058,8 +25157,15 @@ function projectEvent(view, event) {
25058
25157
  turns: view.stats.turns + 1
25059
25158
  }
25060
25159
  };
25061
- case "step/start":
25062
- view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time);
25160
+ case "step/start": {
25161
+ const key = `${event.data.turn}:${event.data.step}`;
25162
+ const previous = view.anchors.turnSteps.get(event.data.turn);
25163
+ if (previous !== void 0 && previous !== key) {
25164
+ view.anchors.stepStart.delete(previous);
25165
+ view.anchors.firstChunkAt.delete(previous);
25166
+ }
25167
+ view.anchors.turnSteps.set(event.data.turn, key);
25168
+ view.anchors.stepStart.set(key, event.time);
25063
25169
  return {
25064
25170
  ...view,
25065
25171
  stats: {
@@ -25067,14 +25173,17 @@ function projectEvent(view, event) {
25067
25173
  steps: view.stats.steps + 1
25068
25174
  }
25069
25175
  };
25176
+ }
25070
25177
  case "turn/end": {
25071
25178
  const reason = event.data.reason;
25072
25179
  const appended = [];
25073
- if (reason.kind === "error") appended.push({
25074
- kind: "error",
25075
- text: `${reason.error.code}: ${reason.error.message}`
25076
- });
25077
- else {
25180
+ if (reason.kind === "error") {
25181
+ const recovery = reason.error.code === "MISSING_CREDENTIAL" ? " · open /model to add an API key" : "";
25182
+ appended.push({
25183
+ kind: "error",
25184
+ text: `${reason.error.code}: ${reason.error.message}${recovery}`
25185
+ });
25186
+ } else {
25078
25187
  const marker = reason.kind === "aborted" ? reason.reason.kind === "user" ? "turn cancelled by the user" : `turn cancelled (${reason.reason.kind})` : reason.kind === "max-tokens" ? "turn hit the output-token ceiling (max-tokens)" : reason.kind === "blocked" ? "turn ended blocked" : reason.kind === "interrupted" ? "turn was interrupted by a restart" : void 0;
25079
25188
  if (marker !== void 0) appended.push({
25080
25189
  kind: "turn-marker",
@@ -25087,6 +25196,17 @@ function projectEvent(view, event) {
25087
25196
  kind: "files",
25088
25197
  paths: [...files].slice(0, 12)
25089
25198
  });
25199
+ const stepKey = view.anchors.turnSteps.get(event.data.turn);
25200
+ if (stepKey !== void 0) {
25201
+ view.anchors.stepStart.delete(stepKey);
25202
+ view.anchors.firstChunkAt.delete(stepKey);
25203
+ view.anchors.turnSteps.delete(event.data.turn);
25204
+ }
25205
+ const turnToolSet = view.anchors.turnTools.get(event.data.turn);
25206
+ if (turnToolSet !== void 0) {
25207
+ for (const callId of turnToolSet) view.anchors.toolStart.delete(callId);
25208
+ view.anchors.turnTools.delete(event.data.turn);
25209
+ }
25090
25210
  if (appended.length === 0) return {
25091
25211
  ...view,
25092
25212
  busy: false,
@@ -25165,6 +25285,10 @@ function projectEvent(view, event) {
25165
25285
  title: event.data.title
25166
25286
  };
25167
25287
  case "compaction/summary":
25288
+ if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
25289
+ const oldest = view.anchors.compactionTokens.keys().next().value;
25290
+ if (oldest !== void 0) view.anchors.compactionTokens.delete(oldest);
25291
+ }
25168
25292
  view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount);
25169
25293
  return view;
25170
25294
  case "compaction/prune": return {
@@ -25250,13 +25374,561 @@ function projectEvent(view, event) {
25250
25374
  default: return view;
25251
25375
  }
25252
25376
  }
25377
+ /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
25378
+ function createReplayAccumulator() {
25379
+ return {
25380
+ entries: [],
25381
+ toolIndex: /* @__PURE__ */ new Map(),
25382
+ commandIndex: /* @__PURE__ */ new Map(),
25383
+ retryIndex: /* @__PURE__ */ new Map(),
25384
+ pendingIndex: /* @__PURE__ */ new Map(),
25385
+ removedCount: 0,
25386
+ pendingTurn: [],
25387
+ pendingStep: [],
25388
+ streaming: "",
25389
+ streamingReasoning: "",
25390
+ todos: [],
25391
+ busy: false,
25392
+ busySince: 0,
25393
+ model: "",
25394
+ plan: false,
25395
+ permission: "",
25396
+ title: "",
25397
+ sandbox: "",
25398
+ goal: void 0,
25399
+ stats: {
25400
+ turns: 0,
25401
+ steps: 0,
25402
+ llmMs: 0,
25403
+ toolMs: 0,
25404
+ usage: {
25405
+ inputTokens: 0,
25406
+ outputTokens: 0,
25407
+ cacheReadTokens: 0
25408
+ },
25409
+ lastPromptTokens: 0,
25410
+ contextWindow: 0,
25411
+ contextSegments: {
25412
+ system: 0,
25413
+ prompt: 0,
25414
+ assistant: 0,
25415
+ thinking: 0,
25416
+ tools: 0
25417
+ },
25418
+ ttftMs: 0,
25419
+ ttftSteps: 0,
25420
+ decodeMs: 0,
25421
+ decodeTokens: 0,
25422
+ reasoningEffort: ""
25423
+ },
25424
+ stepStart: /* @__PURE__ */ new Map(),
25425
+ toolStart: /* @__PURE__ */ new Map(),
25426
+ firstChunkAt: /* @__PURE__ */ new Map(),
25427
+ compactionTokens: /* @__PURE__ */ new Map(),
25428
+ lastPruneTokens: 0,
25429
+ turnFiles: /* @__PURE__ */ new Map(),
25430
+ turnSteps: /* @__PURE__ */ new Map(),
25431
+ turnTools: /* @__PURE__ */ new Map(),
25432
+ ops: 0
25433
+ };
25434
+ }
25435
+ /** Append one entry (O(1)) and account the push. */
25436
+ function appendReplayEntry(acc, entry) {
25437
+ acc.entries.push(entry);
25438
+ acc.ops += 1;
25439
+ }
25440
+ /**
25441
+ * Get (or create) the index list an id owns. Lists are never removed: every
25442
+ * appended row registers its index, so a lookup miss later proves no matching
25443
+ * row exists and the caller can no-op in O(1).
25444
+ */
25445
+ function indexList(map, id) {
25446
+ let list = map.get(id);
25447
+ if (list === void 0) {
25448
+ list = [];
25449
+ map.set(id, list);
25450
+ }
25451
+ return list;
25452
+ }
25453
+ /**
25454
+ * Apply an id-keyed update to every row that registered the id, mirroring the
25455
+ * copy-on-write reducer's full-array map semantics (all matching rows update,
25456
+ * in order). Each registered index is O(1), so a duplicate id costs
25457
+ * O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
25458
+ * registered indices are valid by construction, because tool/command/retry
25459
+ * rows are never removed and tombstones never shift indices.
25460
+ */
25461
+ function updateReplayById(acc, map, id, isMatch, update) {
25462
+ const list = map.get(id);
25463
+ if (list === void 0) return;
25464
+ for (const index of list) {
25465
+ const entry = acc.entries[index];
25466
+ if (entry === void 0 || !isMatch(entry)) continue;
25467
+ acc.entries[index] = update(entry);
25468
+ acc.ops += 1;
25469
+ }
25470
+ }
25471
+ /** Tombstone a retired pending row, keeping every other index stable. */
25472
+ function retireReplayEntry(acc, index) {
25473
+ if (acc.entries[index] !== void 0) {
25474
+ acc.entries[index] = void 0;
25475
+ acc.removedCount += 1;
25476
+ acc.ops += 1;
25477
+ }
25478
+ }
25479
+ /**
25480
+ * Fold one session event into a replay accumulator. This mirrors
25481
+ * {@link projectEvent} case for case — same stats arithmetic, same anchor
25482
+ * set/delete behavior, same entry shapes — so the finished view is identical
25483
+ * to a sequential fold; only the `entries` container operations are mutable.
25484
+ *
25485
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
25486
+ */
25487
+ function replayProjectEvent(acc, event) {
25488
+ switch (event.type) {
25489
+ case "user/message": {
25490
+ const message = event.data;
25491
+ for (const target of ["next-turn", "next-step"]) {
25492
+ const ids = target === "next-turn" ? acc.pendingTurn : acc.pendingStep;
25493
+ const index = ids.indexOf(message.id);
25494
+ acc.ops += index < 0 ? ids.length : index + 1;
25495
+ if (index < 0) continue;
25496
+ ids.splice(index, 1);
25497
+ acc.ops += 1;
25498
+ const list = acc.pendingIndex.get(message.id);
25499
+ if (list !== void 0) {
25500
+ for (const entryIndex of list) retireReplayEntry(acc, entryIndex);
25501
+ acc.ops += 1;
25502
+ }
25503
+ }
25504
+ const text = textOf(message.content);
25505
+ if (message.source.kind === "user") {
25506
+ appendReplayEntry(acc, {
25507
+ kind: "user",
25508
+ text,
25509
+ notice: false
25510
+ });
25511
+ acc.stats = {
25512
+ ...acc.stats,
25513
+ contextSegments: {
25514
+ ...acc.stats.contextSegments,
25515
+ prompt: acc.stats.contextSegments.prompt + estimateTokens(text)
25516
+ }
25517
+ };
25518
+ return;
25519
+ }
25520
+ const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
25521
+ const summary = boundContextSummary(notice);
25522
+ appendReplayEntry(acc, {
25523
+ kind: "user",
25524
+ text: summary,
25525
+ notice: true
25526
+ });
25527
+ acc.stats = {
25528
+ ...acc.stats,
25529
+ contextSegments: {
25530
+ ...acc.stats.contextSegments,
25531
+ system: acc.stats.contextSegments.system + estimateTokens(summary)
25532
+ }
25533
+ };
25534
+ return;
25535
+ }
25536
+ case "agent/inbox/spliced": {
25537
+ const { target, start, removedCount = 0, inserted } = event.data;
25538
+ const ids = target === "next-turn" ? acc.pendingTurn : acc.pendingStep;
25539
+ const removed = ids.slice(start, start + removedCount);
25540
+ acc.ops += removed.length;
25541
+ ids.splice(start, removedCount);
25542
+ acc.ops += removed.length;
25543
+ for (const id of removed) {
25544
+ const list = acc.pendingIndex.get(id);
25545
+ if (list === void 0) continue;
25546
+ for (const entryIndex of list) {
25547
+ const entry = acc.entries[entryIndex];
25548
+ if (entry !== void 0 && entry.kind === "pending" && entry.target === target) retireReplayEntry(acc, entryIndex);
25549
+ }
25550
+ }
25551
+ for (const message of inserted) {
25552
+ appendReplayEntry(acc, {
25553
+ kind: "pending",
25554
+ messageId: message.id,
25555
+ target,
25556
+ text: pendingText(message.content)
25557
+ });
25558
+ indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1);
25559
+ ids.push(message.id);
25560
+ acc.ops += 1;
25561
+ }
25562
+ return;
25563
+ }
25564
+ case "assistant/chunk": {
25565
+ const chunk = event.data.chunk;
25566
+ const key = `${event.data.turn}:${event.data.step}`;
25567
+ if ((chunk.type === "text-delta" || chunk.type === "reasoning-delta" ? chunk.text : "") !== "" && !acc.firstChunkAt.has(key)) {
25568
+ acc.firstChunkAt.set(key, event.time);
25569
+ const started = acc.stepStart.get(key);
25570
+ if (started !== void 0) acc.stats = {
25571
+ ...acc.stats,
25572
+ ttftMs: acc.stats.ttftMs + Math.max(0, event.time - started),
25573
+ ttftSteps: acc.stats.ttftSteps + 1
25574
+ };
25575
+ }
25576
+ if (chunk.type === "text-delta") {
25577
+ acc.streaming = appendStreamingTail(acc.streaming, chunk.text);
25578
+ return;
25579
+ }
25580
+ if (chunk.type === "reasoning-delta") {
25581
+ acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text);
25582
+ return;
25583
+ }
25584
+ return;
25585
+ }
25586
+ case "assistant/message": {
25587
+ const key = `${event.data.turn}:${event.data.step}`;
25588
+ const started = acc.stepStart.get(key);
25589
+ acc.stepStart.delete(key);
25590
+ const firstChunk = acc.firstChunkAt.get(key);
25591
+ acc.firstChunkAt.delete(key);
25592
+ if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn);
25593
+ const usage = event.data.usage;
25594
+ const totals = acc.stats.usage;
25595
+ const text = textOf(event.data.message.content);
25596
+ const reasoning = reasoningOf(event.data.message.content);
25597
+ acc.streaming = "";
25598
+ acc.streamingReasoning = "";
25599
+ appendReplayEntry(acc, {
25600
+ kind: "assistant",
25601
+ text,
25602
+ reasoning
25603
+ });
25604
+ acc.stats = {
25605
+ ...acc.stats,
25606
+ llmMs: acc.stats.llmMs + (started === void 0 ? 0 : Math.max(0, event.time - started)),
25607
+ usage: usage === void 0 ? totals : {
25608
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
25609
+ outputTokens: totals.outputTokens + usage.outputTokens,
25610
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0)
25611
+ },
25612
+ lastPromptTokens: usage === void 0 ? acc.stats.lastPromptTokens : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
25613
+ decodeMs: acc.stats.decodeMs + (firstChunk === void 0 ? 0 : Math.max(0, event.time - firstChunk)),
25614
+ decodeTokens: acc.stats.decodeTokens + (firstChunk === void 0 || usage === void 0 ? 0 : usage.outputTokens),
25615
+ contextSegments: {
25616
+ ...acc.stats.contextSegments,
25617
+ thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
25618
+ assistant: acc.stats.contextSegments.assistant + estimateTokens(text)
25619
+ }
25620
+ };
25621
+ return;
25622
+ }
25623
+ case "tool/call": {
25624
+ const data = event.data;
25625
+ acc.toolStart.set(data.callId, event.time);
25626
+ const turnTools = acc.turnTools.get(data.turn) ?? /* @__PURE__ */ new Set();
25627
+ turnTools.add(data.callId);
25628
+ acc.turnTools.set(data.turn, turnTools);
25629
+ appendReplayEntry(acc, {
25630
+ kind: "tool",
25631
+ callId: data.callId,
25632
+ name: data.name,
25633
+ arguments: data.arguments,
25634
+ preview: toolArgumentsPreview(data.arguments, data.name),
25635
+ state: "running",
25636
+ summary: "",
25637
+ detail: void 0
25638
+ });
25639
+ indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1);
25640
+ acc.stats = {
25641
+ ...acc.stats,
25642
+ contextSegments: {
25643
+ ...acc.stats.contextSegments,
25644
+ tools: acc.stats.contextSegments.tools + (typeof data.arguments === "string" ? estimateTokens(data.arguments) : 0)
25645
+ }
25646
+ };
25647
+ return;
25648
+ }
25649
+ case "tool/result": {
25650
+ const block = event.data.message.content[0];
25651
+ const started = acc.toolStart.get(block.toolCallId);
25652
+ acc.toolStart.delete(block.toolCallId);
25653
+ const turnTools = acc.turnTools.get(event.data.turn);
25654
+ if (turnTools !== void 0) {
25655
+ turnTools.delete(block.toolCallId);
25656
+ if (turnTools.size === 0) acc.turnTools.delete(event.data.turn);
25657
+ }
25658
+ const rawText = textOf(block.content);
25659
+ const summary = boundContextSummary(rawText);
25660
+ const detail = toolResultDetail(event.data.meta, rawText);
25661
+ if (detail?.kind === "diff") {
25662
+ const set = acc.turnFiles.get(event.data.turn) ?? /* @__PURE__ */ new Set();
25663
+ for (const diff of detail.diffs) set.add(diff.path);
25664
+ acc.turnFiles.set(event.data.turn, set);
25665
+ }
25666
+ const update = (entry) => ({
25667
+ ...entry,
25668
+ state: block.isError === true ? "error" : "done",
25669
+ summary,
25670
+ detail
25671
+ });
25672
+ updateReplayById(acc, acc.toolIndex, block.toolCallId, (entry) => entry.callId === block.toolCallId, update);
25673
+ acc.stats = {
25674
+ ...acc.stats,
25675
+ toolMs: acc.stats.toolMs + (started === void 0 ? 0 : Math.max(0, event.time - started)),
25676
+ contextSegments: {
25677
+ ...acc.stats.contextSegments,
25678
+ tools: acc.stats.contextSegments.tools + estimateTokens(rawText)
25679
+ }
25680
+ };
25681
+ return;
25682
+ }
25683
+ case "todo/write":
25684
+ acc.todos = event.data.todos;
25685
+ return;
25686
+ case "turn/start": {
25687
+ const wasBusy = acc.busy;
25688
+ acc.busy = true;
25689
+ acc.busySince = wasBusy ? acc.busySince : event.time;
25690
+ acc.todos = [];
25691
+ acc.stats = {
25692
+ ...acc.stats,
25693
+ turns: acc.stats.turns + 1
25694
+ };
25695
+ return;
25696
+ }
25697
+ case "step/start": {
25698
+ const key = `${event.data.turn}:${event.data.step}`;
25699
+ const previous = acc.turnSteps.get(event.data.turn);
25700
+ if (previous !== void 0 && previous !== key) {
25701
+ acc.stepStart.delete(previous);
25702
+ acc.firstChunkAt.delete(previous);
25703
+ }
25704
+ acc.turnSteps.set(event.data.turn, key);
25705
+ acc.stepStart.set(key, event.time);
25706
+ acc.stats = {
25707
+ ...acc.stats,
25708
+ steps: acc.stats.steps + 1
25709
+ };
25710
+ return;
25711
+ }
25712
+ case "turn/end": {
25713
+ const reason = event.data.reason;
25714
+ const appended = [];
25715
+ if (reason.kind === "error") {
25716
+ const recovery = reason.error.code === "MISSING_CREDENTIAL" ? " · open /model to add an API key" : "";
25717
+ appended.push({
25718
+ kind: "error",
25719
+ text: `${reason.error.code}: ${reason.error.message}${recovery}`
25720
+ });
25721
+ } else {
25722
+ const marker = reason.kind === "aborted" ? reason.reason.kind === "user" ? "turn cancelled by the user" : `turn cancelled (${reason.reason.kind})` : reason.kind === "max-tokens" ? "turn hit the output-token ceiling (max-tokens)" : reason.kind === "blocked" ? "turn ended blocked" : reason.kind === "interrupted" ? "turn was interrupted by a restart" : void 0;
25723
+ if (marker !== void 0) appended.push({
25724
+ kind: "turn-marker",
25725
+ text: marker
25726
+ });
25727
+ }
25728
+ const files = acc.turnFiles.get(event.data.turn);
25729
+ acc.turnFiles.delete(event.data.turn);
25730
+ if (files !== void 0 && files.size > 0) appended.push({
25731
+ kind: "files",
25732
+ paths: [...files].slice(0, 12)
25733
+ });
25734
+ const stepKey = acc.turnSteps.get(event.data.turn);
25735
+ if (stepKey !== void 0) {
25736
+ acc.stepStart.delete(stepKey);
25737
+ acc.firstChunkAt.delete(stepKey);
25738
+ acc.turnSteps.delete(event.data.turn);
25739
+ }
25740
+ const turnToolSet = acc.turnTools.get(event.data.turn);
25741
+ if (turnToolSet !== void 0) {
25742
+ for (const callId of turnToolSet) acc.toolStart.delete(callId);
25743
+ acc.turnTools.delete(event.data.turn);
25744
+ }
25745
+ acc.busy = false;
25746
+ acc.busySince = 0;
25747
+ for (const entry of appended) appendReplayEntry(acc, entry);
25748
+ return;
25749
+ }
25750
+ case "llm/retry": {
25751
+ const data = event.data;
25752
+ appendReplayEntry(acc, {
25753
+ kind: "retry",
25754
+ retryId: data.retryId,
25755
+ attempt: data.retry,
25756
+ max: "maxRetries" in data ? data.maxRetries : data.retry,
25757
+ code: data.failure.code,
25758
+ delayMs: data.delayMs,
25759
+ state: "running"
25760
+ });
25761
+ indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1);
25762
+ return;
25763
+ }
25764
+ case "llm/retry-started": {
25765
+ const data = event.data;
25766
+ updateReplayById(acc, acc.retryIndex, data.retryId, (entry) => entry.retryId === data.retryId, (entry) => ({
25767
+ ...entry,
25768
+ state: "done"
25769
+ }));
25770
+ return;
25771
+ }
25772
+ case "sandbox/mode":
25773
+ acc.sandbox = event.data.mode;
25774
+ return;
25775
+ case "goal/change": {
25776
+ const data = event.data;
25777
+ const clip = (text) => text.length > 60 ? `${text.slice(0, 59)}…` : text;
25778
+ if (data.operation === "clear") {
25779
+ acc.goal = void 0;
25780
+ appendReplayEntry(acc, {
25781
+ kind: "turn-marker",
25782
+ text: "◎ goal cleared"
25783
+ });
25784
+ return;
25785
+ }
25786
+ const goal = {
25787
+ objective: data.goal.objective,
25788
+ phase: data.goal.phase,
25789
+ rounds: data.roundsStarted,
25790
+ max: data.goal.maxGoalRounds,
25791
+ blocked: data.goal.blockedReason?.message ?? ""
25792
+ };
25793
+ const line = data.operation === "create" ? `◎ goal: ${clip(data.goal.objective)}` : data.operation === "complete" ? "◎ goal complete" : data.operation === "pause" ? "◎ goal paused" : data.operation === "resume" ? "◎ goal resumed" : data.operation === "block" ? `◎ goal blocked: ${clip(goal.blocked)}` : void 0;
25794
+ acc.goal = goal;
25795
+ if (line !== void 0) appendReplayEntry(acc, {
25796
+ kind: "turn-marker",
25797
+ text: line
25798
+ });
25799
+ return;
25800
+ }
25801
+ case "session/title":
25802
+ acc.title = event.data.title;
25803
+ return;
25804
+ case "compaction/summary":
25805
+ if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
25806
+ const oldest = acc.compactionTokens.keys().next().value;
25807
+ if (oldest !== void 0) acc.compactionTokens.delete(oldest);
25808
+ }
25809
+ acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount);
25810
+ return;
25811
+ case "compaction/prune":
25812
+ acc.lastPruneTokens = event.data.shadowedTokenCount;
25813
+ return;
25814
+ case "compaction/end": {
25815
+ const ok = event.data.error === void 0;
25816
+ const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens;
25817
+ acc.compactionTokens.delete(event.data.compactionId);
25818
+ appendReplayEntry(acc, {
25819
+ kind: "compaction",
25820
+ ok,
25821
+ tokens,
25822
+ error: event.data.error ?? ""
25823
+ });
25824
+ return;
25825
+ }
25826
+ case "request/context":
25827
+ acc.stats = {
25828
+ ...acc.stats,
25829
+ contextWindow: event.data.contextWindow ?? acc.stats.contextWindow
25830
+ };
25831
+ return;
25832
+ case "request/header": {
25833
+ const config = event.data.header.config;
25834
+ acc.model = `${config.provider}/${config.model}`;
25835
+ acc.stats = {
25836
+ ...acc.stats,
25837
+ reasoningEffort: config.reasoningEffort === void 0 ? "" : String(config.reasoningEffort),
25838
+ contextSegments: {
25839
+ ...acc.stats.contextSegments,
25840
+ system: estimateTokens(event.data.header.system ?? "")
25841
+ }
25842
+ };
25843
+ return;
25844
+ }
25845
+ case "plan/mode":
25846
+ acc.plan = event.data.active;
25847
+ return;
25848
+ case "permission/preset":
25849
+ acc.permission = event.data.preset;
25850
+ return;
25851
+ case "command/run": {
25852
+ const data = event.data;
25853
+ appendReplayEntry(acc, {
25854
+ kind: "command",
25855
+ commandId: data.commandId,
25856
+ name: data.name,
25857
+ args: data.args ?? "",
25858
+ state: "running",
25859
+ summary: ""
25860
+ });
25861
+ indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1);
25862
+ return;
25863
+ }
25864
+ case "command/done": {
25865
+ const data = event.data;
25866
+ const update = (candidate) => ({
25867
+ ...candidate,
25868
+ state: data.kind === "success" ? "done" : "error",
25869
+ summary: boundContextSummary(data.text ?? "")
25870
+ });
25871
+ updateReplayById(acc, acc.commandIndex, data.commandId, (entry) => entry.commandId === data.commandId, update);
25872
+ return;
25873
+ }
25874
+ default: return;
25875
+ }
25876
+ }
25877
+ /**
25878
+ * Materialize the accumulated fold as a `TranscriptView`, compacting any
25879
+ * retired tombstones. The anchors maps are handed through as-is (their
25880
+ * content is identical to a sequential fold's).
25881
+ *
25882
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
25883
+ */
25884
+ function finishReplay(acc) {
25885
+ const entries = acc.removedCount === 0 ? acc.entries : acc.entries.filter((entry) => entry !== void 0);
25886
+ if (acc.removedCount > 0) acc.ops += acc.entries.length;
25887
+ return {
25888
+ entries,
25889
+ streaming: acc.streaming,
25890
+ streamingReasoning: acc.streamingReasoning,
25891
+ todos: acc.todos,
25892
+ busy: acc.busy,
25893
+ busySince: acc.busySince,
25894
+ model: acc.model,
25895
+ plan: acc.plan,
25896
+ permission: acc.permission,
25897
+ title: acc.title,
25898
+ sandbox: acc.sandbox,
25899
+ goal: acc.goal,
25900
+ pending: {
25901
+ "next-turn": [...acc.pendingTurn],
25902
+ "next-step": [...acc.pendingStep]
25903
+ },
25904
+ stats: acc.stats,
25905
+ anchors: {
25906
+ stepStart: acc.stepStart,
25907
+ toolStart: acc.toolStart,
25908
+ firstChunkAt: acc.firstChunkAt,
25909
+ compactionTokens: acc.compactionTokens,
25910
+ lastPruneTokens: acc.lastPruneTokens,
25911
+ turnFiles: acc.turnFiles,
25912
+ turnSteps: acc.turnSteps,
25913
+ turnTools: acc.turnTools
25914
+ }
25915
+ };
25916
+ }
25253
25917
  /**
25254
25918
  * Fold a replayed event history into one view.
25919
+ *
25920
+ * Folding is near-linear in the log size: the mutable replay accumulator
25921
+ * appends in place and resolves id-keyed updates through index maps, so a
25922
+ * long persisted session replays without the O(N²) copy-on-write rebuilds a
25923
+ * naive sequential fold would incur. The result is identical to folding
25924
+ * {@link projectEvent} per event in order.
25255
25925
  * @param events - events in `seq` order.
25256
25926
  * @returns the folded view.
25257
25927
  */
25258
25928
  function projectEvents(events) {
25259
- return events.reduce(projectEvent, createTranscriptView());
25929
+ const acc = createReplayAccumulator();
25930
+ for (const event of events) replayProjectEvent(acc, event);
25931
+ return finishReplay(acc);
25260
25932
  }
25261
25933
  /**
25262
25934
  * The append-only flush boundary for a transcript view: the count of entries
@@ -25267,8 +25939,11 @@ function projectEvents(events) {
25267
25939
  * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
25268
25940
  * `user/message` retirement), and an append-only `<Static>` flush cannot
25269
25941
  * erase a row that vanishes from the view — the retired row would ghost on
25270
- * screen until the next source-backed replay. Everything else (including a
25271
- * completed tail) is final: later events only APPEND new rows.
25942
+ * screen until the next source-backed replay. Running commands join the
25943
+ * mutable boundary for the same reason in reverse: `command/done` mutates the
25944
+ * row's state/summary, so a flushed row would keep its stale running mark
25945
+ * until a resize-triggered replay. Everything else (including a completed
25946
+ * tail) is final: later events only APPEND new rows.
25272
25947
  * @param entries - the view's transcript entries in order.
25273
25948
  * @returns the count of entries safe to flush (0 for an empty transcript).
25274
25949
  */
@@ -25278,6 +25953,7 @@ function settledEntryCount(entries) {
25278
25953
  if (entry.kind === "pending") return index;
25279
25954
  if (entry.kind === "tool" && entry.state === "running") return index;
25280
25955
  if (entry.kind === "retry" && entry.state === "running") return index;
25956
+ if (entry.kind === "command" && entry.state === "running") return index;
25281
25957
  }
25282
25958
  return entries.length;
25283
25959
  }
@@ -25439,6 +26115,259 @@ const RULE = /^(?:---|\*\*\*|___)\s*$/u;
25439
26115
  const QUOTE = /^>\s?(.*)$/u;
25440
26116
  const UNORDERED = /^\s*[-*+]\s+(.*)$/u;
25441
26117
  const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u;
26118
+ const TABLE_DELIMITER = /^(:)?-+(:)?$/u;
26119
+ const TABLE_COLUMN_GAP = 2;
26120
+ const TABLE_CELL_PADDING = 1;
26121
+ const TABLE_MIN_COLUMN_WIDTH = 3;
26122
+ const TABLE_MIN_ALIGNED_VALUE_WIDTH = 16;
26123
+ const MAX_TABLE_COLUMNS = 12;
26124
+ /** Split one pipe row without treating escaped or inline-code pipes as cells. */
26125
+ function splitTableRow(line) {
26126
+ const source = line.trim();
26127
+ if (!source.includes("|")) return void 0;
26128
+ const cells = [];
26129
+ let current = "";
26130
+ let inCode = false;
26131
+ let sawSeparator = false;
26132
+ for (let index = 0; index < source.length; index += 1) {
26133
+ const char = source[index] ?? "";
26134
+ if (char === "\\" && source[index + 1] === "|") {
26135
+ current += "|";
26136
+ index += 1;
26137
+ continue;
26138
+ }
26139
+ if (char === "`") {
26140
+ inCode = !inCode;
26141
+ current += char;
26142
+ continue;
26143
+ }
26144
+ if (char === "|" && !inCode) {
26145
+ cells.push(current.trim());
26146
+ current = "";
26147
+ sawSeparator = true;
26148
+ continue;
26149
+ }
26150
+ current += char;
26151
+ }
26152
+ if (!sawSeparator) return void 0;
26153
+ cells.push(current.trim());
26154
+ if (source.startsWith("|") && cells[0] === "") cells.shift();
26155
+ if (source.endsWith("|") && cells.at(-1) === "") cells.pop();
26156
+ return cells.length === 0 ? void 0 : cells;
26157
+ }
26158
+ /** Parse the alignment marker carried by one GFM delimiter cell. */
26159
+ function tableAlignment(cell) {
26160
+ const match = TABLE_DELIMITER.exec(cell);
26161
+ if (match === null) return void 0;
26162
+ if (match[1] !== void 0 && match[2] !== void 0) return "center";
26163
+ if (match[2] !== void 0) return "right";
26164
+ return "left";
26165
+ }
26166
+ /** Recognize one complete GFM pipe table at `startIndex`. */
26167
+ function parseTable(source, startIndex) {
26168
+ const headers = splitTableRow(source[startIndex] ?? "");
26169
+ const delimiters = splitTableRow(source[startIndex + 1] ?? "");
26170
+ if (headers === void 0 || delimiters === void 0 || headers.length !== delimiters.length) return void 0;
26171
+ if (headers.length === 0 || headers.length > MAX_TABLE_COLUMNS) return void 0;
26172
+ const alignments = delimiters.map(tableAlignment);
26173
+ if (alignments.some((alignment) => alignment === void 0)) return void 0;
26174
+ const rows = [];
26175
+ let nextIndex = startIndex + 2;
26176
+ while (nextIndex < source.length) {
26177
+ const cells = splitTableRow(source[nextIndex] ?? "");
26178
+ if (cells === void 0) break;
26179
+ rows.push(Array.from({ length: headers.length }, (_, column) => cells[column] ?? ""));
26180
+ nextIndex += 1;
26181
+ }
26182
+ return {
26183
+ headers,
26184
+ alignments,
26185
+ rows,
26186
+ nextIndex
26187
+ };
26188
+ }
26189
+ /** Visible width of one styled cell line. */
26190
+ function segmentsWidth(segments) {
26191
+ return segments.reduce((total, segment) => total + visibleColumns(segment.text), 0);
26192
+ }
26193
+ /** Drop wrapping-only leading spaces while preserving styles. */
26194
+ function trimLeadingSpaces(segments) {
26195
+ const trimmed = segments.map((segment) => ({ ...segment }));
26196
+ while (trimmed[0]?.text.startsWith(" ") === true) {
26197
+ const first = trimmed[0];
26198
+ const text = first.text.replace(/^ +/u, "");
26199
+ if (text === "") trimmed.shift();
26200
+ else trimmed[0] = {
26201
+ ...first,
26202
+ text
26203
+ };
26204
+ }
26205
+ return trimmed;
26206
+ }
26207
+ /** Hard-split an oversized soft-wrapped row while retaining inline styles. */
26208
+ function hardWrapSegments(segments, width) {
26209
+ const out = [];
26210
+ let current = [];
26211
+ let used = 0;
26212
+ const flush = () => {
26213
+ out.push(current);
26214
+ current = [];
26215
+ used = 0;
26216
+ };
26217
+ for (const segment of segments) for (const char of segment.text) {
26218
+ const cells = visibleColumns(char);
26219
+ if (used > 0 && used + cells > width) flush();
26220
+ const previous = current.at(-1);
26221
+ if (previous?.style === segment.style) previous.text += char;
26222
+ else current.push({
26223
+ text: char,
26224
+ style: segment.style
26225
+ });
26226
+ used += cells;
26227
+ }
26228
+ if (current.length > 0 || out.length === 0) flush();
26229
+ return out;
26230
+ }
26231
+ /** Wrap one table cell to its allocated content width. */
26232
+ function wrapTableCell(runs, width) {
26233
+ const soft = wrapSegments(runs.map((run) => seg(run.text, run.style)), Math.max(1, width));
26234
+ if (soft.length === 0) return [[]];
26235
+ const wrapped = [];
26236
+ for (const line of soft) {
26237
+ const trimmed = trimLeadingSpaces(line);
26238
+ if (segmentsWidth(trimmed) <= width) wrapped.push(trimmed.map((segment) => ({ ...segment })));
26239
+ else wrapped.push(...hardWrapSegments(trimmed, width));
26240
+ }
26241
+ return wrapped;
26242
+ }
26243
+ /** Visible width after removing inline Markdown delimiters. */
26244
+ function tableCellWidth(cell) {
26245
+ return segmentsWidth(parseInline(cell).map((run) => seg(run.text, run.style)));
26246
+ }
26247
+ /** Allocate readable grid widths or request the vertical record fallback. */
26248
+ function tableColumnWidths(table, width) {
26249
+ const columnCount = table.headers.length;
26250
+ const available = width - (columnCount * TABLE_CELL_PADDING + (columnCount - 1) * TABLE_COLUMN_GAP);
26251
+ if (available < columnCount * TABLE_MIN_COLUMN_WIDTH) return void 0;
26252
+ const widths = table.headers.map((header, column) => Math.max(TABLE_MIN_COLUMN_WIDTH, tableCellWidth(header), ...table.rows.map((row) => tableCellWidth(row[column] ?? ""))));
26253
+ let overflow = widths.reduce((total, value) => total + value, 0) - available;
26254
+ while (overflow > 0) {
26255
+ let widest = -1;
26256
+ for (let column = 0; column < widths.length; column += 1) {
26257
+ if ((widths[column] ?? 0) <= TABLE_MIN_COLUMN_WIDTH) continue;
26258
+ if (widest < 0 || (widths[column] ?? 0) > (widths[widest] ?? 0)) widest = column;
26259
+ }
26260
+ if (widest < 0) return void 0;
26261
+ widths[widest] = (widths[widest] ?? TABLE_MIN_COLUMN_WIDTH) - 1;
26262
+ overflow -= 1;
26263
+ }
26264
+ return widths;
26265
+ }
26266
+ /**
26267
+ * Reject grids whose headers or body values become fragmented vertical strips.
26268
+ * This mirrors Codex's readability fallback without importing its larger table
26269
+ * classification machinery: systemic long-token breaks or a seven-line prose
26270
+ * cell are clearer as vertical field records.
26271
+ */
26272
+ function tableGridIsReadable(table, widths) {
26273
+ if (table.headers.filter((header, column) => tableCellWidth(header) > (widths[column] ?? 0)).length >= 2) return false;
26274
+ let affectedRows = 0;
26275
+ for (const row of table.rows) {
26276
+ let affected = false;
26277
+ for (let column = 0; column < table.headers.length; column += 1) {
26278
+ const width = widths[column] ?? TABLE_MIN_COLUMN_WIDTH;
26279
+ const runs = parseInline(row[column] ?? "");
26280
+ const plain = runs.map((run) => run.text).join("");
26281
+ const fragmentedToken = plain.split(/\s+/u).some((token) => visibleColumns(token) > width);
26282
+ const wrappedHeight = wrapTableCell(runs, width).length;
26283
+ const catastrophicProse = plain.trim().split(/\s+/u).length >= 4 && width < 12 && wrappedHeight >= 7;
26284
+ if (fragmentedToken || catastrophicProse) {
26285
+ affected = true;
26286
+ break;
26287
+ }
26288
+ }
26289
+ if (affected) affectedRows += 1;
26290
+ }
26291
+ const threshold = table.rows.length <= 1 ? 1 : Math.max(2, Math.ceil(table.rows.length / 3));
26292
+ return affectedRows < threshold;
26293
+ }
26294
+ /** Apply one table-cell alignment to a wrapped content row. */
26295
+ function alignedCell(segments, width, alignment) {
26296
+ const remaining = Math.max(0, width - segmentsWidth(segments));
26297
+ if (alignment === "right") return {
26298
+ left: remaining,
26299
+ right: 0
26300
+ };
26301
+ if (alignment === "center") return {
26302
+ left: Math.floor(remaining / 2),
26303
+ right: Math.ceil(remaining / 2)
26304
+ };
26305
+ return {
26306
+ left: 0,
26307
+ right: remaining
26308
+ };
26309
+ }
26310
+ /** Render one logical grid row, including wrapped cell continuations. */
26311
+ function renderTableGridRow(cells, widths, alignments, header) {
26312
+ const wrapped = cells.map((cell, column) => wrapTableCell(parseInline(cell), widths[column] ?? TABLE_MIN_COLUMN_WIDTH));
26313
+ const height = Math.max(1, ...wrapped.map((lines) => lines.length));
26314
+ return Array.from({ length: height }, (_, rowIndex) => {
26315
+ const segments = [];
26316
+ for (let column = 0; column < cells.length; column += 1) {
26317
+ const line = wrapped[column]?.[rowIndex] ?? [];
26318
+ const styled = header ? line.map((segment) => ({
26319
+ ...segment,
26320
+ style: "accentBold"
26321
+ })) : line;
26322
+ const alignment = alignedCell(styled, widths[column] ?? TABLE_MIN_COLUMN_WIDTH, alignments[column] ?? "left");
26323
+ segments.push(seg(" ".repeat(TABLE_CELL_PADDING + alignment.left)));
26324
+ segments.push(...styled);
26325
+ if (column + 1 < cells.length) segments.push(seg(" ".repeat(alignment.right + TABLE_COLUMN_GAP)));
26326
+ }
26327
+ return { segments: merge(segments) };
26328
+ });
26329
+ }
26330
+ /** Render a table as Codex-style borderless rows with measured separators. */
26331
+ function renderTableGrid(table, widths) {
26332
+ const separator = (char) => ({ segments: [seg(widths.map((width) => char.repeat(width + TABLE_CELL_PADDING)).join(" ".repeat(TABLE_COLUMN_GAP)), "dim")] });
26333
+ const lines = [...renderTableGridRow(table.headers, widths, table.alignments, true), separator("━")];
26334
+ for (let row = 0; row < table.rows.length; row += 1) {
26335
+ lines.push(...renderTableGridRow(table.rows[row] ?? [], widths, table.alignments, false));
26336
+ if (row + 1 < table.rows.length) lines.push(separator("─"));
26337
+ }
26338
+ return lines;
26339
+ }
26340
+ /** Render an unreadably narrow grid as vertically scannable field records. */
26341
+ function renderTableRecords(table, width) {
26342
+ const labelWidth = Math.max(...table.headers.map(tableCellWidth));
26343
+ const prefixWidth = TABLE_CELL_PADDING + labelWidth + TABLE_COLUMN_GAP;
26344
+ const aligned = prefixWidth + TABLE_MIN_ALIGNED_VALUE_WIDTH <= width;
26345
+ const lines = [];
26346
+ for (let rowIndex = 0; rowIndex < table.rows.length; rowIndex += 1) {
26347
+ const row = table.rows[rowIndex] ?? [];
26348
+ for (let column = 0; column < table.headers.length; column += 1) {
26349
+ const label = table.headers[column] ?? "";
26350
+ const value = row[column] ?? "";
26351
+ const labelRuns = parseInline(label).map((run) => seg(run.text, "accentBold"));
26352
+ if (aligned) {
26353
+ const valueLines = wrapTableCell(parseInline(value), Math.max(1, width - prefixWidth));
26354
+ for (let lineIndex = 0; lineIndex < valueLines.length; lineIndex += 1) {
26355
+ const prefix = lineIndex === 0 ? [
26356
+ seg(" "),
26357
+ ...labelRuns,
26358
+ seg(" ".repeat(labelWidth - tableCellWidth(label) + TABLE_COLUMN_GAP))
26359
+ ] : [seg(" ".repeat(prefixWidth))];
26360
+ lines.push({ segments: merge([...prefix, ...valueLines[lineIndex] ?? []]) });
26361
+ }
26362
+ } else {
26363
+ lines.push({ segments: merge([seg(" "), ...labelRuns]) });
26364
+ for (const valueLine of wrapTableCell(parseInline(value), Math.max(1, width - 2))) lines.push({ segments: merge([seg(" "), ...valueLine]) });
26365
+ }
26366
+ }
26367
+ if (rowIndex + 1 < table.rows.length) lines.push({ segments: [seg("─".repeat(width), "dim")] });
26368
+ }
26369
+ return lines;
26370
+ }
25442
26371
  /** Render markdown text into styled lines of at most `width` columns. */
25443
26372
  function renderMarkdown(text, width) {
25444
26373
  const lines = [];
@@ -25450,7 +26379,7 @@ function renderMarkdown(text, width) {
25450
26379
  if (separatorPending && lines.length > 0 && lines.at(-1)?.segments.length !== 0) lines.push({ segments: [] });
25451
26380
  separatorPending = false;
25452
26381
  };
25453
- const source = text.replaceAll("\r", "").split("\n");
26382
+ const source = text.replaceAll("\r", "").replaceAll(" ", " ").split("\n");
25454
26383
  let index = 0;
25455
26384
  while (index < source.length) {
25456
26385
  const line = source[index] ?? "";
@@ -25460,6 +26389,14 @@ function renderMarkdown(text, width) {
25460
26389
  continue;
25461
26390
  }
25462
26391
  startBlock();
26392
+ const table = parseTable(source, index - 1);
26393
+ if (table !== void 0) {
26394
+ const tableWidth = Math.max(10, Math.floor(width));
26395
+ const columnWidths = tableColumnWidths(table, tableWidth);
26396
+ lines.push(...columnWidths === void 0 || !tableGridIsReadable(table, columnWidths) ? renderTableRecords(table, tableWidth) : renderTableGrid(table, columnWidths));
26397
+ index = table.nextIndex;
26398
+ continue;
26399
+ }
25463
26400
  const fence = FENCE.exec(line);
25464
26401
  if (fence !== null) {
25465
26402
  const language = fence[1] ?? "";
@@ -25628,19 +26565,28 @@ const DEEPSEEK_WAVE_BANDS = {
25628
26565
  ]]
25629
26566
  }
25630
26567
  };
26568
+ /** Original Codex duration used as the animation's sampling timeline. */
26569
+ function deepseekWaveBaseDuration(tier, style) {
26570
+ switch (style) {
26571
+ case "aurora": return tier === "flash" ? 1300 : 1600;
26572
+ case "pulse": return tier === "flash" ? 900 : 1250;
26573
+ case "wave": return tier === "flash" ? 1e3 : 1300;
26574
+ }
26575
+ }
25631
26576
  /**
25632
- * Total animation duration Codex `IgnitionStyle::total_duration`: three
25633
- * styles × two tiers.
26577
+ * Total visible duration: the Codex ignition duration plus 200ms so its motion
26578
+ * remains readable in a busy terminal.
25634
26579
  * @param tier - the active wave tier.
25635
26580
  * @param style - the active ignition style.
25636
26581
  * @returns the duration in milliseconds.
25637
26582
  */
25638
26583
  function deepseekWaveDuration(tier, style = "wave") {
25639
- switch (style) {
25640
- case "aurora": return tier === "flash" ? 1300 : 1600;
25641
- case "pulse": return tier === "flash" ? 900 : 1250;
25642
- case "wave": return tier === "flash" ? 1e3 : 1300;
25643
- }
26584
+ return deepseekWaveBaseDuration(tier, style) + 200;
26585
+ }
26586
+ /** Map the extended display timeline back onto the original Codex samples. */
26587
+ function deepseekWaveSampleElapsedMs(tick, tier, style) {
26588
+ const base = deepseekWaveBaseDuration(tier, style);
26589
+ return tick * 33 * base / deepseekWaveDuration(tier, style);
25644
26590
  }
25645
26591
  /**
25646
26592
  * Pick one ignition style at random, never repeating the previous one —
@@ -25780,8 +26726,8 @@ function blendRgb(fg, bg, alpha) {
25780
26726
  * @returns the blended RGB background, or null for transparent.
25781
26727
  */
25782
26728
  function deepseekWaveColumnBg(tick, column, width, tier, style, hues, base) {
25783
- const total = deepseekWaveDuration(tier, style) / 1e3;
25784
- const elapsed = tick * 33 / 1e3;
26729
+ const total = deepseekWaveBaseDuration(tier, style) / 1e3;
26730
+ const elapsed = deepseekWaveSampleElapsedMs(tick, tier, style) / 1e3;
25785
26731
  const fade = style === "aurora" ? envelope(elapsed, total, .25, .4) : 1;
25786
26732
  const weights = [
25787
26733
  0,
@@ -25812,14 +26758,14 @@ function deepseekWaveColumnBg(tick, column, width, tier, style, hues, base) {
25812
26758
  return blendRgb(mixed, base, alpha);
25813
26759
  }
25814
26760
  /**
25815
- * The sparkle glyph for a tick — Codex `spark_frame`: from 900ms on, one
25816
- * glyph every 100ms through ✧`, then silent. The deepseek (Ultra) tier
25817
- * only; the Ink layer still must skip occupied cells.
26761
+ * The sparkle glyph for a tick — Codex `spark_frame`, sampled on the same
26762
+ * proportionally slowed DeepSeek Wave timeline as the composer background.
26763
+ * The Ink layer still must skip occupied cells.
25818
26764
  * @param tick - wave frame at DEEPSEEK_WAVE_TICK_MS.
25819
- * @returns the sparkle glyph, or null outside the 900..1200ms window.
26765
+ * @returns the sparkle glyph, or null outside the stretched tail window.
25820
26766
  */
25821
26767
  function deepseekWaveSpark(tick) {
25822
- const elapsed = tick * 33;
26768
+ const elapsed = deepseekWaveSampleElapsedMs(tick, "deepseek", "wave");
25823
26769
  if (elapsed < 900) return null;
25824
26770
  const frame = Math.floor((elapsed - 900) / 100);
25825
26771
  return SPARK_GLYPHS[frame] ?? null;
@@ -25836,8 +26782,8 @@ function deepseekWaveSpark(tick) {
25836
26782
  * @returns the blended border RGB.
25837
26783
  */
25838
26784
  function deepseekWaveBorderColor(tick, tier, style, hues, dim) {
25839
- const total = deepseekWaveDuration(tier, style) / 1e3;
25840
- const glow = envelope(tick * 33 / 1e3, total, total * .25, total * .4);
26785
+ const total = deepseekWaveBaseDuration(tier, style) / 1e3;
26786
+ const glow = envelope(deepseekWaveSampleElapsedMs(tick, tier, style) / 1e3, total, total * .25, total * .4);
25841
26787
  return blendRgb(hues[0], dim, .25 + glow * .6);
25842
26788
  }
25843
26789
  /**
@@ -25850,8 +26796,8 @@ function deepseekWaveBorderColor(tick, tier, style, hues, dim) {
25850
26796
  * @returns true while the wordmark should be visible.
25851
26797
  */
25852
26798
  function deepseekWaveWordVisible(tick, tier, style = "wave") {
25853
- const total = deepseekWaveDuration(tier, style) / 1e3;
25854
- return envelope(tick * 33 / 1e3, total, total * .2, total * .35) > .25;
26799
+ const total = deepseekWaveBaseDuration(tier, style) / 1e3;
26800
+ return envelope(deepseekWaveSampleElapsedMs(tick, tier, style) / 1e3, total, total * .2, total * .35) > .25;
25855
26801
  }
25856
26802
  /**
25857
26803
  * The per-character color for the `deepseek` wordmark: the tier's hues
@@ -25916,136 +26862,43 @@ function formatDuration(ms) {
25916
26862
  * Compact decode rate: one decimal under a hundred, whole below a thousand,
25917
26863
  * then thousands (15.3 / 124 / 1.2K).
25918
26864
  * @param n - tokens per second.
25919
- * @returns display string.
25920
- */
25921
- function formatRate(n) {
25922
- if (n < 100) return String(Math.round(n * 10) / 10);
25923
- if (n < 1e3) return String(Math.round(n));
25924
- return String(Math.round(n / 100) / 10) + "K";
25925
- }
25926
- /**
25927
- * Cache-hit share of billed prompt-side input.
25928
- * @param usage - cumulative token totals.
25929
- * @returns rounded integer percent, or null when no input was billed.
25930
- */
25931
- function cacheHitPercent(usage) {
25932
- return usage.inputTokens === 0 ? null : Math.round(usage.cacheReadTokens / usage.inputTokens * 100);
25933
- }
25934
- /** Separator between trailing state spans. */
25935
- const STATUS_ITEM_SEPARATOR = " · ";
25936
- /** The Codex-style mode cycle hint appended to the permission badge. */
25937
- const STATUS_CYCLE_HINT = " (shift+tab to cycle)";
25938
- /** Free-tail floor in columns: wide enough for the bare percent readout, so
25939
- * the warning stays visible even at 100%+ occupancy. */
25940
- const CONTEXT_MIN_FREE = 5;
25941
- /** The five content types in conversation order, dark → light blue. */
25942
- const CONTEXT_SEGMENTS = [
25943
- {
25944
- key: "system",
25945
- tone: "ctxSystem",
25946
- labels: [
25947
- "system",
25948
- "sys",
25949
- "s"
25950
- ]
25951
- },
25952
- {
25953
- key: "prompt",
25954
- tone: "ctxPrompt",
25955
- labels: [
25956
- "prompt",
25957
- "pr",
25958
- "p"
25959
- ]
25960
- },
25961
- {
25962
- key: "assistant",
25963
- tone: "ctxAssistant",
25964
- labels: [
25965
- "assistant",
25966
- "ast",
25967
- "a"
25968
- ]
25969
- },
25970
- {
25971
- key: "thinking",
25972
- tone: "ctxThinking",
25973
- labels: [
25974
- "think",
25975
- "th",
25976
- "t"
25977
- ]
25978
- },
25979
- {
25980
- key: "tools",
25981
- tone: "ctxTools",
25982
- labels: [
25983
- "tools",
25984
- "tl",
25985
- "x"
25986
- ]
25987
- }
25988
- ];
25989
- /** First label whose visible width fits the segment; empty only when none do. */
25990
- function chooseContextLabel(labels, width) {
25991
- for (const label of labels) if (visibleColumns(label) <= width) return label;
25992
- return "";
25993
- }
25994
- /** Center a label inside its segment width; the padding spaces carry the tone. */
25995
- function centerInSegment(text, width) {
25996
- const textWidth = visibleColumns(text);
25997
- const left = Math.floor((width - textWidth) / 2);
25998
- return " ".repeat(Math.max(0, left)) + text + " ".repeat(Math.max(0, width - textWidth - left));
26865
+ * @returns display string.
26866
+ */
26867
+ function formatRate(n) {
26868
+ if (n < 100) return String(Math.round(n * 10) / 10);
26869
+ if (n < 1e3) return String(Math.round(n));
26870
+ return String(Math.round(n / 100) / 10) + "K";
25999
26871
  }
26000
26872
  /**
26001
- * Largest-remainder allocation: distribute `columns` across `values`
26002
- * proportionally, handing each leftover column to the largest remainder.
26873
+ * Cache-hit share of billed prompt-side input.
26874
+ * @param usage - cumulative token totals.
26875
+ * @returns rounded integer percent, or null when no input was billed.
26003
26876
  */
26004
- function allocateProportionally(values, columns) {
26005
- if (columns <= 0) return values.map(() => 0);
26006
- const total = values.reduce((sum, value) => sum + value, 0);
26007
- if (total <= 0) return values.map(() => 0);
26008
- const raw = values.map((value) => value / total * columns);
26009
- const allocated = raw.map(Math.floor);
26010
- let remaining = columns - allocated.reduce((sum, value) => sum + value, 0);
26011
- const remainders = raw.map((value, index) => ({
26012
- index,
26013
- remainder: value - Math.floor(value)
26014
- })).sort((left, right) => right.remainder - left.remainder);
26015
- for (const slot of remainders) {
26016
- if (remaining <= 0) break;
26017
- allocated[slot.index] = (allocated[slot.index] ?? 0) + 1;
26018
- remaining -= 1;
26019
- }
26020
- return allocated;
26021
- }
26022
- /** Bar column widths: every non-zero segment keeps at least one column before
26023
- * the remaining columns share by token proportion. */
26024
- function allocateBarColumns(values, width) {
26025
- const visible = values.map((value, index) => value > 0 ? index : -1).filter((index) => index >= 0);
26026
- if (visible.length === 0 || visible.length >= width) return allocateProportionally(values, width);
26027
- const minimum = values.map(() => 0);
26028
- for (const index of visible) minimum[index] = 1;
26029
- const remaining = allocateProportionally(values, width - visible.length);
26030
- return minimum.map((min, index) => min + (remaining[index] ?? 0));
26877
+ function cacheHitPercent(usage) {
26878
+ return usage.inputTokens === 0 ? null : Math.round(usage.cacheReadTokens / usage.inputTokens * 100);
26031
26879
  }
26880
+ /** Separator between trailing state spans. */
26881
+ const STATUS_ITEM_SEPARATOR = " · ";
26882
+ /** The Codex-style mode cycle hint appended to the permission badge. */
26883
+ const STATUS_CYCLE_HINT = " (shift+tab to cycle)";
26884
+ /** Free-tail floor in columns: wide enough for the bare percent readout, so
26885
+ * the warning stays visible even at 100%+ occupancy. */
26886
+ const CONTEXT_MIN_FREE = 5;
26032
26887
  /**
26033
- * Render context occupancy as a segmented bar: one DeepSeek-blue run per
26034
- * content type (system/prompt/assistant/thinking/tools), column widths
26035
- * proportional to their estimated token share, each with a centered label
26036
- * that shortens to fit (system→sys→s). The remaining free tail is a dim
26037
- * track whose right edge carries the usage readout (`12.3K/1.0M 25%`,
26038
- * shrinking to the bare percent as the tail narrows). The readout flips to
26039
- * amber once occupancy reaches the warning threshold; the segment blues stay
26040
- * untouched so the composition remains readable at full context. The used
26041
- * total comes from the reported `lastPromptTokens`, never from the estimates.
26042
- * @param segments - estimated used tokens per content type.
26888
+ * Render context occupancy as ONE stepless bar: a solid DeepSeek-blue fill
26889
+ * run, a dim dotted free track, and the usage readout riding the track's
26890
+ * right edge (`12.3K/1.0M 25%`, shrinking to the bare percent as the track
26891
+ * narrows). No per-content-type segmentation. Column split is deterministic:
26892
+ * the free share is `Math.round(free/window*width)` clamped to at least
26893
+ * CONTEXT_MIN_FREE columns and at most the full width; the fill takes every
26894
+ * remaining column, so a given occupancy always renders the identical bar.
26895
+ * The readout flips to amber once occupancy reaches the warning threshold.
26043
26896
  * @param usedTokens - reported used tokens (drives the readout and percent).
26044
26897
  * @param contextWindow - route capacity.
26045
26898
  * @param width - total bar interior columns.
26046
26899
  * @returns tone-split spans for the footer to paint.
26047
26900
  */
26048
- function contextBar(segments, usedTokens, contextWindow, width) {
26901
+ function contextBar(usedTokens, contextWindow, width) {
26049
26902
  if (width <= 0 || contextWindow <= 0) return [];
26050
26903
  const used = Math.max(0, usedTokens);
26051
26904
  const percent = Math.round(used / contextWindow * 100);
@@ -26056,26 +26909,14 @@ function contextBar(segments, usedTokens, contextWindow, width) {
26056
26909
  const total = `${formatTokens(used)}/${formatTokens(contextWindow)}`;
26057
26910
  const percentText = `${percent}%`;
26058
26911
  const readout = freeColumns >= visibleColumns(`${total} ${percentText}`) ? `${total} ${percentText}` : freeColumns >= visibleColumns(percentText) ? percentText : "";
26059
- const values = CONTEXT_SEGMENTS.map((segment) => segments[segment.key]);
26060
- const allocation = allocateBarColumns(values.reduce((sum, value) => sum + value, 0) > 0 ? values : [
26061
- 0,
26062
- used,
26063
- 0,
26064
- 0,
26065
- 0
26066
- ], usedColumns);
26067
26912
  const spans = [];
26068
- for (let index = 0; index < CONTEXT_SEGMENTS.length; index += 1) {
26069
- const columns = allocation[index] ?? 0;
26070
- if (columns <= 0) continue;
26071
- spans.push({
26072
- text: centerInSegment(chooseContextLabel(CONTEXT_SEGMENTS[index].labels, columns), columns),
26073
- tone: CONTEXT_SEGMENTS[index].tone
26074
- });
26075
- }
26913
+ if (usedColumns > 0) spans.push({
26914
+ text: "█".repeat(usedColumns),
26915
+ tone: "ctxFill"
26916
+ });
26076
26917
  const pad = freeColumns - visibleColumns(readout);
26077
26918
  if (pad > 0) spans.push({
26078
- text: "".repeat(pad),
26919
+ text: "".repeat(pad),
26079
26920
  tone: "label"
26080
26921
  });
26081
26922
  if (readout !== "") spans.push({
@@ -26098,6 +26939,12 @@ const STATUS_ITEMS = [
26098
26939
  description: "working-directory basename",
26099
26940
  side: "left"
26100
26941
  },
26942
+ {
26943
+ id: "mode",
26944
+ label: "mode",
26945
+ description: "agent preset composing the session",
26946
+ side: "left"
26947
+ },
26101
26948
  {
26102
26949
  id: "branch",
26103
26950
  label: "branch",
@@ -26105,15 +26952,21 @@ const STATUS_ITEMS = [
26105
26952
  side: "left"
26106
26953
  },
26107
26954
  {
26108
- id: "plan",
26109
- label: "plan",
26110
- description: "plan-mode state mark",
26955
+ id: "context",
26956
+ label: "context",
26957
+ description: "context-window occupancy meter",
26111
26958
  side: "left"
26112
26959
  },
26113
26960
  {
26114
- id: "mode",
26115
- label: "mode",
26116
- description: "agent preset composing the session",
26961
+ id: "permission",
26962
+ label: "permission",
26963
+ description: "permission preset badge with cycle hint",
26964
+ side: "right"
26965
+ },
26966
+ {
26967
+ id: "plan",
26968
+ label: "plan",
26969
+ description: "plan-mode state mark",
26117
26970
  side: "left"
26118
26971
  },
26119
26972
  {
@@ -26134,12 +26987,6 @@ const STATUS_ITEMS = [
26134
26987
  description: "cache-hit share of billed input",
26135
26988
  side: "left"
26136
26989
  },
26137
- {
26138
- id: "context",
26139
- label: "context",
26140
- description: "context-window occupancy meter",
26141
- side: "left"
26142
- },
26143
26990
  {
26144
26991
  id: "tokens",
26145
26992
  label: "tokens",
@@ -26156,19 +27003,13 @@ const STATUS_ITEMS = [
26156
27003
  id: "goal",
26157
27004
  label: "goal",
26158
27005
  description: "live goal phase and round progress",
26159
- side: "right"
27006
+ side: "left"
26160
27007
  },
26161
27008
  {
26162
27009
  id: "sandbox",
26163
27010
  label: "sandbox",
26164
27011
  description: "divergent sandbox-mode override",
26165
- side: "right"
26166
- },
26167
- {
26168
- id: "permission",
26169
- label: "permission",
26170
- description: "permission preset badge with cycle hint",
26171
- side: "right"
27012
+ side: "left"
26172
27013
  }
26173
27014
  ];
26174
27015
  /**
@@ -26199,23 +27040,22 @@ const WIDTH_SAFETY = 1;
26199
27040
  /** Column budget for the session title before it ellipsizes. */
26200
27041
  const TITLE_BUDGET = 48;
26201
27042
  /**
26202
- * Row 1 drop ranks (lowest drops first): the session title, then the token
26203
- * figures, then turn/step counts, then the goal and divergent-sandbox badges,
26204
- * with the permission badge last. The identity cluster never drops — it
26205
- * ellipsizes instead.
27043
+ * Primary-row drop ranks: context drops before permission; the identity
27044
+ * cluster never drops and ellipsizes only after the right badge is gone.
27045
+ * Secondary-row groups reuse the remaining ranks independently.
26206
27046
  */
26207
27047
  const RANK_TITLE = 10;
26208
27048
  const RANK_TOKENS = 50;
26209
27049
  const RANK_COUNTS = 90;
27050
+ const RANK_CONTEXT = 90;
26210
27051
  const RANK_SANDBOX = 92;
26211
27052
  const RANK_GOAL = 95;
26212
27053
  const RANK_BADGE = 100;
26213
27054
  const RANK_IDENTITY = Number.POSITIVE_INFINITY;
26214
- /** Row 2 drop ranks: duration figures go first, then cache, then the context bar, and mode survives longest. */
27055
+ /** Row 2 drop ranks: title and durations go first; state and counts survive longest. */
26215
27056
  const RANK2_DURATIONS = 40;
26216
27057
  const RANK2_CACHE = 50;
26217
- const RANK2_CONTEXT = 60;
26218
- const RANK2_MODE = 70;
27058
+ const RANK2_PLAN = 70;
26219
27059
  /**
26220
27060
  * Traffic-light tone for a permission preset: read-only stays success green,
26221
27061
  * full access reads error red, and every workspace-scoped middle ground
@@ -26276,15 +27116,22 @@ function buildCandidates(facts, stats, busy, enabled) {
26276
27116
  text: cwd,
26277
27117
  tone: "path"
26278
27118
  });
27119
+ const mode = safe(facts.mode ?? "");
27120
+ if (mode !== "" && enabled.has("mode")) {
27121
+ push({
27122
+ text: "/mode ",
27123
+ tone: "label"
27124
+ });
27125
+ identity.push({
27126
+ text: mode,
27127
+ tone: "accent"
27128
+ });
27129
+ }
26279
27130
  const branch = safe(facts.branch);
26280
27131
  if (branch !== "" && enabled.has("branch")) push({
26281
27132
  text: "⑂ " + branch,
26282
27133
  tone: "branch"
26283
27134
  });
26284
- if (facts.plan && enabled.has("plan")) push({
26285
- text: "⧉ plan",
26286
- tone: "accent"
26287
- });
26288
27135
  const left = [{
26289
27136
  group: { spans: identity },
26290
27137
  rank: RANK_IDENTITY,
@@ -26292,17 +27139,13 @@ function buildCandidates(facts, stats, busy, enabled) {
26292
27139
  }];
26293
27140
  const right = [];
26294
27141
  const row2 = [];
26295
- const mode = safe(facts.mode ?? "");
26296
- if (mode !== "" && enabled.has("mode")) row2.push({
27142
+ if (facts.plan && enabled.has("plan")) row2.push({
26297
27143
  group: { spans: [{
26298
- text: "mode ",
26299
- tone: "label"
26300
- }, {
26301
- text: mode,
27144
+ text: " plan",
26302
27145
  tone: "accent"
26303
27146
  }] },
26304
- rank: RANK2_MODE,
26305
- id: "mode"
27147
+ rank: RANK2_PLAN,
27148
+ id: "plan"
26306
27149
  });
26307
27150
  if (stats.turns > 0 || stats.steps > 0) {
26308
27151
  if (enabled.has("turns")) {
@@ -26319,7 +27162,7 @@ function buildCandidates(facts, stats, busy, enabled) {
26319
27162
  };
26320
27163
  pair("turns", String(stats.turns));
26321
27164
  pair("steps", String(stats.steps));
26322
- left.push({
27165
+ row2.push({
26323
27166
  group: { spans: counts },
26324
27167
  rank: RANK_COUNTS,
26325
27168
  id: "turns"
@@ -26369,12 +27212,12 @@ function buildCandidates(facts, stats, busy, enabled) {
26369
27212
  rank: RANK2_CACHE,
26370
27213
  id: "cache"
26371
27214
  });
26372
- if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has("context")) row2.push({
27215
+ if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has("context")) left.push({
26373
27216
  group: { spans: [{
26374
27217
  text: "context ",
26375
27218
  tone: "label"
26376
- }, ...contextBar(stats.contextSegments, stats.lastPromptTokens, stats.contextWindow, 24)] },
26377
- rank: RANK2_CONTEXT,
27219
+ }, ...contextBar(stats.lastPromptTokens, stats.contextWindow, 24)] },
27220
+ rank: RANK_CONTEXT,
26378
27221
  id: "context"
26379
27222
  });
26380
27223
  if ((stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) && enabled.has("tokens")) {
@@ -26391,14 +27234,14 @@ function buildCandidates(facts, stats, busy, enabled) {
26391
27234
  };
26392
27235
  pair("in", formatTokens(stats.usage.inputTokens));
26393
27236
  pair("out", formatTokens(stats.usage.outputTokens));
26394
- left.push({
27237
+ row2.push({
26395
27238
  group: { spans: tokens },
26396
27239
  rank: RANK_TOKENS,
26397
27240
  id: "tokens"
26398
27241
  });
26399
27242
  }
26400
27243
  const label = truncateColumns(safe(facts.title !== void 0 && facts.title !== "" ? facts.title : facts.sessionId), TITLE_BUDGET);
26401
- if (label !== "" && enabled.has("title")) left.push({
27244
+ if (label !== "" && enabled.has("title")) row2.push({
26402
27245
  group: { spans: [{
26403
27246
  text: label,
26404
27247
  tone: "meta"
@@ -26406,20 +27249,20 @@ function buildCandidates(facts, stats, busy, enabled) {
26406
27249
  rank: RANK_TITLE,
26407
27250
  id: "title"
26408
27251
  });
26409
- if (facts.goal !== void 0 && enabled.has("goal")) right.push({
26410
- span: {
27252
+ if (facts.goal !== void 0 && enabled.has("goal")) row2.push({
27253
+ group: { spans: [{
26411
27254
  text: facts.goal.phase === "active" ? "◎ round " + facts.goal.rounds + "/" + facts.goal.max : "◎ " + safe(facts.goal.phase),
26412
27255
  tone: "accent"
26413
- },
27256
+ }] },
26414
27257
  rank: RANK_GOAL,
26415
27258
  id: "goal"
26416
27259
  });
26417
27260
  const sandbox = safe(facts.sandbox ?? "");
26418
- if (sandbox !== "" && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has("sandbox")) right.push({
26419
- span: {
27261
+ if (sandbox !== "" && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has("sandbox")) row2.push({
27262
+ group: { spans: [{
26420
27263
  text: "sandbox " + sandbox,
26421
27264
  tone: "warn"
26422
- },
27265
+ }] },
26423
27266
  rank: RANK_SANDBOX,
26424
27267
  id: "sandbox"
26425
27268
  });
@@ -26444,12 +27287,10 @@ function buildCandidates(facts, stats, busy, enabled) {
26444
27287
  };
26445
27288
  }
26446
27289
  /**
26447
- * Compose the two-row footer layout under a column budget. Row 1 (identity
26448
- * and state badges) degrades in a fixed order cycle hint, then title, token
26449
- * figures, turn/step counts, goal, divergent sandbox, permission badge and
26450
- * only then ellipsizes the identity cluster, so the row never wraps. Row 2
26451
- * (mode, context bar, cache, duration figures) fits its own budget and
26452
- * degrades to empty before any row-1 content is touched.
27290
+ * Compose the two-row footer layout under a column budget. Row 1 keeps model,
27291
+ * cwd, mode, branch, context, then the right-pinned permission badge and cycle
27292
+ * hint. It drops hint, context, and permission before ellipsizing identity.
27293
+ * Row 2 fits all secondary figures and state within its own budget.
26453
27294
  * @param facts - identity facts resolved by the runner.
26454
27295
  * @param stats - session figures folded from the durable log.
26455
27296
  * @param columns - usable columns for each row (before their left padding).
@@ -26525,8 +27366,9 @@ function layoutStatusBar(facts, stats, columns, options = {}) {
26525
27366
  };
26526
27367
  }
26527
27368
  const row2Kept = [...orderedRow2];
27369
+ const row2Budget = Math.max(1, budget - 2);
26528
27370
  const row2Width = () => joinWidth(row2Kept.map((entry) => spansWidth(entry.group.spans)), groupSeparator);
26529
- while (row2Width() > budget && row2Kept.length > 0) {
27371
+ while (row2Width() > row2Budget && row2Kept.length > 0) {
26530
27372
  let dropIndex = 0;
26531
27373
  let dropRank = Number.POSITIVE_INFINITY;
26532
27374
  for (let index = 0; index < row2Kept.length; index += 1) if (row2Kept[index].rank < dropRank) {
@@ -26612,6 +27454,17 @@ function markdownLines(text, columns) {
26612
27454
  const width = Math.max(1, Math.floor(columns));
26613
27455
  return renderMarkdown(displayText(text), Math.max(10, width)).flatMap((line) => styledLines(line.segments.map((segment) => lineSegment(segment.text, segment.style)), width));
26614
27456
  }
27457
+ /**
27458
+ * Codex-style reasoning rows: the marker occupies the reply gutter and every
27459
+ * wrapped or explicit continuation starts with the same two-column indent, so
27460
+ * reasoning content and assistant Markdown share one left edge.
27461
+ */
27462
+ function reasoningLines(text, columns) {
27463
+ const width = Math.max(1, Math.floor(columns));
27464
+ if (width < 3) return textLines(text, width, "dimItalic");
27465
+ const contentWidth = width - 2;
27466
+ return displayText(text).replaceAll(" ", " ").replaceAll("\r", "").split("\n").flatMap((line) => styledLines([lineSegment(line, "dimItalic")], contentWidth)).map((line, index) => ({ segments: [lineSegment(index === 0 ? "✻ " : " ", "dimItalic"), ...line.segments] }));
27467
+ }
26615
27468
  /** Expanded structured tool detail as scrollable, width-safe rows. */
26616
27469
  function toolDetailLines(detail, columns) {
26617
27470
  switch (detail.kind) {
@@ -26637,7 +27490,7 @@ function transcriptEntryLines(entry, columns) {
26637
27490
  case "user": return styledLines([lineSegment(entry.notice ? "⤷ " : "❯ ", entry.notice ? "dim" : "brand"), lineSegment(entry.text, entry.notice ? "dim" : "plain")], width);
26638
27491
  case "pending": return styledLines([lineSegment("❯ ", "brand"), lineSegment(entry.text, "plain")], width);
26639
27492
  case "assistant": {
26640
- const reasoning = entry.reasoning === "" ? [] : styledLines([lineSegment(" ✻ ", "dimItalic"), lineSegment(entry.reasoning, "dimItalic")], width);
27493
+ const reasoning = entry.reasoning === "" ? [] : reasoningLines(entry.reasoning, width);
26641
27494
  const body = markdownLines(entry.text, Math.max(10, width - 2)).map((line) => ({ segments: [{
26642
27495
  text: " ",
26643
27496
  style: "plain"
@@ -26681,7 +27534,7 @@ function ListFrame(props) {
26681
27534
  const stdout = useStdout().stdout;
26682
27535
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
26683
27536
  if (viewport.maxHeight === 0) return (0, import_react.createElement)(Box, { display: "none" });
26684
- if (viewport.compact) return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(`${props.title} · esc close`, viewport.contentColumns));
27537
+ if (viewport.compact) return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(singleLineText(`${props.title} · esc close`), viewport.contentColumns));
26685
27538
  const stateRows = props.loading ? [{
26686
27539
  key: "loading",
26687
27540
  text: " loading…"
@@ -26704,10 +27557,10 @@ function ListFrame(props) {
26704
27557
  }, (0, import_react.createElement)(Text, {
26705
27558
  color: inkColor(getPalette().brandBright),
26706
27559
  wrap: "truncate-end"
26707
- }, truncateColumns(props.title, viewport.contentColumns)), (0, import_react.createElement)(Text, {
27560
+ }, truncateColumns(singleLineText(props.title), viewport.contentColumns)), (0, import_react.createElement)(Text, {
26708
27561
  dimColor: true,
26709
27562
  wrap: "truncate-end"
26710
- }, truncateColumns(`search: ${props.query === "" ? "type to filter" : props.query}`, viewport.contentColumns)), ...visible.map((row, index) => {
27563
+ }, truncateColumns(singleLineText(`search: ${props.query === "" ? "type to filter" : props.query}`), viewport.contentColumns)), ...visible.map((row, index) => {
26711
27564
  const absolute = offset + index;
26712
27565
  const selected = !props.loading && props.error === void 0 && props.rows.length > 0 && absolute === props.cursor;
26713
27566
  return (0, import_react.createElement)(Text, {
@@ -26715,11 +27568,11 @@ function ListFrame(props) {
26715
27568
  color: selected ? inkColor(getPalette().brandBright) : row.disabled ? inkColor(getPalette().dim) : void 0,
26716
27569
  dimColor: row.disabled,
26717
27570
  wrap: "truncate-end"
26718
- }, truncateColumns(`${selected ? "› " : " "}${row.text}`, viewport.contentColumns));
27571
+ }, truncateColumns(`${selected ? "› " : " "}${singleLineText(row.text)}`, viewport.contentColumns));
26719
27572
  }), (0, import_react.createElement)(Text, {
26720
27573
  dimColor: true,
26721
27574
  wrap: "truncate-end"
26722
- }, truncateColumns(props.footer, viewport.contentColumns)));
27575
+ }, truncateColumns(singleLineText(props.footer), viewport.contentColumns)));
26723
27576
  }
26724
27577
  function editQuery(query, input, key) {
26725
27578
  if (key.backspace || key.delete) return query.slice(0, -1);
@@ -26771,6 +27624,51 @@ function ModePanel({ current, load, select, close }) {
26771
27624
  footer: "↑↓ choose · enter switch · r refresh · esc close"
26772
27625
  });
26773
27626
  }
27627
+ function PermissionPanel({ current, load, select, close }) {
27628
+ const [rows, setRows] = (0, import_react.useState)([]);
27629
+ const [query, setQuery] = (0, import_react.useState)("");
27630
+ const [cursor, setCursor] = (0, import_react.useState)(0);
27631
+ const [loading, setLoading] = (0, import_react.useState)(true);
27632
+ const [error, setError] = (0, import_react.useState)();
27633
+ const refresh = () => {
27634
+ setLoading(true);
27635
+ setError(void 0);
27636
+ Promise.resolve().then(load).then((value) => {
27637
+ setRows(value);
27638
+ setLoading(false);
27639
+ }, (reason) => {
27640
+ setError(reason instanceof Error ? reason.message : String(reason));
27641
+ setLoading(false);
27642
+ });
27643
+ };
27644
+ (0, import_react.useEffect)(refresh, []);
27645
+ const visible = (0, import_react.useMemo)(() => rows.filter((row) => `${row.id} ${row.description ?? ""}`.toLowerCase().includes(query.toLowerCase())), [rows, query]);
27646
+ (0, import_react.useEffect)(() => setCursor((value) => Math.min(value, Math.max(0, visible.length - 1))), [visible.length]);
27647
+ useInput((input, key) => {
27648
+ if (key.escape || input === "q") return close();
27649
+ if (input === "r" && query === "") return refresh();
27650
+ if (key.upArrow) return setCursor((value) => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length);
27651
+ if (key.downArrow) return setCursor((value) => visible.length === 0 ? 0 : (value + 1) % visible.length);
27652
+ if (key.return && visible[cursor] !== void 0) return select(visible[cursor].id);
27653
+ const next = editQuery(query, input, key);
27654
+ if (next !== void 0) {
27655
+ setQuery(next);
27656
+ setCursor(0);
27657
+ }
27658
+ });
27659
+ return (0, import_react.createElement)(ListFrame, {
27660
+ title: `/permission · current ${current}`,
27661
+ rows: visible.map((row) => ({
27662
+ key: row.id,
27663
+ text: `${row.id === current ? "●" : "○"} ${row.id}${row.description === void 0 ? "" : ` · ${row.description}`}`
27664
+ })),
27665
+ cursor,
27666
+ loading,
27667
+ error,
27668
+ query,
27669
+ footer: "↑↓ choose · enter select · r refresh · esc close"
27670
+ });
27671
+ }
26774
27672
  function PluginPanel({ load, close, initialQuery = "" }) {
26775
27673
  const [epoch, setEpoch] = (0, import_react.useState)(0);
26776
27674
  const [query, setQuery] = (0, import_react.useState)(initialQuery);
@@ -26950,7 +27848,7 @@ function DocumentPanel({ title, text, error, close }) {
26950
27848
  }, (0, import_react.createElement)(Text, {
26951
27849
  color: inkColor(getPalette().brandBright),
26952
27850
  wrap: "truncate-end"
26953
- }, truncateColumns(title, viewport.contentColumns)), ...body.map((line, index) => (0, import_react.createElement)(Text, {
27851
+ }, truncateColumns(singleLineText(title), viewport.contentColumns)), ...body.map((line, index) => (0, import_react.createElement)(Text, {
26954
27852
  key: `${scroll}-${index}`,
26955
27853
  wrap: "truncate-end"
26956
27854
  }, truncateColumns(line, viewport.contentColumns))), (0, import_react.createElement)(Text, {
@@ -27020,7 +27918,7 @@ function HistoryPanel({ entries, fill, close }) {
27020
27918
  key: `history-${absolute}`,
27021
27919
  color: selected ? inkColor(getPalette().brandBright) : void 0,
27022
27920
  wrap: "truncate-end"
27023
- }, truncateColumns((selected ? "› " : " ") + displayText(entry), viewport.contentColumns));
27921
+ }, truncateColumns((selected ? "› " : " ") + singleLineText(entry), viewport.contentColumns));
27024
27922
  }), (0, import_react.createElement)(Text, {
27025
27923
  dimColor: true,
27026
27924
  wrap: "truncate-end"
@@ -27168,7 +28066,7 @@ function serializeHistoryEntry(text) {
27168
28066
  * @param max - entry cap.
27169
28067
  * @returns persistent entries, oldest first.
27170
28068
  */
27171
- function parseHistoryFile(raw, max = 500) {
28069
+ function parseHistoryFile(raw, max = 100) {
27172
28070
  const kept = [];
27173
28071
  for (const line of raw.split("\n")) {
27174
28072
  if (line === "") continue;
@@ -27185,27 +28083,30 @@ function parseHistoryFile(raw, max = 500) {
27185
28083
  return kept.slice(-max);
27186
28084
  }
27187
28085
  /**
27188
- * Append one entry to the persistent file content: JSON line, capped to the
27189
- * newest `max` entries with a trailing newline.
27190
- * @param current - existing file content.
27191
- * @param text - submission to persist.
27192
- * @param max - entry cap.
27193
- * @returns the new file content.
27194
- */
27195
- function appendHistoryContent(current, text, max = 500) {
27196
- return [...parseHistoryFile(current, max), text].slice(-max).map(serializeHistoryEntry).join("\n") + "\n";
27197
- }
27198
- /**
27199
28086
  * Record one in-session submission: empty text is ignored and an adjacent
27200
- * duplicate collapses (Codex `record_local_submission` semantics).
28087
+ * duplicate collapses (Codex `record_local_submission` semantics). The local
28088
+ * pool shares the persistent pool's cap so the recall space stays bounded.
27201
28089
  * @param local - current in-session entries, oldest first.
27202
28090
  * @param text - the submitted prompt.
28091
+ * @param max - the local pool cap.
27203
28092
  * @returns the updated local list.
27204
28093
  */
27205
- function recordLocalEntry(local, text) {
28094
+ function recordLocalEntry(local, text, max = 100) {
27206
28095
  if (text === "") return local;
27207
28096
  if (local.length > 0 && local[local.length - 1] === text) return local;
27208
- return [...local, text];
28097
+ return [...local, text].slice(-max);
28098
+ }
28099
+ /**
28100
+ * Serialize a capped entry list to the history file format (one JSON line per
28101
+ * entry, trailing newline). The runner writes the in-memory list as the whole
28102
+ * file, so rapid same-process submissions cannot lose entries to a
28103
+ * read-modify-write race (the file is never read back before writing).
28104
+ * @param entries - the entries to persist, oldest first.
28105
+ * @returns the file content, '' for an empty list.
28106
+ */
28107
+ function serializeHistoryList(entries) {
28108
+ if (entries.length === 0) return "";
28109
+ return entries.map(serializeHistoryEntry).join("\n") + "\n";
27209
28110
  }
27210
28111
  /**
27211
28112
  * Build the recall space, newest first: local entries, then persistent
@@ -27386,13 +28287,18 @@ function DeepDivingLine({ since }) {
27386
28287
  * the freshest tokens stay visible while a long reply streams; the complete
27387
28288
  * text lands in the flushed scrollback once the turn assembles it.
27388
28289
  */
27389
- function StreamTail({ text, dim, maxRows, prefix, children }) {
28290
+ function StreamTail({ text, dim, maxRows, prefix = "", continuationPrefix = prefix, children }) {
27390
28291
  const columns = useStdout().stdout?.columns ?? 80;
27391
28292
  const safeRows = Math.max(1, maxRows);
27392
- const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ""));
28293
+ const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix));
28294
+ const contentColumns = Math.max(10, columns - 3 - prefixColumns);
27393
28295
  const initial = displayTail(text, contentColumns, safeRows);
27394
28296
  const tail = initial.truncated && safeRows > 1 ? displayTail(text, contentColumns, safeRows - 1) : initial;
27395
- return (0, import_react.createElement)(Box, { flexDirection: "column" }, tail.truncated && safeRows > 1 ? (0, import_react.createElement)(Text, { dimColor: true }, "") : void 0, (0, import_react.createElement)(Text, { dimColor: dim || void 0 }, prefix, tail.text, children));
28297
+ const rows = tail.text.split("\n");
28298
+ return (0, import_react.createElement)(Box, { flexDirection: "column" }, tail.truncated && safeRows > 1 ? (0, import_react.createElement)(Text, { color: inkColor(getPalette().dim) }, continuationPrefix, "…") : void 0, ...rows.map((row, index) => (0, import_react.createElement)(Text, {
28299
+ key: index,
28300
+ dimColor: dim || void 0
28301
+ }, index === 0 ? prefix : continuationPrefix, row, index + 1 === rows.length ? children : void 0)));
27396
28302
  }
27397
28303
  /** Ink props for one markdown style class. */
27398
28304
  function segmentProps(style) {
@@ -27403,6 +28309,12 @@ function segmentProps(style) {
27403
28309
  italic: void 0,
27404
28310
  strikethrough: void 0
27405
28311
  };
28312
+ case "accentBold": return {
28313
+ color: inkColor(getPalette().brandBright),
28314
+ bold: true,
28315
+ italic: void 0,
28316
+ strikethrough: void 0
28317
+ };
27406
28318
  case "code": return {
27407
28319
  color: inkColor(getPalette().code),
27408
28320
  bold: void 0,
@@ -27479,11 +28391,11 @@ function lineStyleProps(style) {
27479
28391
  dimColor: void 0
27480
28392
  };
27481
28393
  case "dimItalic": return {
27482
- color: void 0,
28394
+ color: inkColor(getPalette().dim),
27483
28395
  bold: void 0,
27484
28396
  italic: true,
27485
28397
  strikethrough: void 0,
27486
- dimColor: true
28398
+ dimColor: void 0
27487
28399
  };
27488
28400
  default: return {
27489
28401
  ...segmentProps(style),
@@ -27521,6 +28433,12 @@ function MarkdownBody({ text, indent = 0 }) {
27521
28433
  ...segmentProps(segment.style)
27522
28434
  }, segment.text)))));
27523
28435
  }
28436
+ /** Expanded reasoning with the same two-column content edge as the reply. */
28437
+ function ReasoningBody({ text }) {
28438
+ const columns = useStdout().stdout?.columns ?? 80;
28439
+ const lines = (0, import_react.useMemo)(() => reasoningLines(text, Math.max(10, columns - 2)), [text, columns]);
28440
+ return (0, import_react.createElement)(StyledRows, { lines });
28441
+ }
27524
28442
  /**
27525
28443
  * One expanded tool-card body for the verbose transcript (Ctrl+O): the
27526
28444
  * presentation contract's structured cards — inline diffs, read windows,
@@ -27568,23 +28486,20 @@ function ToolDetailBody({ detail }) {
27568
28486
  function EntryLine({ entry, showReasoning, verbose }) {
27569
28487
  switch (entry.kind) {
27570
28488
  case "user": return entry.notice ? (0, import_react.createElement)(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`) : (0, import_react.createElement)(Text, null, brand("❯ "), displayText(entry.text));
27571
- case "assistant": return (0, import_react.createElement)(Box, { flexDirection: "column" }, entry.reasoning === "" ? void 0 : showReasoning ? (0, import_react.createElement)(Text, {
27572
- dimColor: true,
27573
- italic: true
27574
- }, ` ✻ ${displayText(entry.reasoning)}`) : (0, import_react.createElement)(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`), (0, import_react.createElement)(MarkdownBody, {
28489
+ case "assistant": return (0, import_react.createElement)(Box, { flexDirection: "column" }, entry.reasoning === "" ? void 0 : showReasoning ? (0, import_react.createElement)(ReasoningBody, { text: entry.reasoning }) : (0, import_react.createElement)(Text, { color: inkColor(getPalette().dim) }, `✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`), (0, import_react.createElement)(MarkdownBody, {
27575
28490
  text: entry.text,
27576
28491
  indent: 2
27577
28492
  }));
27578
28493
  case "tool": {
27579
28494
  const mark = entry.state === "running" ? (0, import_react.createElement)(Pulse) : entry.state === "error" ? (0, import_react.createElement)(Text, { color: inkColor(getPalette().error) }, "⨯") : (0, import_react.createElement)(Text, { color: inkColor(getPalette().success) }, "⏺");
27580
- return (0, import_react.createElement)(Box, { flexDirection: "column" }, (0, import_react.createElement)(Text, { wrap: verbose ? "truncate-end" : void 0 }, mark, " ", brand(entry.name), entry.preview === "" ? "" : ` ${dim(displayText(entry.preview))}`), entry.summary === "" ? void 0 : (0, import_react.createElement)(Text, {
28495
+ return (0, import_react.createElement)(Box, { flexDirection: "column" }, (0, import_react.createElement)(Text, { wrap: verbose ? "truncate-end" : void 0 }, mark, " ", brand(displayText(entry.name)), entry.preview === "" ? "" : ` ${dim(displayText(entry.preview))}`), entry.summary === "" ? void 0 : (0, import_react.createElement)(Text, {
27581
28496
  color: entry.state === "error" ? inkColor(getPalette().error) : inkColor(getPalette().dim),
27582
28497
  wrap: verbose ? "truncate-end" : void 0
27583
28498
  }, ` ⎿ ${displayText(entry.summary)}`), verbose && entry.detail !== void 0 ? (0, import_react.createElement)(ToolDetailBody, { detail: entry.detail }) : void 0);
27584
28499
  }
27585
28500
  case "command": {
27586
28501
  const mark = entry.state === "running" ? (0, import_react.createElement)(Pulse) : entry.state === "error" ? (0, import_react.createElement)(Text, { color: inkColor(getPalette().error) }, "⨯") : (0, import_react.createElement)(Text, { color: inkColor(getPalette().success) }, "⏺");
27587
- return (0, import_react.createElement)(Box, { flexDirection: "column" }, (0, import_react.createElement)(Text, { wrap: verbose ? "truncate-end" : void 0 }, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`), entry.summary === "" ? void 0 : (0, import_react.createElement)(Text, {
28502
+ return (0, import_react.createElement)(Box, { flexDirection: "column" }, (0, import_react.createElement)(Text, { wrap: verbose ? "truncate-end" : void 0 }, mark, " ", brand(displayText(`/${entry.name}`)), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`), entry.summary === "" ? void 0 : (0, import_react.createElement)(Text, {
27588
28503
  color: inkColor(getPalette().dim),
27589
28504
  wrap: verbose ? "truncate-end" : void 0
27590
28505
  }, ` ⎿ ${displayText(entry.summary)}`));
@@ -27615,26 +28530,30 @@ function EntryLine({ entry, showReasoning, verbose }) {
27615
28530
  }
27616
28531
  }
27617
28532
  /**
27618
- * The whale wordmark header in DeepSeek blue, hugging its content width.
27619
- * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
27620
- * short to show it whole (or mid-resize) the clipped pairs garble the
27621
- * screen — below the height floor the header collapses to a single-line
27622
- * wordmark that stays correct at any size.
28533
+ * The whale header with a compact three-line copy lockup. The title, bilingual
28534
+ * slogan, and key hint stay centered inside the existing eight content rows,
28535
+ * preserving the Static header's ten physical rows. Short or narrow terminals
28536
+ * keep a one-line form.
27623
28537
  */
27624
28538
  function Header({ resumed }) {
27625
- const rows = useStdout().stdout?.rows ?? 40;
27626
- const hint = resumed ? "resumed session · /help commands · Esc interrupt" : "/help commands · Esc interrupt · Ctrl+C quit";
27627
- if (rows < 20) return (0, import_react.createElement)(Box, {
27628
- flexDirection: "row",
27629
- gap: 1,
28539
+ const stdout = useStdout().stdout;
28540
+ const rows = stdout?.rows ?? 40;
28541
+ const columns = stdout?.columns ?? 80;
28542
+ const title = `DeepSeek Harness · v${DSH_CODE_VERSION}`;
28543
+ const slogan = "Into the Unknown 探索未至之境";
28544
+ const hint = resumed ? "resumed · /help · Esc interrupt" : "/help · Esc interrupt · Ctrl+C quit";
28545
+ const copyColumns = Math.max(visibleColumns(title), visibleColumns(slogan), visibleColumns(hint));
28546
+ const compact = `${title} · ${hint}`;
28547
+ if (rows < 20 || columns < 26 + copyColumns + 8) return (0, import_react.createElement)(Box, {
28548
+ width: Math.max(1, columns - 1),
27630
28549
  borderStyle: "round",
27631
28550
  borderColor: inkColor(getPalette().brand),
27632
- paddingX: 1,
27633
- alignSelf: "flex-start"
28551
+ paddingX: 1
27634
28552
  }, (0, import_react.createElement)(Text, {
27635
28553
  color: inkColor(getPalette().brandBright),
27636
- bold: true
27637
- }, "DeepSeek Harness"), (0, import_react.createElement)(Text, { dimColor: true }, hint));
28554
+ bold: true,
28555
+ wrap: "truncate-end"
28556
+ }, truncateColumns(compact, Math.max(1, columns - 5))));
27638
28557
  return (0, import_react.createElement)(Box, {
27639
28558
  flexDirection: "row",
27640
28559
  gap: 2,
@@ -27651,11 +28570,19 @@ function Header({ resumed }) {
27651
28570
  color: inkColor(getPalette().brand)
27652
28571
  }, row))), (0, import_react.createElement)(Box, {
27653
28572
  flexDirection: "column",
28573
+ width: copyColumns,
27654
28574
  justifyContent: "center"
27655
28575
  }, (0, import_react.createElement)(Text, {
27656
28576
  color: inkColor(getPalette().brandBright),
27657
- bold: true
27658
- }, "DeepSeek Harness"), (0, import_react.createElement)(Text, { dimColor: true }, hint)));
28577
+ bold: true,
28578
+ wrap: "truncate-end"
28579
+ }, title), (0, import_react.createElement)(Text, {
28580
+ color: inkColor(getPalette().code),
28581
+ wrap: "truncate-end"
28582
+ }, (0, import_react.createElement)(Text, { bold: true }, "Into the Unknown"), " 探索未至之境"), (0, import_react.createElement)(Text, {
28583
+ color: inkColor(getPalette().dim),
28584
+ wrap: "truncate-end"
28585
+ }, hint)));
27659
28586
  }
27660
28587
  /** Todo status glyph: web TodoPanel's three-state marker. */
27661
28588
  function todoMark(status) {
@@ -27718,31 +28645,11 @@ function statusToneProps(tone) {
27718
28645
  bold: void 0,
27719
28646
  dimColor: void 0
27720
28647
  };
27721
- case "ctxSystem": return {
27722
- color: inkColor(getPalette().brandDeep),
27723
- bold: void 0,
27724
- dimColor: void 0
27725
- };
27726
- case "ctxPrompt": return {
28648
+ case "ctxFill": return {
27727
28649
  color: inkColor(getPalette().brand),
27728
28650
  bold: void 0,
27729
28651
  dimColor: void 0
27730
28652
  };
27731
- case "ctxAssistant": return {
27732
- color: inkColor(getPalette().brandMid),
27733
- bold: void 0,
27734
- dimColor: void 0
27735
- };
27736
- case "ctxThinking": return {
27737
- color: inkColor(getPalette().brandBright),
27738
- bold: void 0,
27739
- dimColor: void 0
27740
- };
27741
- case "ctxTools": return {
27742
- color: inkColor(getPalette().code),
27743
- bold: void 0,
27744
- dimColor: void 0
27745
- };
27746
28653
  case "success": return {
27747
28654
  color: inkColor(getPalette().code),
27748
28655
  bold: true,
@@ -27805,7 +28712,7 @@ function StatusLine({ facts, stats, busy, columns, items }) {
27805
28712
  busy,
27806
28713
  items
27807
28714
  });
27808
- const renderRow = (row, key) => {
28715
+ const renderRow = (row, key, indent = 0) => {
27809
28716
  const leftParts = [];
27810
28717
  row.left.forEach((group, groupIndex) => {
27811
28718
  if (groupIndex > 0) leftParts.push((0, import_react.createElement)(Text, {
@@ -27837,12 +28744,12 @@ function StatusLine({ facts, stats, busy, columns, items }) {
27837
28744
  color: inkColor(getPalette().dim)
27838
28745
  }, STATUS_CYCLE_HINT));
27839
28746
  return (0, import_react.createElement)(Box, {
27840
- paddingLeft: 2,
28747
+ paddingLeft: 2 + indent,
27841
28748
  justifyContent: rightParts.length > 0 ? "space-between" : void 0
27842
28749
  }, (0, import_react.createElement)(Text, { wrap: "truncate-end" }, ...leftParts), rightParts.length > 0 ? (0, import_react.createElement)(Text, { wrap: "truncate-end" }, ...rightParts) : void 0);
27843
28750
  };
27844
28751
  const row2Present = layout.row2.left.length > 0;
27845
- return (0, import_react.createElement)(Box, { flexDirection: "column" }, renderRow(layout.row1, "s1"), row2Present ? renderRow(layout.row2, "s2") : void 0);
28752
+ return (0, import_react.createElement)(Box, { flexDirection: "column" }, renderRow(layout.row1, "s1"), row2Present ? renderRow(layout.row2, "s2", 2) : void 0);
27846
28753
  }
27847
28754
  /**
27848
28755
  * One fixed-height local feedback row. Errors remain visible while a slash
@@ -28149,22 +29056,132 @@ function QuestionBar({ store, snapshot, locked }) {
28149
29056
  width: viewport.outerColumns,
28150
29057
  paddingX: 1,
28151
29058
  borderStyle: "round",
28152
- borderColor: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep)
29059
+ borderColor: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep)
29060
+ }, (0, import_react.createElement)(Text, {
29061
+ color: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep),
29062
+ bold: true,
29063
+ wrap: "truncate-end"
29064
+ }, truncateColumns(`${isPlan ? "📋 plan review" : "❓ question"} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(Text, {
29065
+ dimColor: true,
29066
+ wrap: "truncate-end"
29067
+ }, dim(truncateColumns(footer, viewport.contentColumns))));
29068
+ }
29069
+ /** The /model panel: a scrolling list over the advisory model directory. */
29070
+ function ModelPanel({ directory, error, onSelect, onProviders, onRetry, onClose }) {
29071
+ const [cursor, setCursor] = (0, import_react.useState)(0);
29072
+ const stdout = useStdout().stdout;
29073
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
29074
+ const rows = directory?.rows ?? [];
29075
+ (0, import_react.useEffect)(() => {
29076
+ if (rows.length === 0) {
29077
+ if (cursor !== 0) setCursor(0);
29078
+ return;
29079
+ }
29080
+ if (cursor >= rows.length) setCursor(rows.length - 1);
29081
+ }, [rows.length, cursor]);
29082
+ useInput((input, key) => {
29083
+ if (key.escape || input === "q") {
29084
+ onClose();
29085
+ return;
29086
+ }
29087
+ if (input === "r") {
29088
+ onRetry();
29089
+ return;
29090
+ }
29091
+ if (input === "a" && onProviders !== void 0) {
29092
+ onProviders();
29093
+ return;
29094
+ }
29095
+ if (rows.length === 0) return;
29096
+ if (key.upArrow) {
29097
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
29098
+ return;
29099
+ }
29100
+ if (key.downArrow) {
29101
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
29102
+ return;
29103
+ }
29104
+ if (key.pageUp) {
29105
+ setCursor((current) => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)));
29106
+ return;
29107
+ }
29108
+ if (key.pageDown) {
29109
+ setCursor((current) => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)));
29110
+ return;
29111
+ }
29112
+ if (input === "g") {
29113
+ setCursor(0);
29114
+ return;
29115
+ }
29116
+ if (input === "G") {
29117
+ setCursor(rows.length - 1);
29118
+ return;
29119
+ }
29120
+ if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
29121
+ });
29122
+ if (viewport.maxHeight === 0) return (0, import_react.createElement)(Box, { display: "none" });
29123
+ if (viewport.compact) return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(`/model${onProviders === void 0 ? "" : " · a providers"} · r retry · esc/q close`, viewport.contentColumns));
29124
+ const visibleStateRows = (directory === void 0 && error === void 0 ? [(0, import_react.createElement)(Text, {
29125
+ key: "loading",
29126
+ dimColor: true,
29127
+ wrap: "truncate-end"
29128
+ }, " loading models…")] : error !== void 0 ? [(0, import_react.createElement)(Text, {
29129
+ key: "error",
29130
+ color: inkColor(getPalette().error),
29131
+ wrap: "truncate-end"
29132
+ }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))] : [...directory?.failures.length === 0 ? [] : [(0, import_react.createElement)(Text, {
29133
+ key: "failures",
29134
+ color: inkColor(getPalette().warn),
29135
+ wrap: "truncate-end"
29136
+ }, truncateColumns(` unavailable providers: ${directory?.failures.join(", ")}`, viewport.contentColumns))], ...rows.length === 0 ? [(0, import_react.createElement)(Text, {
29137
+ key: "empty",
29138
+ dimColor: true,
29139
+ wrap: "truncate-end"
29140
+ }, " no models available")] : []]).slice(0, viewport.bodyRows);
29141
+ const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length);
29142
+ const first = selectionWindow(cursor, rows.length, rowBudget);
29143
+ const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget);
29144
+ return (0, import_react.createElement)(Box, {
29145
+ flexDirection: "column",
29146
+ width: viewport.outerColumns,
29147
+ paddingX: 1,
29148
+ borderStyle: "round",
29149
+ borderColor: inkColor(getPalette().brand)
28153
29150
  }, (0, import_react.createElement)(Text, {
28154
- color: inkColor(isPlan ? getPalette().brand : getPalette().brandDeep),
29151
+ color: inkColor(getPalette().brand),
28155
29152
  bold: true,
28156
29153
  wrap: "truncate-end"
28157
- }, truncateColumns(`${isPlan ? "📋 plan review" : "❓ question"} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(Text, {
29154
+ }, truncateColumns(`/model select model${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), ...visibleStateRows, ...visible.map((row) => {
29155
+ const index = rows.indexOf(row);
29156
+ const label = displayText(`${row.providerName} · ${row.modelName}`);
29157
+ return (0, import_react.createElement)(Text, {
29158
+ key: `${row.provider}/${row.model}`,
29159
+ color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim),
29160
+ wrap: "truncate-end"
29161
+ }, truncateColumns(`${index === cursor ? "❯ " : " "}${label}`, viewport.contentColumns));
29162
+ }), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(Text, {
28158
29163
  dimColor: true,
28159
29164
  wrap: "truncate-end"
28160
- }, dim(truncateColumns(footer, viewport.contentColumns))));
29165
+ }, dim(truncateColumns(`↑↓ move · pgup/pgdn page · enter select${onProviders === void 0 ? "" : " · a providers"} · r retry · esc/q close`, viewport.contentColumns))));
28161
29166
  }
28162
- /** The /model panel: a scrolling list over the advisory model directory. */
28163
- function ModelPanel({ directory, error, onSelect, onRetry, onClose }) {
28164
- const [cursor, setCursor] = (0, import_react.useState)(0);
29167
+ /** Compact provider-state copy; only value-free credential facts cross this boundary. */
29168
+ function providerStateLabel(row) {
29169
+ const route = row.active ? "active" : "dormant";
29170
+ const credential = row.credential;
29171
+ if (credential?.kind === "error") return `${route} · key status unavailable`;
29172
+ if (credential?.kind === "facts") {
29173
+ if (!credential.configured) return `${route} · key missing`;
29174
+ return `${route} · key ${credential.source === void 0 ? "configured" : singleLineText(credential.source)}${credential.writable ? "" : " · read-only"}`;
29175
+ }
29176
+ return `${route} · ${row.configured ? "provider auth" : "not configured"}`;
29177
+ }
29178
+ /** The provider-management stage reached from /model with `a`. */
29179
+ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRetry, onBack }) {
28165
29180
  const stdout = useStdout().stdout;
28166
29181
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
28167
29182
  const rows = directory?.rows ?? [];
29183
+ const [cursor, setCursor] = (0, import_react.useState)(0);
29184
+ const [actionError, setActionError] = (0, import_react.useState)(void 0);
28168
29185
  (0, import_react.useEffect)(() => {
28169
29186
  if (rows.length === 0) {
28170
29187
  if (cursor !== 0) setCursor(0);
@@ -28172,62 +29189,87 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }) {
28172
29189
  }
28173
29190
  if (cursor >= rows.length) setCursor(rows.length - 1);
28174
29191
  }, [rows.length, cursor]);
28175
- useInput((input, key) => {
29192
+ useStableInput((input, key) => {
28176
29193
  if (key.escape || input === "q") {
28177
- onClose();
29194
+ onBack();
28178
29195
  return;
28179
29196
  }
28180
29197
  if (input === "r") {
29198
+ setActionError(void 0);
28181
29199
  onRetry();
28182
29200
  return;
28183
29201
  }
28184
29202
  if (rows.length === 0) return;
28185
29203
  if (key.upArrow) {
29204
+ setActionError(void 0);
28186
29205
  setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
28187
29206
  return;
28188
29207
  }
28189
29208
  if (key.downArrow) {
29209
+ setActionError(void 0);
28190
29210
  setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
28191
29211
  return;
28192
29212
  }
28193
29213
  if (key.pageUp) {
29214
+ setActionError(void 0);
28194
29215
  setCursor((current) => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)));
28195
29216
  return;
28196
29217
  }
28197
29218
  if (key.pageDown) {
29219
+ setActionError(void 0);
28198
29220
  setCursor((current) => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)));
28199
29221
  return;
28200
29222
  }
28201
- if (input === "g") {
28202
- setCursor(0);
29223
+ const target = rows[cursor];
29224
+ if (target === void 0) return;
29225
+ if (input === "d") {
29226
+ const facts = target.credential;
29227
+ if (facts?.kind !== "facts" || !facts.configured) setActionError("this provider has no configured API key to remove");
29228
+ else if (!facts.writable) setActionError("this API key is supplied read-only by the environment");
29229
+ else onUnset(target);
28203
29230
  return;
28204
29231
  }
28205
- if (input === "G") {
28206
- setCursor(rows.length - 1);
29232
+ if (input === "x") {
29233
+ if (!target.removable) setActionError("this provider profile is not removable");
29234
+ else onRemove(target);
28207
29235
  return;
28208
29236
  }
28209
- if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
28210
- });
29237
+ if (key.return) {
29238
+ if (target.settingsNs.length === 0) setActionError("this provider is not managed by Harness settings");
29239
+ else if (target.credential?.kind === "error") setActionError("credential status is unavailable; retry before writing");
29240
+ else if (target.credential?.kind === "facts" && !target.credential.writable) setActionError("this API key is supplied read-only by the environment");
29241
+ else if (target.credentialRef === void 0 && directory?.writable !== true) setActionError("settings are read-only; this provider cannot be activated here");
29242
+ else onCredential(target);
29243
+ }
29244
+ }, true);
28211
29245
  if (viewport.maxHeight === 0) return (0, import_react.createElement)(Box, { display: "none" });
28212
- if (viewport.compact) return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns("/model · r retry · esc/q close", viewport.contentColumns));
28213
- const stateRows = directory === void 0 && error === void 0 ? [(0, import_react.createElement)(Text, {
29246
+ if (viewport.compact) return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns("/model providers · enter key · d remove key · esc back", viewport.contentColumns));
29247
+ const visibleStateRows = (directory === void 0 && error === void 0 ? [(0, import_react.createElement)(Text, {
28214
29248
  key: "loading",
28215
- dimColor: true,
29249
+ color: inkColor(getPalette().dim),
28216
29250
  wrap: "truncate-end"
28217
- }, " loading models…")] : error !== void 0 ? [(0, import_react.createElement)(Text, {
29251
+ }, " loading providers…")] : error !== void 0 ? [(0, import_react.createElement)(Text, {
28218
29252
  key: "error",
28219
29253
  color: inkColor(getPalette().error),
28220
29254
  wrap: "truncate-end"
28221
- }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))] : [...directory?.failures.length === 0 ? [] : [(0, import_react.createElement)(Text, {
28222
- key: "failures",
28223
- color: inkColor(getPalette().warn),
28224
- wrap: "truncate-end"
28225
- }, truncateColumns(` unavailable providers: ${directory?.failures.join(", ")}`, viewport.contentColumns))], ...rows.length === 0 ? [(0, import_react.createElement)(Text, {
28226
- key: "empty",
28227
- dimColor: true,
28228
- wrap: "truncate-end"
28229
- }, " no models available")] : []];
28230
- const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length);
29255
+ }, truncateColumns(` ${singleLineText(error)}`, viewport.contentColumns))] : [
29256
+ ...actionError === void 0 ? [] : [(0, import_react.createElement)(Text, {
29257
+ key: "action-error",
29258
+ color: inkColor(getPalette().error),
29259
+ wrap: "truncate-end"
29260
+ }, truncateColumns(` ${actionError}`, viewport.contentColumns))],
29261
+ ...(directory?.failures ?? []).map((failure, index) => (0, import_react.createElement)(Text, {
29262
+ key: `failure-${index}`,
29263
+ color: inkColor(getPalette().warn),
29264
+ wrap: "truncate-end"
29265
+ }, truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns))),
29266
+ ...rows.length === 0 ? [(0, import_react.createElement)(Text, {
29267
+ key: "empty",
29268
+ color: inkColor(getPalette().dim),
29269
+ wrap: "truncate-end"
29270
+ }, " no configurable providers")] : []
29271
+ ]).slice(0, viewport.bodyRows);
29272
+ const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length);
28231
29273
  const first = selectionWindow(cursor, rows.length, rowBudget);
28232
29274
  const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget);
28233
29275
  return (0, import_react.createElement)(Box, {
@@ -28240,18 +29282,168 @@ function ModelPanel({ directory, error, onSelect, onRetry, onClose }) {
28240
29282
  color: inkColor(getPalette().brand),
28241
29283
  bold: true,
28242
29284
  wrap: "truncate-end"
28243
- }, truncateColumns(`/model — select model${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), ...stateRows, ...visible.map((row) => {
29285
+ }, truncateColumns(`/model — providers${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), ...visibleStateRows, ...visible.map((row) => {
28244
29286
  const index = rows.indexOf(row);
28245
- const label = displayText(`${row.providerName} · ${row.modelName}`);
29287
+ const label = `${row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`} · ${providerStateLabel(row)}${row.removable ? " · custom" : ""}`;
28246
29288
  return (0, import_react.createElement)(Text, {
28247
- key: `${row.provider}/${row.model}`,
29289
+ key: row.provider,
28248
29290
  color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim),
28249
29291
  wrap: "truncate-end"
28250
- }, truncateColumns(`${index === cursor ? "❯ " : " "}${label}`, viewport.contentColumns));
29292
+ }, truncateColumns(`${index === cursor ? "❯ " : " "}${displayText(label)}`, viewport.contentColumns));
28251
29293
  }), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(Text, {
28252
- dimColor: true,
29294
+ color: inkColor(getPalette().dim),
29295
+ wrap: "truncate-end"
29296
+ }, truncateColumns("↑↓ move · enter add/update key · d remove key · x remove custom provider · r retry · esc back", viewport.contentColumns)));
29297
+ }
29298
+ /** Write-only masked API-key editor; the secret lives only in this mounted component. */
29299
+ function ProviderCredentialPanel({ target, save, done, back }) {
29300
+ const stdout = useStdout().stdout;
29301
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
29302
+ const [draft, setDraft] = (0, import_react.useState)("");
29303
+ const [busy, setBusy] = (0, import_react.useState)(false);
29304
+ const [error, setError] = (0, import_react.useState)(void 0);
29305
+ const submit = () => {
29306
+ if (busy) return;
29307
+ setBusy(true);
29308
+ setError(void 0);
29309
+ Promise.resolve().then(() => save(target, draft)).then(() => {
29310
+ setDraft("");
29311
+ done();
29312
+ }, (reason) => {
29313
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)));
29314
+ setBusy(false);
29315
+ });
29316
+ };
29317
+ useStableInput((input, key) => {
29318
+ if (busy) return;
29319
+ if (key.escape) {
29320
+ setDraft("");
29321
+ back();
29322
+ return;
29323
+ }
29324
+ if (key.return) {
29325
+ submit();
29326
+ return;
29327
+ }
29328
+ if (key.backspace || key.delete) {
29329
+ setError(void 0);
29330
+ setDraft((current) => [...current].slice(0, -1).join(""));
29331
+ return;
29332
+ }
29333
+ if (key.ctrl && input === "u") {
29334
+ setError(void 0);
29335
+ setDraft("");
29336
+ return;
29337
+ }
29338
+ if (key.ctrl || key.meta || input.length === 0) return;
29339
+ const next = draft + input;
29340
+ if (next.length > 4096) {
29341
+ setError("API key input is too long");
29342
+ return;
29343
+ }
29344
+ setError(void 0);
29345
+ setDraft(next);
29346
+ }, true);
29347
+ if (viewport.maxHeight === 0) return (0, import_react.createElement)(Box, { display: "none" });
29348
+ const keyBudget = Math.max(1, viewport.contentColumns - 4);
29349
+ const bullets = "•".repeat(Math.min([...draft].length, keyBudget));
29350
+ if (viewport.compact) return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(`API key ${bullets}${busy ? " saving…" : " ▏"} · esc back`, viewport.contentColumns));
29351
+ const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`;
29352
+ const source = target.credential?.kind === "facts" && target.credential.configured ? `replaces ${singleLineText(target.credential.source ?? "stored key")}` : "new key";
29353
+ const providerRow = (0, import_react.createElement)(Text, {
29354
+ key: "provider",
29355
+ wrap: "truncate-end"
29356
+ }, truncateColumns(` provider ${displayText(identity)}`, viewport.contentColumns));
29357
+ const referenceRow = (0, import_react.createElement)(Text, {
29358
+ key: "reference",
29359
+ color: inkColor(getPalette().dim),
29360
+ wrap: "truncate-end"
29361
+ }, truncateColumns(` reference ${displayText(target.credentialRef ?? target.suggestedRef)} · ${source}`, viewport.contentColumns));
29362
+ const keyRow = (0, import_react.createElement)(Text, {
29363
+ key: "key",
29364
+ color: error === void 0 ? inkColor(getPalette().brandBright) : inkColor(getPalette().error),
29365
+ wrap: "truncate-end"
29366
+ }, truncateColumns(` key ${bullets}${busy ? " saving…" : " ▏"}`, viewport.contentColumns));
29367
+ const errorRow = error === void 0 ? void 0 : (0, import_react.createElement)(Text, {
29368
+ key: "error",
29369
+ color: inkColor(getPalette().error),
29370
+ wrap: "truncate-end"
29371
+ }, truncateColumns(` ${error}`, viewport.contentColumns));
29372
+ const detailRows = errorRow === void 0 ? [providerRow, referenceRow] : [providerRow, errorRow];
29373
+ const primaryRow = viewport.bodyRows === 1 && errorRow !== void 0 ? errorRow : keyRow;
29374
+ const detailBudget = Math.max(0, viewport.bodyRows - 1);
29375
+ const bodyRows = [...detailBudget === 0 ? [] : detailRows.slice(-detailBudget), ...viewport.bodyRows === 0 ? [] : [primaryRow]];
29376
+ return (0, import_react.createElement)(Box, {
29377
+ flexDirection: "column",
29378
+ width: viewport.outerColumns,
29379
+ paddingX: 1,
29380
+ borderStyle: "round",
29381
+ borderColor: inkColor(getPalette().brand)
29382
+ }, (0, import_react.createElement)(Text, {
29383
+ color: inkColor(getPalette().brand),
29384
+ bold: true,
29385
+ wrap: "truncate-end"
29386
+ }, truncateColumns("/model — add API key", viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), ...bodyRows, (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(Text, {
29387
+ color: inkColor(getPalette().dim),
29388
+ wrap: "truncate-end"
29389
+ }, truncateColumns("type or paste key · enter save · ctrl+u clear · esc back", viewport.contentColumns)));
29390
+ }
29391
+ /** Bounded destructive-action confirmation for credential or provider removal. */
29392
+ function ProviderConfirmPanel({ target, kind, confirm, done, back }) {
29393
+ const stdout = useStdout().stdout;
29394
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
29395
+ const [busy, setBusy] = (0, import_react.useState)(false);
29396
+ const [error, setError] = (0, import_react.useState)(void 0);
29397
+ const run = () => {
29398
+ if (busy) return;
29399
+ setBusy(true);
29400
+ setError(void 0);
29401
+ Promise.resolve().then(() => confirm(target)).then(done, (reason) => {
29402
+ setError(singleLineText(reason instanceof Error ? reason.message : String(reason)));
29403
+ setBusy(false);
29404
+ });
29405
+ };
29406
+ useStableInput((input, key) => {
29407
+ if (busy) return;
29408
+ if (key.escape || input === "n") {
29409
+ back();
29410
+ return;
29411
+ }
29412
+ if (input === "y") run();
29413
+ }, true);
29414
+ if (viewport.maxHeight === 0) return (0, import_react.createElement)(Box, { display: "none" });
29415
+ const action = kind === "credential" ? "remove API key" : "remove provider";
29416
+ if (viewport.compact) return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, truncateColumns(`${action} ${target.displayName}? · y confirm · n/esc back`, viewport.contentColumns));
29417
+ const identity = target.displayName === target.provider ? target.provider : `${target.displayName} (${target.provider})`;
29418
+ const identityRow = (0, import_react.createElement)(Text, {
29419
+ key: "identity",
29420
+ wrap: "truncate-end"
29421
+ }, truncateColumns(` ${displayText(identity)}`, viewport.contentColumns));
29422
+ const descriptionRow = (0, import_react.createElement)(Text, {
29423
+ key: "description",
29424
+ color: inkColor(getPalette().dim),
28253
29425
  wrap: "truncate-end"
28254
- }, dim(truncateColumns("↑↓ move · pgup/pgdn page · g/G ends · enter select · r retry · esc/q close", viewport.contentColumns))));
29426
+ }, truncateColumns(kind === "credential" ? " the provider profile and selected model stay available" : " the user settings profile and its managed key will be removed", viewport.contentColumns));
29427
+ const errorRow = error === void 0 ? void 0 : (0, import_react.createElement)(Text, {
29428
+ key: "error",
29429
+ color: inkColor(getPalette().error),
29430
+ wrap: "truncate-end"
29431
+ }, truncateColumns(` ${error}`, viewport.contentColumns));
29432
+ const bodyRows = errorRow === void 0 ? [identityRow, descriptionRow].slice(0, viewport.bodyRows) : [identityRow, errorRow].slice(-viewport.bodyRows);
29433
+ return (0, import_react.createElement)(Box, {
29434
+ flexDirection: "column",
29435
+ width: viewport.outerColumns,
29436
+ paddingX: 1,
29437
+ borderStyle: "round",
29438
+ borderColor: inkColor(getPalette().warn)
29439
+ }, (0, import_react.createElement)(Text, {
29440
+ color: inkColor(getPalette().warn),
29441
+ bold: true,
29442
+ wrap: "truncate-end"
29443
+ }, truncateColumns(`/model — ${action}`, viewport.contentColumns)), (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), ...bodyRows, (0, import_react.createElement)(PanelGap, { visible: viewport.gapRows > 0 }), (0, import_react.createElement)(Text, {
29444
+ color: inkColor(getPalette().dim),
29445
+ wrap: "truncate-end"
29446
+ }, truncateColumns(busy ? "working…" : "y confirm · n/esc back", viewport.contentColumns)));
28255
29447
  }
28256
29448
  /**
28257
29449
  * The /help overlay: one scrolling card with the keyboard map, the TUI-local
@@ -28319,6 +29511,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }) {
28319
29511
  (0, import_react.createElement)(Box, { key: "local-model" }, row("/model", "switch the model")),
28320
29512
  (0, import_react.createElement)(Box, { key: "local-effort" }, row("/effort", "adjust reasoning effort for the current model")),
28321
29513
  (0, import_react.createElement)(Box, { key: "local-mode" }, row("/mode", "inspect or select the agent preset (/mode [preset])")),
29514
+ (0, import_react.createElement)(Box, { key: "local-permission" }, row("/permission", "inspect or select the permission preset (/permission [preset])")),
28322
29515
  (0, import_react.createElement)(Box, { key: "local-new" }, row("/new", "create and switch to a fresh session (/new [preset])")),
28323
29516
  (0, import_react.createElement)(Box, { key: "local-resume" }, row("/resume", "browse or switch root sessions (/resume [id|prefix])")),
28324
29517
  (0, import_react.createElement)(Box, { key: "local-plugin" }, row("/plugin", "inspect the live plugin composition")),
@@ -28591,6 +29784,11 @@ function completionCandidates(value, descriptors, skills) {
28591
29784
  description: "select the agent preset",
28592
29785
  origin: "command"
28593
29786
  },
29787
+ {
29788
+ label: "/permission",
29789
+ description: "inspect or select the permission preset",
29790
+ origin: "command"
29791
+ },
28594
29792
  {
28595
29793
  label: "/new",
28596
29794
  description: "start a fresh session",
@@ -28722,7 +29920,7 @@ function CompletionMenu({ active, mention, index, rows }) {
28722
29920
  * While a modal (approval / question / model panel) owns the keys, the
28723
29921
  * box passes every key through untouched.
28724
29922
  */
28725
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openResume, openPlugin, openStatusline, openTheme, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTick, waveTier, waveStyle }) {
29923
+ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openStatusline, openTheme, openHistory, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle }) {
28726
29924
  const columns = useStdout().stdout?.columns ?? 80;
28727
29925
  const [value, setValue] = (0, import_react.useState)("");
28728
29926
  const [cursor, setCursor] = (0, import_react.useState)(0);
@@ -28922,6 +30120,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
28922
30120
  openEffort();
28923
30121
  return;
28924
30122
  }
30123
+ if (text === "/permission") {
30124
+ openPermission();
30125
+ return;
30126
+ }
30127
+ if (text.startsWith("/permission ")) {
30128
+ dispatch(text);
30129
+ return;
30130
+ }
28925
30131
  if (text === "/mode" || text.startsWith("/mode ")) {
28926
30132
  if (text.slice(5).trim() === "") openMode();
28927
30133
  else dispatch(text);
@@ -29066,6 +30272,40 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
29066
30272
  setDismissedMenuValue(void 0);
29067
30273
  }
29068
30274
  });
30275
+ const [waveTick, setWaveTick] = (0, import_react.useState)(null);
30276
+ const wavePrevious = (0, import_react.useRef)({
30277
+ tier: null,
30278
+ style: null
30279
+ });
30280
+ (0, import_react.useEffect)(() => {
30281
+ const previous = wavePrevious.current;
30282
+ wavePrevious.current = {
30283
+ tier: waveTier,
30284
+ style: waveStyle
30285
+ };
30286
+ if (waveTier === null) {
30287
+ setWaveTick(null);
30288
+ return;
30289
+ }
30290
+ if (previous.tier !== waveTier || previous.style !== waveStyle) setWaveTick(0);
30291
+ }, [waveTier, waveStyle]);
30292
+ const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null && waveTick * 33 < deepseekWaveDuration(waveTier, waveStyle);
30293
+ (0, import_react.useEffect)(() => {
30294
+ if (!waveActive) return;
30295
+ const id = setInterval(() => {
30296
+ setWaveTick((current) => current === null ? 0 : current + 1);
30297
+ }, 33);
30298
+ return () => {
30299
+ clearInterval(id);
30300
+ };
30301
+ }, [waveActive]);
30302
+ (0, import_react.useEffect)(() => {
30303
+ if (waveTick !== null && waveTier !== null && waveStyle !== null && waveTick * 33 >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null);
30304
+ }, [
30305
+ waveTick,
30306
+ waveTier,
30307
+ waveStyle
30308
+ ]);
29069
30309
  const tierActive = waveTier !== null;
29070
30310
  const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier);
29071
30311
  const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0]);
@@ -29179,13 +30419,156 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
29179
30419
  };
29180
30420
  return (0, import_react.createElement)(Box, { flexDirection: "column" }, menu, waveTick !== null && waveTier !== null && !busy ? waveRow() : frame(staticRow));
29181
30421
  }
30422
+ /** Build one settled row (row Box plus its roomy-prompt spacers). */
30423
+ function buildSettledRow(entry, index, showReasoning) {
30424
+ const row = (0, import_react.createElement)(EntryLine, {
30425
+ entry,
30426
+ showReasoning,
30427
+ verbose: false
30428
+ });
30429
+ const roomyPrompt = entry.kind === "user" && !entry.notice;
30430
+ return {
30431
+ box: (0, import_react.createElement)(Box, {
30432
+ key: index,
30433
+ paddingX: 1
30434
+ }, row),
30435
+ before: roomyPrompt ? (0, import_react.createElement)(Box, {
30436
+ key: `prompt-before-${index}`,
30437
+ paddingX: 1
30438
+ }, (0, import_react.createElement)(Text, null, " ")) : void 0,
30439
+ after: roomyPrompt ? (0, import_react.createElement)(Box, {
30440
+ key: `prompt-after-${index}`,
30441
+ paddingX: 1
30442
+ }, (0, import_react.createElement)(Text, null, " ")) : void 0,
30443
+ reasonSensitive: entry.kind === "assistant" && entry.reasoning !== "",
30444
+ showReasoning
30445
+ };
30446
+ }
30447
+ /**
30448
+ * The settled `<Static>` row set as a PURE incremental state machine (App
30449
+ * drives it from the memo; tests drive it directly and read `built`).
30450
+ *
30451
+ * The settled prefix is permanently final: the projection only APPENDS below
30452
+ * the flush boundary, removes pending rows at or beyond it, and replaces
30453
+ * running tool/retry/command rows there too. So extending the cache never
30454
+ * rescans the old prefix — a grown boundary builds ONLY the newly settled
30455
+ * suffix and reuses every cached element, letting React bail out of unchanged
30456
+ * rows and keeping long histories out of the per-durable-event path (no O(N)
30457
+ * rebuild of rows, Map, or MarkdownBody parses). `records` is mutated in place
30458
+ * on the append/toggle paths to stay O(delta).
30459
+ *
30460
+ * Full rebuilds run only on the rare, deliberate paths: no cache yet, a
30461
+ * source-backed replay (`epoch` bump: resize / Ctrl+L / Ctrl+R remounts
30462
+ * `<Static>` and must re-flush the CURRENT rows), a `resumed` change, or a shrink
30463
+ * (`store.reset`). A reasoning toggle rebuilds only the rows whose text
30464
+ * depends on it, preserving the other rows' element identity.
30465
+ */
30466
+ function computeSettledRows(previous, entries, settled, showReasoning, resumed, epoch) {
30467
+ if (previous === void 0 || previous.epoch !== epoch || previous.resumed !== resumed || settled < previous.entries.length) {
30468
+ const records = /* @__PURE__ */ new Map();
30469
+ const flat = [(0, import_react.createElement)(Header, {
30470
+ key: "header",
30471
+ resumed
30472
+ })];
30473
+ for (let index = 0; index < settled; index++) {
30474
+ const entry = entries[index];
30475
+ const record = buildSettledRow(entry, index, showReasoning);
30476
+ records.set(entry, record);
30477
+ if (record.before !== void 0) flat.push(record.before);
30478
+ flat.push(record.box);
30479
+ if (record.after !== void 0) flat.push(record.after);
30480
+ }
30481
+ return {
30482
+ cache: {
30483
+ entries: entries.slice(0, settled),
30484
+ records,
30485
+ header: flat[0],
30486
+ resumed,
30487
+ showReasoning,
30488
+ epoch,
30489
+ flat
30490
+ },
30491
+ built: settled
30492
+ };
30493
+ }
30494
+ if (previous.showReasoning !== showReasoning) {
30495
+ const records = previous.records;
30496
+ const flat = [previous.header];
30497
+ let built = 0;
30498
+ for (let index = 0; index < previous.entries.length; index++) {
30499
+ const entry = previous.entries[index];
30500
+ const record = records.get(entry);
30501
+ const current = record.reasonSensitive ? {
30502
+ ...record,
30503
+ box: (0, import_react.createElement)(Box, {
30504
+ key: index,
30505
+ paddingX: 1
30506
+ }, (0, import_react.createElement)(EntryLine, {
30507
+ entry,
30508
+ showReasoning,
30509
+ verbose: false
30510
+ })),
30511
+ showReasoning
30512
+ } : record;
30513
+ if (current !== record) {
30514
+ records.set(entry, current);
30515
+ built += 1;
30516
+ }
30517
+ if (current.before !== void 0) flat.push(current.before);
30518
+ flat.push(current.box);
30519
+ if (current.after !== void 0) flat.push(current.after);
30520
+ }
30521
+ return {
30522
+ cache: {
30523
+ ...previous,
30524
+ records,
30525
+ showReasoning,
30526
+ flat
30527
+ },
30528
+ built
30529
+ };
30530
+ }
30531
+ if (settled === previous.entries.length) return {
30532
+ cache: previous,
30533
+ built: 0
30534
+ };
30535
+ const records = previous.records;
30536
+ const suffix = [];
30537
+ const added = [];
30538
+ for (let index = previous.entries.length; index < settled; index++) {
30539
+ const entry = entries[index];
30540
+ const record = buildSettledRow(entry, index, showReasoning);
30541
+ records.set(entry, record);
30542
+ suffix.push(entry);
30543
+ if (record.before !== void 0) added.push(record.before);
30544
+ added.push(record.box);
30545
+ if (record.after !== void 0) added.push(record.after);
30546
+ }
30547
+ return {
30548
+ cache: {
30549
+ entries: previous.entries.concat(suffix),
30550
+ records,
30551
+ header: previous.header,
30552
+ resumed: previous.resumed,
30553
+ showReasoning,
30554
+ epoch: previous.epoch,
30555
+ flat: previous.flat.concat(added)
30556
+ },
30557
+ built: settled - previous.entries.length
30558
+ };
30559
+ }
29182
30560
  /** The whole terminal app; state arrives via the store, output via Ink. */
29183
30561
  function App(props) {
29184
30562
  const view = (0, import_react.useSyncExternalStore)(props.store.subscribe, props.store.getView);
29185
- const descriptors = (0, import_react.useSyncExternalStore)(props.commands.subscribe, () => props.commands.descriptors);
29186
- const skills = (0, import_react.useSyncExternalStore)(props.skills.subscribe, () => props.skills.rows);
30563
+ const readDescriptors = (0, import_react.useCallback)(() => props.commands.descriptors, [props.commands]);
30564
+ const readSkills = (0, import_react.useCallback)(() => props.skills.rows, [props.skills]);
30565
+ const descriptors = (0, import_react.useSyncExternalStore)(props.commands.subscribe, readDescriptors);
30566
+ const skills = (0, import_react.useSyncExternalStore)(props.skills.subscribe, readSkills);
29187
30567
  const [modelLabel, setModelLabel] = (0, import_react.useState)(props.model);
29188
30568
  const [modelOpen, setModelOpen] = (0, import_react.useState)(false);
30569
+ /** Nested /model stages; only one owns terminal input at a time. */
30570
+ const [providerOpen, setProviderOpen] = (0, import_react.useState)(false);
30571
+ const [providerAction, setProviderAction] = (0, import_react.useState)(void 0);
29189
30572
  /** The model row whose effort levels the /model stage lists; undefined shows the model list. */
29190
30573
  const [effortFor, setEffortFor] = (0, import_react.useState)(void 0);
29191
30574
  /** Effective reasoning effort, shown in the /model picker and switch notice. */
@@ -29196,8 +30579,10 @@ function App(props) {
29196
30579
  * per-style durations), then the band returns to static while the prompt
29197
30580
  * marker keeps the tier accent. The trigger follows the applied model
29198
30581
  * label (what the status bar actually shows), never the initial paint,
29199
- * and the tier is derived from the label and cached at the switch. */
29200
- const [waveTick, setWaveTick] = (0, import_react.useState)(null);
30582
+ * and the tier is derived from the label and cached at the switch. The
30583
+ * 33ms tick itself lives inside Input, so the sweep re-renders only the
30584
+ * composer row, not the whole tree, at 30fps; App owns the rarely-changing
30585
+ * tier/style and Input starts the sweep whenever that pair changes. */
29201
30586
  const [waveTier, setWaveTier] = (0, import_react.useState)(null);
29202
30587
  const [waveStyle, setWaveStyle] = (0, import_react.useState)(null);
29203
30588
  const previousModel = (0, import_react.useRef)(void 0);
@@ -29212,7 +30597,6 @@ function App(props) {
29212
30597
  if (!isOfficialDeepSeekLabel(modelLabel)) {
29213
30598
  setWaveTier(null);
29214
30599
  setWaveStyle(null);
29215
- setWaveTick(null);
29216
30600
  return;
29217
30601
  }
29218
30602
  if (modelChanged || effortChanged) {
@@ -29220,28 +30604,12 @@ function App(props) {
29220
30604
  const nextStyle = deepseekWaveStyleRandom(previousStyle.current);
29221
30605
  previousStyle.current = nextStyle;
29222
30606
  setWaveStyle(nextStyle);
29223
- setWaveTick(0);
29224
30607
  }
29225
30608
  }, [modelLabel, effortLabel]);
29226
- const waveActive = waveTick !== null && waveTier !== null && waveStyle !== null && waveTick * 33 < deepseekWaveDuration(waveTier, waveStyle);
29227
- (0, import_react.useEffect)(() => {
29228
- if (!waveActive) return;
29229
- const id = setInterval(() => {
29230
- setWaveTick((current) => current === null ? 0 : current + 1);
29231
- }, 33);
29232
- return () => {
29233
- clearInterval(id);
29234
- };
29235
- }, [waveActive]);
29236
- (0, import_react.useEffect)(() => {
29237
- if (waveTick !== null && waveTier !== null && waveStyle !== null && waveTick * 33 >= deepseekWaveDuration(waveTier, waveStyle)) setWaveTick(null);
29238
- }, [
29239
- waveTick,
29240
- waveTier,
29241
- waveStyle
29242
- ]);
29243
30609
  const [directory, setDirectory] = (0, import_react.useState)(void 0);
29244
30610
  const [modelError, setModelError] = (0, import_react.useState)(void 0);
30611
+ const [providerDirectory, setProviderDirectory] = (0, import_react.useState)(void 0);
30612
+ const [providerError, setProviderError] = (0, import_react.useState)(void 0);
29245
30613
  const [modelLoadEpoch, setModelLoadEpoch] = (0, import_react.useState)(0);
29246
30614
  const [notice, setNotice] = (0, import_react.useState)(void 0);
29247
30615
  const notify = (0, import_react.useCallback)((text, tone = "info") => {
@@ -29271,11 +30639,39 @@ function App(props) {
29271
30639
  modelLoadEpoch,
29272
30640
  props.loadModels
29273
30641
  ]);
30642
+ (0, import_react.useEffect)(() => {
30643
+ if (!modelOpen || props.loadModelProviders === void 0) return;
30644
+ let cancelled = false;
30645
+ setProviderDirectory(void 0);
30646
+ setProviderError(void 0);
30647
+ Promise.resolve().then(() => props.loadModelProviders()).then((loaded) => {
30648
+ if (!cancelled) setProviderDirectory(loaded);
30649
+ }, (error) => {
30650
+ if (!cancelled) setProviderError(error instanceof Error ? error.message : String(error));
30651
+ });
30652
+ return () => {
30653
+ cancelled = true;
30654
+ };
30655
+ }, [
30656
+ modelOpen,
30657
+ modelLoadEpoch,
30658
+ props.loadModelProviders
30659
+ ]);
30660
+ (0, import_react.useEffect)(() => {
30661
+ const subscribe = props.subscribeModelProviders;
30662
+ if (!modelOpen || subscribe === void 0) return;
30663
+ try {
30664
+ return subscribe(() => setModelLoadEpoch((epoch) => epoch + 1));
30665
+ } catch (error) {
30666
+ setProviderError(error instanceof Error ? error.message : String(error));
30667
+ }
30668
+ }, [modelOpen, props.subscribeModelProviders]);
29274
30669
  const busy = view.busy;
29275
30670
  const [showReasoning, setShowReasoning] = (0, import_react.useState)(false);
29276
30671
  const [verboseOpen, setVerboseOpen] = (0, import_react.useState)(false);
29277
30672
  const [helpOpen, setHelpOpen] = (0, import_react.useState)(false);
29278
30673
  const [modeOpen, setModeOpen] = (0, import_react.useState)(false);
30674
+ const [permissionOpen, setPermissionOpen] = (0, import_react.useState)(false);
29279
30675
  const [resumeOpen, setResumeOpen] = (0, import_react.useState)(false);
29280
30676
  const [pluginOpen, setPluginOpen] = (0, import_react.useState)(false);
29281
30677
  const [pluginQuery, setPluginQuery] = (0, import_react.useState)("");
@@ -29295,20 +30691,39 @@ function App(props) {
29295
30691
  const historyConsumed = (0, import_react.useCallback)(() => {
29296
30692
  setHistoryFill(void 0);
29297
30693
  }, []);
29298
- /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). */
29299
- const queuedRows = (0, import_react.useMemo)(() => view.entries.filter((entry) => entry.kind === "pending"), [view.entries]);
30694
+ /** The append-only flush boundary (see `settledEntryCount`): entries below
30695
+ * this index are final and ride the `<Static>` scrollback; everything at or
30696
+ * beyond stays in the live tree. Pending inbox rows always live at
30697
+ * index >= settled, so the queued-inbox scan below only walks the mutable
30698
+ * tail instead of the whole history. */
30699
+ const settled = (0, import_react.useMemo)(() => settledEntryCount(view.entries), [view.entries]);
30700
+ /** Live queued inbox rows (event-sourced from `agent/inbox/spliced`). The
30701
+ * projection only appends and removes pending rows at index >= settled, so
30702
+ * a bounded tail scan replaces an unconditional O(history) filter on every
30703
+ * event. */
30704
+ const queuedRows = (0, import_react.useMemo)(() => {
30705
+ const rows = [];
30706
+ for (let index = settled; index < view.entries.length; index++) {
30707
+ const entry = view.entries[index];
30708
+ if (entry.kind === "pending") rows.push(entry);
30709
+ }
30710
+ return rows;
30711
+ }, [view.entries, settled]);
29300
30712
  const [refreshEpoch, setRefreshEpoch] = (0, import_react.useState)(0);
29301
30713
  const approvalSnapshot = (0, import_react.useSyncExternalStore)(props.approval.subscribe, props.approval.getSnapshot);
29302
30714
  const questionSnapshot = (0, import_react.useSyncExternalStore)(props.questions.subscribe, props.questions.getSnapshot);
29303
30715
  const approvalPending = approvalSnapshot.pending !== void 0;
29304
30716
  const questionPending = questionSnapshot.pending !== void 0;
29305
- const inputActive = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending;
30717
+ const inputActive = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending;
29306
30718
  (0, import_react.useEffect)(() => {
29307
30719
  if (!approvalPending && !questionPending) return;
29308
30720
  setModelOpen(false);
30721
+ setProviderOpen(false);
30722
+ setProviderAction(void 0);
29309
30723
  setEffortFor(void 0);
29310
30724
  setHelpOpen(false);
29311
30725
  setModeOpen(false);
30726
+ setPermissionOpen(false);
29312
30727
  setResumeOpen(false);
29313
30728
  setPluginOpen(false);
29314
30729
  setStatuslineOpen(false);
@@ -29316,38 +30731,17 @@ function App(props) {
29316
30731
  setHistoryOpen(false);
29317
30732
  setVerboseOpen(false);
29318
30733
  }, [approvalPending, questionPending]);
29319
- const settled = (0, import_react.useMemo)(() => settledEntryCount(view.entries), [view.entries]);
30734
+ const settledRowsCache = (0, import_react.useRef)(void 0);
29320
30735
  const settledRows = (0, import_react.useMemo)(() => {
29321
- const rows = [(0, import_react.createElement)(Header, {
29322
- key: "header",
29323
- resumed: props.resumed
29324
- })];
29325
- view.entries.slice(0, settled).forEach((entry, index) => {
29326
- const row = (0, import_react.createElement)(EntryLine, {
29327
- entry,
29328
- showReasoning,
29329
- verbose: false
29330
- });
29331
- const roomyPrompt = entry.kind === "user" && !entry.notice;
29332
- if (roomyPrompt) rows.push((0, import_react.createElement)(Box, {
29333
- key: `prompt-before-${index}`,
29334
- paddingX: 1
29335
- }, (0, import_react.createElement)(Text, null, " ")));
29336
- rows.push((0, import_react.createElement)(Box, {
29337
- key: index,
29338
- paddingX: 1
29339
- }, row));
29340
- if (roomyPrompt) rows.push((0, import_react.createElement)(Box, {
29341
- key: `prompt-after-${index}`,
29342
- paddingX: 1
29343
- }, (0, import_react.createElement)(Text, null, " ")));
29344
- });
29345
- return rows;
30736
+ const result = computeSettledRows(settledRowsCache.current, view.entries, settled, showReasoning, props.resumed, refreshEpoch);
30737
+ settledRowsCache.current = result.cache;
30738
+ return result.cache.flat;
29346
30739
  }, [
29347
30740
  view.entries,
29348
30741
  settled,
29349
30742
  showReasoning,
29350
- props.resumed
30743
+ props.resumed,
30744
+ refreshEpoch
29351
30745
  ]);
29352
30746
  const appStdout = useStdout().stdout;
29353
30747
  const [terminalSize, setTerminalSize] = (0, import_react.useState)(() => ({
@@ -29394,8 +30788,8 @@ function App(props) {
29394
30788
  const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length);
29395
30789
  const reasoningRows = view.streamingReasoning === "" ? 0 : view.streaming === "" ? streamRows : streamRows <= 1 ? 0 : showReasoning ? Math.max(1, Math.floor(streamRows / 3)) : 1;
29396
30790
  const answerRows = view.streaming === "" ? 0 : Math.max(1, streamRows - reasoningRows);
29397
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending;
29398
- const modalVisible = modelOpen || helpOpen || modeOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || verboseOpen && !approvalPending && !questionPending || approvalPending || questionPending;
30791
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !verboseOpen && !approvalPending && !questionPending;
30792
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || verboseOpen && !approvalPending && !questionPending || approvalPending || questionPending;
29399
30793
  const closeInspector = (0, import_react.useCallback)(() => {
29400
30794
  setVerboseOpen(false);
29401
30795
  }, []);
@@ -29411,11 +30805,120 @@ function App(props) {
29411
30805
  setEffortLabel(effortId);
29412
30806
  notify(`model → next step uses ${label}${effortId === void 0 || effortId === "" ? "" : `@${effortId}`}`);
29413
30807
  setModelOpen(false);
30808
+ setProviderOpen(false);
30809
+ setProviderAction(void 0);
29414
30810
  setEffortFor(void 0);
29415
30811
  } catch (error) {
29416
30812
  notify(`model switch failed: ${error instanceof Error ? error.message : String(error)}`, "error");
29417
30813
  }
29418
30814
  };
30815
+ const reloadModelSurfaces = () => {
30816
+ setModelLoadEpoch((epoch) => epoch + 1);
30817
+ };
30818
+ const closeModelSurface = () => {
30819
+ setModelOpen(false);
30820
+ setProviderOpen(false);
30821
+ setProviderAction(void 0);
30822
+ setEffortFor(void 0);
30823
+ };
30824
+ let modelSurface;
30825
+ if (modelOpen && !approvalPending && !questionPending) {
30826
+ if (providerAction?.kind === "credential" && props.saveModelProviderCredential !== void 0) modelSurface = (0, import_react.createElement)(ProviderCredentialPanel, {
30827
+ target: providerAction.target,
30828
+ save: props.saveModelProviderCredential,
30829
+ done: () => {
30830
+ const target = providerAction.target;
30831
+ setProviderAction(void 0);
30832
+ setProviderOpen(false);
30833
+ reloadModelSurfaces();
30834
+ notify(`API key saved for ${target.displayName}; select a model`);
30835
+ },
30836
+ back: () => setProviderAction(void 0)
30837
+ });
30838
+ else if (providerAction?.kind === "unset" && props.unsetModelProviderCredential !== void 0) modelSurface = (0, import_react.createElement)(ProviderConfirmPanel, {
30839
+ target: providerAction.target,
30840
+ kind: "credential",
30841
+ confirm: props.unsetModelProviderCredential,
30842
+ done: () => {
30843
+ const target = providerAction.target;
30844
+ setProviderAction(void 0);
30845
+ setProviderOpen(true);
30846
+ reloadModelSurfaces();
30847
+ notify(`API key removed for ${target.displayName}`);
30848
+ },
30849
+ back: () => setProviderAction(void 0)
30850
+ });
30851
+ else if (providerAction?.kind === "remove" && props.removeModelProvider !== void 0) modelSurface = (0, import_react.createElement)(ProviderConfirmPanel, {
30852
+ target: providerAction.target,
30853
+ kind: "provider",
30854
+ confirm: props.removeModelProvider,
30855
+ done: () => {
30856
+ const target = providerAction.target;
30857
+ setProviderAction(void 0);
30858
+ setProviderOpen(true);
30859
+ reloadModelSurfaces();
30860
+ notify(`provider removed: ${target.displayName}`);
30861
+ },
30862
+ back: () => setProviderAction(void 0)
30863
+ });
30864
+ else if (providerOpen) modelSurface = (0, import_react.createElement)(ProviderPanel, {
30865
+ directory: providerDirectory,
30866
+ error: providerError,
30867
+ onCredential: (target) => {
30868
+ if (props.saveModelProviderCredential === void 0) {
30869
+ notify("API key storage is unavailable in this profile", "warning");
30870
+ return;
30871
+ }
30872
+ setProviderAction({
30873
+ kind: "credential",
30874
+ target
30875
+ });
30876
+ },
30877
+ onUnset: (target) => {
30878
+ if (props.unsetModelProviderCredential === void 0) {
30879
+ notify("API key removal is unavailable in this profile", "warning");
30880
+ return;
30881
+ }
30882
+ setProviderAction({
30883
+ kind: "unset",
30884
+ target
30885
+ });
30886
+ },
30887
+ onRemove: (target) => {
30888
+ if (props.removeModelProvider === void 0) {
30889
+ notify("provider removal is unavailable in this profile", "warning");
30890
+ return;
30891
+ }
30892
+ setProviderAction({
30893
+ kind: "remove",
30894
+ target
30895
+ });
30896
+ },
30897
+ onRetry: reloadModelSurfaces,
30898
+ onBack: () => setProviderOpen(false)
30899
+ });
30900
+ else if (effortFor !== void 0) modelSurface = (0, import_react.createElement)(EffortPanel, {
30901
+ row: effortFor,
30902
+ current: effortLabel,
30903
+ select: (effortId) => applyModel(effortFor, effortId),
30904
+ back: () => setEffortFor(void 0)
30905
+ });
30906
+ else modelSurface = (0, import_react.createElement)(ModelPanel, {
30907
+ directory,
30908
+ error: modelError,
30909
+ onSelect: (row) => {
30910
+ if (row.reasoning !== void 0 && row.reasoning.efforts.length > 1) {
30911
+ setEffortFor(row);
30912
+ return;
30913
+ }
30914
+ const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : void 0;
30915
+ applyModel(row, effortId);
30916
+ },
30917
+ ...props.loadModelProviders === void 0 || props.saveModelProviderCredential === void 0 ? {} : { onProviders: () => setProviderOpen(true) },
30918
+ onRetry: reloadModelSurfaces,
30919
+ onClose: closeModelSurface
30920
+ });
30921
+ }
29419
30922
  return (0, import_react.createElement)(Box, { flexDirection: "column" }, (0, import_react.createElement)(MemoStaticTranscript, {
29420
30923
  key: refreshEpoch,
29421
30924
  items: settledRows
@@ -29424,7 +30927,8 @@ function App(props) {
29424
30927
  paddingX: 2
29425
30928
  }, visibleLiveLines.length === 0 ? void 0 : (0, import_react.createElement)(StyledRows, { lines: visibleLiveLines }), view.streamingReasoning !== "" && reasoningRows > 0 ? (0, import_react.createElement)(StreamTail, {
29426
30929
  text: showReasoning ? view.streamingReasoning : "Thinking…",
29427
- prefix: " ✻ ",
30930
+ prefix: "✻ ",
30931
+ continuationPrefix: " ",
29428
30932
  dim: true,
29429
30933
  maxRows: reasoningRows
29430
30934
  }) : void 0, view.streaming !== "" && answerRows > 0 ? (0, import_react.createElement)(StreamTail, {
@@ -29439,30 +30943,7 @@ function App(props) {
29439
30943
  }), (0, import_react.createElement)(ApprovalBar, {
29440
30944
  snapshot: approvalSnapshot,
29441
30945
  locked: questionPending
29442
- }), modelOpen && !approvalPending && !questionPending ? effortFor === void 0 ? (0, import_react.createElement)(ModelPanel, {
29443
- directory,
29444
- error: modelError,
29445
- onSelect: (row) => {
29446
- if (row.reasoning !== void 0 && row.reasoning.efforts.length > 1) {
29447
- setEffortFor(row);
29448
- return;
29449
- }
29450
- const effortId = row.reasoning?.efforts.length === 1 ? row.reasoning.efforts[0].id : void 0;
29451
- applyModel(row, effortId);
29452
- },
29453
- onRetry: () => {
29454
- setModelLoadEpoch((epoch) => epoch + 1);
29455
- },
29456
- onClose: () => {
29457
- setModelOpen(false);
29458
- setEffortFor(void 0);
29459
- }
29460
- }) : (0, import_react.createElement)(EffortPanel, {
29461
- row: effortFor,
29462
- current: effortLabel,
29463
- select: (effortId) => applyModel(effortFor, effortId),
29464
- back: () => setEffortFor(void 0)
29465
- }) : void 0, helpOpen && !approvalPending && !questionPending ? (0, import_react.createElement)(HelpPanel, {
30946
+ }), modelSurface, helpOpen && !approvalPending && !questionPending ? (0, import_react.createElement)(HelpPanel, {
29466
30947
  descriptors,
29467
30948
  skills,
29468
30949
  commandError: props.commands.error,
@@ -29483,6 +30964,19 @@ function App(props) {
29483
30964
  }, (reason) => notify(`mode switch failed: ${reason instanceof Error ? reason.message : String(reason)}`, "error"));
29484
30965
  },
29485
30966
  close: () => setModeOpen(false)
30967
+ }) : void 0, permissionOpen && !approvalPending && !questionPending ? (0, import_react.createElement)(PermissionPanel, {
30968
+ current: props.permission,
30969
+ load: props.loadPermissions,
30970
+ select: (id) => {
30971
+ try {
30972
+ const selected = props.setPermission(id);
30973
+ notify(`permission → ${selected}`);
30974
+ setPermissionOpen(false);
30975
+ } catch (reason) {
30976
+ notify(`permission change failed: ${reason instanceof Error ? reason.message : String(reason)}`, "error");
30977
+ }
30978
+ },
30979
+ close: () => setPermissionOpen(false)
29486
30980
  }) : void 0, resumeOpen && !approvalPending && !questionPending ? (0, import_react.createElement)(ResumePanel, {
29487
30981
  currentCwd: props.workspaceRoot,
29488
30982
  load: props.loadSessions,
@@ -29542,6 +31036,10 @@ function App(props) {
29542
31036
  openModel: () => {
29543
31037
  setDirectory(void 0);
29544
31038
  setModelError(void 0);
31039
+ setProviderDirectory(void 0);
31040
+ setProviderError(void 0);
31041
+ setProviderOpen(false);
31042
+ setProviderAction(void 0);
29545
31043
  setEffortFor(void 0);
29546
31044
  setModelOpen(true);
29547
31045
  },
@@ -29572,6 +31070,7 @@ function App(props) {
29572
31070
  setHelpOpen(true);
29573
31071
  },
29574
31072
  openMode: () => setModeOpen(true),
31073
+ openPermission: () => setPermissionOpen(true),
29575
31074
  openResume: () => setResumeOpen(true),
29576
31075
  openPlugin: (query = "") => {
29577
31076
  setPluginQuery(query);
@@ -29609,7 +31108,6 @@ function App(props) {
29609
31108
  cancelQueued: props.cancelQueued,
29610
31109
  historyFill,
29611
31110
  historyConsumed,
29612
- waveTick,
29613
31111
  waveTier,
29614
31112
  waveStyle
29615
31113
  }), (0, import_react.createElement)(StatusLine, {
@@ -29621,7 +31119,7 @@ function App(props) {
29621
31119
  sessionId: props.sessionId,
29622
31120
  title: view.title,
29623
31121
  plan: view.plan,
29624
- permission: view.permission,
31122
+ permission: view.permission !== "" ? view.permission : props.permission,
29625
31123
  sandbox: view.sandbox,
29626
31124
  goal: view.goal === void 0 ? void 0 : {
29627
31125
  phase: view.goal.phase,
@@ -29665,16 +31163,22 @@ function mountApprovalAnswerer(ctx, owns, preview) {
29665
31163
  if (request.signal?.aborted === true) return Promise.resolve("cancelled");
29666
31164
  let resolved = false;
29667
31165
  let settle;
31166
+ const signal = request.signal;
31167
+ const onAbort = () => withdraw();
31168
+ const detachAbort = () => {
31169
+ if (signal !== void 0) signal.removeEventListener("abort", onAbort);
31170
+ };
29668
31171
  const withdraw = () => {
29669
31172
  if (resolved) return;
29670
31173
  resolved = true;
31174
+ detachAbort();
29671
31175
  set({
29672
31176
  pending: void 0,
29673
31177
  answered: false
29674
31178
  });
29675
31179
  settle("cancelled");
29676
31180
  };
29677
- if (request.signal !== void 0) request.signal.addEventListener("abort", withdraw, { once: true });
31181
+ if (signal !== void 0) signal.addEventListener("abort", onAbort, { once: true });
29678
31182
  const pending = {
29679
31183
  headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
29680
31184
  toolName: request.toolName,
@@ -29682,6 +31186,7 @@ function mountApprovalAnswerer(ctx, owns, preview) {
29682
31186
  answer: (outcome) => {
29683
31187
  if (resolved) return;
29684
31188
  resolved = true;
31189
+ detachAbort();
29685
31190
  set({
29686
31191
  pending,
29687
31192
  answered: true
@@ -29914,6 +31419,292 @@ async function loadModelDirectory(ctx) {
29914
31419
  };
29915
31420
  }
29916
31421
  //#endregion
31422
+ //#region src/provider-settings.ts
31423
+ /** Human text for a rejection value (mirrors the web page's `messageOf`). */
31424
+ function messageOf(error) {
31425
+ return error instanceof Error ? error.message : String(error);
31426
+ }
31427
+ /** Collapse every whitespace/control run to one space so a notice stays one line. */
31428
+ function singleLine(message) {
31429
+ return message.replace(/[\u0000-\u001F\u007F]/g, " ").replace(/\s+/g, " ").trim();
31430
+ }
31431
+ /** Keep a misbehaving credential provider from reflecting the submitted secret. */
31432
+ function credentialWriteMessage(error, secret) {
31433
+ const message = singleLine(messageOf(error));
31434
+ return message.includes(secret) ? "credentials service rejected the API key" : message;
31435
+ }
31436
+ /** Obvious shell-assignment paste; mirrors the official Web Models editor. */
31437
+ const ENV_ASSIGNMENT = /^[A-Z][A-Z0-9_]*=[^=]/;
31438
+ /** Whether the whole draft is wrapped in one matching quote pair. */
31439
+ function hasWrappingQuotes(value) {
31440
+ const first = value[0];
31441
+ return (first === "\"" || first === "'" || first === "`") && value.length > 1 && value.endsWith(first);
31442
+ }
31443
+ /** Read the value at a path through plain objects; undefined when any segment misses. */
31444
+ function getPath(value, path) {
31445
+ let current = value;
31446
+ for (const segment of path) {
31447
+ if (typeof current !== "object" || current === null) return void 0;
31448
+ current = current[segment];
31449
+ }
31450
+ return current;
31451
+ }
31452
+ /** Whether a path resolves to a defined value (the empty path reads the root). */
31453
+ function hasPath(value, path) {
31454
+ return path.length === 0 ? value !== void 0 : getPath(value, path) !== void 0;
31455
+ }
31456
+ /** The credential reference a resolved profile names (its `apiKeyEnv` field). */
31457
+ function profileRefOf(profile) {
31458
+ if (typeof profile !== "object" || profile === null) return void 0;
31459
+ const ref = profile.apiKeyEnv;
31460
+ return typeof ref === "string" && ref.length > 0 ? ref : void 0;
31461
+ }
31462
+ /**
31463
+ * The conventional credential reference for a provider route: `<ROUTE>_API_KEY`
31464
+ * with the route uppercased and every non-alphanumeric run collapsed to one
31465
+ * underscore — the exact derivation the official Models page uses
31466
+ * (`deriveKeyRef` in `ui-settings-models`), so a key saved here is found there.
31467
+ * @param provider - provider route id (e.g. `pi-ai`, `minimax-cn`).
31468
+ * @returns the derived reference name (e.g. `PI_AI_API_KEY`).
31469
+ */
31470
+ function deriveCredentialRef(provider) {
31471
+ return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
31472
+ }
31473
+ /** Events that invalidate the official Models provider/settings/credential join. */
31474
+ const PROVIDER_SETTINGS_EVENTS = [
31475
+ "credentials/updated",
31476
+ "settings/document-updated",
31477
+ "llm/adapters-updated"
31478
+ ];
31479
+ /** Subscribe to the same provider-directory invalidations as the official Web Models page. */
31480
+ function subscribeProviderSettings(ctx, listener) {
31481
+ const events = ctx;
31482
+ const disposers = PROVIDER_SETTINGS_EVENTS.map((event) => events.on(event, () => listener()));
31483
+ return () => {
31484
+ for (const dispose of disposers) dispose();
31485
+ };
31486
+ }
31487
+ /** A single-line, bounded error from the provider-management adapter. */
31488
+ var ProviderSettingsError = class extends Error {
31489
+ constructor(message) {
31490
+ super(message);
31491
+ this.name = "ProviderSettingsError";
31492
+ }
31493
+ };
31494
+ /**
31495
+ * Join the configurable-provider directory, the redacted settings
31496
+ * namespaces, and the referenced credentials into panel rows, web-parity:
31497
+ * - directory entries merge with `listProviders()` to mark each live or
31498
+ * dormant, and routes registered without a directory declaration appear as
31499
+ * read-only/unmanaged rows (no settings address);
31500
+ * - a whole-section entry is configured whenever its namespace resolves;
31501
+ * a path-addressed one only when the profile resolves there;
31502
+ * - a row is removable when the user layer alone carries its profile;
31503
+ * - only refs named by resolved profiles are described, and a per-ref failure
31504
+ * degrades to that row's bounded error instead of losing it.
31505
+ * Absent `settings`/`credentials` services are tolerated the same way.
31506
+ * @param ctx - context carrying the `llm` service (settings/credentials optional).
31507
+ * @returns the resolved directory; empty rows when `llm` is unavailable.
31508
+ */
31509
+ async function loadProviderSettings(ctx) {
31510
+ const llm = ctx.get("llm");
31511
+ if (llm === void 0) return {
31512
+ rows: [],
31513
+ writable: false,
31514
+ failures: []
31515
+ };
31516
+ const registered = llm.listProviders();
31517
+ const failures = [];
31518
+ const directoryEntries = [];
31519
+ if (llm.listConfigurableProviders !== void 0) try {
31520
+ directoryEntries.push(...llm.listConfigurableProviders());
31521
+ } catch (error) {
31522
+ failures.push(`configurable-provider directory failed: ${singleLine(messageOf(error))}`);
31523
+ }
31524
+ const settings = ctx.get("settings");
31525
+ let descriptors = [];
31526
+ let writable = false;
31527
+ if (settings !== void 0) try {
31528
+ descriptors = settings.describe({ redactSecrets: true });
31529
+ writable = settings.writable === true;
31530
+ } catch (error) {
31531
+ failures.push(`settings describe failed: ${singleLine(messageOf(error))}`);
31532
+ }
31533
+ const namespaces = new Map(descriptors.map((descriptor) => [descriptor.ns, descriptor]));
31534
+ const active = new Set(registered.map((provider) => provider.id));
31535
+ const declared = new Set(directoryEntries.map((entry) => entry.provider));
31536
+ const rows = [...directoryEntries.map((entry) => ({
31537
+ provider: entry.provider,
31538
+ displayName: entry.displayName,
31539
+ active: active.has(entry.provider),
31540
+ settingsNs: entry.settingsNs,
31541
+ settingsPath: entry.settingsPath,
31542
+ ...entry.declared === void 0 ? {} : { declared: entry.declared }
31543
+ })), ...registered.filter((provider) => !declared.has(provider.id)).map((provider) => ({
31544
+ provider: provider.id,
31545
+ displayName: provider.name,
31546
+ active: true,
31547
+ settingsNs: "",
31548
+ settingsPath: []
31549
+ }))].map((base) => {
31550
+ const namespace = base.settingsNs.length === 0 ? void 0 : namespaces.get(base.settingsNs);
31551
+ const profile = namespace === void 0 ? void 0 : base.settingsPath.length === 0 ? namespace.value : getPath(namespace.value, base.settingsPath);
31552
+ const configured = namespace !== void 0 && (base.settingsPath.length === 0 || profile !== void 0);
31553
+ const removable = namespace !== void 0 && base.settingsPath.length > 0 && hasPath(namespace.user, base.settingsPath) && !hasPath(namespace.base, base.settingsPath);
31554
+ const credentialRef = profileRefOf(profile);
31555
+ return {
31556
+ provider: base.provider,
31557
+ displayName: base.displayName,
31558
+ active: base.active,
31559
+ settingsNs: base.settingsNs,
31560
+ settingsPath: base.settingsPath,
31561
+ settingsRevision: namespace?.revision ?? 0,
31562
+ configured,
31563
+ removable,
31564
+ ...credentialRef === void 0 ? {} : { credentialRef },
31565
+ suggestedRef: deriveCredentialRef(base.provider),
31566
+ ...base.declared === void 0 ? {} : { declared: base.declared }
31567
+ };
31568
+ });
31569
+ const refs = [...new Set(rows.flatMap((row) => row.credentialRef === void 0 ? [] : [row.credentialRef]))];
31570
+ const credentialViews = /* @__PURE__ */ new Map();
31571
+ const credentials = ctx.get("credentials");
31572
+ if (refs.length > 0) {
31573
+ if (credentials === void 0) for (const ref of refs) credentialViews.set(ref, {
31574
+ kind: "error",
31575
+ message: "credentials service is unavailable"
31576
+ });
31577
+ else await Promise.all(refs.map(async (ref) => {
31578
+ try {
31579
+ const facts = await credentials.describe(ref);
31580
+ credentialViews.set(ref, {
31581
+ kind: "facts",
31582
+ configured: facts.configured,
31583
+ writable: facts.writable,
31584
+ ...facts.source === void 0 ? {} : { source: facts.source }
31585
+ });
31586
+ } catch (error) {
31587
+ credentialViews.set(ref, {
31588
+ kind: "error",
31589
+ message: singleLine(messageOf(error))
31590
+ });
31591
+ }
31592
+ }));
31593
+ }
31594
+ return {
31595
+ rows: rows.map((row) => ({
31596
+ ...row,
31597
+ credential: row.credentialRef === void 0 ? void 0 : credentialViews.get(row.credentialRef) ?? {
31598
+ kind: "error",
31599
+ message: "credential describe returned no view"
31600
+ }
31601
+ })),
31602
+ writable,
31603
+ failures
31604
+ };
31605
+ }
31606
+ /**
31607
+ * Store a provider API key, web-parity: validate with `normalizeApiKey`
31608
+ * (single-line, actionable errors that never echo the key), materialize the
31609
+ * profile/`apiKeyEnv` through `settings.mutate` first when the resolved
31610
+ * profile names no reference (dormant route or ref-less profile), then store
31611
+ * under the trusted named ref or the derived conventional ref. An existing
31612
+ * whole-section DeepSeek whose resolved profile already names
31613
+ * `DEEPSEEK_API_KEY` needs no settings mutation. Env-supplied read-only keys
31614
+ * are refused before any service call.
31615
+ * @param ctx - context carrying `settings` (when materializing) and `credentials`.
31616
+ * @param target - the joined row to write through.
31617
+ * @param rawKey - the key exactly as typed; surrounding whitespace is trimmed.
31618
+ * @throws {@link ProviderSettingsError} with a single-line, key-free message.
31619
+ */
31620
+ async function saveProviderCredential(ctx, target, rawKey) {
31621
+ const trimmed = rawKey.trim();
31622
+ if (ENV_ASSIGNMENT.test(trimmed) || hasWrappingQuotes(trimmed)) throw new ProviderSettingsError("paste only the API key, without an environment-variable name or wrapping quotes");
31623
+ const checked = normalizeApiKey(rawKey);
31624
+ if (!checked.ok) throw new ProviderSettingsError(checked.reason === "empty" ? "the API key is empty after trimming surrounding whitespace" : "the API key contains characters an HTTP header cannot carry; type a plain printable-ASCII key");
31625
+ if (target.settingsNs.length === 0) throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings namespace; configure it in settings.yaml`);
31626
+ if (target.credential?.kind === "facts" && target.credential.writable === false) throw new ProviderSettingsError(`the key for provider "${target.provider}" is supplied read-only by the environment; unset it in the shell instead of overwriting it here`);
31627
+ const credentials = ctx.get("credentials");
31628
+ if (credentials === void 0) throw new ProviderSettingsError("credentials service is unavailable; cannot store the API key");
31629
+ const ref = target.credentialRef ?? deriveCredentialRef(target.provider);
31630
+ if (target.credentialRef === void 0) {
31631
+ const settings = ctx.get("settings");
31632
+ if (settings === void 0) throw new ProviderSettingsError("settings service is unavailable; cannot materialize the credential reference");
31633
+ try {
31634
+ await settings.mutate(target.settingsNs, [{
31635
+ op: "set",
31636
+ path: [...target.settingsPath, "apiKeyEnv"],
31637
+ value: ref
31638
+ }]);
31639
+ } catch (error) {
31640
+ throw new ProviderSettingsError(singleLine(messageOf(error)));
31641
+ }
31642
+ }
31643
+ try {
31644
+ await credentials.set(ref, checked.value);
31645
+ } catch (error) {
31646
+ throw new ProviderSettingsError(credentialWriteMessage(error, checked.value));
31647
+ }
31648
+ }
31649
+ /**
31650
+ * Remove the currently named credential without touching the provider
31651
+ * profile. Only the resolved profile's own reference is unset; a dormant or
31652
+ * ref-less row (nothing to remove), an already-absent key, and an
31653
+ * env-supplied read-only key are rejected safely before any service call.
31654
+ * @param ctx - context carrying the `credentials` service.
31655
+ * @param target - the joined row whose named credential to unset.
31656
+ * @throws {@link ProviderSettingsError} with a single-line, key-free message.
31657
+ */
31658
+ async function unsetProviderCredential(ctx, target) {
31659
+ const ref = target.credentialRef;
31660
+ if (ref === void 0) throw new ProviderSettingsError(`provider "${target.provider}" names no credential reference to remove`);
31661
+ const facts = target.credential;
31662
+ if (facts?.kind === "facts" && facts.configured === false) throw new ProviderSettingsError(`provider "${target.provider}" has no configured credential to remove`);
31663
+ if (facts?.kind === "facts" && facts.writable === false) throw new ProviderSettingsError(`the key for provider "${target.provider}" is supplied read-only by the environment; unset it in the shell instead`);
31664
+ const credentials = ctx.get("credentials");
31665
+ if (credentials === void 0) throw new ProviderSettingsError("credentials service is unavailable; cannot remove the API key");
31666
+ try {
31667
+ await credentials.unset(ref);
31668
+ } catch (error) {
31669
+ throw new ProviderSettingsError(singleLine(messageOf(error)));
31670
+ }
31671
+ }
31672
+ /**
31673
+ * Remove a user-added provider profile, web-parity: only `removable` rows may
31674
+ * be removed; a page-managed credential — the derived ref, configured and
31675
+ * writable — is unset first (so a second-step failure leaves the row visible
31676
+ * and the operation retryable), then `settings.mutate` unsets
31677
+ * `target.settingsPath`. Both steps are idempotent. A hand-named credential
31678
+ * ref may be shared elsewhere and is left alone.
31679
+ * @param ctx - context carrying `credentials` and `settings`.
31680
+ * @param target - the joined row to remove.
31681
+ * @throws {@link ProviderSettingsError} with a single-line, key-free message.
31682
+ */
31683
+ async function removeProviderSettings(ctx, target) {
31684
+ if (!target.removable) throw new ProviderSettingsError(`provider "${target.provider}" is not removable from the user settings layer`);
31685
+ if (target.settingsNs.length === 0) throw new ProviderSettingsError(`provider "${target.provider}" has no managed settings profile to remove`);
31686
+ const managedRef = target.credentialRef === target.suggestedRef && target.credential?.kind === "facts" && target.credential.configured === true && target.credential.writable === true ? target.credentialRef : void 0;
31687
+ if (managedRef !== void 0) {
31688
+ const credentials = ctx.get("credentials");
31689
+ if (credentials === void 0) throw new ProviderSettingsError("credentials service is unavailable; cannot remove the managed API key");
31690
+ try {
31691
+ await credentials.unset(managedRef);
31692
+ } catch (error) {
31693
+ throw new ProviderSettingsError(singleLine(messageOf(error)));
31694
+ }
31695
+ }
31696
+ const settings = ctx.get("settings");
31697
+ if (settings === void 0) throw new ProviderSettingsError("settings service is unavailable; cannot remove the provider profile");
31698
+ try {
31699
+ await settings.mutate(target.settingsNs, [{
31700
+ op: "unset",
31701
+ path: [...target.settingsPath]
31702
+ }]);
31703
+ } catch (error) {
31704
+ throw new ProviderSettingsError(singleLine(messageOf(error)));
31705
+ }
31706
+ }
31707
+ //#endregion
29917
31708
  //#region src/mentions.ts
29918
31709
  /**
29919
31710
  * Workspace @mention support: file and directory candidates from a bounded
@@ -30106,11 +31897,6 @@ function mountQuestionProvider(ctx) {
30106
31897
  };
30107
31898
  if (service !== void 0) service.registerProvider({ ask(request) {
30108
31899
  return new Promise((resolve, reject) => {
30109
- const pending = {
30110
- request,
30111
- resolve,
30112
- reject
30113
- };
30114
31900
  const onAbort = () => {
30115
31901
  if (active === pending) {
30116
31902
  active = void 0;
@@ -30122,6 +31908,15 @@ function mountQuestionProvider(ctx) {
30122
31908
  }
30123
31909
  reject(ABORT_ERROR);
30124
31910
  };
31911
+ const detachAbort = () => {
31912
+ if (request.signal !== void 0) request.signal.removeEventListener("abort", onAbort);
31913
+ };
31914
+ const pending = {
31915
+ request,
31916
+ resolve,
31917
+ reject,
31918
+ detachAbort
31919
+ };
30125
31920
  if (request.signal?.aborted === true) {
30126
31921
  reject(ABORT_ERROR);
30127
31922
  return;
@@ -30147,6 +31942,7 @@ function mountQuestionProvider(ctx) {
30147
31942
  if (active !== pending) return;
30148
31943
  active = void 0;
30149
31944
  set({ pending: void 0 });
31945
+ pending.detachAbort?.();
30150
31946
  pending.resolve(answers);
30151
31947
  advance();
30152
31948
  },
@@ -30154,6 +31950,7 @@ function mountQuestionProvider(ctx) {
30154
31950
  if (active !== pending) return;
30155
31951
  active = void 0;
30156
31952
  set({ pending: void 0 });
31953
+ pending.detachAbort?.();
30157
31954
  pending.reject(ABORT_ERROR);
30158
31955
  advance();
30159
31956
  }
@@ -30174,6 +31971,15 @@ function mountQuestionProvider(ctx) {
30174
31971
  function createTranscriptStore(replay) {
30175
31972
  let view = replay === void 0 ? createTranscriptView() : projectEvents(replay);
30176
31973
  const listeners = /* @__PURE__ */ new Set();
31974
+ let scheduled = false;
31975
+ const notify = () => {
31976
+ if (scheduled) return;
31977
+ scheduled = true;
31978
+ queueMicrotask(() => {
31979
+ scheduled = false;
31980
+ for (const listener of listeners) listener();
31981
+ });
31982
+ };
30177
31983
  return {
30178
31984
  getView: () => view,
30179
31985
  subscribe(listener) {
@@ -30186,11 +31992,11 @@ function createTranscriptStore(replay) {
30186
31992
  const next = projectEvent(view, event);
30187
31993
  if (next === view) return;
30188
31994
  view = next;
30189
- for (const listener of listeners) listener();
31995
+ notify();
30190
31996
  },
30191
31997
  reset() {
30192
31998
  view = createTranscriptView();
30193
- for (const listener of listeners) listener();
31999
+ notify();
30194
32000
  }
30195
32001
  };
30196
32002
  }
@@ -30219,12 +32025,13 @@ function watchSkills(ctx) {
30219
32025
  let error;
30220
32026
  const listeners = /* @__PURE__ */ new Set();
30221
32027
  const reload = () => {
30222
- const currentAgent = agent;
30223
- if (skills === void 0 || currentAgent === void 0) return;
32028
+ const target = agent;
32029
+ if (skills === void 0 || target === void 0) return;
30224
32030
  Promise.resolve().then(() => skills.list({
30225
- cwd: currentAgent.session.header.cwd,
30226
- scope: currentAgent
32031
+ cwd: target.session.header.cwd,
32032
+ scope: target
30227
32033
  })).then((summaries) => {
32034
+ if (agent !== target) return;
30228
32035
  const next = toRows(summaries);
30229
32036
  const unchanged = next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name);
30230
32037
  rows = next;
@@ -30233,6 +32040,7 @@ function watchSkills(ctx) {
30233
32040
  if (unchanged && !recovered) return;
30234
32041
  for (const listener of listeners) listener();
30235
32042
  }).catch((cause) => {
32043
+ if (agent !== target) return;
30236
32044
  rows = [...rows];
30237
32045
  error = cause instanceof Error ? cause.message : String(cause);
30238
32046
  for (const listener of listeners) listener();
@@ -30393,6 +32201,13 @@ function resolvePreset(session) {
30393
32201
  }
30394
32202
  return session.header.agentPreset ?? "standard";
30395
32203
  }
32204
+ /** Resolve a pre-session choice, or recompose an active blank Agent. */
32205
+ async function selectPreset(service, agent, presetId) {
32206
+ if (agent !== void 0) return switchPreset(service, agent, presetId);
32207
+ const preset = await service.resolve(presetId);
32208
+ if (preset.broken !== void 0) throw new Error(preset.broken);
32209
+ return preset;
32210
+ }
30396
32211
  /** Recompose atomically from the caller's perspective, logging only success. */
30397
32212
  async function switchPreset(service, agent, presetId) {
30398
32213
  if (!isBlankSession(agent.session.events)) throw new Error("mode is locked after the first turn; use /new <mode>");
@@ -30401,6 +32216,51 @@ async function switchPreset(service, agent, presetId) {
30401
32216
  return preset;
30402
32217
  }
30403
32218
  //#endregion
32219
+ //#region src/permissions.ts
32220
+ /** Read the optional Harness service without importing its runtime package. */
32221
+ function permissionPresetsFrom(ctx) {
32222
+ return ctx.get("permissionPresets");
32223
+ }
32224
+ /** Effective label for either an active session or the not-yet-created first one. */
32225
+ function effectivePermission(service, session, pending) {
32226
+ return session === void 0 ? pending ?? service.defaultPreset : service.current(session.events);
32227
+ }
32228
+ /** Validate a preset and write it only when a durable session already exists. */
32229
+ function selectPermission(service, session, preset) {
32230
+ service.resolve(preset);
32231
+ if (session !== void 0) service.set(session, preset);
32232
+ return preset;
32233
+ }
32234
+ /** Cycle table order from the active, pending, or configured-default value. */
32235
+ function cyclePermission(service, session, pending) {
32236
+ if (service.names.length === 0) return "";
32237
+ const at = service.names.indexOf(effectivePermission(service, session, pending));
32238
+ const next = service.names[(at + 1) % service.names.length] ?? "";
32239
+ return next === "" ? "" : selectPermission(service, session, next);
32240
+ }
32241
+ /** Materialize a pre-session choice after Harness creates the first session. */
32242
+ function applyPendingPermission(service, session, pending) {
32243
+ if (pending !== void 0 && effectivePermission(service, session, void 0) !== pending) selectPermission(service, session, pending);
32244
+ }
32245
+ /**
32246
+ * List every switchable preset for the /permission panel, table order kept.
32247
+ * Description lookup failures degrade to an undocumented row, never a failed
32248
+ * panel load — `optionOf` rejects names its table no longer knows.
32249
+ */
32250
+ function listPermissionRows(service) {
32251
+ return service.names.map((id) => {
32252
+ if (service.optionOf === void 0) return { id };
32253
+ try {
32254
+ return {
32255
+ id,
32256
+ description: service.optionOf(id)?.description
32257
+ };
32258
+ } catch {
32259
+ return { id };
32260
+ }
32261
+ });
32262
+ }
32263
+ //#endregion
30404
32264
  //#region src/plugin-inventory.ts
30405
32265
  const PHASES = {
30406
32266
  0: "pending",
@@ -30429,16 +32289,46 @@ function listPluginRows(ctx) {
30429
32289
  //#endregion
30430
32290
  //#region src/session-directory.ts
30431
32291
  /** Lightweight session-directory projection for the /resume picker. */
32292
+ /** Case-insensitive filesystems (Windows, macOS) compare paths by lowercased form. */
32293
+ const CASE_INSENSITIVE_FS = process.platform === "win32" || process.platform === "darwin";
32294
+ /** True when the header describes a subagent conversation (durable lineage). */
32295
+ function isSubagentSession(header) {
32296
+ return header.origin === "subagent" || header.parentSession !== void 0;
32297
+ }
32298
+ function comparablePath(value) {
32299
+ const resolved = resolve(value);
32300
+ return CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved;
32301
+ }
32302
+ /** Platform-consistent path equality for session cwd comparisons. */
30432
32303
  function samePath(left, right) {
30433
32304
  if (left === void 0) return false;
30434
- return resolve(left).toLowerCase() === resolve(right).toLowerCase();
32305
+ return comparablePath(left) === comparablePath(right);
32306
+ }
32307
+ /**
32308
+ * Unique header match by exact id or unique id prefix (root and subagent
32309
+ * headers alike); the caller applies any lineage gate.
32310
+ * @param headers - the persisted headers.
32311
+ * @param wanted - the id or id prefix.
32312
+ * @returns the uniquely matched header.
32313
+ * @throws when nothing matches or the prefix is ambiguous.
32314
+ */
32315
+ function matchSessionId(headers, wanted) {
32316
+ const exact = headers.filter((header) => header.id === wanted);
32317
+ const matches = exact.length > 0 ? exact : headers.filter((header) => header.id.startsWith(wanted));
32318
+ if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`);
32319
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`);
32320
+ return matches[0];
32321
+ }
32322
+ /** The newest persisted ROOT session pinned to this cwd, or undefined. */
32323
+ function newestRootForCwd(headers, cwd) {
32324
+ return headers.filter((header) => !isSubagentSession(header) && samePath(header.cwd, cwd)).sort((left, right) => right.createdAt - left.createdAt)[0];
30435
32325
  }
30436
32326
  /** Filter/sort header-only records. No session log is loaded here. */
30437
32327
  function projectSessionRows(records, options) {
30438
32328
  const needle = options.query.trim().toLowerCase();
30439
- return records.filter((record) => options.sessions === "all" || record.header.parentSession === void 0 && record.header.origin !== "subagent").filter((record) => options.cwd === "all" || samePath(record.header.cwd, options.currentCwd)).map((record) => {
32329
+ return records.filter((record) => options.sessions === "all" || !isSubagentSession(record.header)).filter((record) => options.cwd === "all" || samePath(record.header.cwd, options.currentCwd)).map((record) => {
30440
32330
  const cwd = record.header.cwd ?? "";
30441
- const subagent = record.header.origin === "subagent" || record.header.parentSession !== void 0;
32331
+ const subagent = isSubagentSession(record.header);
30442
32332
  return {
30443
32333
  id: record.header.id,
30444
32334
  createdAt: record.header.createdAt,
@@ -30510,6 +32400,46 @@ function gitBranch(cwd) {
30510
32400
  }
30511
32401
  }
30512
32402
  /**
32403
+ * Reduce a session id to a filename-safe /export default-name suffix. Session
32404
+ * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
32405
+ * user text: path separators must never leak into the default export filename
32406
+ * (which would escape the session cwd).
32407
+ * @param id - the session id.
32408
+ * @returns at most the last 8 filename-safe characters.
32409
+ */
32410
+ function exportSessionIdSuffix(id) {
32411
+ return id.replace(/[^a-zA-Z0-9._-]/gu, "_").slice(-8);
32412
+ }
32413
+ /**
32414
+ * Run the ordered quit cleanup, then request exit. Every step rejection is
32415
+ * contained (reported through `onError`) so a failed flush or dispose never
32416
+ * skips the remaining cleanup; the exit request is always reached exactly
32417
+ * once.
32418
+ * @param steps - the cleanup steps in dependency order (settle the visible
32419
+ * session, await the final in-flight composition, await durable recall).
32420
+ * @param exit - the terminal exit request (code 0).
32421
+ * @param onError - optional failure sink; called once per failing step and
32422
+ * itself contained, so a throwing sink cannot abort the sequence.
32423
+ * @returns the names of the steps that started, in order (for tests).
32424
+ */
32425
+ async function runQuitSequence(steps, exit, onError) {
32426
+ const started = [];
32427
+ for (const step of steps) {
32428
+ started.push(step.name);
32429
+ try {
32430
+ await step.run();
32431
+ } catch (error) {
32432
+ try {
32433
+ onError?.(step.name, error);
32434
+ } catch {}
32435
+ }
32436
+ }
32437
+ try {
32438
+ exit(0);
32439
+ } catch {}
32440
+ return started;
32441
+ }
32442
+ /**
30513
32443
  * Resolve the invocation's target session against the persisted headers.
30514
32444
  * @param startup - the parsed startup flags.
30515
32445
  * @param persistence - the persistence service; required for resume/latest.
@@ -30523,28 +32453,30 @@ async function resolveTarget(startup, persistence, cwd) {
30523
32453
  resume: false,
30524
32454
  mode: startup.mode
30525
32455
  };
30526
- if (startup.kind === "named") return {
30527
- sessionId: startup.sessionId,
30528
- resume: false,
30529
- mode: startup.mode
30530
- };
32456
+ if (startup.kind === "named") {
32457
+ if (persistence !== void 0) {
32458
+ if ((await persistence.list()).some((header) => header.id === startup.sessionId)) throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`);
32459
+ }
32460
+ return {
32461
+ sessionId: startup.sessionId,
32462
+ resume: false,
32463
+ mode: startup.mode
32464
+ };
32465
+ }
30531
32466
  if (persistence === void 0) throw new Error("cannot resolve the requested session: session persistence is not configured");
30532
32467
  const headers = await persistence.list();
30533
32468
  if (startup.kind === "resume") {
30534
- const wanted = startup.sessionId;
30535
- const exact = headers.filter((header) => header.id === wanted);
30536
- const matches = exact.length > 0 ? exact : headers.filter((header) => header.id.startsWith(wanted));
30537
- if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`);
30538
- if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`);
32469
+ const matched = matchSessionId(headers, startup.sessionId);
32470
+ if (isSubagentSession(matched)) throw new Error("subagent conversations are read-only; resume a root session");
30539
32471
  return {
30540
- sessionId: matches[0].id,
32472
+ sessionId: matched.id,
30541
32473
  resume: true
30542
32474
  };
30543
32475
  }
30544
- const local = headers.filter((header) => header.cwd === cwd).sort((left, right) => right.createdAt - left.createdAt);
30545
- if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`);
32476
+ const newest = newestRootForCwd(headers, cwd);
32477
+ if (newest === void 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`);
30546
32478
  return {
30547
- sessionId: local[0].id,
32479
+ sessionId: newest.id,
30548
32480
  resume: true
30549
32481
  };
30550
32482
  }
@@ -30583,12 +32515,13 @@ async function run(ctx, startup, io) {
30583
32515
  const defaults = defaultModel.currentSelection();
30584
32516
  const presets = agentPresetsFrom(ctx);
30585
32517
  if (presets === void 0) throw new Error("agent preset service is unavailable; check the dsh-code bundle patch");
32518
+ const permissionPresets = permissionPresetsFrom(ctx);
30586
32519
  const lazy = startup.kind === "fresh" && startup.mode === void 0;
30587
32520
  /** Prepare a complete next session before disturbing the currently visible one. */
30588
32521
  const prepare = async (next) => {
30589
32522
  const nextCwd = next.cwd ?? cwd;
30590
32523
  const selectionState = pendingSelection === void 0 ? {} : { picked: pendingSelection };
30591
- let mode = next.mode;
32524
+ let mode = next.resume ? next.mode : next.mode ?? pendingMode;
30592
32525
  if (!next.resume) mode = (await presets.resolve(mode)).id;
30593
32526
  const setup = async (agentCtx) => {
30594
32527
  const sessionPreset = next.resume ? resolvePreset(agentCtx.agent.session) : mode;
@@ -30609,6 +32542,7 @@ async function run(ctx, startup, io) {
30609
32542
  provider: defaults.provider,
30610
32543
  model: defaults.model
30611
32544
  },
32545
+ signal: quitAbort.signal,
30612
32546
  setup
30613
32547
  }) : await agents.create({
30614
32548
  sessionId: SessionId(next.sessionId),
@@ -30620,9 +32554,11 @@ async function run(ctx, startup, io) {
30620
32554
  provider: defaults.provider,
30621
32555
  model: defaults.model
30622
32556
  },
32557
+ signal: quitAbort.signal,
30623
32558
  setup
30624
32559
  });
30625
32560
  const session = handle.agent.session;
32561
+ if (!next.resume && permissionPresets !== void 0) applyPendingPermission(permissionPresets, session, pendingPermission);
30626
32562
  const sessionCwd = session.header.cwd ?? nextCwd;
30627
32563
  return {
30628
32564
  handle,
@@ -30642,6 +32578,44 @@ async function run(ctx, startup, io) {
30642
32578
  let mentions = createMentions(ctx, void 0, cwd);
30643
32579
  /** Explicit model pick made before any session exists (a bare launch). */
30644
32580
  let pendingSelection;
32581
+ /** Agent preset selected before the first session exists. */
32582
+ let pendingMode;
32583
+ /** Ordered pre-session preset resolutions; first composition awaits them. */
32584
+ let pendingModeWork = Promise.resolve();
32585
+ /** Permission preset selected before the first session exists. */
32586
+ let pendingPermission;
32587
+ /**
32588
+ * Monotonic session epoch: bumped on every successful activation, on every
32589
+ * first-session creation, and on quit. Async callbacks (mention prepares,
32590
+ * command executions) capture it at call time and drop their result when it
32591
+ * changed, so a stale callback can never deliver to an agent that is no
32592
+ * longer on screen.
32593
+ */
32594
+ let epoch = 0;
32595
+ /** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
32596
+ const quitAbort = new AbortController();
32597
+ /** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
32598
+ const pendingControllers = /* @__PURE__ */ new Set();
32599
+ const abortPendingControllers = () => {
32600
+ for (const controller of [...pendingControllers]) {
32601
+ pendingControllers.delete(controller);
32602
+ controller.abort();
32603
+ }
32604
+ };
32605
+ /** The in-flight session-composition turn (create/resume/activate), if any. */
32606
+ let composing;
32607
+ /**
32608
+ * Run one session composition exclusively: concurrent compositions wait
32609
+ * their turn, so a bare-launch first-session creation and a /resume
32610
+ * activation can never compose agents in parallel (the loser would leak its
32611
+ * agent or mis-deliver). Errors propagate to the caller; the shared slot
32612
+ * always continues.
32613
+ */
32614
+ const compose = (work) => {
32615
+ const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work);
32616
+ composing = turn.catch(() => {});
32617
+ return turn;
32618
+ };
30645
32619
  if (!lazy) {
30646
32620
  const prepared = await prepare(await resolveTarget(startup, persistence, cwd));
30647
32621
  active = prepared;
@@ -30696,16 +32670,12 @@ async function run(ctx, startup, io) {
30696
32670
  } catch {
30697
32671
  inputHistory = [];
30698
32672
  }
32673
+ /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
32674
+ let historyWriteChain = Promise.resolve();
30699
32675
  const recordHistory = (text) => {
30700
32676
  if (text === "") return;
30701
- inputHistory = [...inputHistory, text].slice(-500);
30702
- let current = "";
30703
- try {
30704
- current = readFileSync(historyPath, "utf8");
30705
- } catch {
30706
- current = "";
30707
- }
30708
- mkdir(dirname(historyPath), { recursive: true }).then(() => writeFile(historyPath, appendHistoryContent(current, text), "utf8")).catch((writeError) => {
32677
+ inputHistory = [...inputHistory, text].slice(-100);
32678
+ historyWriteChain = historyWriteChain.then(() => mkdir(dirname(historyPath), { recursive: true })).then(() => writeFile(historyPath, serializeHistoryList(inputHistory), "utf8")).catch((writeError) => {
30709
32679
  bridge.notify("history save failed: " + (writeError instanceof Error ? writeError.message : String(writeError)), "error");
30710
32680
  });
30711
32681
  };
@@ -30724,30 +32694,40 @@ async function run(ctx, startup, io) {
30724
32694
  if (quitting) return;
30725
32695
  quitting = true;
30726
32696
  switchQueue.cancel();
32697
+ abortPendingControllers();
32698
+ quitAbort.abort();
32699
+ epoch += 1;
30727
32700
  off();
30728
32701
  mountRef.current?.unmount();
30729
32702
  const currentSession = session;
30730
32703
  const currentActive = active;
30731
- if (currentSession === void 0 || currentActive === void 0) {
30732
- io.exit(0);
30733
- return;
30734
- }
30735
- sessions.flush(currentSession).catch((flushError) => {
30736
- internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`);
30737
- }).then(() => currentActive.handle.dispose()).catch((disposeError) => {
30738
- internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`);
30739
- }).then(() => {
30740
- io.exit(0);
30741
- });
32704
+ const report = (name, error) => {
32705
+ internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`);
32706
+ };
32707
+ runQuitSequence([
32708
+ ...currentSession === void 0 || currentActive === void 0 ? [] : [{
32709
+ name: "flush",
32710
+ run: async () => {
32711
+ await sessions.flush(currentSession);
32712
+ }
32713
+ }, {
32714
+ name: "dispose",
32715
+ run: () => currentActive.handle.dispose()
32716
+ }],
32717
+ {
32718
+ name: "composing",
32719
+ run: () => composing ?? Promise.resolve()
32720
+ },
32721
+ {
32722
+ name: "history",
32723
+ run: () => historyWriteChain
32724
+ }
32725
+ ], io.exit, report);
30742
32726
  };
30743
32727
  /** Run one slash line through the command registry (closed namespace). */
30744
32728
  const runSlash = (line) => {
30745
32729
  const currentAgent = agent;
30746
32730
  if (currentAgent === void 0) return;
30747
- if (line.startsWith("/mode ")) {
30748
- switchModeAction(line.slice(6).trim());
30749
- return;
30750
- }
30751
32731
  if (line.startsWith("/resume ")) {
30752
32732
  requestResume(line.slice(8).trim());
30753
32733
  return;
@@ -30758,7 +32738,14 @@ async function run(ctx, startup, io) {
30758
32738
  return;
30759
32739
  }
30760
32740
  const controller = new AbortController();
32741
+ const atEpoch = epoch;
32742
+ pendingControllers.add(controller);
32743
+ const finish = () => {
32744
+ pendingControllers.delete(controller);
32745
+ };
30761
32746
  Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
32747
+ finish();
32748
+ if (epoch !== atEpoch || agent !== currentAgent) return;
30762
32749
  if (execution === void 0) try {
30763
32750
  currentAgent.followup(createUserMessage({
30764
32751
  content: [{
@@ -30771,6 +32758,8 @@ async function run(ctx, startup, io) {
30771
32758
  bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, "error");
30772
32759
  }
30773
32760
  }, (error) => {
32761
+ finish();
32762
+ if (epoch !== atEpoch || agent !== currentAgent) return;
30774
32763
  bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, "error");
30775
32764
  });
30776
32765
  };
@@ -30789,7 +32778,9 @@ async function run(ctx, startup, io) {
30789
32778
  bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, "error");
30790
32779
  return;
30791
32780
  }
32781
+ const atEpoch = epoch;
30792
32782
  const deliver = (readable, context) => {
32783
+ if (epoch !== atEpoch || agent !== currentAgent) return;
30793
32784
  try {
30794
32785
  if (context !== void 0) currentAgent.inject(context);
30795
32786
  const message = createUserMessage({
@@ -30810,49 +32801,78 @@ async function run(ctx, startup, io) {
30810
32801
  return;
30811
32802
  }
30812
32803
  const controller = new AbortController();
32804
+ pendingControllers.add(controller);
30813
32805
  currentMentions.prepare(parsed, controller.signal).then((prepared) => {
32806
+ pendingControllers.delete(controller);
30814
32807
  deliver(prepared.text, prepared.additionalContext);
30815
32808
  }, (error) => {
30816
- if (controller.signal.aborted) return;
32809
+ pendingControllers.delete(controller);
32810
+ if (controller.signal.aborted || epoch !== atEpoch) return;
30817
32811
  bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, "error");
30818
32812
  });
30819
32813
  };
30820
32814
  const pendingInputs = [];
30821
- let creating;
32815
+ let creating = false;
30822
32816
  const ensureSession = (mode) => {
30823
- if (creating !== void 0) return;
30824
- creating = (async () => {
30825
- const next = await prepare({
30826
- sessionId: `session-${randomUUID()}`,
30827
- resume: false,
30828
- ...mode === void 0 ? {} : { mode }
30829
- });
30830
- if (quitting) {
30831
- next.handle.dispose().catch(() => {});
30832
- return;
32817
+ if (creating) return;
32818
+ creating = true;
32819
+ compose(async () => {
32820
+ try {
32821
+ await pendingModeWork;
32822
+ if (session !== void 0) {
32823
+ const queued = pendingInputs.splice(0);
32824
+ for (const item of queued) deliverLine(item.text, item.mode);
32825
+ return;
32826
+ }
32827
+ const next = await prepare({
32828
+ sessionId: `session-${randomUUID()}`,
32829
+ resume: false,
32830
+ ...mode === void 0 ? {} : { mode }
32831
+ });
32832
+ if (quitting) {
32833
+ next.handle.dispose().catch(() => {});
32834
+ return;
32835
+ }
32836
+ active = next;
32837
+ agent = next.agent;
32838
+ session = next.session;
32839
+ store = next.store;
32840
+ mentions = next.mentions;
32841
+ pendingMode = void 0;
32842
+ pendingPermission = void 0;
32843
+ commands.setAgent(agent);
32844
+ skills.setAgent(agent);
32845
+ process.stdout.write("\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[3J\x1B[H");
32846
+ renderCurrent();
32847
+ abortPendingControllers();
32848
+ epoch += 1;
32849
+ const queued = pendingInputs.splice(0);
32850
+ for (const item of queued) deliverLine(item.text, item.mode);
32851
+ } finally {
32852
+ creating = false;
30833
32853
  }
30834
- active = next;
30835
- agent = next.agent;
30836
- session = next.session;
30837
- store = next.store;
30838
- mentions = next.mentions;
30839
- commands.setAgent(agent);
30840
- skills.setAgent(agent);
30841
- process.stdout.write("\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[3J\x1B[H");
30842
- renderCurrent();
30843
- const queued = pendingInputs.splice(0);
30844
- for (const item of queued) deliverLine(item.text, item.mode);
30845
- })().catch((error) => {
32854
+ }).catch((error) => {
30846
32855
  pendingInputs.length = 0;
30847
32856
  bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, "error");
30848
- }).finally(() => {
30849
- creating = void 0;
30850
32857
  });
30851
32858
  };
30852
32859
  /** Deliver one readable line to the agent, expanding session mentions first. */
30853
32860
  const send = (text, mode) => {
30854
32861
  const line = text.trim();
30855
32862
  if (line === "") return;
32863
+ if (line.startsWith("/mode ")) {
32864
+ switchModeAction(line.slice(6).trim()).then((selected) => bridge.notify(`mode → ${selected}`), (error) => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, "error"));
32865
+ return;
32866
+ }
32867
+ if (line.startsWith("/permission ")) {
32868
+ try {
32869
+ const selected = setPermissionAction(line.slice(12).trim());
32870
+ bridge.notify(`permission → ${selected}`);
32871
+ } catch (error) {
32872
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, "error");
32873
+ }
32874
+ return;
32875
+ }
30856
32876
  if (session === void 0) {
30857
32877
  pendingInputs.push({
30858
32878
  text: line,
@@ -30887,23 +32907,33 @@ async function run(ctx, startup, io) {
30887
32907
  return false;
30888
32908
  }
30889
32909
  };
32910
+ /** Select one permission preset before the first session or on the active one. */
32911
+ const setPermissionAction = (id) => {
32912
+ if (permissionPresets === void 0 || permissionPresets.names.length === 0) throw new Error("permission presets are not mounted in this composition");
32913
+ if (id === "") throw new Error("usage: /permission <preset>");
32914
+ const selected = selectPermission(permissionPresets, session, id);
32915
+ if (session === void 0) {
32916
+ pendingPermission = selected;
32917
+ renderCurrent();
32918
+ }
32919
+ return selected;
32920
+ };
30890
32921
  /**
30891
- * Cycle to the next permission preset (Shift+Tab, the Claude-Code
30892
- * permission-mode convention mapped onto dsh presets). A session in a
30893
- * custom knob state wraps to the first declared preset.
32922
+ * Cycle to the next permission preset (Shift+Tab). Before the first session,
32923
+ * the choice remains process-local and is materialized when Harness creates
32924
+ * that session; afterwards the canonical service writes durable events.
30894
32925
  */
30895
- const cyclePermission = () => {
30896
- if (session === void 0) throw new Error("no session yet — submit a message to start");
30897
- const service = ctx.get("permissionPresets");
30898
- if (service === void 0 || service.names.length === 0) {
32926
+ const cyclePermission$1 = () => {
32927
+ if (permissionPresets === void 0 || permissionPresets.names.length === 0) {
30899
32928
  bridge.notify("permission presets are not mounted in this composition", "warning");
30900
32929
  return "";
30901
32930
  }
30902
- const at = service.names.indexOf(service.current(session.events));
30903
- const next = service.names[(at + 1) % service.names.length] ?? "";
30904
- if (next === "") return "";
30905
32931
  try {
30906
- service.set(session, next);
32932
+ const next = cyclePermission(permissionPresets, session, pendingPermission);
32933
+ if (session === void 0 && next !== "") {
32934
+ pendingPermission = next;
32935
+ renderCurrent();
32936
+ }
30907
32937
  return next;
30908
32938
  } catch (error) {
30909
32939
  bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -30934,7 +32964,7 @@ async function run(ctx, startup, io) {
30934
32964
  }
30935
32965
  const wanted = argument.trim();
30936
32966
  const sessionCwd = session.header.cwd ?? cwd;
30937
- const defaultName = `dsh-session-${session.id.slice(-8)}.md`;
32967
+ const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`;
30938
32968
  const target = wanted === "" ? join(sessionCwd, defaultName) : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith("/") ? wanted : join(sessionCwd, wanted);
30939
32969
  const markdown = buildExportMarkdown(store.getView(), session.id);
30940
32970
  try {
@@ -30977,56 +33007,78 @@ async function run(ctx, startup, io) {
30977
33007
  const switchModeAction = async (id) => {
30978
33008
  if (id === "") throw new Error("usage: /mode <preset>");
30979
33009
  const currentAgent = agent;
30980
- const currentActive = active;
30981
- if (currentAgent === void 0 || currentActive === void 0) throw new Error("no session yet — submit a message to start");
30982
- const preset = await switchPreset(presets, currentAgent, id);
30983
- currentActive.mode = preset.id;
33010
+ if (currentAgent === void 0) {
33011
+ const choice = pendingModeWork.then(async () => {
33012
+ const preset = await selectPreset(presets, void 0, id);
33013
+ if (agent === void 0) {
33014
+ pendingMode = preset.id;
33015
+ renderCurrent();
33016
+ }
33017
+ return preset.id;
33018
+ });
33019
+ pendingModeWork = choice.then(() => {}, () => {});
33020
+ return choice;
33021
+ }
33022
+ const preset = await selectPreset(presets, currentAgent, id);
33023
+ if (active === void 0) throw new Error("active Agent has no session state");
33024
+ active.mode = preset.id;
30984
33025
  commands.setAgent(currentAgent);
30985
33026
  skills.setAgent(currentAgent);
30986
33027
  renderCurrent();
30987
33028
  return preset.id;
30988
33029
  };
30989
- const activate = async (nextTarget) => {
30990
- const previous = active;
30991
- const next = await prepare(nextTarget);
30992
- active = next;
30993
- agent = next.agent;
30994
- session = next.session;
30995
- store = next.store;
30996
- mentions = next.mentions;
30997
- commands.setAgent(agent);
30998
- skills.setAgent(agent);
30999
- try {
31000
- process.stdout.write("\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[3J\x1B[H");
31001
- renderCurrent();
31002
- } catch (error) {
31003
- active = previous;
31004
- agent = previous?.agent;
31005
- session = previous?.session;
31006
- store = previous === void 0 ? createTranscriptStore() : previous.store;
31007
- mentions = previous === void 0 ? createMentions(ctx, void 0, cwd) : previous.mentions;
31008
- if (agent !== void 0) commands.setAgent(agent);
31009
- if (agent !== void 0) skills.setAgent(agent);
31010
- await next.handle.dispose();
31011
- renderCurrent();
31012
- throw error;
31013
- }
31014
- if (previous === void 0) {
31015
- bridge.notify(`${next.resumed ? "resumed" : "created"} ${next.session.id.slice(-12)} · mode ${next.mode}`);
31016
- return;
31017
- }
31018
- let cleanupWarning;
31019
- try {
31020
- await sessions.flush(previous.session);
31021
- } catch (error) {
31022
- cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`;
31023
- }
31024
- try {
31025
- await previous.handle.dispose();
31026
- } catch (error) {
31027
- cleanupWarning = `${cleanupWarning === void 0 ? "" : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`;
31028
- }
31029
- bridge.notify(cleanupWarning === void 0 ? `${next.resumed ? "resumed" : "created"} ${next.session.id.slice(-12)} · mode ${next.mode}` : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`, cleanupWarning === void 0 ? "info" : "warning");
33030
+ const activate = (nextTarget) => {
33031
+ if (quitting) return Promise.resolve();
33032
+ return compose(async () => {
33033
+ const previous = active;
33034
+ const next = await prepare(nextTarget);
33035
+ if (quitting) {
33036
+ await next.handle.dispose().catch(() => {});
33037
+ return;
33038
+ }
33039
+ active = next;
33040
+ agent = next.agent;
33041
+ session = next.session;
33042
+ store = next.store;
33043
+ mentions = next.mentions;
33044
+ pendingMode = void 0;
33045
+ pendingPermission = void 0;
33046
+ commands.setAgent(agent);
33047
+ skills.setAgent(agent);
33048
+ try {
33049
+ process.stdout.write("\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[3J\x1B[H");
33050
+ renderCurrent();
33051
+ } catch (error) {
33052
+ active = previous;
33053
+ agent = previous?.agent;
33054
+ session = previous?.session;
33055
+ store = previous === void 0 ? createTranscriptStore() : previous.store;
33056
+ mentions = previous === void 0 ? createMentions(ctx, void 0, cwd) : previous.mentions;
33057
+ if (agent !== void 0) commands.setAgent(agent);
33058
+ if (agent !== void 0) skills.setAgent(agent);
33059
+ await next.handle.dispose();
33060
+ if (!quitting) renderCurrent();
33061
+ throw error;
33062
+ }
33063
+ abortPendingControllers();
33064
+ epoch += 1;
33065
+ if (previous === void 0) {
33066
+ bridge.notify(`${next.resumed ? "resumed" : "created"} ${next.session.id.slice(-12)} · mode ${next.mode}`);
33067
+ return;
33068
+ }
33069
+ let cleanupWarning;
33070
+ try {
33071
+ await sessions.flush(previous.session);
33072
+ } catch (error) {
33073
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`;
33074
+ }
33075
+ try {
33076
+ await previous.handle.dispose();
33077
+ } catch (error) {
33078
+ cleanupWarning = `${cleanupWarning === void 0 ? "" : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`;
33079
+ }
33080
+ bridge.notify(cleanupWarning === void 0 ? `${next.resumed ? "resumed" : "created"} ${next.session.id.slice(-12)} · mode ${next.mode}` : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`, cleanupWarning === void 0 ? "info" : "warning");
33081
+ });
31030
33082
  };
31031
33083
  const switchQueue = new SessionSwitchQueue(async (request) => {
31032
33084
  if (!quitting) await activate(request.target);
@@ -31052,9 +33104,10 @@ async function run(ctx, startup, io) {
31052
33104
  const matches = exact.length > 0 ? exact : records.filter((record) => record.header.id.startsWith(wanted));
31053
33105
  if (matches.length === 0) throw new Error(`no session matches "${wanted}"`);
31054
33106
  if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`);
31055
- if (matches[0].header.parentSession !== void 0 || matches[0].header.origin === "subagent") throw new Error("subagent conversations are read-only in /resume; resume a root session");
31056
- if (session !== void 0 && agents.get(SessionId(matches[0].header.id)) !== void 0 && matches[0].header.id !== session.id) throw new Error("that session is already live in another owner");
31057
- return matches[0].header.id;
33107
+ const matched = matches[0];
33108
+ if (isSubagentSession(matched.header)) throw new Error("subagent conversations are read-only; resume a root session");
33109
+ if (session !== void 0 && agents.get(SessionId(matched.header.id)) !== void 0 && matched.header.id !== session.id) throw new Error("that session is already live in another owner");
33110
+ return matched.header.id;
31058
33111
  };
31059
33112
  const requestResume = (wanted) => {
31060
33113
  resolveResumeId(wanted).then((id) => {
@@ -31102,8 +33155,10 @@ async function run(ctx, startup, io) {
31102
33155
  };
31103
33156
  const appElement = () => {
31104
33157
  const sessionCwd = session?.header.cwd ?? cwd;
31105
- const model = store.getView().model !== "" ? store.getView().model : pendingSelection !== void 0 ? `${pendingSelection.provider}/${pendingSelection.model}` : `${defaults.provider}/${defaults.model}`;
33158
+ const currentView = store.getView();
33159
+ const model = currentView.model !== "" ? currentView.model : pendingSelection !== void 0 ? `${pendingSelection.provider}/${pendingSelection.model}` : `${defaults.provider}/${defaults.model}`;
31106
33160
  const effort = resolveEffectiveSelection(active?.selection.picked ?? pendingSelection, session?.requestHeader()?.config, defaults).reasoningEffort;
33161
+ const permission = permissionPresets === void 0 ? currentView.permission : effectivePermission(permissionPresets, session, pendingPermission);
31107
33162
  return (0, import_react.createElement)(App, {
31108
33163
  key: session?.id ?? "pending",
31109
33164
  store,
@@ -31118,19 +33173,27 @@ async function run(ctx, startup, io) {
31118
33173
  branch: gitBranch(sessionCwd),
31119
33174
  sessionId: session === void 0 ? "" : session.id.slice(-8),
31120
33175
  resumed: active?.resumed ?? false,
31121
- mode: active?.mode ?? "",
33176
+ mode: active?.mode ?? pendingMode ?? presets.defaultId,
33177
+ permission,
31122
33178
  dispatch,
31123
33179
  steer,
31124
33180
  interrupt,
31125
33181
  quit,
31126
33182
  loadModels: () => loadModelDirectory(ctx),
33183
+ loadModelProviders: () => loadProviderSettings(ctx),
33184
+ subscribeModelProviders: (listener) => subscribeProviderSettings(ctx, listener),
33185
+ saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
33186
+ unsetModelProviderCredential: (target) => unsetProviderCredential(ctx, target),
33187
+ removeModelProvider: (target) => removeProviderSettings(ctx, target),
31127
33188
  loadMentions: (query, signal) => mentions.candidates(query, signal),
31128
- cyclePermission,
33189
+ cyclePermission: cyclePermission$1,
33190
+ setPermission: setPermissionAction,
31129
33191
  selectModel,
31130
33192
  exportTranscript,
31131
33193
  renameTitle,
31132
33194
  loadPresets: () => presets.list(),
31133
33195
  switchMode: switchModeAction,
33196
+ loadPermissions: () => permissionPresets === void 0 ? Promise.reject(/* @__PURE__ */ new Error("permission presets are not mounted in this composition")) : Promise.resolve(listPermissionRows(permissionPresets)),
31134
33197
  createSession,
31135
33198
  loadSessions,
31136
33199
  loadSessionTranscript,
@@ -31194,4 +33257,4 @@ function apply(ctx, config) {
31194
33257
  });
31195
33258
  }
31196
33259
  //#endregion
31197
- export { Config, apply, inject, name };
33260
+ export { Config, apply, exportSessionIdSuffix, inject, name, resolveTarget, runQuitSequence };