dsh-code 1.4.0 → 1.5.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.
package/lib/index.mjs CHANGED
@@ -24336,6 +24336,15 @@ function panelAccent(id, border, title = border) {
24336
24336
  }
24337
24337
  //#endregion
24338
24338
  //#region src/render/inspector.ts
24339
+ /**
24340
+ * Remove assistant settlements that contain reasoning but no final response.
24341
+ * They remain in the durable transcript and in the Ctrl/Alt+R fold; excluding
24342
+ * them only from Ctrl+O navigation prevents short progress thoughts from
24343
+ * appearing as a long run of misleading `reply` entries.
24344
+ */
24345
+ function inspectableTranscriptEntries(entries) {
24346
+ return entries.filter((entry) => !(entry.kind === "assistant" && entry.text.trim() === "" && entry.reasoning.trim() !== ""));
24347
+ }
24339
24348
  /** Composer (3) + two-row status chrome, plus one optional fixed-height local notice row. */
24340
24349
  const INSPECTOR_CHROME_ROWS = 6;
24341
24350
  /** One transcript-to-composer gutter, collapsed on short terminals. */
@@ -24895,7 +24904,7 @@ const en = {
24895
24904
  "cmd.theme": "switch the color theme",
24896
24905
  "cmd.language": "switch the interface language (/language [en|zh])",
24897
24906
  "cmd.rainbow": "reroll or pin the rainbow palette (/rainbow [seed])",
24898
- "cmd.animation": "toggle timed animations (/animation [on|off])",
24907
+ "cmd.animation": "toggle decorative animations; shimmer and activity stay live (/animation [on|off])",
24899
24908
  "cmd.history": "search and recall past prompts",
24900
24909
  "cmd.queue": "manage messages queued for the next turn",
24901
24910
  "cmd.usage": "show this session token usage",
@@ -25031,12 +25040,12 @@ const en = {
25031
25040
  "notice.imageCancelled": "image submission cancelled",
25032
25041
  "notice.themeConfigUnreadable": "theme config unreadable, using dark: {message}",
25033
25042
  "notice.languageConfigUnreadable": "language config unreadable, using english: {message}",
25034
- "notice.animationsConfigUnreadable": "animations config unreadable, animations stay on: {message}",
25043
+ "notice.animationsConfigUnreadable": "decorative animation config unreadable, effects stay on: {message}",
25035
25044
  "notice.statuslineConfigUnreadable": "statusline config unreadable, using defaults: {message}",
25036
25045
  "notice.sessionSearchUnavailable": "session search is unavailable in this deployment",
25037
25046
  "notice.subagentsReadOnly": "subagent conversations are read-only",
25038
25047
  "notice.statuslineSaveFailed": "statusline save failed: {message}",
25039
- "notice.animationsSaveFailed": "animations save failed: {message}",
25048
+ "notice.animationsSaveFailed": "decorative animation save failed: {message}",
25040
25049
  "notice.commandRegistryMissing": "no command registry is mounted in this composition",
25041
25050
  "notice.commandFallbackFailed": "command fallback failed: {message}",
25042
25051
  "notice.commandFailed": "command failed: {message}",
@@ -25087,7 +25096,7 @@ const en = {
25087
25096
  "notice.commandAttachments": "commands cannot carry attachments; the line will be sent to the model as a prompt",
25088
25097
  "notice.copyFailed": "copy failed: {message}",
25089
25098
  "notice.vscodeKeysFailed": "vscode-keys failed: {message}",
25090
- "notice.animationState": "animations {state}",
25099
+ "notice.animationState": "decorative animations {state}; activity indicators stay live",
25091
25100
  "notice.deleteFailed": "delete failed: {message}",
25092
25101
  "notice.modelChangedPlaceholder": "model → {model} · image history will be sent as text placeholders",
25093
25102
  "notice.modelNextStep": "model → next step uses {model}",
@@ -25398,7 +25407,7 @@ const zh = {
25398
25407
  "cmd.theme": "切换配色主题",
25399
25408
  "cmd.language": "切换界面语言(/language [en|zh])",
25400
25409
  "cmd.rainbow": "重掷或指定彩虹配色(/rainbow [seed])",
25401
- "cmd.animation": "开关计时动画(/animation [on|off])",
25410
+ "cmd.animation": "开关装饰动画,shimmer 与活动指示始终保留(/animation [on|off])",
25402
25411
  "cmd.history": "搜索并复用历史提示词",
25403
25412
  "cmd.queue": "管理等待下一轮的消息",
25404
25413
  "cmd.usage": "查看本会话的 token 用量",
@@ -25534,12 +25543,12 @@ const zh = {
25534
25543
  "notice.imageCancelled": "图片提交已取消",
25535
25544
  "notice.themeConfigUnreadable": "主题配置无法读取,使用暗色:{message}",
25536
25545
  "notice.languageConfigUnreadable": "语言配置无法读取,使用英文:{message}",
25537
- "notice.animationsConfigUnreadable": "动画配置无法读取,保持开启:{message}",
25546
+ "notice.animationsConfigUnreadable": "装饰动画配置无法读取,保持开启:{message}",
25538
25547
  "notice.statuslineConfigUnreadable": "状态栏配置无法读取,使用默认:{message}",
25539
25548
  "notice.sessionSearchUnavailable": "此部署未启用会话检索",
25540
25549
  "notice.subagentsReadOnly": "子代理会话为只读",
25541
25550
  "notice.statuslineSaveFailed": "状态栏保存失败:{message}",
25542
- "notice.animationsSaveFailed": "动画保存失败:{message}",
25551
+ "notice.animationsSaveFailed": "装饰动画保存失败:{message}",
25543
25552
  "notice.commandRegistryMissing": "当前组合未加载命令注册表",
25544
25553
  "notice.commandFallbackFailed": "命令回退失败:{message}",
25545
25554
  "notice.commandFailed": "命令执行失败:{message}",
@@ -25590,7 +25599,7 @@ const zh = {
25590
25599
  "notice.commandAttachments": "命令不能携带附件;该行将作为提示发送给模型",
25591
25600
  "notice.copyFailed": "复制失败:{message}",
25592
25601
  "notice.vscodeKeysFailed": "vscode-keys 失败:{message}",
25593
- "notice.animationState": "动画{state}",
25602
+ "notice.animationState": "装饰动画{state};活动指示保持开启",
25594
25603
  "notice.deleteFailed": "删除失败:{message}",
25595
25604
  "notice.modelChangedPlaceholder": "模型 → {model} · 历史图片将以文本占位符发送",
25596
25605
  "notice.modelNextStep": "模型 → 下一步使用 {model}",
@@ -26501,6 +26510,94 @@ function useTerminalTitle(title, options = {}) {
26501
26510
  }, [stdout, title]);
26502
26511
  }
26503
26512
  //#endregion
26513
+ //#region src/render/projection-events.ts
26514
+ /** Events folded directly into `TranscriptView`. */
26515
+ const TRANSCRIPT_SESSION_EVENT_TYPES = [
26516
+ "agent/inbox/spliced",
26517
+ "assistant/attempt",
26518
+ "assistant/message",
26519
+ "command/done",
26520
+ "command/run",
26521
+ "compaction/end",
26522
+ "compaction/prune",
26523
+ "compaction/summary",
26524
+ "goal/change",
26525
+ "llm/retry",
26526
+ "llm/retry-started",
26527
+ "permission/preset",
26528
+ "plan/mode",
26529
+ "request/context",
26530
+ "request/header",
26531
+ "sandbox/mode",
26532
+ "schedule/change",
26533
+ "session/title",
26534
+ "step/start",
26535
+ "system/message",
26536
+ "todo/write",
26537
+ "tool-workflow/agent-end",
26538
+ "tool-workflow/agent-start",
26539
+ "tool-workflow/run-end",
26540
+ "tool-workflow/run-start",
26541
+ "tool/call",
26542
+ "tool/ptc-dispatch",
26543
+ "tool/ptc-dispatch-start",
26544
+ "tool/result",
26545
+ "turn/end",
26546
+ "turn/start",
26547
+ "user/message"
26548
+ ];
26549
+ /**
26550
+ * Events consumed by another TUI adapter rather than the transcript fold.
26551
+ * Keeping these separate documents that they are visible behavior, not
26552
+ * accidental no-ops.
26553
+ */
26554
+ const OTHER_SURFACE_SESSION_EVENT_TYPES = [
26555
+ "agent-preset/selected",
26556
+ "model/selection",
26557
+ "subagent/catalog"
26558
+ ];
26559
+ /**
26560
+ * Kernel events deliberately omitted from terminal presentation. Their
26561
+ * model-visible consequences arrive through projected message/tool/session
26562
+ * events, while audit-only facts do not need a second transcript row.
26563
+ */
26564
+ const IGNORED_SESSION_EVENT_TYPES = [
26565
+ "approval/asked",
26566
+ "approval/decided",
26567
+ "approval/policy",
26568
+ "compaction/start",
26569
+ "deliverables/presented",
26570
+ "feedback/message-delete",
26571
+ "feedback/message-put",
26572
+ "feedback/record",
26573
+ "hook/invoked",
26574
+ "hook/result",
26575
+ "session-log-deepseek/delivery-accepted",
26576
+ "session/end-seed",
26577
+ "session/title-llm-request",
26578
+ "step/end",
26579
+ "subagent/descriptor",
26580
+ "subagent/model-selection-policy",
26581
+ "team/member",
26582
+ "team/message/delivered",
26583
+ "team/message/queued",
26584
+ "team/task",
26585
+ "web/deepseek-search-llm-request"
26586
+ ];
26587
+ const EVENT_DISPOSITIONS = new Map([
26588
+ ...TRANSCRIPT_SESSION_EVENT_TYPES.map((type) => [type, "transcript"]),
26589
+ ...OTHER_SURFACE_SESSION_EVENT_TYPES.map((type) => [type, "other-surface"]),
26590
+ ...IGNORED_SESSION_EVENT_TYPES.map((type) => [type, "ignored"])
26591
+ ]);
26592
+ /**
26593
+ * Classify a durable event without rejecting merge-extensible plugin events.
26594
+ * `unknown` remains a runtime no-op for forward/plugin compatibility; the
26595
+ * known-event coverage test is what makes a first-party addition fail CI.
26596
+ */
26597
+ function sessionEventDisposition(type) {
26598
+ return EVENT_DISPOSITIONS.get(type) ?? "unknown";
26599
+ }
26600
+ //#endregion
26504
26601
  //#region src/render/tool-preview.ts
26505
26602
  /**
26506
26603
  * Bounded preview line for a tool invocation's raw JSON arguments: the first
@@ -26517,10 +26614,12 @@ function useTerminalTitle(title, options = {}) {
26517
26614
  const PREVIEW_KEYS = [
26518
26615
  "command",
26519
26616
  "cmd",
26617
+ "file_path",
26520
26618
  "description",
26521
26619
  "path",
26522
26620
  "pattern",
26523
- "query"
26621
+ "query",
26622
+ "name"
26524
26623
  ];
26525
26624
  /**
26526
26625
  * Raw arguments longer than this are skipped without parsing and fall back
@@ -26532,6 +26631,28 @@ const MAX_PARSE_CHARS = 4096;
26532
26631
  function boundedRawPreview(args) {
26533
26632
  return args.length > 80 ? `${args.slice(0, 77)}...` : args;
26534
26633
  }
26634
+ /** Escape a fixed preview key before embedding it in a bounded regex. */
26635
+ function regexKey(key) {
26636
+ return key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
26637
+ }
26638
+ /**
26639
+ * Recover one complete JSON string property from the bounded head/tail of a
26640
+ * large argument object. Write/edit payloads commonly put a multi-kilobyte
26641
+ * `content` first and `file_path` last; parsing the whole payload is forbidden
26642
+ * on the render path, but the short trailing path is still safe to decode.
26643
+ */
26644
+ function boundedStringProperty(args, key) {
26645
+ const slices = [args.slice(0, MAX_PARSE_CHARS), args.slice(-4096)];
26646
+ const pattern = new RegExp(`"${regexKey(key)}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`, "u");
26647
+ for (const slice of slices) {
26648
+ const literal = pattern.exec(slice)?.[1];
26649
+ if (literal === void 0) continue;
26650
+ try {
26651
+ const value = JSON.parse(literal);
26652
+ if (typeof value === "string" && value !== "") return value;
26653
+ } catch {}
26654
+ }
26655
+ }
26535
26656
  /**
26536
26657
  * Resolve one bounded preview for raw tool arguments.
26537
26658
  * @param args - raw JSON arguments string as the model produced it.
@@ -26540,7 +26661,13 @@ function boundedRawPreview(args) {
26540
26661
  */
26541
26662
  function toolArgumentsPreview(args, toolName) {
26542
26663
  if (args === "") return toolName;
26543
- if (args.length > MAX_PARSE_CHARS) return boundedRawPreview(args);
26664
+ if (args.length > MAX_PARSE_CHARS) {
26665
+ for (const key of PREVIEW_KEYS) {
26666
+ const value = boundedStringProperty(args, key);
26667
+ if (value !== void 0) return value;
26668
+ }
26669
+ return boundedRawPreview(args);
26670
+ }
26544
26671
  try {
26545
26672
  const parsed = JSON.parse(args);
26546
26673
  if (parsed !== null && typeof parsed === "object") {
@@ -27139,6 +27266,7 @@ function replayProjectEvent(acc, event) {
27139
27266
  }
27140
27267
  };
27141
27268
  }
27269
+ if (sessionEventDisposition(event.type) !== "transcript") return shadow.changed;
27142
27270
  switch (event.type) {
27143
27271
  case "user/message": {
27144
27272
  const message = event.data;
@@ -27646,7 +27774,7 @@ function replayProjectEvent(acc, event) {
27646
27774
  }));
27647
27775
  return true;
27648
27776
  }
27649
- return false;
27777
+ throw new Error(`missing transcript replay projection for session event "${type}"`);
27650
27778
  }
27651
27779
  }
27652
27780
  }
@@ -30209,13 +30337,13 @@ function reasoningLines(text, columns) {
30209
30337
  * diff/read/web/raw rows align under them instead of floating two columns
30210
30338
  * shallower.
30211
30339
  */
30212
- function toolDetailLines(detail, columns) {
30340
+ function toolDetailLines(detail, columns, rawStyle) {
30213
30341
  switch (detail.kind) {
30214
30342
  case "diff": return detail.diffs.flatMap((diff) => [...prefixedTextLines(`${diff.path}${diff.truncated ? " (diff truncated)" : ""}`, columns, " ── ", "dim"), ...fillDiffLineBars(diff.lines.flatMap((line) => prefixedTextLines(`${line.mark}${line.text}`, columns, " ", line.mark === "+" ? "diffAdd" : line.mark === "-" ? "diffDel" : "dim")), columns)]);
30215
30343
  case "read": return [...prefixedTextLines(`${detail.path} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1].number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? " (window truncated)" : ""}`, columns, " ── ", "dim"), ...detail.lines.flatMap((line) => prefixedTextLines(`${String(line.number).padStart(5, " ")} | ${line.text}`, columns, " ", "dim"))];
30216
30344
  case "web-search": return [...detail.sources.flatMap((source) => [...prefixedStyledLines([lineSegment(source.title ?? source.url, "brand"), lineSegment(` - ${source.url}`, "dim")], columns, " ? "), ...source.snippet === "" ? [] : prefixedTextLines(source.snippet, columns, " ", "dim")]), ...prefixedTextLines(`${detail.sources.length} sources${detail.truncated ? " (capped)" : ""}`, columns, " ", "dim")];
30217
30345
  case "web-fetch": return prefixedTextLines(`${detail.url} · HTTP ${detail.statusCode}`, columns, " ", "dim");
30218
- case "raw": return [...prefixedTextLines(detail.text, columns, " ", "dim"), ...prefixedTextLines(detail.truncated ? "… (output truncated)" : "(end of output)", columns, " ", "dim")];
30346
+ case "raw": return [...prefixedTextLines(detail.text, columns, " ", rawStyle), ...prefixedTextLines(detail.truncated ? "… (output truncated)" : "(end of output)", columns, " ", rawStyle)];
30219
30347
  default: return detail;
30220
30348
  }
30221
30349
  }
@@ -30322,10 +30450,14 @@ function subDispatchLines(entry, columns) {
30322
30450
  const width = Math.max(1, Math.floor(columns));
30323
30451
  const rows = entry.subs.flatMap((sub) => {
30324
30452
  const mark = sub.state === "running" ? "●" : sub.state === "error" ? "⨯" : "⏺";
30325
- const style = sub.state === "running" ? "brand" : sub.state === "error" ? "error" : "dim";
30326
- const label = sub.preview === "" ? sub.name : `${sub.name} ${sub.preview}`;
30327
- if (sub.state === "error" && sub.summary !== "") return textLines(` ┆ ${mark} ${label} · ${sub.summary}${subDispatchSeconds(sub.durationMs)}`, width, style);
30328
- return textLines(` ┆ ${mark} ${label}${subDispatchSeconds(sub.durationMs)}`, width, style);
30453
+ const markStyle = sub.state === "running" ? "brand" : sub.state === "error" ? "error" : "success";
30454
+ return hangingStyledLines([
30455
+ lineSegment(`${mark} `, markStyle),
30456
+ lineSegment(sub.name, "brand"),
30457
+ lineSegment(sub.preview === "" ? "" : ` ${sub.preview}`, "dim"),
30458
+ lineSegment(sub.state === "error" && sub.summary !== "" ? ` · ${sub.summary}` : "", sub.state === "error" ? "error" : "dim"),
30459
+ lineSegment(subDispatchSeconds(sub.durationMs), "dim")
30460
+ ], width, " ┆ ", "dim", " ┆ ", "dim");
30329
30461
  });
30330
30462
  if (entry.subsDropped === 0) return rows;
30331
30463
  return [...rows, ...textLines(` ┆ … ${entry.subsDropped} earlier dispatch${entry.subsDropped === 1 ? "" : "es"}`, width, "dim")];
@@ -30375,18 +30507,32 @@ function transcriptEntryLines(entry, columns, showReasoning = true, reasoningTog
30375
30507
  const mark = entry.state === "running" ? "●" : entry.state === "error" ? "⨯" : "⏺";
30376
30508
  const markStyle = entry.state === "running" ? "brand" : entry.state === "error" ? "error" : "success";
30377
30509
  const summaryStyle = entry.state === "error" ? "error" : "dim";
30378
- const lines = [
30510
+ const invocation = [
30379
30511
  ...hangingStyledLines([
30380
30512
  lineSegment(`[${entry.ordinal}] `, "dim"),
30381
30513
  lineSegment(entry.name, "brand"),
30382
30514
  lineSegment(entry.preview === "" ? "" : ` ${entry.preview}`, "dim")
30383
30515
  ], width, `${mark} `, markStyle, " ", "plain"),
30384
30516
  ...entry.prompt === "" ? [] : hangingTextLines(entry.prompt, width, " └ ", "dim", " "),
30385
- ...subDispatchLines(entry, width),
30386
- ...entry.summary === "" ? [] : hangingTextLines(entry.state === "error" ? `call ${entry.ordinal}: ${entry.summary}` : entry.summary, width, " ⎿ ", summaryStyle, " ", summaryStyle),
30387
- ...entry.detail === void 0 ? [] : toolDetailLines(entry.detail, width)
30517
+ ...subDispatchLines(entry, width)
30518
+ ];
30519
+ const summary = entry.summary === "" ? [] : hangingTextLines(entry.state === "error" ? `call ${entry.ordinal}: ${entry.summary}` : entry.summary, width, " ⎿ ", summaryStyle, " ", summaryStyle);
30520
+ if (!showToolDetails) {
30521
+ const rawFullySummarized = entry.detail?.kind === "raw" && !entry.detail.truncated && entry.detail.text.trim() === entry.summary.trim();
30522
+ const hiddenDetail = entry.detail !== void 0 && !rawFullySummarized ? textLines(" … output hidden · Ctrl/Alt+R", width, "dim").slice(0, 1) : [];
30523
+ return compactToolLines([
30524
+ ...invocation,
30525
+ ...summary,
30526
+ ...hiddenDetail
30527
+ ], width);
30528
+ }
30529
+ const detail = entry.detail === void 0 ? [] : toolDetailLines(entry.detail, width, entry.state === "error" ? "error" : "dim");
30530
+ const detailSupersedesSummary = entry.detail?.kind === "raw" || entry.detail?.kind === "read" || entry.detail?.kind === "diff";
30531
+ return [
30532
+ ...invocation,
30533
+ ...detailSupersedesSummary ? [] : summary,
30534
+ ...detail
30388
30535
  ];
30389
- return showToolDetails ? lines : compactToolLines(lines, width);
30390
30536
  }
30391
30537
  case "command": {
30392
30538
  const mark = entry.state === "running" ? "●" : entry.state === "error" ? "⨯" : "⏺";
@@ -30401,9 +30547,9 @@ function transcriptEntryLines(entry, columns, showReasoning = true, reasoningTog
30401
30547
  ...hangingStyledLines([lineSegment("workflow ", "brand"), lineSegment(entry.name, "dim")], width, `${mark} `, markStyle, " ", "plain"),
30402
30548
  ...entry.members.flatMap((member) => {
30403
30549
  const state = member.outcome === "running" ? "●" : member.outcome === "failed" ? "⨯" : member.outcome === "cancelled" ? "⏹" : "⏺";
30404
- const style = member.outcome === "running" ? "brand" : member.outcome === "failed" ? "error" : "dim";
30550
+ const stateStyle = member.outcome === "running" ? "brand" : member.outcome === "failed" ? "error" : member.outcome === "cancelled" ? "dim" : "success";
30405
30551
  const phase = member.phase === "" ? "" : ` [${member.phase}]`;
30406
- return textLines(` ┆ ${state} ${member.label}${phase}`, width, style);
30552
+ return hangingStyledLines([lineSegment(`${state} `, stateStyle), lineSegment(`${member.label}${phase}`, "dim")], width, " ┆ ", "dim", " ┆ ", "dim");
30407
30553
  }),
30408
30554
  ...entry.membersDropped === 0 ? [] : textLines(` ┆ … ${entry.membersDropped} earlier member${entry.membersDropped === 1 ? "" : "s"}`, width, "dim")
30409
30555
  ];
@@ -30460,1743 +30606,1743 @@ function clampLiveAllocation(allocation, dynamicRows) {
30460
30606
  };
30461
30607
  }
30462
30608
  //#endregion
30463
- //#region src/render/status.ts
30464
- /**
30465
- * Status-bar composition for the TUI footer. Codex/Claude-Code-style split
30466
- * line: identity facts and session figures flow from the left, while the
30467
- * permission badge (the Codex "autonomous selection" anchor, with its
30468
- * shift+tab cycle hint) pins to the right edge. Every segment carries a tone
30469
- * the footer maps to a theme color, and layoutStatusBar degrades the line
30470
- * item by item so it always fits one physical row — truncation with an
30471
- * ellipsis happens only after every lesser group has already dropped out.
30472
- *
30473
- * @module @deepseek-ai/dsh-tui/render/status
30474
- */
30609
+ //#region src/render/usage.ts
30610
+ /** Prompt-side tokens the provider billed: uncached input plus both cache buckets. */
30611
+ function billedInputTokens(totals) {
30612
+ return totals.uncachedInputTokens + totals.cacheReadTokens + totals.cacheWriteTokens;
30613
+ }
30614
+ /** Prompt plus completion tokens over the whole log. */
30615
+ function usageTotalTokens(totals) {
30616
+ return billedInputTokens(totals) + totals.outputTokens;
30617
+ }
30475
30618
  /**
30476
- * Compact duration: 45.2s under a minute, 2m42s from there on.
30477
- * @param ms - duration in milliseconds.
30478
- * @returns display string.
30619
+ * Cache-hit share of billed prompt-side input.
30620
+ * @param totals - cumulative provider-reported buckets.
30621
+ * @returns percent rounded to one decimal place, or null when nothing was billed.
30479
30622
  */
30480
- function formatDuration(ms) {
30481
- const s = ms / 1e3;
30482
- if (s < 60) return String(Math.round(s * 10) / 10) + "s";
30483
- const whole = Math.round(s);
30484
- const hours = Math.floor(whole / 3600);
30485
- const minutes = Math.floor(whole % 3600 / 60);
30486
- const seconds = whole % 60;
30487
- if (hours > 0) return `${hours}h${minutes}m${seconds === 0 ? "" : `${seconds}s`}`;
30488
- return `${minutes}m${seconds}s`;
30623
+ function usageCacheHitPercent(totals) {
30624
+ const billed = billedInputTokens(totals);
30625
+ return billed === 0 ? null : Math.round(totals.cacheReadTokens / billed * 1e3) / 10;
30489
30626
  }
30490
30627
  /**
30491
- * Compact decode rate: one decimal under a hundred, whole below a thousand,
30492
- * then thousands (15.3 / 124 / 1.2K).
30493
- * @param n - tokens per second.
30494
- * @returns display string.
30628
+ * Split a durable log into COMPLETE turns. A turn still running has no
30629
+ * `turn/end` yet, and an unfinished attempt has no exact accounting, so the
30630
+ * trailing slice is dropped rather than guessed at.
30631
+ * @param events - the whole durable log, in log order.
30632
+ * @returns one slice per `turn/start`…`turn/end` span, oldest first.
30495
30633
  */
30496
- function formatRate(n) {
30497
- if (n < 100) return String(Math.round(n * 10) / 10);
30498
- if (n < 1e3) return String(Math.round(n));
30499
- return String(Math.round(n / 100) / 10) + "K";
30634
+ function completedTurns(events) {
30635
+ const slices = [];
30636
+ let open;
30637
+ for (const event of events) {
30638
+ if (event.type === "turn/start") {
30639
+ open = {
30640
+ turn: event.data.turn,
30641
+ events: [event]
30642
+ };
30643
+ continue;
30644
+ }
30645
+ if (open === void 0) continue;
30646
+ open.events.push(event);
30647
+ if (event.type === "turn/end") {
30648
+ slices.push(open);
30649
+ open = void 0;
30650
+ }
30651
+ }
30652
+ return slices;
30500
30653
  }
30501
30654
  /**
30502
- * Cache-hit share of billed prompt-side input. The denominator is the same
30503
- * billed total the /usage panel shows (uncached input plus both cache
30504
- * buckets), so the two readouts can never disagree.
30505
- * @param usage - cumulative token totals.
30506
- * @returns percent rounded to one decimal place, or null when no input was billed.
30655
+ * Exact usage for every completed turn that can be proven.
30656
+ * @param events - the whole durable log, in log order.
30657
+ * @param derive - the meter's per-turn fold, injected so this module stays pure.
30658
+ * @returns one row per provable turn, oldest first; turns whose attempts did
30659
+ * not all report usage are omitted rather than estimated, and so is a turn
30660
+ * that billed nothing at all — an empty row is noise in a usage table.
30507
30661
  */
30508
- function cacheHitPercent(usage) {
30509
- const billed = billedInputTokens(usage);
30510
- return billed === 0 ? null : Math.round(usage.cacheReadTokens / billed * 1e3) / 10;
30662
+ function turnUsages(events, derive) {
30663
+ const rows = [];
30664
+ for (const slice of completedTurns(events)) {
30665
+ const usage = derive(slice.events);
30666
+ if (usage === void 0 || usage.totalTokens === 0) continue;
30667
+ rows.push({
30668
+ turn: slice.turn,
30669
+ usage,
30670
+ model: turnModel(slice, usage)
30671
+ });
30672
+ }
30673
+ return rows;
30511
30674
  }
30512
- /** Separator between trailing state spans. */
30513
- const STATUS_ITEM_SEPARATOR = " · ";
30514
- /** Localized mode-cycle hint used by the live layout. */
30515
- function statusCycleHint() {
30516
- return ` ${t("status.cycleHint")}`;
30675
+ /** Pad to a column budget so labels line up across terminal widths. */
30676
+ function padColumns(text, columns) {
30677
+ return text + " ".repeat(Math.max(0, columns - visibleColumns(text)));
30678
+ }
30679
+ /** One `label` + `value` row of a summary block. */
30680
+ function pairLine(label, value, labelColumns, style = "plain") {
30681
+ const gap = " ".repeat(Math.max(1, labelColumns - visibleColumns(label)));
30682
+ return { segments: [lineSegment(" " + label + gap, "dim"), lineSegment(value, style)] };
30517
30683
  }
30518
- /** Occupancy at which the usage readout flips from brand blue to amber. */
30519
- const CONTEXT_WARN_PERCENT = 90;
30520
- /**
30521
- * Narrowest bar width the drop ladder keeps before dropping the whole
30522
- * context group: the bar shrinks to this floor first (the absolute readout
30523
- * survives), and only past it does the readout degrade and the group go.
30524
- */
30525
- const CONTEXT_MIN_WIDTH = 5;
30526
30684
  /**
30527
- * Render context occupancy as ONE stepless proportional bar: a solid
30528
- * DeepSeek-blue fill run tracking the occupancy and a dim dotted free
30529
- * track for the rest. Nothing else lives inside the bar the usage
30530
- * readout rides outside it (see contextGroupSpans) so the geometry
30531
- * always reads as the true remaining share. A given occupancy always
30532
- * renders the identical bar.
30533
- * @param usedTokens - reported used tokens.
30534
- * @param contextWindow - route capacity.
30535
- * @param width - total bar columns.
30536
- * @returns tone-split spans for the footer to paint.
30685
+ * Merge the per-turn rows by the model that billed them, biggest spender
30686
+ * first. A turn lands in exactly one group — the group named by every model it
30687
+ * used so the sums stay additive and a mid-turn model switch never counts
30688
+ * the same tokens twice. Each bucket is merged over the turns that reported
30689
+ * it, and {@link ModelUsage.partial} records the ones that stayed silent.
30690
+ * @param turns - provable per-turn rows, oldest first.
30691
+ * @returns one row per model group.
30537
30692
  */
30538
- function contextBar(usedTokens, contextWindow, width) {
30539
- if (width <= 0 || contextWindow <= 0) return [];
30540
- const fill = Math.min(width, Math.max(0, Math.round(Math.max(0, usedTokens) / contextWindow * width)));
30541
- const spans = [];
30542
- if (fill > 0) spans.push({
30543
- text: "█".repeat(fill),
30544
- tone: "ctxFill"
30545
- });
30546
- const free = width - fill;
30547
- if (free > 0) spans.push({
30548
- text: "░".repeat(free),
30549
- tone: "label"
30550
- });
30551
- return spans;
30693
+ function modelTotals(turns) {
30694
+ const groups = /* @__PURE__ */ new Map();
30695
+ for (const row of turns) groups.set(row.model, [...groups.get(row.model) ?? [], row]);
30696
+ const merged = [];
30697
+ for (const [model, rows] of groups) {
30698
+ const partial = [];
30699
+ const sum = (key) => {
30700
+ const reported = rows.flatMap((row) => row.usage[key] === void 0 ? [] : [row.usage[key]]);
30701
+ if (reported.length === 0) return void 0;
30702
+ if (reported.length < rows.length) partial.push(key);
30703
+ return reported.reduce((total, value) => total + value, 0);
30704
+ };
30705
+ const cacheReadTokens = sum("cacheReadTokens");
30706
+ const cacheWriteTokens = sum("cacheWriteTokens");
30707
+ const reasoningTokens = sum("reasoningTokens");
30708
+ merged.push({
30709
+ model,
30710
+ turns: rows.length,
30711
+ uncachedInputTokens: rows.reduce((sum, row) => sum + row.usage.uncachedInputTokens, 0),
30712
+ outputTokens: rows.reduce((sum, row) => sum + row.usage.outputTokens, 0),
30713
+ totalTokens: rows.reduce((sum, row) => sum + row.usage.totalTokens, 0),
30714
+ cacheReadTokens,
30715
+ cacheWriteTokens,
30716
+ reasoningTokens,
30717
+ partial
30718
+ });
30719
+ }
30720
+ return merged.sort((left, right) => left.model === "" ? 1 : right.model === "" ? -1 : right.totalTokens - left.totalTokens);
30721
+ }
30722
+ /** Cut one styled line to a column budget, keeping every style it fits. */
30723
+ function boundLine(line, width) {
30724
+ const segments = [];
30725
+ let left = width;
30726
+ for (const segment of line.segments) {
30727
+ if (left <= 0) break;
30728
+ const columns = visibleColumns(segment.text);
30729
+ if (columns <= left) {
30730
+ segments.push(segment);
30731
+ left -= columns;
30732
+ continue;
30733
+ }
30734
+ segments.push(lineSegment(truncateColumns(segment.text, left), segment.style));
30735
+ left = 0;
30736
+ }
30737
+ return { segments };
30738
+ }
30739
+ /** A block heading. */
30740
+ function heading(text) {
30741
+ return { segments: [lineSegment(text, "accentBold")] };
30742
+ }
30743
+ /** A dim explanatory or empty-state row. */
30744
+ function note(text) {
30745
+ return { segments: [lineSegment(" " + text, "dim")] };
30552
30746
  }
30553
30747
  /**
30554
- * Compose the context group: the proportional bar plus the usage readout
30555
- * OUTSIDE the bar, so the dotted track keeps its proportional meaning no
30556
- * matter how wide the readout is. `full` reads `12.3K/1.0M 25%`; `percent`
30557
- * drops the absolute pair; `none` is the bare bar. The readout turns amber
30558
- * once occupancy reaches the warning threshold.
30748
+ * Cache-hit share of one row's billed prompt side, or undefined when the row
30749
+ * has no cache read to report. An unreported cache-write bucket counts as
30750
+ * zero: the adapters that bill cache writes always report the bucket, so its
30751
+ * absence means the route never tracked one the same reading the status
30752
+ * bar's own fold uses.
30559
30753
  */
30560
- function contextGroupSpans(usedTokens, contextWindow, barWidth, readout) {
30561
- const spans = [{
30562
- text: t("status.label.context") + " ",
30563
- tone: "label"
30564
- }];
30565
- spans.push(...contextBar(usedTokens, contextWindow, barWidth));
30566
- if (readout === "none" || barWidth <= 0 || contextWindow <= 0) return spans;
30567
- const used = Math.max(0, usedTokens);
30568
- const percent = Math.round(used / contextWindow * 100);
30569
- const text = readout === "full" ? `${formatTokens(used)}/${formatTokens(contextWindow)} ${percent}%` : `${percent}%`;
30570
- spans.push({
30571
- text: " ",
30572
- tone: "label"
30573
- }, {
30574
- text,
30575
- tone: percent >= CONTEXT_WARN_PERCENT ? "warn" : "value"
30576
- });
30577
- return spans;
30754
+ function hitShare(row) {
30755
+ if (row.cacheReadTokens === void 0) return void 0;
30756
+ const billed = row.uncachedInputTokens + row.cacheReadTokens + (row.cacheWriteTokens ?? 0);
30757
+ if (billed === 0) return void 0;
30758
+ return Math.round(row.cacheReadTokens / billed * 1e3) / 10;
30578
30759
  }
30579
- /** The full item catalog in canonical order (the /statusline default). */
30580
- const STATUS_ITEMS = [
30581
- {
30582
- id: "model",
30583
- label: "model",
30584
- description: "provider/model serving this session",
30585
- side: "left"
30586
- },
30587
- {
30588
- id: "cwd",
30589
- label: "cwd",
30590
- description: "working-directory basename",
30591
- side: "left"
30592
- },
30593
- {
30594
- id: "mode",
30595
- label: "mode",
30596
- description: "agent preset composing the session",
30597
- side: "left"
30598
- },
30599
- {
30600
- id: "branch",
30601
- label: "branch",
30602
- description: "git branch inside a repository",
30603
- side: "left"
30604
- },
30605
- {
30606
- id: "context",
30607
- label: "context",
30608
- description: "context-window occupancy meter",
30609
- side: "left"
30610
- },
30611
- {
30612
- id: "permission",
30613
- label: "permission",
30614
- description: "permission preset badge with cycle hint",
30615
- side: "right"
30616
- },
30617
- {
30618
- id: "plan",
30619
- label: "plan",
30620
- description: "plan-mode state mark",
30621
- side: "left"
30622
- },
30760
+ /** The token columns, in display order (shared by both tables). */
30761
+ /** True when the row's bucket is a floor rather than a complete figure. */
30762
+ function isPartial(row, key) {
30763
+ return row.partial?.includes(key) === true;
30764
+ }
30765
+ const BUCKET_COLUMNS = [
30623
30766
  {
30624
- id: "turns",
30625
- label: "turns",
30626
- description: "turn and step counters",
30627
- side: "left"
30767
+ label: () => t("panel.usage.colTotal"),
30768
+ value: (row) => formatTokens(row.totalTokens),
30769
+ width: 9
30628
30770
  },
30629
30771
  {
30630
- id: "durations",
30631
- label: "durations",
30632
- description: "llm/ttft/decode/tool wall time",
30633
- side: "left"
30772
+ label: () => t("panel.usage.colUncached"),
30773
+ value: (row) => formatTokens(row.uncachedInputTokens),
30774
+ width: 9
30634
30775
  },
30635
30776
  {
30636
- id: "cache",
30637
- label: "cache",
30638
- description: "cache-hit share of billed input",
30639
- side: "left"
30777
+ label: () => t("panel.usage.colCacheRead"),
30778
+ value: (row) => optionalTokens(row.cacheReadTokens, isPartial(row, "cacheReadTokens")),
30779
+ width: 9
30640
30780
  },
30641
30781
  {
30642
- id: "tokens",
30643
- label: "tokens",
30644
- description: "cumulative input/output tokens",
30645
- side: "left"
30782
+ label: () => t("panel.usage.colCacheHit"),
30783
+ value: (row) => percent(hitShare(row), isPartial(row, "cacheReadTokens") || isPartial(row, "cacheWriteTokens")),
30784
+ width: 8
30646
30785
  },
30647
30786
  {
30648
- id: "title",
30649
- label: "title",
30650
- description: "session title or short id",
30651
- side: "left"
30787
+ label: () => t("panel.usage.colCacheWrite"),
30788
+ value: (row) => optionalTokens(row.cacheWriteTokens, isPartial(row, "cacheWriteTokens")),
30789
+ width: 9
30652
30790
  },
30653
30791
  {
30654
- id: "goal",
30655
- label: "goal",
30656
- description: "live goal phase and round progress",
30657
- side: "left"
30792
+ label: () => t("panel.usage.colOut"),
30793
+ value: (row) => formatTokens(row.outputTokens),
30794
+ width: 9
30658
30795
  },
30659
30796
  {
30660
- id: "sandbox",
30661
- label: "sandbox",
30662
- description: "divergent sandbox-mode override",
30663
- side: "left"
30797
+ label: () => t("panel.usage.colThink"),
30798
+ value: (row) => optionalTokens(row.reasoningTokens, isPartial(row, "reasoningTokens")),
30799
+ width: 9
30664
30800
  }
30665
30801
  ];
30666
- const STATUSLINE_ITEM_KEYS = {
30667
- model: {
30668
- label: "statusline.item.model",
30669
- description: "statusline.item.model.desc"
30670
- },
30671
- cwd: {
30672
- label: "statusline.item.cwd",
30673
- description: "statusline.item.cwd.desc"
30674
- },
30675
- mode: {
30676
- label: "statusline.item.mode",
30677
- description: "statusline.item.mode.desc"
30678
- },
30679
- branch: {
30680
- label: "statusline.item.branch",
30681
- description: "statusline.item.branch.desc"
30682
- },
30683
- context: {
30684
- label: "statusline.item.context",
30685
- description: "statusline.item.context.desc"
30686
- },
30687
- permission: {
30688
- label: "statusline.item.permission",
30689
- description: "statusline.item.permission.desc"
30690
- },
30691
- plan: {
30692
- label: "statusline.item.plan",
30693
- description: "statusline.item.plan.desc"
30694
- },
30695
- turns: {
30696
- label: "statusline.item.turns",
30697
- description: "statusline.item.turns.desc"
30698
- },
30699
- durations: {
30700
- label: "statusline.item.durations",
30701
- description: "statusline.item.durations.desc"
30702
- },
30703
- cache: {
30704
- label: "statusline.item.cache",
30705
- description: "statusline.item.cache.desc"
30706
- },
30707
- tokens: {
30708
- label: "statusline.item.tokens",
30709
- description: "statusline.item.tokens.desc"
30710
- },
30711
- title: {
30712
- label: "statusline.item.title",
30713
- description: "statusline.item.title.desc"
30714
- },
30715
- goal: {
30716
- label: "statusline.item.goal",
30717
- description: "statusline.item.goal.desc"
30718
- },
30719
- sandbox: {
30720
- label: "statusline.item.sandbox",
30721
- description: "statusline.item.sandbox.desc"
30722
- }
30723
- };
30724
- /** Picker rows with labels/descriptions in the active interface language. */
30725
- function localizedStatusItems() {
30726
- return STATUS_ITEMS.map((item) => {
30727
- const keys = STATUSLINE_ITEM_KEYS[item.id];
30728
- return {
30729
- ...item,
30730
- label: t(keys.label),
30731
- description: t(keys.description)
30732
- };
30733
- });
30802
+ /** A percentage, or an em dash when the row cannot prove one; `+` for a floor. */
30803
+ function percent(value, partial = false) {
30804
+ return value === void 0 ? "—" : value + "%" + (partial ? "+" : "");
30734
30805
  }
30735
30806
  /**
30736
- * Default order: the whole catalog (matches the pre-customization bar).
30737
- * The busy dot is not an item it always leads the identity cluster.
30807
+ * A bucket as a table cell: an em dash when nothing reported it, and a `+`
30808
+ * when the figure counts only the turns of a group that did report it.
30738
30809
  */
30739
- const DEFAULT_STATUSLINE_ITEMS = STATUS_ITEMS.map((item) => item.id);
30810
+ function optionalTokens(value, partial = false) {
30811
+ if (value === void 0) return "—";
30812
+ return formatTokens(value) + (partial ? "+" : "");
30813
+ }
30814
+ /** What an unattributed turn or group is called in the tables. */
30815
+ function modelLabel(model) {
30816
+ return model === "" ? t("panel.usage.unknownModel") : model;
30817
+ }
30740
30818
  /**
30741
- * Parse a persisted statusline item list. The stored value is the ordered
30742
- * set of ENABLED items (the Codex /statusline contract): unknown ids and
30743
- * duplicates drop out, and a non-array value (missing or corrupt file)
30744
- * falls back to the full default set. An explicitly empty array is valid —
30745
- * the bar degrades to its busy dot alone.
30746
- * @param value - the raw parsed JSON value (expected string[]).
30747
- * @returns the normalized ordered item list.
30819
+ * The model a turn's assistant messages came from: the one that produced most
30820
+ * of them, with a tie naming every model involved. This is the fallback for
30821
+ * turns whose meter `routes` were withheld, and it reads only what the log
30822
+ * recorded nothing is inferred from the current selection.
30748
30823
  */
30749
- function parseStatuslineItems(value) {
30750
- if (!Array.isArray(value)) return [...DEFAULT_STATUSLINE_ITEMS];
30751
- const known = new Set(STATUS_ITEMS.map((item) => item.id));
30752
- const kept = [];
30753
- for (const entry of value) if (typeof entry === "string" && known.has(entry) && !kept.includes(entry)) kept.push(entry);
30754
- return kept;
30824
+ function dominantModel(events) {
30825
+ const counts = /* @__PURE__ */ new Map();
30826
+ for (const event of events) {
30827
+ if (event.type !== "assistant/message") continue;
30828
+ const source = event.data.message.source;
30829
+ if (source.kind !== "model" || source.model === "") continue;
30830
+ counts.set(source.model, (counts.get(source.model) ?? 0) + 1);
30831
+ }
30832
+ if (counts.size === 0) return "";
30833
+ const most = Math.max(...counts.values());
30834
+ return [...counts.entries()].filter(([, count]) => count === most).map(([model]) => model).sort().join(" + ");
30835
+ }
30836
+ /** The model attribution for one turn: the meter's routes, else its messages. */
30837
+ function turnModel(slice, usage) {
30838
+ const routes = usage.routes ?? [];
30839
+ if (routes.length > 0) return routes.map((route) => route.model).join(" + ");
30840
+ return dominantModel(slice.events);
30755
30841
  }
30756
- /** Minimum blank gap kept between the leading and trailing sides. */
30757
- const LEFT_RIGHT_GAP = 2;
30758
- /** Column held back so Ink/yoga measurement drift can never force a wrap. */
30759
- const WIDTH_SAFETY = 1;
30760
- /** Column budget for the session title before it ellipsizes. */
30761
- const TITLE_BUDGET = 48;
30762
30842
  /**
30763
- * Primary-row drop ranks: identity never drops; permission outranks context.
30764
- * The drop ladder peels hint and trailing identity facts before removing
30765
- * the context meter, so occupancy stays visible on a typical 120-column
30766
- * terminal. Secondary-row groups reuse the remaining ranks independently.
30843
+ * Render one table: a dim header plus one row per entry. The free-text cell
30844
+ * (the model name) keeps a readable share of the width, so trailing numeric
30845
+ * columns drop first on a narrow terminal instead of leaving an ellipsised
30846
+ * model name the name is what the reader came for.
30847
+ * @param columns - the numeric columns, most important first.
30848
+ * @param rows - one entry per line, already in display order.
30849
+ * @param text - how to read the free-text cell of one entry.
30850
+ * @param width - the column budget.
30851
+ * @param textFirst - true puts the free-text cell before the numbers (the
30852
+ * model-group table); false appends it (the per-turn table).
30853
+ * @returns the header row plus one row per entry.
30767
30854
  */
30768
- const RANK_TITLE = 10;
30769
- const RANK_TOKENS = 50;
30770
- const RANK_COUNTS = 90;
30771
- const RANK_CONTEXT = 90;
30772
- const RANK_SANDBOX = 92;
30773
- const RANK_GOAL = 95;
30774
- const RANK_BADGE = 100;
30775
- const RANK_IDENTITY = Number.POSITIVE_INFINITY;
30776
- /** Row 2 drop ranks: title and durations go first; state and counts survive longest. */
30777
- const RANK2_DURATIONS = 40;
30778
- const RANK2_CACHE = 50;
30779
- const RANK2_PLAN = 70;
30855
+ function table(columns, rows, text, width, textFirst) {
30856
+ const budget = width - 2;
30857
+ const minText = 14;
30858
+ const kept = [];
30859
+ let used = 0;
30860
+ for (const column of columns) {
30861
+ if (used + column.width > budget - minText - 1) break;
30862
+ kept.push(column);
30863
+ used += column.width;
30864
+ }
30865
+ const textWidth = Math.min(24, Math.max(6, budget - used - 1));
30866
+ const numbers = (row) => kept.map((column) => padColumns(column.value(row), column.width)).join("");
30867
+ const header = kept.map((column) => padColumns(column.label(), column.width)).join("");
30868
+ const lines = [{ segments: [lineSegment(truncateColumns(" " + (textFirst ? padColumns(t("panel.usage.colModel"), textWidth) + " " + header : header + " " + t("panel.usage.colModel")), width), "dim")] }];
30869
+ for (const row of rows) {
30870
+ const label = truncateColumns(text(row), textWidth);
30871
+ lines.push({ segments: [lineSegment(" " + (textFirst ? padColumns(label, textWidth) + " " + numbers(row) : numbers(row) + " " + label), "plain")] });
30872
+ }
30873
+ return lines;
30874
+ }
30780
30875
  /**
30781
- * Traffic-light tone for a permission preset: read-only stays success green,
30782
- * full access reads error red, and every workspace-scoped middle ground
30783
- * (including unknown presets) reads warning amber.
30784
- * @param permission - active permission preset label.
30785
- * @returns tone for the badge span.
30876
+ * Render the panel body.
30877
+ * @param view - the projection totals plus the derived per-turn rows.
30878
+ * @param columns - usable content columns inside the panel border.
30879
+ * @returns bounded, styled rows ready to draw.
30786
30880
  */
30787
- function permissionTone(permission) {
30788
- const label = permission.toLowerCase();
30789
- if (label.includes("read")) return "success";
30790
- if (label.includes("danger") || label.includes("full")) return "error";
30791
- return "warn";
30881
+ function usageLines(view, columns) {
30882
+ const width = Math.max(8, Math.floor(columns));
30883
+ const lines = [];
30884
+ lines.push(heading(t("panel.usage.totals")));
30885
+ const totals = view.totals;
30886
+ if (totals === void 0) lines.push(note(t("panel.usage.unavailable")));
30887
+ else {
30888
+ const label = 12;
30889
+ lines.push(pairLine(t("panel.usage.uncached"), formatTokens(totals.uncachedInputTokens), label));
30890
+ lines.push(pairLine(t("panel.usage.cacheWrite"), formatTokens(totals.cacheWriteTokens), label));
30891
+ lines.push(pairLine(t("panel.usage.cacheRead"), formatTokens(totals.cacheReadTokens), label));
30892
+ lines.push(pairLine(t("panel.usage.output"), formatTokens(totals.outputTokens), label));
30893
+ lines.push(pairLine(t("panel.usage.total"), formatTokens(usageTotalTokens(totals)), label, "bold"));
30894
+ const hit = usageCacheHitPercent(totals);
30895
+ if (hit !== null) lines.push(pairLine(t("panel.usage.cacheHit"), hit + "%", label));
30896
+ }
30897
+ lines.push({ segments: [] });
30898
+ const models = modelTotals(view.turns);
30899
+ lines.push(heading(models.length === 0 ? t("panel.usage.byModel") : `${t("panel.usage.byModel")} · ${models.length}`));
30900
+ if (models.length === 0) lines.push(note(t("panel.usage.noTurns")));
30901
+ else {
30902
+ lines.push(...table([{
30903
+ label: () => t("panel.usage.colTurns"),
30904
+ value: (row) => String(row.turns),
30905
+ width: 7
30906
+ }, ...BUCKET_COLUMNS.map((column) => ({
30907
+ ...column,
30908
+ value: (row) => column.value(row)
30909
+ }))], models, (row) => modelLabel(row.model), width, true));
30910
+ if (models.some((row) => row.partial.length > 0)) lines.push(note(t("panel.usage.partialNote")));
30911
+ }
30912
+ lines.push({ segments: [] });
30913
+ lines.push(heading(`${t("panel.usage.turns")} · ${view.turns.length}`));
30914
+ if (view.turns.length === 0) lines.push(note(t("panel.usage.noTurns")));
30915
+ else {
30916
+ const withTurn = [{
30917
+ label: () => t("panel.usage.colTurn"),
30918
+ value: (row) => `#${row.turn}`,
30919
+ width: 7
30920
+ }, ...BUCKET_COLUMNS.map((column) => ({
30921
+ ...column,
30922
+ value: (row) => column.value(row.usage)
30923
+ }))];
30924
+ lines.push(...table(withTurn, [...view.turns].reverse(), (row) => modelLabel(row.model), width, false));
30925
+ }
30926
+ return lines.map((line) => boundLine(line, width));
30792
30927
  }
30793
- /** Display-safe external text: one row, controls escaped. */
30794
- function safe(text) {
30795
- return singleLineText(text);
30928
+ //#endregion
30929
+ //#region src/render/editor.ts
30930
+ /**
30931
+ * Pure composer editor model with Codex textarea semantics: a grapheme
30932
+ * cursor over a column-safe multiline layout, word/piece motion, single-entry
30933
+ * kill + yank, and the shell-recall boundary gate that keeps Up/Down usable
30934
+ * inside a multiline draft.
30935
+ *
30936
+ * The model is intentionally string-offset based (UTF-16 indices clamped to
30937
+ * grapheme boundaries) so the React state stays two primitives
30938
+ * (value, cursor) and every operation here stays pure and testable.
30939
+ *
30940
+ * Word motion follows Codex's piece semantics: whitespace separates runs,
30941
+ * punctuation runs stay atomic, and each Han grapheme is its own boundary.
30942
+ *
30943
+ * @module @deepseek-ai/dsh-code/render/editor
30944
+ */
30945
+ const segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter("en", { granularity: "grapheme" }) : void 0;
30946
+ /**
30947
+ * Split text into grapheme clusters. Falls back to code points when
30948
+ * Intl.Segmenter is unavailable; the fallback still keeps surrogate pairs
30949
+ * (emoji) atomic so the cursor can never split one.
30950
+ */
30951
+ function splitGraphemes(text) {
30952
+ if (text === "") return [];
30953
+ const spans = [];
30954
+ if (segmenter !== void 0) {
30955
+ for (const piece of segmenter.segment(text)) spans.push({
30956
+ text: piece.segment,
30957
+ start: piece.index,
30958
+ end: piece.index + piece.segment.length,
30959
+ width: visibleColumns(piece.segment)
30960
+ });
30961
+ return spans;
30962
+ }
30963
+ let start = 0;
30964
+ for (const char of text) {
30965
+ spans.push({
30966
+ text: char,
30967
+ start,
30968
+ end: start + char.length,
30969
+ width: visibleColumns(char)
30970
+ });
30971
+ start += char.length;
30972
+ }
30973
+ return spans;
30796
30974
  }
30797
- /** Dim junction separator span inside a cluster. */
30798
- function sep() {
30799
- return {
30800
- text: " · ",
30801
- tone: "label"
30802
- };
30975
+ /** Round one UTF-16 offset down to the grapheme boundary at or before it. */
30976
+ function floorBoundary(spans, offset) {
30977
+ for (let index = spans.length - 1; index >= 0; index -= 1) {
30978
+ const span = spans[index];
30979
+ if (span.end <= offset) return span.end;
30980
+ if (span.start < offset) return span.start;
30981
+ }
30982
+ return 0;
30803
30983
  }
30804
- /** True for the dim ` · ` that joins identity facts. */
30805
- function isIdentitySep(span) {
30806
- return span.text === " · " && span.tone === "label";
30984
+ /** Clamp a cursor offset to the nearest grapheme boundary (surrogates, ZWJ, marks stay whole). */
30985
+ function clampCursor(value, offset) {
30986
+ if (value === "") return 0;
30987
+ const target = Math.max(0, Math.min(value.length, Math.floor(offset)));
30988
+ if (target === 0 || target === value.length) return target;
30989
+ const spans = splitGraphemes(value);
30990
+ const down = floorBoundary(spans, target);
30991
+ if (down === target) return target;
30992
+ const up = spans.find((span) => span.start >= target)?.start ?? value.length;
30993
+ return up - target < target - down ? up : down;
30807
30994
  }
30808
30995
  /**
30809
- * Drop the trailing identity fact (branch, then mode, then cwd) so the
30810
- * context meter can keep a column. Returns undefined once only the busy
30811
- * dot and model remain.
30996
+ * Delete the final grapheme cluster (append-only drafts without a cursor).
30997
+ * Surrogate pairs and multi-codepoint emoji stay whole instead of leaving a
30998
+ * lone trailing code unit behind.
30812
30999
  */
30813
- function peelIdentityFact(spans) {
30814
- if (spans.length < 3) return void 0;
30815
- const last = spans[spans.length - 1];
30816
- const before = spans[spans.length - 2];
30817
- const modeSep = spans[spans.length - 3];
30818
- if (last?.tone === "accent" && modeSep !== void 0 && isIdentitySep(modeSep) && before?.tone === "label") return spans.slice(0, -3);
30819
- if ((last?.tone === "branch" || last?.tone === "path") && before !== void 0 && isIdentitySep(before)) return spans.slice(0, -2);
31000
+ function deleteLastGrapheme(text) {
31001
+ if (text === "") return "";
31002
+ const spans = splitGraphemes(text);
31003
+ return text.slice(0, spans[spans.length - 1].start);
30820
31004
  }
30821
- /** Total visible columns of a span list (separators ride inside the spans). */
30822
- function spansWidth(spans) {
30823
- let width = 0;
30824
- for (const span of spans) width += visibleColumns(span.text);
30825
- return width;
31005
+ /**
31006
+ * Step the cursor by whole graphemes (negative steps left). The cursor is
31007
+ * assumed to sit on a boundary; any drift is clamped first.
31008
+ */
31009
+ function moveCursorBy(value, offset, delta) {
31010
+ if (delta === 0 || value === "") return clampCursor(value, offset);
31011
+ const spans = splitGraphemes(value);
31012
+ const boundaries = [0];
31013
+ for (const span of spans) boundaries.push(span.end);
31014
+ const current = boundaries.indexOf(clampCursor(value, offset));
31015
+ if (current === -1) return clampCursor(value, offset);
31016
+ return boundaries[Math.max(0, Math.min(boundaries.length - 1, current + delta))];
30826
31017
  }
30827
- /** Join widths of parts with one fixed separator between neighbors. */
30828
- function joinWidth(parts, separator) {
30829
- if (parts.length === 0) return 0;
30830
- let width = 0;
30831
- for (const part of parts) width += part;
30832
- return width + separator * (parts.length - 1);
31018
+ /**
31019
+ * Normalize text entering the draft: CRLF/CR become LF, tabs become two
31020
+ * spaces (terminal tab stops are contextual and cannot join a deterministic
31021
+ * row budget), and every other C0 control byte plus DEL is REMOVED — the
31022
+ * draft is data, so a stray ESC (Windows Terminal file drops) disappears
31023
+ * instead of rendering as literal backslash-x-1-b text. Newlines survive.
31024
+ */
31025
+ function sanitizeDraftText(text) {
31026
+ return text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").replaceAll(" ", " ");
30833
31027
  }
30834
- /** Build every candidate group/span with its drop rank and item id. */
30835
- function buildCandidates(facts, stats, busy, enabled, contextWidth) {
30836
- const identity = [{
30837
- text: busy ? "● " : "○ ",
30838
- tone: busy ? "live" : "meta"
30839
- }];
30840
- const push = (span) => {
30841
- if (identity.length > 1) identity.push(sep());
30842
- identity.push(span);
30843
- };
30844
- const model = safe(facts.model);
30845
- if (model !== "" && enabled.has("model")) {
30846
- const effort = safe(stats.reasoningEffort);
30847
- push({
30848
- text: effort === "" ? model : `${model}@${effort}`,
30849
- tone: "model"
31028
+ /**
31029
+ * Hard-wrap the draft into column-safe physical rows. Wide graphemes never
31030
+ * split across rows (a grapheme that does not fit flushes the row first) and
31031
+ * explicit newlines end their row without occupying a cell.
31032
+ */
31033
+ function editorModel(value, columns) {
31034
+ const width = Math.max(1, Math.floor(columns));
31035
+ const spans = splitGraphemes(value);
31036
+ const rows = [];
31037
+ let text = "";
31038
+ let start = 0;
31039
+ let used = 0;
31040
+ const offsets = [0];
31041
+ const rowColumns = [0];
31042
+ const rowCuts = [0];
31043
+ const flush = (end) => {
31044
+ rows.push({
31045
+ text,
31046
+ start,
31047
+ end,
31048
+ offsets: [...offsets],
31049
+ columns: [...rowColumns],
31050
+ cuts: [...rowCuts]
30850
31051
  });
30851
- }
30852
- const cwd = safe(facts.cwd);
30853
- if (cwd !== "" && enabled.has("cwd")) push({
30854
- text: cwd,
30855
- tone: "path"
30856
- });
30857
- const mode = safe(facts.mode ?? "");
30858
- if (mode !== "" && enabled.has("mode")) {
30859
- push({
30860
- text: t("status.label.mode") + " ",
30861
- tone: "label"
30862
- });
30863
- identity.push({
30864
- text: mode,
30865
- tone: "accent"
30866
- });
30867
- }
30868
- const branch = safe(facts.branch);
30869
- if (branch !== "" && enabled.has("branch")) push({
30870
- text: "⑂ " + branch,
30871
- tone: "branch"
30872
- });
30873
- const left = [{
30874
- group: { spans: identity },
30875
- rank: RANK_IDENTITY,
30876
- id: "identity"
30877
- }];
30878
- const right = [];
30879
- const row2 = [];
30880
- if (stats.turns > 0 || stats.steps > 0) {
30881
- if (enabled.has("turns")) {
30882
- const counts = [];
30883
- const pair = (label, value) => {
30884
- if (counts.length > 0) counts.push(sep());
30885
- counts.push({
30886
- text: label + " ",
30887
- tone: "label"
30888
- }, {
30889
- text: value,
30890
- tone: "value"
30891
- });
30892
- };
30893
- pair(t("status.label.turns"), String(stats.turns));
30894
- pair(t("status.label.steps"), String(stats.steps));
30895
- row2.push({
30896
- group: { spans: counts },
30897
- rank: RANK_COUNTS,
30898
- id: "turns"
30899
- });
31052
+ text = "";
31053
+ used = 0;
31054
+ offsets.length = 0;
31055
+ rowColumns.length = 0;
31056
+ rowCuts.length = 0;
31057
+ };
31058
+ for (const span of spans) {
31059
+ if (span.text === "\n") {
31060
+ flush(span.start);
31061
+ start = span.end;
31062
+ offsets.push(span.end);
31063
+ rowColumns.push(0);
31064
+ rowCuts.push(0);
31065
+ continue;
30900
31066
  }
30901
- if (enabled.has("durations")) {
30902
- const durations = [];
30903
- const pair = (label, value) => {
30904
- if (durations.length > 0) durations.push(sep());
30905
- durations.push({
30906
- text: label + " ",
30907
- tone: "label"
30908
- }, {
30909
- text: value,
30910
- tone: "value"
30911
- });
30912
- };
30913
- if (stats.llmMs > 0) pair(t("status.label.modelTime"), formatDuration(stats.llmMs));
30914
- if (stats.ttftSteps > 0) pair(t("status.label.latency"), formatDuration(stats.ttftMs / stats.ttftSteps));
30915
- if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
30916
- if (durations.length > 0) durations.push(sep());
30917
- durations.push({
30918
- text: formatRate(stats.decodeTokens / (stats.decodeMs / 1e3)),
30919
- tone: "value"
30920
- }, {
30921
- text: t("status.label.tokensPerSec"),
30922
- tone: "label"
30923
- });
30924
- }
30925
- if (stats.toolMs > 0) pair(t("status.label.tool"), formatDuration(stats.toolMs));
30926
- if (durations.length > 0) row2.push({
30927
- group: { spans: durations },
30928
- rank: RANK2_DURATIONS,
30929
- id: "durations"
30930
- });
31067
+ if (used > 0 && used + span.width > width) {
31068
+ flush(span.start);
31069
+ start = span.start;
31070
+ offsets.push(span.start);
31071
+ rowColumns.push(0);
31072
+ rowCuts.push(0);
30931
31073
  }
31074
+ text += span.text;
31075
+ used += span.width;
31076
+ offsets.push(span.end);
31077
+ rowColumns.push(used);
31078
+ rowCuts.push(text.length);
30932
31079
  }
30933
- const cacheHit = cacheHitPercent(stats.usage);
30934
- if (cacheHit !== null && enabled.has("cache")) {
30935
- const spans = [{
30936
- text: t("status.label.cache") + " ",
30937
- tone: "label"
30938
- }];
30939
- if (stats.usage.cacheReadTokens > 0) spans.push({
30940
- text: formatTokens(stats.usage.cacheReadTokens),
30941
- tone: "value"
30942
- }, sep());
30943
- spans.push({
30944
- text: cacheHit + "%",
30945
- tone: "value"
30946
- });
30947
- row2.push({
30948
- group: { spans },
30949
- rank: RANK2_CACHE,
30950
- id: "cache"
30951
- });
30952
- }
30953
- if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has("context")) left.push({
30954
- group: { spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, "full") },
30955
- rank: RANK_CONTEXT,
30956
- id: "context"
31080
+ if (text !== "" || rows.length === 0) flush(value.length);
31081
+ else if (offsets.length > 0) rows.push({
31082
+ text: "",
31083
+ start,
31084
+ end: value.length,
31085
+ offsets: [...offsets],
31086
+ columns: [...rowColumns],
31087
+ cuts: [...rowCuts]
30957
31088
  });
30958
- if ((stats.usage.uncachedInputTokens > 0 || stats.usage.outputTokens > 0) && enabled.has("tokens")) {
30959
- const tokens = [];
30960
- const pair = (label, value) => {
30961
- if (tokens.length > 0) tokens.push(sep());
30962
- tokens.push({
30963
- text: label + " ",
30964
- tone: "label"
30965
- }, {
30966
- text: value,
30967
- tone: "value"
30968
- });
31089
+ return {
31090
+ rows,
31091
+ length: value.length
31092
+ };
31093
+ }
31094
+ /** Split one row around the authoritative caret; every other row stays whole. */
31095
+ function editorRowParts(row, rowIndex, caretRow, cursor, caretEnabled = true) {
31096
+ if (!caretEnabled || rowIndex !== caretRow) return {
31097
+ before: "",
31098
+ caret: "",
31099
+ after: row.text,
31100
+ hasCaret: false
31101
+ };
31102
+ const at = row.offsets.indexOf(cursor);
31103
+ if (at < 0) return {
31104
+ before: "",
31105
+ caret: "",
31106
+ after: row.text,
31107
+ hasCaret: false
31108
+ };
31109
+ return {
31110
+ before: at > 0 ? row.text.slice(0, row.cuts[at]) : "",
31111
+ caret: at < row.cuts.length - 1 ? row.text.slice(row.cuts[at], row.cuts[at + 1]) : " ",
31112
+ after: at < row.cuts.length - 1 ? row.text.slice(row.cuts[at + 1]) : "",
31113
+ hasCaret: true
31114
+ };
31115
+ }
31116
+ /** Map a cursor offset to its caret site on the wrapped rows. */
31117
+ function caretSite(model, offset) {
31118
+ const target = Math.max(0, Math.min(model.length, offset));
31119
+ for (let index = model.rows.length - 1; index >= 0; index -= 1) {
31120
+ const row = model.rows[index];
31121
+ const at = row.offsets.indexOf(target);
31122
+ if (at >= 0) return {
31123
+ row: index,
31124
+ column: row.columns[at]
30969
31125
  };
30970
- pair(t("status.label.in"), formatTokens(stats.usage.uncachedInputTokens));
30971
- pair(t("status.label.out"), formatTokens(stats.usage.outputTokens));
30972
- row2.push({
30973
- group: { spans: tokens },
30974
- rank: RANK_TOKENS,
30975
- id: "tokens"
30976
- });
30977
- }
30978
- const label = truncateColumns(safe(facts.title !== void 0 && facts.title !== "" ? facts.title : facts.sessionId), TITLE_BUDGET);
30979
- if (label !== "" && enabled.has("title")) row2.push({
30980
- group: { spans: [{
30981
- text: label,
30982
- tone: "meta"
30983
- }] },
30984
- rank: RANK_TITLE,
30985
- id: "title"
30986
- });
30987
- if (facts.goal !== void 0 && enabled.has("goal")) row2.push({
30988
- group: { spans: [{
30989
- text: facts.goal.phase === "active" ? t("status.goal.round", {
30990
- current: facts.goal.rounds,
30991
- max: facts.goal.max
30992
- }) : t("status.goal.phase", { phase: safe(facts.goal.phase) }),
30993
- tone: "accent"
30994
- }] },
30995
- rank: RANK_GOAL,
30996
- id: "goal"
30997
- });
30998
- const sandbox = safe(facts.sandbox ?? "");
30999
- if (sandbox !== "" && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has("sandbox")) row2.push({
31000
- group: { spans: [{
31001
- text: t("status.label.sandbox") + " " + sandbox,
31002
- tone: "warn"
31003
- }] },
31004
- rank: RANK_SANDBOX,
31005
- id: "sandbox"
31006
- });
31007
- const permission = safe(facts.permission);
31008
- let badge = -1;
31009
- const planStation = facts.plan && permissionTone(permission) === "success";
31010
- if (permission !== "" && enabled.has("permission")) {
31011
- right.push({
31012
- span: planStation ? {
31013
- text: t("status.plan.on"),
31014
- tone: "plan"
31015
- } : {
31016
- text: permission,
31017
- tone: permissionTone(permission)
31018
- },
31019
- rank: RANK_BADGE,
31020
- id: "permission"
31021
- });
31022
- badge = right.length - 1;
31023
31126
  }
31024
- if (facts.plan && enabled.has("plan")) row2.push({
31025
- group: { spans: [{
31026
- text: t("status.plan.mark"),
31027
- tone: "accent"
31028
- }] },
31029
- rank: RANK2_PLAN,
31030
- id: "plan"
31031
- });
31127
+ const last = model.rows[model.rows.length - 1];
31032
31128
  return {
31033
- left,
31034
- right,
31035
- badge,
31036
- row2
31129
+ row: model.rows.length - 1,
31130
+ column: last === void 0 ? 0 : last.columns[last.columns.length - 1]
31037
31131
  };
31038
31132
  }
31039
31133
  /**
31040
- * Compose the two-row footer layout under a column budget. Row 1 keeps model,
31041
- * cwd, mode, branch, context, then the right-pinned permission badge and cycle
31042
- * hint. It shrinks the context bar, drops the hint, and peels trailing
31043
- * identity facts before removing the context group or the permission badge.
31044
- * Row 2 fits all secondary figures and state within its own budget.
31045
- * @param facts - identity facts resolved by the runner.
31046
- * @param stats - session figures folded from the durable log.
31047
- * @param columns - usable columns for each row (before their left padding).
31048
- * @param options - 'busy' hides the cycle hint while a turn runs (Codex
31049
- * keeps mode hints idle-only); 'items' is the ordered enabled-item config
31050
- * from /statusline (defaults to the full catalog). Display order follows the
31051
- * config per side while the drop ladder keeps its fixed ranks.
31052
- * @returns the two rows to render; row1.left is never empty.
31134
+ * Move the caret across physical rows keeping a preferred display column
31135
+ * (Codex `preferred_col`): horizontal moves reset the preference, vertical
31136
+ * moves reuse it, clamped to each row's width.
31053
31137
  */
31054
- function layoutStatusBar(facts, stats, columns, options = {}) {
31055
- const busy = options.busy === true;
31056
- const items = options.items ?? DEFAULT_STATUSLINE_ITEMS;
31057
- const enabled = new Set(items);
31058
- const budget = Math.max(1, Math.floor(columns) - WIDTH_SAFETY);
31059
- const maxContextWidth = Math.max(CONTEXT_MIN_WIDTH, Math.min(budget, Math.floor(options.contextWidth ?? 24)));
31060
- const { left, right, badge, row2 } = buildCandidates(facts, stats, busy, enabled, maxContextWidth);
31061
- const groupSeparator = visibleColumns(" | ");
31062
- const itemSeparator = visibleColumns(STATUS_ITEM_SEPARATOR);
31063
- const position = new Map(items.map((id, index) => [id, index]));
31064
- const byPosition = (a, b) => (position.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (position.get(b.id) ?? Number.MAX_SAFE_INTEGER);
31065
- const orderedLeft = [left[0], ...left.slice(1).sort(byPosition)];
31066
- const orderedRight = right.slice().sort(byPosition);
31067
- const orderedRow2 = row2.slice().sort(byPosition);
31068
- let hint = badge >= 0 && !busy;
31069
- const leftKept = [...orderedLeft];
31070
- const rightKept = [...orderedRight];
31071
- let contextReadout = "full";
31072
- let contextWidth = maxContextWidth;
31073
- const rebuildContext = () => {
31074
- const index = leftKept.findIndex((entry) => entry.id === "context");
31075
- if (index < 0) return;
31076
- leftKept[index] = {
31077
- group: { spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, contextReadout) },
31078
- rank: RANK_CONTEXT,
31079
- id: "context"
31080
- };
31081
- };
31082
- const width = () => {
31083
- const leftWidth = joinWidth(leftKept.map((entry) => spansWidth(entry.group.spans)), groupSeparator);
31084
- const rightWidth = joinWidth(rightKept.map((entry) => visibleColumns(entry.span.text)), itemSeparator) + (hint ? visibleColumns(statusCycleHint()) : 0);
31085
- return rightWidth > 0 ? leftWidth + LEFT_RIGHT_GAP + rightWidth : leftWidth;
31138
+ function moveCursorVertically(model, offset, preferredColumn, delta) {
31139
+ const target = caretSite(model, offset).row + delta;
31140
+ if (delta === 0) return offset;
31141
+ if (target < 0) return 0;
31142
+ if (target >= model.rows.length) return model.length;
31143
+ const row = model.rows[target];
31144
+ const wanted = Math.max(0, Math.min(preferredColumn, row.columns[row.columns.length - 1]));
31145
+ let best = 0;
31146
+ for (let index = 1; index < row.columns.length; index += 1) if (row.columns[index] <= wanted) best = index;
31147
+ else break;
31148
+ return row.offsets[best];
31149
+ }
31150
+ /** The start/end offsets of the logical line containing the cursor. */
31151
+ function lineBounds(value, offset) {
31152
+ const target = Math.max(0, Math.min(value.length, offset));
31153
+ const start = value.lastIndexOf("\n", Math.max(0, target - 1)) + 1;
31154
+ const end = value.indexOf("\n", target);
31155
+ return {
31156
+ start,
31157
+ end: end === -1 ? value.length : end
31086
31158
  };
31087
- while (width() > budget) {
31088
- if (leftKept.some((entry) => entry.id === "context")) {
31089
- if (contextWidth > CONTEXT_MIN_WIDTH) {
31090
- const overflow = width() - budget;
31091
- contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow);
31092
- rebuildContext();
31093
- continue;
31094
- }
31095
- if (contextReadout === "full") {
31096
- contextReadout = "percent";
31097
- rebuildContext();
31098
- continue;
31099
- }
31100
- }
31101
- if (hint) {
31102
- hint = false;
31103
- continue;
31104
- }
31105
- const identityEntry = leftKept[0];
31106
- if (identityEntry?.id === "identity") {
31107
- const peeled = peelIdentityFact(identityEntry.group.spans);
31108
- if (peeled !== void 0) {
31109
- leftKept[0] = {
31110
- ...identityEntry,
31111
- group: { spans: peeled }
31112
- };
31113
- continue;
31114
- }
31115
- }
31116
- if (leftKept.some((entry) => entry.id === "context")) {
31117
- leftKept.splice(leftKept.findIndex((entry) => entry.id === "context"), 1);
31159
+ }
31160
+ /** Codex WORD_SEPARATORS: punctuation runs are their own word pieces. */
31161
+ const WORD_SEPARATORS = /* @__PURE__ */ new Set("`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?");
31162
+ const HAN_GRAPHEME = /^\p{Script=Han}(?:\p{Mark}|\uFE0F)*$/u;
31163
+ const UNICODE_PUNCTUATION = /^\p{P}+$/u;
31164
+ function classifyGrapheme(text) {
31165
+ if (/^\s$/u.test(text)) return "space";
31166
+ return WORD_SEPARATORS.has(text) || UNICODE_PUNCTUATION.test(text) ? "punct" : "word";
31167
+ }
31168
+ /** Maximal same-class runs; Han graphemes deliberately stay one run each. */
31169
+ function pieceRuns(value) {
31170
+ const runs = [];
31171
+ let current;
31172
+ for (const span of splitGraphemes(value)) {
31173
+ const klass = span.text === "\n" ? "space" : classifyGrapheme(span.text);
31174
+ const atomic = klass === "word" && HAN_GRAPHEME.test(span.text);
31175
+ if (current !== void 0 && current.class === klass && !current.atomic && !atomic) {
31176
+ current.end = span.end;
31118
31177
  continue;
31119
31178
  }
31120
- let dropLeft = -1;
31121
- let dropRight = -1;
31122
- let dropRank = Number.POSITIVE_INFINITY;
31123
- for (let index = 0; index < leftKept.length; index += 1) {
31124
- const rank = leftKept[index].rank;
31125
- if (rank < dropRank) {
31126
- dropRank = rank;
31127
- dropLeft = index;
31128
- dropRight = -1;
31129
- }
31130
- }
31131
- for (let index = 0; index < rightKept.length; index += 1) {
31132
- const rank = rightKept[index].rank;
31133
- if (rank < dropRank) {
31134
- dropRank = rank;
31135
- dropRight = index;
31136
- dropLeft = -1;
31137
- }
31138
- }
31139
- if (dropLeft < 0 && dropRight < 0) break;
31140
- if (dropLeft >= 0) leftKept.splice(dropLeft, 1);
31141
- else {
31142
- rightKept.splice(dropRight, 1);
31143
- if (dropRight === rightKept.length) hint = false;
31144
- }
31145
- }
31146
- if (width() > budget) {
31147
- rightKept.length = 0;
31148
- hint = false;
31149
- while (leftKept.length > 1) leftKept.pop();
31150
- leftKept[0] = {
31151
- group: { spans: [{
31152
- text: truncateColumns(leftKept[0].group.spans.map((span) => span.text).join(""), budget),
31153
- tone: "model"
31154
- }] },
31155
- rank: RANK_IDENTITY,
31156
- id: "identity"
31179
+ current = {
31180
+ start: span.start,
31181
+ end: span.end,
31182
+ class: klass,
31183
+ atomic
31157
31184
  };
31185
+ runs.push(current);
31158
31186
  }
31159
- const row2Kept = [...orderedRow2];
31160
- const row2Budget = Math.max(1, budget - 2);
31161
- const row2Width = () => joinWidth(row2Kept.map((entry) => spansWidth(entry.group.spans)), groupSeparator);
31162
- while (row2Width() > row2Budget && row2Kept.length > 0) {
31163
- let dropIndex = 0;
31164
- let dropRank = Number.POSITIVE_INFINITY;
31165
- for (let index = 0; index < row2Kept.length; index += 1) if (row2Kept[index].rank < dropRank) {
31166
- dropRank = row2Kept[index].rank;
31167
- dropIndex = index;
31168
- }
31169
- row2Kept.splice(dropIndex, 1);
31170
- }
31171
- return {
31172
- row1: {
31173
- left: leftKept.map((entry) => entry.group),
31174
- right: rightKept.map((entry) => entry.span),
31175
- hint
31176
- },
31177
- row2: {
31178
- left: row2Kept.map((entry) => entry.group),
31179
- right: [],
31180
- hint: false
31181
- }
31182
- };
31183
- }
31184
- //#endregion
31185
- //#region src/render/usage.ts
31186
- /** Prompt-side tokens the provider billed: uncached input plus both cache buckets. */
31187
- function billedInputTokens(totals) {
31188
- return totals.uncachedInputTokens + totals.cacheReadTokens + totals.cacheWriteTokens;
31189
- }
31190
- /** Prompt plus completion tokens over the whole log. */
31191
- function usageTotalTokens(totals) {
31192
- return billedInputTokens(totals) + totals.outputTokens;
31193
- }
31194
- /**
31195
- * Cache-hit share of billed prompt-side input.
31196
- * @param totals - cumulative provider-reported buckets.
31197
- * @returns percent rounded to one decimal place, or null when nothing was billed.
31198
- */
31199
- function usageCacheHitPercent(totals) {
31200
- const billed = billedInputTokens(totals);
31201
- return billed === 0 ? null : Math.round(totals.cacheReadTokens / billed * 1e3) / 10;
31187
+ return runs;
31202
31188
  }
31203
31189
  /**
31204
- * Split a durable log into COMPLETE turns. A turn still running has no
31205
- * `turn/end` yet, and an unfinished attempt has no exact accounting, so the
31206
- * trailing slice is dropped rather than guessed at.
31207
- * @param events - the whole durable log, in log order.
31208
- * @returns one slice per `turn/start`…`turn/end` span, oldest first.
31190
+ * Codex `beginning_of_previous_word`: skip whitespace left, then land on the
31191
+ * START of the trailing non-space piece (extending over separator pieces).
31209
31192
  */
31210
- function completedTurns(events) {
31211
- const slices = [];
31212
- let open;
31213
- for (const event of events) {
31214
- if (event.type === "turn/start") {
31215
- open = {
31216
- turn: event.data.turn,
31217
- events: [event]
31218
- };
31219
- continue;
31220
- }
31221
- if (open === void 0) continue;
31222
- open.events.push(event);
31223
- if (event.type === "turn/end") {
31224
- slices.push(open);
31225
- open = void 0;
31226
- }
31193
+ function moveWordLeft(value, offset) {
31194
+ const cursor = clampCursor(value, offset);
31195
+ const runs = pieceRuns(value);
31196
+ let index = runs.findLastIndex((run) => run.start < cursor);
31197
+ if (index < 0) return 0;
31198
+ if (runs[index].class === "space") {
31199
+ index -= 1;
31200
+ if (index < 0) return 0;
31227
31201
  }
31228
- return slices;
31202
+ let target = index;
31203
+ while (target > 0 && runs[target].class === "punct" && runs[target - 1].class === "punct") target -= 1;
31204
+ return runs[target].start;
31229
31205
  }
31230
31206
  /**
31231
- * Exact usage for every completed turn that can be proven.
31232
- * @param events - the whole durable log, in log order.
31233
- * @param derive - the meter's per-turn fold, injected so this module stays pure.
31234
- * @returns one row per provable turn, oldest first; turns whose attempts did
31235
- * not all report usage are omitted rather than estimated, and so is a turn
31236
- * that billed nothing at all — an empty row is noise in a usage table.
31207
+ * Codex `end_of_next_word`: skip whitespace right, then land on the END of
31208
+ * the leading non-space piece (extending over separator pieces).
31237
31209
  */
31238
- function turnUsages(events, derive) {
31239
- const rows = [];
31240
- for (const slice of completedTurns(events)) {
31241
- const usage = derive(slice.events);
31242
- if (usage === void 0 || usage.totalTokens === 0) continue;
31243
- rows.push({
31244
- turn: slice.turn,
31245
- usage,
31246
- model: turnModel(slice, usage)
31247
- });
31210
+ function moveWordRight(value, offset) {
31211
+ const cursor = clampCursor(value, offset);
31212
+ const runs = pieceRuns(value);
31213
+ let index = runs.findIndex((run) => run.end > cursor);
31214
+ if (index >= runs.length) return value.length;
31215
+ if (runs[index].class === "space") {
31216
+ index += 1;
31217
+ if (index >= runs.length) return value.length;
31248
31218
  }
31249
- return rows;
31219
+ let target = index;
31220
+ while (target < runs.length - 1 && runs[target].class === "punct" && runs[target + 1].class === "punct") target += 1;
31221
+ return runs[target].end;
31250
31222
  }
31251
- /** Pad to a column budget so labels line up across terminal widths. */
31252
- function padColumns(text, columns) {
31253
- return text + " ".repeat(Math.max(0, columns - visibleColumns(text)));
31254
- }
31255
- /** One `label` + `value` row of a summary block. */
31256
- function pairLine(label, value, labelColumns, style = "plain") {
31257
- const gap = " ".repeat(Math.max(1, labelColumns - visibleColumns(label)));
31258
- return { segments: [lineSegment(" " + label + gap, "dim"), lineSegment(value, style)] };
31223
+ function replaceRange(value, cursor, start, end, killed) {
31224
+ const from = Math.min(start, end);
31225
+ const to = Math.max(start, end);
31226
+ if (from >= to) return {
31227
+ value,
31228
+ cursor,
31229
+ killed: void 0
31230
+ };
31231
+ const text = value.slice(from, to);
31232
+ return {
31233
+ value: value.slice(0, from) + value.slice(to),
31234
+ cursor: Math.max(0, cursor - Math.max(0, Math.min(cursor, to) - from)),
31235
+ killed: killed ? text : void 0
31236
+ };
31259
31237
  }
31260
- /**
31261
- * Merge the per-turn rows by the model that billed them, biggest spender
31262
- * first. A turn lands in exactly one group — the group named by every model it
31263
- * used — so the sums stay additive and a mid-turn model switch never counts
31264
- * the same tokens twice. Each bucket is merged over the turns that reported
31265
- * it, and {@link ModelUsage.partial} records the ones that stayed silent.
31266
- * @param turns - provable per-turn rows, oldest first.
31267
- * @returns one row per model group.
31268
- */
31269
- function modelTotals(turns) {
31270
- const groups = /* @__PURE__ */ new Map();
31271
- for (const row of turns) groups.set(row.model, [...groups.get(row.model) ?? [], row]);
31272
- const merged = [];
31273
- for (const [model, rows] of groups) {
31274
- const partial = [];
31275
- const sum = (key) => {
31276
- const reported = rows.flatMap((row) => row.usage[key] === void 0 ? [] : [row.usage[key]]);
31277
- if (reported.length === 0) return void 0;
31278
- if (reported.length < rows.length) partial.push(key);
31279
- return reported.reduce((total, value) => total + value, 0);
31280
- };
31281
- const cacheReadTokens = sum("cacheReadTokens");
31282
- const cacheWriteTokens = sum("cacheWriteTokens");
31283
- const reasoningTokens = sum("reasoningTokens");
31284
- merged.push({
31285
- model,
31286
- turns: rows.length,
31287
- uncachedInputTokens: rows.reduce((sum, row) => sum + row.usage.uncachedInputTokens, 0),
31288
- outputTokens: rows.reduce((sum, row) => sum + row.usage.outputTokens, 0),
31289
- totalTokens: rows.reduce((sum, row) => sum + row.usage.totalTokens, 0),
31290
- cacheReadTokens,
31291
- cacheWriteTokens,
31292
- reasoningTokens,
31293
- partial
31294
- });
31295
- }
31296
- return merged.sort((left, right) => left.model === "" ? 1 : right.model === "" ? -1 : right.totalTokens - left.totalTokens);
31238
+ /** Delete the grapheme cluster before the cursor. */
31239
+ function deleteBackward(value, cursor) {
31240
+ if (cursor === 0) return {
31241
+ value,
31242
+ cursor,
31243
+ killed: void 0
31244
+ };
31245
+ const spans = splitGraphemes(value.slice(0, cursor));
31246
+ return replaceRange(value, cursor, spans.length === 0 ? 0 : spans[spans.length - 1].start, cursor, false);
31297
31247
  }
31298
- /** Cut one styled line to a column budget, keeping every style it fits. */
31299
- function boundLine(line, width) {
31300
- const segments = [];
31301
- let left = width;
31302
- for (const segment of line.segments) {
31303
- if (left <= 0) break;
31304
- const columns = visibleColumns(segment.text);
31305
- if (columns <= left) {
31306
- segments.push(segment);
31307
- left -= columns;
31308
- continue;
31309
- }
31310
- segments.push(lineSegment(truncateColumns(segment.text, left), segment.style));
31311
- left = 0;
31312
- }
31313
- return { segments };
31248
+ /** Delete the grapheme cluster at the cursor. */
31249
+ function deleteForward(value, cursor) {
31250
+ if (cursor >= value.length) return {
31251
+ value,
31252
+ cursor,
31253
+ killed: void 0
31254
+ };
31255
+ const span = splitGraphemes(value).find((candidate) => candidate.start >= cursor);
31256
+ return replaceRange(value, cursor, cursor, span === void 0 ? value.length : span.end, false);
31314
31257
  }
31315
- /** A block heading. */
31316
- function heading(text) {
31317
- return { segments: [lineSegment(text, "accentBold")] };
31258
+ /** Delete back to the start of the previous word (fills the kill buffer). */
31259
+ function deleteWordBackward(value, cursor) {
31260
+ return replaceRange(value, cursor, moveWordLeft(value, cursor), cursor, true);
31318
31261
  }
31319
- /** A dim explanatory or empty-state row. */
31320
- function note(text) {
31321
- return { segments: [lineSegment(" " + text, "dim")] };
31262
+ /** Delete forward to the end of the next word (fills the kill buffer). */
31263
+ function deleteWordForward(value, cursor) {
31264
+ return replaceRange(value, cursor, cursor, moveWordRight(value, cursor), true);
31322
31265
  }
31323
- /**
31324
- * Cache-hit share of one row's billed prompt side, or undefined when the row
31325
- * has no cache read to report. An unreported cache-write bucket counts as
31326
- * zero: the adapters that bill cache writes always report the bucket, so its
31327
- * absence means the route never tracked one the same reading the status
31328
- * bar's own fold uses.
31329
- */
31330
- function hitShare(row) {
31331
- if (row.cacheReadTokens === void 0) return void 0;
31332
- const billed = row.uncachedInputTokens + row.cacheReadTokens + (row.cacheWriteTokens ?? 0);
31333
- if (billed === 0) return void 0;
31334
- return Math.round(row.cacheReadTokens / billed * 1e3) / 10;
31266
+ /** Ctrl+U: kill from the line start to the cursor; at BOL, kill the newline. */
31267
+ function killToLineStart(value, cursor) {
31268
+ const bounds = lineBounds(value, cursor);
31269
+ if (cursor > bounds.start) return replaceRange(value, cursor, bounds.start, cursor, true);
31270
+ if (bounds.start > 0) return replaceRange(value, cursor, bounds.start - 1, bounds.start, true);
31271
+ return {
31272
+ value,
31273
+ cursor,
31274
+ killed: void 0
31275
+ };
31335
31276
  }
31336
- /** The token columns, in display order (shared by both tables). */
31337
- /** True when the row's bucket is a floor rather than a complete figure. */
31338
- function isPartial(row, key) {
31339
- return row.partial?.includes(key) === true;
31277
+ /** Ctrl+K: kill from the cursor to the line end; at EOL, kill the newline. */
31278
+ function killToLineEnd(value, cursor) {
31279
+ const bounds = lineBounds(value, cursor);
31280
+ if (cursor < bounds.end) return replaceRange(value, cursor, cursor, bounds.end, true);
31281
+ if (bounds.end < value.length) return replaceRange(value, cursor, bounds.end, bounds.end + 1, true);
31282
+ return {
31283
+ value,
31284
+ cursor,
31285
+ killed: void 0
31286
+ };
31340
31287
  }
31341
- const BUCKET_COLUMNS = [
31342
- {
31343
- label: () => t("panel.usage.colTotal"),
31344
- value: (row) => formatTokens(row.totalTokens),
31345
- width: 9
31346
- },
31347
- {
31348
- label: () => t("panel.usage.colUncached"),
31349
- value: (row) => formatTokens(row.uncachedInputTokens),
31350
- width: 9
31351
- },
31352
- {
31353
- label: () => t("panel.usage.colCacheRead"),
31354
- value: (row) => optionalTokens(row.cacheReadTokens, isPartial(row, "cacheReadTokens")),
31355
- width: 9
31356
- },
31357
- {
31358
- label: () => t("panel.usage.colCacheHit"),
31359
- value: (row) => percent(hitShare(row), isPartial(row, "cacheReadTokens") || isPartial(row, "cacheWriteTokens")),
31360
- width: 8
31361
- },
31362
- {
31363
- label: () => t("panel.usage.colCacheWrite"),
31364
- value: (row) => optionalTokens(row.cacheWriteTokens, isPartial(row, "cacheWriteTokens")),
31365
- width: 9
31366
- },
31367
- {
31368
- label: () => t("panel.usage.colOut"),
31369
- value: (row) => formatTokens(row.outputTokens),
31370
- width: 9
31371
- },
31372
- {
31373
- label: () => t("panel.usage.colThink"),
31374
- value: (row) => optionalTokens(row.reasoningTokens, isPartial(row, "reasoningTokens")),
31375
- width: 9
31376
- }
31377
- ];
31378
- /** A percentage, or an em dash when the row cannot prove one; `+` for a floor. */
31379
- function percent(value, partial = false) {
31380
- return value === void 0 ? "—" : value + "%" + (partial ? "+" : "");
31288
+ /** Insert sanitized text at the cursor. */
31289
+ function insertText(value, cursor, text) {
31290
+ const safe = sanitizeDraftText(text);
31291
+ if (safe === "") return {
31292
+ value,
31293
+ cursor,
31294
+ killed: void 0
31295
+ };
31296
+ return {
31297
+ value: value.slice(0, cursor) + safe + value.slice(cursor),
31298
+ cursor: cursor + safe.length,
31299
+ killed: void 0
31300
+ };
31381
31301
  }
31382
- /**
31383
- * A bucket as a table cell: an em dash when nothing reported it, and a `+`
31384
- * when the figure counts only the turns of a group that did report it.
31385
- */
31386
- function optionalTokens(value, partial = false) {
31387
- if (value === void 0) return "—";
31388
- return formatTokens(value) + (partial ? "+" : "");
31302
+ /** Ctrl+A: current logical line start, then the previous line start on repeat. */
31303
+ function moveToLineStart(value, cursor, crossOnRepeat) {
31304
+ const site = clampCursor(value, cursor);
31305
+ const bounds = lineBounds(value, site);
31306
+ if (!crossOnRepeat || site !== bounds.start || bounds.start === 0) return bounds.start;
31307
+ return lineBounds(value, bounds.start - 1).start;
31389
31308
  }
31390
- /** What an unattributed turn or group is called in the tables. */
31391
- function modelLabel(model) {
31392
- return model === "" ? t("panel.usage.unknownModel") : model;
31309
+ /** Ctrl+E: current logical line end, then the next line end on repeat. */
31310
+ function moveToLineEnd(value, cursor, crossOnRepeat) {
31311
+ const site = clampCursor(value, cursor);
31312
+ const bounds = lineBounds(value, site);
31313
+ if (!crossOnRepeat || site !== bounds.end || bounds.end >= value.length) return bounds.end;
31314
+ return lineBounds(value, bounds.end + 1).end;
31393
31315
  }
31394
31316
  /**
31395
- * The model a turn's assistant messages came from: the one that produced most
31396
- * of them, with a tie naming every model involved. This is the fallback for
31397
- * turns whose meter `routes` were withheld, and it reads only what the log
31398
- * recorded — nothing is inferred from the current selection.
31317
+ * Remap a captured range when all intervening edits are wholly before or
31318
+ * wholly after it. An edit overlapping either boundary invalidates the
31319
+ * anchor instead of guessing and inserting content at a surprising place.
31399
31320
  */
31400
- function dominantModel(events) {
31401
- const counts = /* @__PURE__ */ new Map();
31402
- for (const event of events) {
31403
- if (event.type !== "assistant/message") continue;
31404
- const source = event.data.message.source;
31405
- if (source.kind !== "model" || source.model === "") continue;
31406
- counts.set(source.model, (counts.get(source.model) ?? 0) + 1);
31321
+ function remapStableRange(original, current, range) {
31322
+ const start = Math.max(0, Math.min(original.length, range.start));
31323
+ const end = Math.max(start, Math.min(original.length, range.end));
31324
+ if (original === current) return {
31325
+ start,
31326
+ end
31327
+ };
31328
+ let prefix = 0;
31329
+ const shared = Math.min(original.length, current.length);
31330
+ while (prefix < shared && original[prefix] === current[prefix]) prefix += 1;
31331
+ let suffix = 0;
31332
+ while (suffix < original.length - prefix && suffix < current.length - prefix && original[original.length - 1 - suffix] === current[current.length - 1 - suffix]) suffix += 1;
31333
+ const oldChangedEnd = original.length - suffix;
31334
+ if (prefix >= end) return {
31335
+ start,
31336
+ end
31337
+ };
31338
+ if (oldChangedEnd <= start) {
31339
+ const delta = current.length - original.length;
31340
+ return {
31341
+ start: start + delta,
31342
+ end: end + delta
31343
+ };
31407
31344
  }
31408
- if (counts.size === 0) return "";
31409
- const most = Math.max(...counts.values());
31410
- return [...counts.entries()].filter(([, count]) => count === most).map(([model]) => model).sort().join(" + ");
31411
31345
  }
31412
- /** The model attribution for one turn: the meter's routes, else its messages. */
31413
- function turnModel(slice, usage) {
31414
- const routes = usage.routes ?? [];
31415
- if (routes.length > 0) return routes.map((route) => route.model).join(" + ");
31416
- return dominantModel(slice.events);
31346
+ /** Replace a current range while preserving a cursor moved after capture. */
31347
+ function replaceRangePreservingCursor(value, cursor, range, replacement) {
31348
+ const start = Math.max(0, Math.min(value.length, range.start));
31349
+ const end = Math.max(start, Math.min(value.length, range.end));
31350
+ const site = clampCursor(value, cursor);
31351
+ const nextCursor = site <= start ? site : site >= end ? site + replacement.length - (end - start) : start + replacement.length;
31352
+ return {
31353
+ value: value.slice(0, start) + replacement + value.slice(end),
31354
+ cursor: nextCursor,
31355
+ killed: void 0
31356
+ };
31417
31357
  }
31418
31358
  /**
31419
- * Render one table: a dim header plus one row per entry. The free-text cell
31420
- * (the model name) keeps a readable share of the width, so trailing numeric
31421
- * columns drop first on a narrow terminal instead of leaving an ellipsised
31422
- * model name — the name is what the reader came for.
31423
- * @param columns - the numeric columns, most important first.
31424
- * @param rows - one entry per line, already in display order.
31425
- * @param text - how to read the free-text cell of one entry.
31426
- * @param width - the column budget.
31427
- * @param textFirst - true puts the free-text cell before the numbers (the
31428
- * model-group table); false appends it (the per-turn table).
31429
- * @returns the header row plus one row per entry.
31359
+ * Composer editor row budget: the editor itself never grows past this many
31360
+ * physical rows; deeper drafts scroll internally to keep the caret visible.
31361
+ * Short terminals collapse toward one row so the live transcript keeps room.
31430
31362
  */
31431
- function table(columns, rows, text, width, textFirst) {
31432
- const budget = width - 2;
31433
- const minText = 14;
31434
- const kept = [];
31435
- let used = 0;
31436
- for (const column of columns) {
31437
- if (used + column.width > budget - minText - 1) break;
31438
- kept.push(column);
31439
- used += column.width;
31440
- }
31441
- const textWidth = Math.min(24, Math.max(6, budget - used - 1));
31442
- const numbers = (row) => kept.map((column) => padColumns(column.value(row), column.width)).join("");
31443
- const header = kept.map((column) => padColumns(column.label(), column.width)).join("");
31444
- const lines = [{ segments: [lineSegment(truncateColumns(" " + (textFirst ? padColumns(t("panel.usage.colModel"), textWidth) + " " + header : header + " " + t("panel.usage.colModel")), width), "dim")] }];
31445
- for (const row of rows) {
31446
- const label = truncateColumns(text(row), textWidth);
31447
- lines.push({ segments: [lineSegment(" " + (textFirst ? padColumns(label, textWidth) + " " + numbers(row) : numbers(row) + " " + label), "plain")] });
31448
- }
31449
- return lines;
31363
+ function composerMaxRows(terminalRows) {
31364
+ return Math.max(1, Math.min(6, Math.floor((Math.max(1, terminalRows) - 10) / 3)));
31450
31365
  }
31451
31366
  /**
31452
- * Render the panel body.
31453
- * @param view - the projection totals plus the derived per-turn rows.
31454
- * @param columns - usable content columns inside the panel border.
31455
- * @returns bounded, styled rows ready to draw.
31367
+ * History navigation starts with Up on an empty draft, or continues from an
31368
+ * unchanged recalled entry whenever the caret sits on either text edge
31369
+ * (start or end) - moving the caret into the interior returns the keys to
31370
+ * ordinary editing until an edge is reached again.
31456
31371
  */
31457
- function usageLines(view, columns) {
31458
- const width = Math.max(8, Math.floor(columns));
31459
- const lines = [];
31460
- lines.push(heading(t("panel.usage.totals")));
31461
- const totals = view.totals;
31462
- if (totals === void 0) lines.push(note(t("panel.usage.unavailable")));
31463
- else {
31464
- const label = 12;
31465
- lines.push(pairLine(t("panel.usage.uncached"), formatTokens(totals.uncachedInputTokens), label));
31466
- lines.push(pairLine(t("panel.usage.cacheWrite"), formatTokens(totals.cacheWriteTokens), label));
31467
- lines.push(pairLine(t("panel.usage.cacheRead"), formatTokens(totals.cacheReadTokens), label));
31468
- lines.push(pairLine(t("panel.usage.output"), formatTokens(totals.outputTokens), label));
31469
- lines.push(pairLine(t("panel.usage.total"), formatTokens(usageTotalTokens(totals)), label, "bold"));
31470
- const hit = usageCacheHitPercent(totals);
31471
- if (hit !== null) lines.push(pairLine(t("panel.usage.cacheHit"), hit + "%", label));
31472
- }
31473
- lines.push({ segments: [] });
31474
- const models = modelTotals(view.turns);
31475
- lines.push(heading(models.length === 0 ? t("panel.usage.byModel") : `${t("panel.usage.byModel")} · ${models.length}`));
31476
- if (models.length === 0) lines.push(note(t("panel.usage.noTurns")));
31477
- else {
31478
- lines.push(...table([{
31479
- label: () => t("panel.usage.colTurns"),
31480
- value: (row) => String(row.turns),
31481
- width: 7
31482
- }, ...BUCKET_COLUMNS.map((column) => ({
31483
- ...column,
31484
- value: (row) => column.value(row)
31485
- }))], models, (row) => modelLabel(row.model), width, true));
31486
- if (models.some((row) => row.partial.length > 0)) lines.push(note(t("panel.usage.partialNote")));
31487
- }
31488
- lines.push({ segments: [] });
31489
- lines.push(heading(`${t("panel.usage.turns")} · ${view.turns.length}`));
31490
- if (view.turns.length === 0) lines.push(note(t("panel.usage.noTurns")));
31491
- else {
31492
- const withTurn = [{
31493
- label: () => t("panel.usage.colTurn"),
31494
- value: (row) => `#${row.turn}`,
31495
- width: 7
31496
- }, ...BUCKET_COLUMNS.map((column) => ({
31497
- ...column,
31498
- value: (row) => column.value(row.usage)
31499
- }))];
31500
- lines.push(...table(withTurn, [...view.turns].reverse(), (row) => modelLabel(row.model), width, false));
31501
- }
31502
- return lines.map((line) => boundLine(line, width));
31372
+ function shouldRecallNavigate(value, cursor, lastRecalled, direction) {
31373
+ if (value === "") return direction < 0;
31374
+ if (lastRecalled !== value) return false;
31375
+ return cursor === 0 || cursor === value.length;
31503
31376
  }
31504
31377
  //#endregion
31505
- //#region src/render/editor.ts
31378
+ //#region src/keyboard.ts
31506
31379
  /**
31507
- * Pure composer editor model with Codex textarea semantics: a grapheme
31508
- * cursor over a column-safe multiline layout, word/piece motion, single-entry
31509
- * kill + yank, and the shell-recall boundary gate that keeps Up/Down usable
31510
- * inside a multiline draft.
31511
- *
31512
- * The model is intentionally string-offset based (UTF-16 indices clamped to
31513
- * grapheme boundaries) so the React state stays two primitives
31514
- * (value, cursor) and every operation here stays pure and testable.
31380
+ * Keyboard enhancement protocol (Codex `keyboard_modes` parity) and the
31381
+ * kitty CSI-u normalization layer.
31515
31382
  *
31516
- * Word motion follows Codex's piece semantics: whitespace separates runs,
31517
- * punctuation runs stay atomic, and each Han grapheme is its own boundary.
31383
+ * The TUI pushes the kitty keyboard protocol with DISAMBIGUATE_ESCAPE_CODES
31384
+ * and REPORT_ALTERNATE_KEYS (flags 1|4 = `\x1b[>5u`). Event types are
31385
+ * deliberately NOT requested: Ink 5's parser cannot decode the
31386
+ * `:event-type` suffix, and repeat/release reporting buys this surface
31387
+ * nothing.
31518
31388
  *
31519
- * @module @deepseek-ai/dsh-code/render/editor
31520
- */
31521
- const segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter("en", { granularity: "grapheme" }) : void 0;
31522
- /**
31523
- * Split text into grapheme clusters. Falls back to code points when
31524
- * Intl.Segmenter is unavailable; the fallback still keeps surrogate pairs
31525
- * (emoji) atomic so the cursor can never split one.
31389
+ * Ink 5 also cannot parse most CSI-u forms at all — they fall through its
31390
+ * regex as unnamed sequences and get INSERTED AS DRAFT TEXT. The composer's
31391
+ * stdin read patch therefore rewrites every CSI-u form it can decode back
31392
+ * to the legacy byte or canonical sequence the existing key handling
31393
+ * already understands, before Ink ever parses the chunk.
31394
+ * @module @deepseek-ai/dsh-code/keyboard
31526
31395
  */
31527
- function splitGraphemes(text) {
31528
- if (text === "") return [];
31529
- const spans = [];
31530
- if (segmenter !== void 0) {
31531
- for (const piece of segmenter.segment(text)) spans.push({
31532
- text: piece.segment,
31533
- start: piece.index,
31534
- end: piece.index + piece.segment.length,
31535
- width: visibleColumns(piece.segment)
31536
- });
31537
- return spans;
31538
- }
31539
- let start = 0;
31540
- for (const char of text) {
31541
- spans.push({
31542
- text: char,
31543
- start,
31544
- end: start + char.length,
31545
- width: visibleColumns(char)
31546
- });
31547
- start += char.length;
31548
- }
31549
- return spans;
31396
+ /** Push keyboard enhancement (modifyOtherKeys off, kitty flags 1|4). */
31397
+ const KEYBOARD_ENHANCE_ENABLE = "\x1B[>4;0m\x1B[>5u";
31398
+ /** Pop the enhancement stack and reset modifyOtherKeys (exit path). */
31399
+ const KEYBOARD_ENHANCE_DISABLE = "\x1B[<u\x1B[>4;0m";
31400
+ const DSH_ENABLE_KEYBOARD_ENHANCEMENT = "DSH_ENABLE_KEYBOARD_ENHANCEMENT";
31401
+ function parseBooleanEnv(value) {
31402
+ if (value === void 0) return void 0;
31403
+ const normalized = value.trim().toLowerCase();
31404
+ if (normalized === "1" || normalized === "true" || normalized === "yes") return true;
31405
+ if (normalized === "0" || normalized === "false" || normalized === "no") return false;
31550
31406
  }
31551
- /** Round one UTF-16 offset down to the grapheme boundary at or before it. */
31552
- function floorBoundary(spans, offset) {
31553
- for (let index = spans.length - 1; index >= 0; index -= 1) {
31554
- const span = spans[index];
31555
- if (span.end <= offset) return span.end;
31556
- if (span.start < offset) return span.start;
31557
- }
31558
- return 0;
31407
+ /** True when the process is running inside the VS Code integrated terminal. */
31408
+ function isVsCodeTerminalEnv(env = process.env) {
31409
+ return env.TERM_PROGRAM?.trim().toLowerCase() === "vscode" || env.VSCODE_INJECTION === "1";
31559
31410
  }
31560
- /** Clamp a cursor offset to the nearest grapheme boundary (surrogates, ZWJ, marks stay whole). */
31561
- function clampCursor(value, offset) {
31562
- if (value === "") return 0;
31563
- const target = Math.max(0, Math.min(value.length, Math.floor(offset)));
31564
- if (target === 0 || target === value.length) return target;
31565
- const spans = splitGraphemes(value);
31566
- const down = floorBoundary(spans, target);
31567
- if (down === target) return target;
31568
- const up = spans.find((span) => span.start >= target)?.start ?? value.length;
31569
- return up - target < target - down ? up : down;
31411
+ /** Whether to push Kitty keyboard enhancement for the current terminal. */
31412
+ function shouldEnableKeyboardEnhancement(env = process.env) {
31413
+ if (parseBooleanEnv(env["DSH_DISABLE_KEYBOARD_ENHANCEMENT"]) === true) return false;
31414
+ const explicitEnable = parseBooleanEnv(env[DSH_ENABLE_KEYBOARD_ENHANCEMENT]);
31415
+ if (explicitEnable !== void 0) return explicitEnable;
31416
+ return !isVsCodeTerminalEnv(env);
31570
31417
  }
31418
+ /** Enable bracketed paste reporting. */
31419
+ const BRACKETED_PASTE_ENABLE = "\x1B[?2004h";
31420
+ /** Disable bracketed paste reporting. */
31421
+ const BRACKETED_PASTE_DISABLE = "\x1B[?2004l";
31422
+ /** Enable terminal focus-in/focus-out reporting (xterm focus protocol). */
31423
+ const TERMINAL_FOCUS_REPORT_ENABLE = "\x1B[?1004h";
31424
+ /** Disable terminal focus-in/focus-out reporting. */
31425
+ const TERMINAL_FOCUS_REPORT_DISABLE = "\x1B[?1004l";
31571
31426
  /**
31572
- * Delete the final grapheme cluster (append-only drafts without a cursor).
31573
- * Surrogate pairs and multi-codepoint emoji stay whole instead of leaving a
31574
- * lone trailing code unit behind.
31427
+ * Remove xterm focus reports from one input chunk and update the caller's
31428
+ * focus state. Focus reports are terminal protocol, not composer text.
31575
31429
  */
31576
- function deleteLastGrapheme(text) {
31577
- if (text === "") return "";
31578
- const spans = splitGraphemes(text);
31579
- return text.slice(0, spans[spans.length - 1].start);
31430
+ function stripTerminalFocusEvents(chunk, onFocus) {
31431
+ return chunk.replace(/\x1b\[(I|O)/gu, (_whole, event) => {
31432
+ onFocus(event === "I");
31433
+ return "";
31434
+ });
31580
31435
  }
31436
+ /** Bracketed paste markers as Ink delivers them (it strips the leading ESC). */
31437
+ const PASTE_START_MARKER = "[200~";
31438
+ const PASTE_END_MARKER = "[201~";
31581
31439
  /**
31582
- * Step the cursor by whole graphemes (negative steps left). The cursor is
31583
- * assumed to sit on a boundary; any drift is clamped first.
31440
+ * How long an unterminated bracketed-paste block may hold buffered bytes
31441
+ * before the input splitter strips its start marker and releases them: a
31442
+ * terminal that loses the end marker must never take the whole keyboard
31443
+ * hostage (Esc/Ctrl+C included). Shared by the splitter and the composer's
31444
+ * lost-paste safety net so both use one window.
31584
31445
  */
31585
- function moveCursorBy(value, offset, delta) {
31586
- if (delta === 0 || value === "") return clampCursor(value, offset);
31587
- const spans = splitGraphemes(value);
31588
- const boundaries = [0];
31589
- for (const span of spans) boundaries.push(span.end);
31590
- const current = boundaries.indexOf(clampCursor(value, offset));
31591
- if (current === -1) return clampCursor(value, offset);
31592
- return boundaries[Math.max(0, Math.min(boundaries.length - 1, current + delta))];
31593
- }
31446
+ const PASTE_BRACKET_TIMEOUT_MS = 1e3;
31594
31447
  /**
31595
- * Normalize text entering the draft: CRLF/CR become LF, tabs become two
31596
- * spaces (terminal tab stops are contextual and cannot join a deterministic
31597
- * row budget), and every other C0 control byte plus DEL is REMOVED — the
31598
- * draft is data, so a stray ESC (Windows Terminal file drops) disappears
31599
- * instead of rendering as literal backslash-x-1-b text. Newlines survive.
31448
+ * Remove bracketed paste markers from one input chunk. Panel drafts accept raw
31449
+ * `input` text, where an unhandled paste would otherwise persist the literal
31450
+ * "[200~"/"[201~" markers Ink leaves after stripping the ESC byte.
31600
31451
  */
31601
- function sanitizeDraftText(text) {
31602
- return text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").replaceAll(" ", " ");
31452
+ function stripPasteMarkers(text) {
31453
+ return text.replaceAll(`\x1b${PASTE_START_MARKER}`, "").replaceAll(`\x1b${PASTE_END_MARKER}`, "").replaceAll(PASTE_START_MARKER, "").replaceAll(PASTE_END_MARKER, "");
31603
31454
  }
31604
- /**
31605
- * Hard-wrap the draft into column-safe physical rows. Wide graphemes never
31606
- * split across rows (a grapheme that does not fit flushes the row first) and
31607
- * explicit newlines end their row without occupying a cell.
31608
- */
31609
- function editorModel(value, columns) {
31610
- const width = Math.max(1, Math.floor(columns));
31611
- const spans = splitGraphemes(value);
31612
- const rows = [];
31613
- let text = "";
31614
- let start = 0;
31615
- let used = 0;
31616
- const offsets = [0];
31617
- const rowColumns = [0];
31618
- const rowCuts = [0];
31619
- const flush = (end) => {
31620
- rows.push({
31621
- text,
31622
- start,
31623
- end,
31624
- offsets: [...offsets],
31625
- columns: [...rowColumns],
31626
- cuts: [...rowCuts]
31627
- });
31628
- text = "";
31629
- used = 0;
31630
- offsets.length = 0;
31631
- rowColumns.length = 0;
31632
- rowCuts.length = 0;
31633
- };
31634
- for (const span of spans) {
31635
- if (span.text === "\n") {
31636
- flush(span.start);
31637
- start = span.end;
31638
- offsets.push(span.end);
31639
- rowColumns.push(0);
31640
- rowCuts.push(0);
31641
- continue;
31642
- }
31643
- if (used > 0 && used + span.width > width) {
31644
- flush(span.start);
31645
- start = span.start;
31646
- offsets.push(span.start);
31647
- rowColumns.push(0);
31648
- rowCuts.push(0);
31649
- }
31650
- text += span.text;
31651
- used += span.width;
31652
- offsets.push(span.end);
31653
- rowColumns.push(used);
31654
- rowCuts.push(text.length);
31455
+ /** Match one CSI-u sequence (code, optional ;modifiers, then :event or ;alternate). */
31456
+ const CSI_U_SOURCE = "\x1B\\[(\\d+)(?:;(\\d+))?(?:(:|;)(\\d+))?u";
31457
+ /** Kitty private-use keycodes for the numeric keypad and keypad Enter. */
31458
+ const KITTY_KEYPAD_CODES = {
31459
+ 57399: "0",
31460
+ 57400: "1",
31461
+ 57401: "2",
31462
+ 57402: "3",
31463
+ 57403: "4",
31464
+ 57404: "5",
31465
+ 57405: "6",
31466
+ 57406: "7",
31467
+ 57407: "8",
31468
+ 57408: "9",
31469
+ 57414: "\r"
31470
+ };
31471
+ /** Legacy equivalent for one decoded CSI-u key, or undefined to pass through. */
31472
+ function legacyForKey(key) {
31473
+ const bits = Math.max(0, key.modifiers - 1);
31474
+ const shift = (bits & 1) !== 0;
31475
+ const alt = (bits & 2) !== 0;
31476
+ const ctrl = (bits & 4) !== 0;
31477
+ if (key.code === 13) {
31478
+ if (ctrl || shift) return "\n";
31479
+ if (alt) return "\x1B\r";
31480
+ return "\r";
31655
31481
  }
31656
- if (text !== "" || rows.length === 0) flush(value.length);
31657
- else if (offsets.length > 0) rows.push({
31658
- text: "",
31659
- start,
31660
- end: value.length,
31661
- offsets: [...offsets],
31662
- columns: [...rowColumns],
31663
- cuts: [...rowCuts]
31482
+ if (key.code === 27) return "\x1B";
31483
+ if (key.code === 9) return shift ? "\x1B[Z" : " ";
31484
+ if (key.code === 127) return alt || ctrl ? "\x1B" : "";
31485
+ const keypad = KITTY_KEYPAD_CODES[key.code];
31486
+ if (keypad !== void 0) {
31487
+ if (alt) return "\x1B" + keypad;
31488
+ return keypad;
31489
+ }
31490
+ if (key.code >= 1 && key.code <= 6) {
31491
+ const mask = (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0);
31492
+ const mods = mask === 0 ? "" : `;${mask + 1}`;
31493
+ if (key.code === 1) return mods === "" ? "\x1B[H" : `\x1b[1${mods}H`;
31494
+ if (key.code === 4) return mods === "" ? "\x1B[F" : `\x1b[1${mods}F`;
31495
+ return `\x1b[${key.code}${mods}~`;
31496
+ }
31497
+ if (key.code >= 97 && key.code <= 122) {
31498
+ const letter = String.fromCodePoint(key.code);
31499
+ if (ctrl) return String.fromCodePoint(key.code - 96);
31500
+ if (alt) return "\x1B" + letter;
31501
+ if (shift) return String.fromCodePoint(key.alternate ?? key.code - 32);
31502
+ return letter;
31503
+ }
31504
+ if (key.code >= 65 && key.code <= 90) {
31505
+ if (ctrl) return String.fromCodePoint(key.code + 32 - 96);
31506
+ if (alt) return "\x1B" + String.fromCodePoint(key.code + 32);
31507
+ return String.fromCodePoint(key.code);
31508
+ }
31509
+ if (key.code >= 32 && key.code <= 126) {
31510
+ const base = key.alternate ?? key.code;
31511
+ if (ctrl && base - 96 >= 1 && base - 96 <= 26) return String.fromCodePoint(base - 96);
31512
+ if (alt) return "\x1B" + String.fromCodePoint(base);
31513
+ return String.fromCodePoint(base);
31514
+ }
31515
+ }
31516
+ /**
31517
+ * Rewrite every decodable kitty CSI-u sequence in one stdin chunk to the
31518
+ * legacy form the input layer already handles. Undecodable or non-key
31519
+ * sequences pass through untouched, so terminals without the protocol are
31520
+ * unaffected.
31521
+ */
31522
+ function normalizeKeyboardChunk(chunk) {
31523
+ if (!chunk.includes("\x1B[") || !chunk.includes("u")) return chunk;
31524
+ const pattern = new RegExp(CSI_U_SOURCE, "g");
31525
+ return chunk.replace(pattern, (whole, code, mods, separator, third) => {
31526
+ return legacyForKey({
31527
+ code: Number.parseInt(code, 10),
31528
+ modifiers: mods === void 0 || mods === "" ? 1 : Math.max(1, Number.parseInt(mods, 10)),
31529
+ alternate: separator === ";" && third !== void 0 && third !== "" ? Number.parseInt(third, 10) : void 0
31530
+ }) ?? whole;
31664
31531
  });
31665
- return {
31666
- rows,
31667
- length: value.length
31668
- };
31669
31532
  }
31670
- /** Split one row around the authoritative caret; every other row stays whole. */
31671
- function editorRowParts(row, rowIndex, caretRow, cursor, caretEnabled = true) {
31672
- if (!caretEnabled || rowIndex !== caretRow) return {
31673
- before: "",
31674
- caret: "",
31675
- after: row.text,
31676
- hasCaret: false
31533
+ const HOME_SEQUENCES = [
31534
+ "\x1B[H",
31535
+ "\x1B[1~",
31536
+ "\x1B[7~",
31537
+ "\x1BOH"
31538
+ ];
31539
+ const END_SEQUENCES = [
31540
+ "\x1B[F",
31541
+ "\x1B[4~",
31542
+ "\x1B[8~",
31543
+ "\x1BOF"
31544
+ ];
31545
+ /** Parse one CSI functional-key sequence at `offset`. */
31546
+ function functionalToken(chunk, offset) {
31547
+ const tail = chunk.slice(offset);
31548
+ for (const sequence of HOME_SEQUENCES) if (tail.startsWith(sequence)) return {
31549
+ token: { kind: "home" },
31550
+ length: sequence.length
31677
31551
  };
31678
- const at = row.offsets.indexOf(cursor);
31679
- if (at < 0) return {
31680
- before: "",
31681
- caret: "",
31682
- after: row.text,
31683
- hasCaret: false
31552
+ for (const sequence of END_SEQUENCES) if (tail.startsWith(sequence)) return {
31553
+ token: { kind: "end" },
31554
+ length: sequence.length
31684
31555
  };
31685
- return {
31686
- before: at > 0 ? row.text.slice(0, row.cuts[at]) : "",
31687
- caret: at < row.cuts.length - 1 ? row.text.slice(row.cuts[at], row.cuts[at + 1]) : " ",
31688
- after: at < row.cuts.length - 1 ? row.text.slice(row.cuts[at + 1]) : "",
31689
- hasCaret: true
31556
+ const modifiedHome = /^\x1b\[1;(\d+)H/u.exec(tail);
31557
+ if (modifiedHome !== null) return {
31558
+ token: { kind: "home" },
31559
+ length: modifiedHome[0].length
31690
31560
  };
31691
- }
31692
- /** Map a cursor offset to its caret site on the wrapped rows. */
31693
- function caretSite(model, offset) {
31694
- const target = Math.max(0, Math.min(model.length, offset));
31695
- for (let index = model.rows.length - 1; index >= 0; index -= 1) {
31696
- const row = model.rows[index];
31697
- const at = row.offsets.indexOf(target);
31698
- if (at >= 0) return {
31699
- row: index,
31700
- column: row.columns[at]
31561
+ const modifiedEnd = /^\x1b\[1;(\d+)F/u.exec(tail);
31562
+ if (modifiedEnd !== null) return {
31563
+ token: { kind: "end" },
31564
+ length: modifiedEnd[0].length
31565
+ };
31566
+ const modifiedDelete = /^\x1b\[3(?:;(\d+))?~/u.exec(tail);
31567
+ if (modifiedDelete !== null) {
31568
+ const modifiers = Number.parseInt(modifiedDelete[1] ?? "1", 10) - 1;
31569
+ return {
31570
+ token: { kind: (modifiers & 2) !== 0 || (modifiers & 4) !== 0 ? "delete-word-forward" : "delete-forward" },
31571
+ length: modifiedDelete[0].length
31701
31572
  };
31702
31573
  }
31703
- const last = model.rows[model.rows.length - 1];
31704
- return {
31705
- row: model.rows.length - 1,
31706
- column: last === void 0 ? 0 : last.columns[last.columns.length - 1]
31707
- };
31708
31574
  }
31709
31575
  /**
31710
- * Move the caret across physical rows keeping a preferred display column
31711
- * (Codex `preferred_col`): horizontal moves reset the preference, vertical
31712
- * moves reuse it, clamped to each row's width.
31576
+ * Tokenize a stdin chunk containing at least one editor-only key. Ink calls
31577
+ * `useInput` once for a pasted/batched chunk, so preserving each action here
31578
+ * prevents repeated Backspace/Home/End/Delete presses from collapsing into
31579
+ * one blurred key event. Unknown escape sequences return undefined and stay
31580
+ * under Ink's ownership.
31713
31581
  */
31714
- function moveCursorVertically(model, offset, preferredColumn, delta) {
31715
- const target = caretSite(model, offset).row + delta;
31716
- if (delta === 0) return offset;
31717
- if (target < 0) return 0;
31718
- if (target >= model.rows.length) return model.length;
31719
- const row = model.rows[target];
31720
- const wanted = Math.max(0, Math.min(preferredColumn, row.columns[row.columns.length - 1]));
31721
- let best = 0;
31722
- for (let index = 1; index < row.columns.length; index += 1) if (row.columns[index] <= wanted) best = index;
31723
- else break;
31724
- return row.offsets[best];
31725
- }
31726
- /** The start/end offsets of the logical line containing the cursor. */
31727
- function lineBounds(value, offset) {
31728
- const target = Math.max(0, Math.min(value.length, offset));
31729
- const start = value.lastIndexOf("\n", Math.max(0, target - 1)) + 1;
31730
- const end = value.indexOf("\n", target);
31731
- return {
31732
- start,
31733
- end: end === -1 ? value.length : end
31582
+ function tokenizeRawEditorChunk(chunk) {
31583
+ const tokens = [];
31584
+ let text = "";
31585
+ let special = false;
31586
+ const flushText = () => {
31587
+ if (text === "") return;
31588
+ tokens.push({
31589
+ kind: "text",
31590
+ text
31591
+ });
31592
+ text = "";
31734
31593
  };
31735
- }
31736
- /** Codex WORD_SEPARATORS: punctuation runs are their own word pieces. */
31737
- const WORD_SEPARATORS = /* @__PURE__ */ new Set("`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?");
31738
- const HAN_GRAPHEME = /^\p{Script=Han}(?:\p{Mark}|\uFE0F)*$/u;
31739
- const UNICODE_PUNCTUATION = /^\p{P}+$/u;
31740
- function classifyGrapheme(text) {
31741
- if (/^\s$/u.test(text)) return "space";
31742
- return WORD_SEPARATORS.has(text) || UNICODE_PUNCTUATION.test(text) ? "punct" : "word";
31743
- }
31744
- /** Maximal same-class runs; Han graphemes deliberately stay one run each. */
31745
- function pieceRuns(value) {
31746
- const runs = [];
31747
- let current;
31748
- for (const span of splitGraphemes(value)) {
31749
- const klass = span.text === "\n" ? "space" : classifyGrapheme(span.text);
31750
- const atomic = klass === "word" && HAN_GRAPHEME.test(span.text);
31751
- if (current !== void 0 && current.class === klass && !current.atomic && !atomic) {
31752
- current.end = span.end;
31594
+ for (let offset = 0; offset < chunk.length;) {
31595
+ if (chunk.startsWith("\x1B", offset) || chunk.startsWith("\x1B\b", offset)) {
31596
+ flushText();
31597
+ tokens.push({ kind: "delete-word-backward" });
31598
+ special = true;
31599
+ offset += 2;
31753
31600
  continue;
31754
31601
  }
31755
- current = {
31756
- start: span.start,
31757
- end: span.end,
31758
- class: klass,
31759
- atomic
31760
- };
31761
- runs.push(current);
31602
+ const functional = functionalToken(chunk, offset);
31603
+ if (functional !== void 0) {
31604
+ flushText();
31605
+ tokens.push(functional.token);
31606
+ special = true;
31607
+ offset += functional.length;
31608
+ continue;
31609
+ }
31610
+ const char = chunk[offset];
31611
+ if (char === "" || char === "\b") {
31612
+ flushText();
31613
+ tokens.push({ kind: "delete-backward" });
31614
+ special = true;
31615
+ offset += 1;
31616
+ continue;
31617
+ }
31618
+ if (char === "\x1B") return void 0;
31619
+ text += char;
31620
+ offset += 1;
31762
31621
  }
31763
- return runs;
31622
+ flushText();
31623
+ return special ? tokens : void 0;
31764
31624
  }
31625
+ //#endregion
31626
+ //#region src/render/status.ts
31765
31627
  /**
31766
- * Codex `beginning_of_previous_word`: skip whitespace left, then land on the
31767
- * START of the trailing non-space piece (extending over separator pieces).
31628
+ * Status-bar composition for the TUI footer. Codex/Claude-Code-style split
31629
+ * line: identity facts and session figures flow from the left, while the
31630
+ * permission badge (the Codex "autonomous selection" anchor, with its
31631
+ * shift+tab cycle hint) pins to the right edge. Every segment carries a tone
31632
+ * the footer maps to a theme color, and layoutStatusBar degrades the line
31633
+ * item by item so it always fits one physical row — truncation with an
31634
+ * ellipsis happens only after every lesser group has already dropped out.
31635
+ *
31636
+ * @module @deepseek-ai/dsh-tui/render/status
31768
31637
  */
31769
- function moveWordLeft(value, offset) {
31770
- const cursor = clampCursor(value, offset);
31771
- const runs = pieceRuns(value);
31772
- let index = runs.findLastIndex((run) => run.start < cursor);
31773
- if (index < 0) return 0;
31774
- if (runs[index].class === "space") {
31775
- index -= 1;
31776
- if (index < 0) return 0;
31777
- }
31778
- let target = index;
31779
- while (target > 0 && runs[target].class === "punct" && runs[target - 1].class === "punct") target -= 1;
31780
- return runs[target].start;
31781
- }
31782
31638
  /**
31783
- * Codex `end_of_next_word`: skip whitespace right, then land on the END of
31784
- * the leading non-space piece (extending over separator pieces).
31639
+ * Compact duration: 45.2s under a minute, 2m42s from there on.
31640
+ * @param ms - duration in milliseconds.
31641
+ * @returns display string.
31785
31642
  */
31786
- function moveWordRight(value, offset) {
31787
- const cursor = clampCursor(value, offset);
31788
- const runs = pieceRuns(value);
31789
- let index = runs.findIndex((run) => run.end > cursor);
31790
- if (index >= runs.length) return value.length;
31791
- if (runs[index].class === "space") {
31792
- index += 1;
31793
- if (index >= runs.length) return value.length;
31794
- }
31795
- let target = index;
31796
- while (target < runs.length - 1 && runs[target].class === "punct" && runs[target + 1].class === "punct") target += 1;
31797
- return runs[target].end;
31798
- }
31799
- function replaceRange(value, cursor, start, end, killed) {
31800
- const from = Math.min(start, end);
31801
- const to = Math.max(start, end);
31802
- if (from >= to) return {
31803
- value,
31804
- cursor,
31805
- killed: void 0
31806
- };
31807
- const text = value.slice(from, to);
31808
- return {
31809
- value: value.slice(0, from) + value.slice(to),
31810
- cursor: Math.max(0, cursor - Math.max(0, Math.min(cursor, to) - from)),
31811
- killed: killed ? text : void 0
31812
- };
31813
- }
31814
- /** Delete the grapheme cluster before the cursor. */
31815
- function deleteBackward(value, cursor) {
31816
- if (cursor === 0) return {
31817
- value,
31818
- cursor,
31819
- killed: void 0
31820
- };
31821
- const spans = splitGraphemes(value.slice(0, cursor));
31822
- return replaceRange(value, cursor, spans.length === 0 ? 0 : spans[spans.length - 1].start, cursor, false);
31823
- }
31824
- /** Delete the grapheme cluster at the cursor. */
31825
- function deleteForward(value, cursor) {
31826
- if (cursor >= value.length) return {
31827
- value,
31828
- cursor,
31829
- killed: void 0
31830
- };
31831
- const span = splitGraphemes(value).find((candidate) => candidate.start >= cursor);
31832
- return replaceRange(value, cursor, cursor, span === void 0 ? value.length : span.end, false);
31833
- }
31834
- /** Delete back to the start of the previous word (fills the kill buffer). */
31835
- function deleteWordBackward(value, cursor) {
31836
- return replaceRange(value, cursor, moveWordLeft(value, cursor), cursor, true);
31837
- }
31838
- /** Delete forward to the end of the next word (fills the kill buffer). */
31839
- function deleteWordForward(value, cursor) {
31840
- return replaceRange(value, cursor, cursor, moveWordRight(value, cursor), true);
31841
- }
31842
- /** Ctrl+U: kill from the line start to the cursor; at BOL, kill the newline. */
31843
- function killToLineStart(value, cursor) {
31844
- const bounds = lineBounds(value, cursor);
31845
- if (cursor > bounds.start) return replaceRange(value, cursor, bounds.start, cursor, true);
31846
- if (bounds.start > 0) return replaceRange(value, cursor, bounds.start - 1, bounds.start, true);
31847
- return {
31848
- value,
31849
- cursor,
31850
- killed: void 0
31851
- };
31852
- }
31853
- /** Ctrl+K: kill from the cursor to the line end; at EOL, kill the newline. */
31854
- function killToLineEnd(value, cursor) {
31855
- const bounds = lineBounds(value, cursor);
31856
- if (cursor < bounds.end) return replaceRange(value, cursor, cursor, bounds.end, true);
31857
- if (bounds.end < value.length) return replaceRange(value, cursor, bounds.end, bounds.end + 1, true);
31858
- return {
31859
- value,
31860
- cursor,
31861
- killed: void 0
31862
- };
31863
- }
31864
- /** Insert sanitized text at the cursor. */
31865
- function insertText(value, cursor, text) {
31866
- const safe = sanitizeDraftText(text);
31867
- if (safe === "") return {
31868
- value,
31869
- cursor,
31870
- killed: void 0
31871
- };
31872
- return {
31873
- value: value.slice(0, cursor) + safe + value.slice(cursor),
31874
- cursor: cursor + safe.length,
31875
- killed: void 0
31876
- };
31877
- }
31878
- /** Ctrl+A: current logical line start, then the previous line start on repeat. */
31879
- function moveToLineStart(value, cursor, crossOnRepeat) {
31880
- const site = clampCursor(value, cursor);
31881
- const bounds = lineBounds(value, site);
31882
- if (!crossOnRepeat || site !== bounds.start || bounds.start === 0) return bounds.start;
31883
- return lineBounds(value, bounds.start - 1).start;
31884
- }
31885
- /** Ctrl+E: current logical line end, then the next line end on repeat. */
31886
- function moveToLineEnd(value, cursor, crossOnRepeat) {
31887
- const site = clampCursor(value, cursor);
31888
- const bounds = lineBounds(value, site);
31889
- if (!crossOnRepeat || site !== bounds.end || bounds.end >= value.length) return bounds.end;
31890
- return lineBounds(value, bounds.end + 1).end;
31643
+ function formatDuration(ms) {
31644
+ const s = ms / 1e3;
31645
+ if (s < 60) return String(Math.round(s * 10) / 10) + "s";
31646
+ const whole = Math.round(s);
31647
+ const hours = Math.floor(whole / 3600);
31648
+ const minutes = Math.floor(whole % 3600 / 60);
31649
+ const seconds = whole % 60;
31650
+ if (hours > 0) return `${hours}h${minutes}m${seconds === 0 ? "" : `${seconds}s`}`;
31651
+ return `${minutes}m${seconds}s`;
31891
31652
  }
31892
31653
  /**
31893
- * Remap a captured range when all intervening edits are wholly before or
31894
- * wholly after it. An edit overlapping either boundary invalidates the
31895
- * anchor instead of guessing and inserting content at a surprising place.
31654
+ * Compact decode rate: one decimal under a hundred, whole below a thousand,
31655
+ * then thousands (15.3 / 124 / 1.2K).
31656
+ * @param n - tokens per second.
31657
+ * @returns display string.
31896
31658
  */
31897
- function remapStableRange(original, current, range) {
31898
- const start = Math.max(0, Math.min(original.length, range.start));
31899
- const end = Math.max(start, Math.min(original.length, range.end));
31900
- if (original === current) return {
31901
- start,
31902
- end
31903
- };
31904
- let prefix = 0;
31905
- const shared = Math.min(original.length, current.length);
31906
- while (prefix < shared && original[prefix] === current[prefix]) prefix += 1;
31907
- let suffix = 0;
31908
- while (suffix < original.length - prefix && suffix < current.length - prefix && original[original.length - 1 - suffix] === current[current.length - 1 - suffix]) suffix += 1;
31909
- const oldChangedEnd = original.length - suffix;
31910
- if (prefix >= end) return {
31911
- start,
31912
- end
31913
- };
31914
- if (oldChangedEnd <= start) {
31915
- const delta = current.length - original.length;
31916
- return {
31917
- start: start + delta,
31918
- end: end + delta
31919
- };
31920
- }
31921
- }
31922
- /** Replace a current range while preserving a cursor moved after capture. */
31923
- function replaceRangePreservingCursor(value, cursor, range, replacement) {
31924
- const start = Math.max(0, Math.min(value.length, range.start));
31925
- const end = Math.max(start, Math.min(value.length, range.end));
31926
- const site = clampCursor(value, cursor);
31927
- const nextCursor = site <= start ? site : site >= end ? site + replacement.length - (end - start) : start + replacement.length;
31928
- return {
31929
- value: value.slice(0, start) + replacement + value.slice(end),
31930
- cursor: nextCursor,
31931
- killed: void 0
31932
- };
31659
+ function formatRate(n) {
31660
+ if (n < 100) return String(Math.round(n * 10) / 10);
31661
+ if (n < 1e3) return String(Math.round(n));
31662
+ return String(Math.round(n / 100) / 10) + "K";
31933
31663
  }
31934
31664
  /**
31935
- * Composer editor row budget: the editor itself never grows past this many
31936
- * physical rows; deeper drafts scroll internally to keep the caret visible.
31937
- * Short terminals collapse toward one row so the live transcript keeps room.
31665
+ * Cache-hit share of billed prompt-side input. The denominator is the same
31666
+ * billed total the /usage panel shows (uncached input plus both cache
31667
+ * buckets), so the two readouts can never disagree.
31668
+ * @param usage - cumulative token totals.
31669
+ * @returns percent rounded to one decimal place, or null when no input was billed.
31938
31670
  */
31939
- function composerMaxRows(terminalRows) {
31940
- return Math.max(1, Math.min(6, Math.floor((Math.max(1, terminalRows) - 10) / 3)));
31671
+ function cacheHitPercent(usage) {
31672
+ const billed = billedInputTokens(usage);
31673
+ return billed === 0 ? null : Math.round(usage.cacheReadTokens / billed * 1e3) / 10;
31674
+ }
31675
+ /** Separator between trailing state spans. */
31676
+ const STATUS_ITEM_SEPARATOR = " · ";
31677
+ /** Localized mode-cycle hint used by the live layout. */
31678
+ function statusCycleHint() {
31679
+ return ` ${t("status.cycleHint")}`;
31941
31680
  }
31681
+ /** Occupancy at which the usage readout flips from brand blue to amber. */
31682
+ const CONTEXT_WARN_PERCENT = 90;
31942
31683
  /**
31943
- * History navigation starts with Up on an empty draft, or continues from an
31944
- * unchanged recalled entry whenever the caret sits on either text edge
31945
- * (start or end) - moving the caret into the interior returns the keys to
31946
- * ordinary editing until an edge is reached again.
31947
- */
31948
- function shouldRecallNavigate(value, cursor, lastRecalled, direction) {
31949
- if (value === "") return direction < 0;
31950
- if (lastRecalled !== value) return false;
31951
- return cursor === 0 || cursor === value.length;
31952
- }
31953
- //#endregion
31954
- //#region src/keyboard.ts
31955
- /**
31956
- * Keyboard enhancement protocol (Codex `keyboard_modes` parity) and the
31957
- * kitty CSI-u normalization layer.
31958
- *
31959
- * The TUI pushes the kitty keyboard protocol with DISAMBIGUATE_ESCAPE_CODES
31960
- * and REPORT_ALTERNATE_KEYS (flags 1|4 = `\x1b[>5u`). Event types are
31961
- * deliberately NOT requested: Ink 5's parser cannot decode the
31962
- * `:event-type` suffix, and repeat/release reporting buys this surface
31963
- * nothing.
31964
- *
31965
- * Ink 5 also cannot parse most CSI-u forms at all — they fall through its
31966
- * regex as unnamed sequences and get INSERTED AS DRAFT TEXT. The composer's
31967
- * stdin read patch therefore rewrites every CSI-u form it can decode back
31968
- * to the legacy byte or canonical sequence the existing key handling
31969
- * already understands, before Ink ever parses the chunk.
31970
- * @module @deepseek-ai/dsh-code/keyboard
31684
+ * Narrowest bar width the drop ladder keeps before dropping the whole
31685
+ * context group: the bar shrinks to this floor first (the absolute readout
31686
+ * survives), and only past it does the readout degrade and the group go.
31971
31687
  */
31972
- /** Push keyboard enhancement (modifyOtherKeys off, kitty flags 1|4). */
31973
- const KEYBOARD_ENHANCE_ENABLE = "\x1B[>4;0m\x1B[>5u";
31974
- /** Pop the enhancement stack and reset modifyOtherKeys (exit path). */
31975
- const KEYBOARD_ENHANCE_DISABLE = "\x1B[<u\x1B[>4;0m";
31976
- const DSH_ENABLE_KEYBOARD_ENHANCEMENT = "DSH_ENABLE_KEYBOARD_ENHANCEMENT";
31977
- function parseBooleanEnv(value) {
31978
- if (value === void 0) return void 0;
31979
- const normalized = value.trim().toLowerCase();
31980
- if (normalized === "1" || normalized === "true" || normalized === "yes") return true;
31981
- if (normalized === "0" || normalized === "false" || normalized === "no") return false;
31982
- }
31983
- /** True when the process is running inside the VS Code integrated terminal. */
31984
- function isVsCodeTerminalEnv(env = process.env) {
31985
- return env.TERM_PROGRAM?.trim().toLowerCase() === "vscode" || env.VSCODE_INJECTION === "1";
31986
- }
31987
- /** Whether to push Kitty keyboard enhancement for the current terminal. */
31988
- function shouldEnableKeyboardEnhancement(env = process.env) {
31989
- if (parseBooleanEnv(env["DSH_DISABLE_KEYBOARD_ENHANCEMENT"]) === true) return false;
31990
- const explicitEnable = parseBooleanEnv(env[DSH_ENABLE_KEYBOARD_ENHANCEMENT]);
31991
- if (explicitEnable !== void 0) return explicitEnable;
31992
- return !isVsCodeTerminalEnv(env);
31993
- }
31994
- /** Enable bracketed paste reporting. */
31995
- const BRACKETED_PASTE_ENABLE = "\x1B[?2004h";
31996
- /** Disable bracketed paste reporting. */
31997
- const BRACKETED_PASTE_DISABLE = "\x1B[?2004l";
31998
- /** Enable terminal focus-in/focus-out reporting (xterm focus protocol). */
31999
- const TERMINAL_FOCUS_REPORT_ENABLE = "\x1B[?1004h";
32000
- /** Disable terminal focus-in/focus-out reporting. */
32001
- const TERMINAL_FOCUS_REPORT_DISABLE = "\x1B[?1004l";
31688
+ const CONTEXT_MIN_WIDTH = 5;
32002
31689
  /**
32003
- * Remove xterm focus reports from one input chunk and update the caller's
32004
- * focus state. Focus reports are terminal protocol, not composer text.
31690
+ * Render context occupancy as ONE stepless proportional bar: a solid
31691
+ * DeepSeek-blue fill run tracking the occupancy and a dim dotted free
31692
+ * track for the rest. Nothing else lives inside the bar — the usage
31693
+ * readout rides outside it (see contextGroupSpans) — so the geometry
31694
+ * always reads as the true remaining share. A given occupancy always
31695
+ * renders the identical bar.
31696
+ * @param usedTokens - reported used tokens.
31697
+ * @param contextWindow - route capacity.
31698
+ * @param width - total bar columns.
31699
+ * @returns tone-split spans for the footer to paint.
32005
31700
  */
32006
- function stripTerminalFocusEvents(chunk, onFocus) {
32007
- return chunk.replace(/\x1b\[(I|O)/gu, (_whole, event) => {
32008
- onFocus(event === "I");
32009
- return "";
31701
+ function contextBar(usedTokens, contextWindow, width) {
31702
+ if (width <= 0 || contextWindow <= 0) return [];
31703
+ const fill = Math.min(width, Math.max(0, Math.round(Math.max(0, usedTokens) / contextWindow * width)));
31704
+ const spans = [];
31705
+ if (fill > 0) spans.push({
31706
+ text: "█".repeat(fill),
31707
+ tone: "ctxFill"
32010
31708
  });
32011
- }
32012
- /** Bracketed paste markers as Ink delivers them (it strips the leading ESC). */
32013
- const PASTE_START_MARKER = "[200~";
32014
- const PASTE_END_MARKER = "[201~";
32015
- /**
32016
- * How long an unterminated bracketed-paste block may hold buffered bytes
32017
- * before the input splitter strips its start marker and releases them: a
32018
- * terminal that loses the end marker must never take the whole keyboard
32019
- * hostage (Esc/Ctrl+C included). Shared by the splitter and the composer's
32020
- * lost-paste safety net so both use one window.
32021
- */
32022
- const PASTE_BRACKET_TIMEOUT_MS = 1e3;
32023
- /**
32024
- * Remove bracketed paste markers from one input chunk. Panel drafts accept raw
32025
- * `input` text, where an unhandled paste would otherwise persist the literal
32026
- * "[200~"/"[201~" markers Ink leaves after stripping the ESC byte.
32027
- */
32028
- function stripPasteMarkers(text) {
32029
- return text.replaceAll(`\x1b${PASTE_START_MARKER}`, "").replaceAll(`\x1b${PASTE_END_MARKER}`, "").replaceAll(PASTE_START_MARKER, "").replaceAll(PASTE_END_MARKER, "");
32030
- }
32031
- /** Match one CSI-u sequence (code, optional ;modifiers, then :event or ;alternate). */
32032
- const CSI_U_SOURCE = "\x1B\\[(\\d+)(?:;(\\d+))?(?:(:|;)(\\d+))?u";
32033
- /** Kitty private-use keycodes for the numeric keypad and keypad Enter. */
32034
- const KITTY_KEYPAD_CODES = {
32035
- 57399: "0",
32036
- 57400: "1",
32037
- 57401: "2",
32038
- 57402: "3",
32039
- 57403: "4",
32040
- 57404: "5",
32041
- 57405: "6",
32042
- 57406: "7",
32043
- 57407: "8",
32044
- 57408: "9",
32045
- 57414: "\r"
32046
- };
32047
- /** Legacy equivalent for one decoded CSI-u key, or undefined to pass through. */
32048
- function legacyForKey(key) {
32049
- const bits = Math.max(0, key.modifiers - 1);
32050
- const shift = (bits & 1) !== 0;
32051
- const alt = (bits & 2) !== 0;
32052
- const ctrl = (bits & 4) !== 0;
32053
- if (key.code === 13) {
32054
- if (ctrl || shift) return "\n";
32055
- if (alt) return "\x1B\r";
32056
- return "\r";
32057
- }
32058
- if (key.code === 27) return "\x1B";
32059
- if (key.code === 9) return shift ? "\x1B[Z" : " ";
32060
- if (key.code === 127) return alt || ctrl ? "\x1B" : "";
32061
- const keypad = KITTY_KEYPAD_CODES[key.code];
32062
- if (keypad !== void 0) {
32063
- if (alt) return "\x1B" + keypad;
32064
- return keypad;
32065
- }
32066
- if (key.code >= 1 && key.code <= 6) {
32067
- const mask = (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0);
32068
- const mods = mask === 0 ? "" : `;${mask + 1}`;
32069
- if (key.code === 1) return mods === "" ? "\x1B[H" : `\x1b[1${mods}H`;
32070
- if (key.code === 4) return mods === "" ? "\x1B[F" : `\x1b[1${mods}F`;
32071
- return `\x1b[${key.code}${mods}~`;
32072
- }
32073
- if (key.code >= 97 && key.code <= 122) {
32074
- const letter = String.fromCodePoint(key.code);
32075
- if (ctrl) return String.fromCodePoint(key.code - 96);
32076
- if (alt) return "\x1B" + letter;
32077
- if (shift) return String.fromCodePoint(key.alternate ?? key.code - 32);
32078
- return letter;
32079
- }
32080
- if (key.code >= 65 && key.code <= 90) {
32081
- if (ctrl) return String.fromCodePoint(key.code + 32 - 96);
32082
- if (alt) return "\x1B" + String.fromCodePoint(key.code + 32);
32083
- return String.fromCodePoint(key.code);
32084
- }
32085
- if (key.code >= 32 && key.code <= 126) {
32086
- const base = key.alternate ?? key.code;
32087
- if (ctrl && base - 96 >= 1 && base - 96 <= 26) return String.fromCodePoint(base - 96);
32088
- if (alt) return "\x1B" + String.fromCodePoint(base);
32089
- return String.fromCodePoint(base);
32090
- }
31709
+ const free = width - fill;
31710
+ if (free > 0) spans.push({
31711
+ text: "".repeat(free),
31712
+ tone: "label"
31713
+ });
31714
+ return spans;
32091
31715
  }
32092
31716
  /**
32093
- * Rewrite every decodable kitty CSI-u sequence in one stdin chunk to the
32094
- * legacy form the input layer already handles. Undecodable or non-key
32095
- * sequences pass through untouched, so terminals without the protocol are
32096
- * unaffected.
31717
+ * Compose the context group: the proportional bar plus the usage readout
31718
+ * OUTSIDE the bar, so the dotted track keeps its proportional meaning no
31719
+ * matter how wide the readout is. `full` reads `12.3K/1.0M 25%`; `percent`
31720
+ * drops the absolute pair; `none` is the bare bar. The readout turns amber
31721
+ * once occupancy reaches the warning threshold.
32097
31722
  */
32098
- function normalizeKeyboardChunk(chunk) {
32099
- if (!chunk.includes("\x1B[") || !chunk.includes("u")) return chunk;
32100
- const pattern = new RegExp(CSI_U_SOURCE, "g");
32101
- return chunk.replace(pattern, (whole, code, mods, separator, third) => {
32102
- return legacyForKey({
32103
- code: Number.parseInt(code, 10),
32104
- modifiers: mods === void 0 || mods === "" ? 1 : Math.max(1, Number.parseInt(mods, 10)),
32105
- alternate: separator === ";" && third !== void 0 && third !== "" ? Number.parseInt(third, 10) : void 0
32106
- }) ?? whole;
31723
+ function contextGroupSpans(usedTokens, contextWindow, barWidth, readout) {
31724
+ const spans = [{
31725
+ text: t("status.label.context") + " ",
31726
+ tone: "label"
31727
+ }];
31728
+ spans.push(...contextBar(usedTokens, contextWindow, barWidth));
31729
+ if (readout === "none" || barWidth <= 0 || contextWindow <= 0) return spans;
31730
+ const used = Math.max(0, usedTokens);
31731
+ const percent = Math.round(used / contextWindow * 100);
31732
+ const text = readout === "full" ? `${formatTokens(used)}/${formatTokens(contextWindow)} ${percent}%` : `${percent}%`;
31733
+ spans.push({
31734
+ text: " ",
31735
+ tone: "label"
31736
+ }, {
31737
+ text,
31738
+ tone: percent >= CONTEXT_WARN_PERCENT ? "warn" : "value"
32107
31739
  });
31740
+ return spans;
32108
31741
  }
32109
- const HOME_SEQUENCES = [
32110
- "\x1B[H",
32111
- "\x1B[1~",
32112
- "\x1B[7~",
32113
- "\x1BOH"
32114
- ];
32115
- const END_SEQUENCES = [
32116
- "\x1B[F",
32117
- "\x1B[4~",
32118
- "\x1B[8~",
32119
- "\x1BOF"
32120
- ];
32121
- /** Parse one CSI functional-key sequence at `offset`. */
32122
- function functionalToken(chunk, offset) {
32123
- const tail = chunk.slice(offset);
32124
- for (const sequence of HOME_SEQUENCES) if (tail.startsWith(sequence)) return {
32125
- token: { kind: "home" },
32126
- length: sequence.length
32127
- };
32128
- for (const sequence of END_SEQUENCES) if (tail.startsWith(sequence)) return {
32129
- token: { kind: "end" },
32130
- length: sequence.length
32131
- };
32132
- const modifiedHome = /^\x1b\[1;(\d+)H/u.exec(tail);
32133
- if (modifiedHome !== null) return {
32134
- token: { kind: "home" },
32135
- length: modifiedHome[0].length
32136
- };
32137
- const modifiedEnd = /^\x1b\[1;(\d+)F/u.exec(tail);
32138
- if (modifiedEnd !== null) return {
32139
- token: { kind: "end" },
32140
- length: modifiedEnd[0].length
32141
- };
32142
- const modifiedDelete = /^\x1b\[3(?:;(\d+))?~/u.exec(tail);
32143
- if (modifiedDelete !== null) {
32144
- const modifiers = Number.parseInt(modifiedDelete[1] ?? "1", 10) - 1;
32145
- return {
32146
- token: { kind: (modifiers & 2) !== 0 || (modifiers & 4) !== 0 ? "delete-word-forward" : "delete-forward" },
32147
- length: modifiedDelete[0].length
32148
- };
31742
+ /** The full item catalog in canonical order (the /statusline default). */
31743
+ const STATUS_ITEMS = [
31744
+ {
31745
+ id: "model",
31746
+ label: "model",
31747
+ description: "provider/model serving this session",
31748
+ side: "left"
31749
+ },
31750
+ {
31751
+ id: "cwd",
31752
+ label: "cwd",
31753
+ description: "working-directory basename",
31754
+ side: "left"
31755
+ },
31756
+ {
31757
+ id: "mode",
31758
+ label: "mode",
31759
+ description: "agent preset composing the session",
31760
+ side: "left"
31761
+ },
31762
+ {
31763
+ id: "branch",
31764
+ label: "branch",
31765
+ description: "git branch inside a repository",
31766
+ side: "left"
31767
+ },
31768
+ {
31769
+ id: "context",
31770
+ label: "context",
31771
+ description: "context-window occupancy meter",
31772
+ side: "left"
31773
+ },
31774
+ {
31775
+ id: "permission",
31776
+ label: "permission",
31777
+ description: "permission preset badge with cycle hint",
31778
+ side: "right"
31779
+ },
31780
+ {
31781
+ id: "plan",
31782
+ label: "plan",
31783
+ description: "plan-mode state mark",
31784
+ side: "left"
31785
+ },
31786
+ {
31787
+ id: "turns",
31788
+ label: "turns",
31789
+ description: "turn and step counters",
31790
+ side: "left"
31791
+ },
31792
+ {
31793
+ id: "durations",
31794
+ label: "durations",
31795
+ description: "llm/ttft/decode/tool wall time",
31796
+ side: "left"
31797
+ },
31798
+ {
31799
+ id: "cache",
31800
+ label: "cache",
31801
+ description: "cache-hit share of billed input",
31802
+ side: "left"
31803
+ },
31804
+ {
31805
+ id: "tokens",
31806
+ label: "tokens",
31807
+ description: "cumulative input/output tokens",
31808
+ side: "left"
31809
+ },
31810
+ {
31811
+ id: "title",
31812
+ label: "title",
31813
+ description: "session title or short id",
31814
+ side: "left"
31815
+ },
31816
+ {
31817
+ id: "goal",
31818
+ label: "goal",
31819
+ description: "live goal phase and round progress",
31820
+ side: "left"
31821
+ },
31822
+ {
31823
+ id: "sandbox",
31824
+ label: "sandbox",
31825
+ description: "divergent sandbox-mode override",
31826
+ side: "left"
31827
+ }
31828
+ ];
31829
+ const STATUSLINE_ITEM_KEYS = {
31830
+ model: {
31831
+ label: "statusline.item.model",
31832
+ description: "statusline.item.model.desc"
31833
+ },
31834
+ cwd: {
31835
+ label: "statusline.item.cwd",
31836
+ description: "statusline.item.cwd.desc"
31837
+ },
31838
+ mode: {
31839
+ label: "statusline.item.mode",
31840
+ description: "statusline.item.mode.desc"
31841
+ },
31842
+ branch: {
31843
+ label: "statusline.item.branch",
31844
+ description: "statusline.item.branch.desc"
31845
+ },
31846
+ context: {
31847
+ label: "statusline.item.context",
31848
+ description: "statusline.item.context.desc"
31849
+ },
31850
+ permission: {
31851
+ label: "statusline.item.permission",
31852
+ description: "statusline.item.permission.desc"
31853
+ },
31854
+ plan: {
31855
+ label: "statusline.item.plan",
31856
+ description: "statusline.item.plan.desc"
31857
+ },
31858
+ turns: {
31859
+ label: "statusline.item.turns",
31860
+ description: "statusline.item.turns.desc"
31861
+ },
31862
+ durations: {
31863
+ label: "statusline.item.durations",
31864
+ description: "statusline.item.durations.desc"
31865
+ },
31866
+ cache: {
31867
+ label: "statusline.item.cache",
31868
+ description: "statusline.item.cache.desc"
31869
+ },
31870
+ tokens: {
31871
+ label: "statusline.item.tokens",
31872
+ description: "statusline.item.tokens.desc"
31873
+ },
31874
+ title: {
31875
+ label: "statusline.item.title",
31876
+ description: "statusline.item.title.desc"
31877
+ },
31878
+ goal: {
31879
+ label: "statusline.item.goal",
31880
+ description: "statusline.item.goal.desc"
31881
+ },
31882
+ sandbox: {
31883
+ label: "statusline.item.sandbox",
31884
+ description: "statusline.item.sandbox.desc"
31885
+ }
31886
+ };
31887
+ /** Picker rows with labels/descriptions in the active interface language. */
31888
+ function localizedStatusItems() {
31889
+ return STATUS_ITEMS.map((item) => {
31890
+ const keys = STATUSLINE_ITEM_KEYS[item.id];
31891
+ return {
31892
+ ...item,
31893
+ label: t(keys.label),
31894
+ description: t(keys.description)
31895
+ };
31896
+ });
31897
+ }
31898
+ /**
31899
+ * Default order: the whole catalog (matches the pre-customization bar).
31900
+ * The busy dot is not an item — it always leads the identity cluster.
31901
+ */
31902
+ const DEFAULT_STATUSLINE_ITEMS = STATUS_ITEMS.map((item) => item.id);
31903
+ /**
31904
+ * Parse a persisted statusline item list. The stored value is the ordered
31905
+ * set of ENABLED items (the Codex /statusline contract): unknown ids and
31906
+ * duplicates drop out, and a non-array value (missing or corrupt file)
31907
+ * falls back to the full default set. An explicitly empty array is valid —
31908
+ * the bar degrades to its busy dot alone.
31909
+ * @param value - the raw parsed JSON value (expected string[]).
31910
+ * @returns the normalized ordered item list.
31911
+ */
31912
+ function parseStatuslineItems(value) {
31913
+ if (!Array.isArray(value)) return [...DEFAULT_STATUSLINE_ITEMS];
31914
+ const known = new Set(STATUS_ITEMS.map((item) => item.id));
31915
+ const kept = [];
31916
+ for (const entry of value) if (typeof entry === "string" && known.has(entry) && !kept.includes(entry)) kept.push(entry);
31917
+ return kept;
31918
+ }
31919
+ /** Minimum blank gap kept between the leading and trailing sides. */
31920
+ const LEFT_RIGHT_GAP = 2;
31921
+ /** Column held back so Ink/yoga measurement drift can never force a wrap. */
31922
+ const WIDTH_SAFETY = 1;
31923
+ /** Column budget for the session title before it ellipsizes. */
31924
+ const TITLE_BUDGET = 48;
31925
+ /**
31926
+ * Primary-row drop ranks: identity never drops; permission outranks context.
31927
+ * The drop ladder peels hint and trailing identity facts before removing
31928
+ * the context meter, so occupancy stays visible on a typical 120-column
31929
+ * terminal. Secondary-row groups reuse the remaining ranks independently.
31930
+ */
31931
+ const RANK_TITLE = 10;
31932
+ const RANK_TOKENS = 50;
31933
+ const RANK_COUNTS = 90;
31934
+ const RANK_CONTEXT = 90;
31935
+ const RANK_SANDBOX = 92;
31936
+ const RANK_GOAL = 95;
31937
+ const RANK_BADGE = 100;
31938
+ const RANK_IDENTITY = Number.POSITIVE_INFINITY;
31939
+ /** Row 2 drop ranks: title and durations go first; state and counts survive longest. */
31940
+ const RANK2_DURATIONS = 40;
31941
+ const RANK2_CACHE = 50;
31942
+ const RANK2_PLAN = 70;
31943
+ /**
31944
+ * Traffic-light tone for a permission preset: read-only stays success green,
31945
+ * full access reads error red, and every workspace-scoped middle ground
31946
+ * (including unknown presets) reads warning amber.
31947
+ * @param permission - active permission preset label.
31948
+ * @returns tone for the badge span.
31949
+ */
31950
+ function permissionTone(permission) {
31951
+ const label = permission.toLowerCase();
31952
+ if (label.includes("read")) return "success";
31953
+ if (label.includes("danger") || label.includes("full")) return "error";
31954
+ return "warn";
31955
+ }
31956
+ /** Display-safe external text: one row, controls escaped. */
31957
+ function safe(text) {
31958
+ return singleLineText(text);
31959
+ }
31960
+ /** Dim junction separator span inside a cluster. */
31961
+ function sep() {
31962
+ return {
31963
+ text: " · ",
31964
+ tone: "label"
31965
+ };
31966
+ }
31967
+ /** True for the dim ` · ` that joins identity facts. */
31968
+ function isIdentitySep(span) {
31969
+ return span.text === " · " && span.tone === "label";
31970
+ }
31971
+ /**
31972
+ * Drop the trailing identity fact (branch, then mode, then cwd) so the
31973
+ * context meter can keep a column. Returns undefined once only the busy
31974
+ * dot and model remain.
31975
+ */
31976
+ function peelIdentityFact(spans) {
31977
+ if (spans.length < 3) return void 0;
31978
+ const last = spans[spans.length - 1];
31979
+ const before = spans[spans.length - 2];
31980
+ const modeSep = spans[spans.length - 3];
31981
+ if (last?.tone === "accent" && modeSep !== void 0 && isIdentitySep(modeSep) && before?.tone === "label") return spans.slice(0, -3);
31982
+ if ((last?.tone === "branch" || last?.tone === "path") && before !== void 0 && isIdentitySep(before)) return spans.slice(0, -2);
31983
+ }
31984
+ /** Total visible columns of a span list (separators ride inside the spans). */
31985
+ function spansWidth(spans) {
31986
+ let width = 0;
31987
+ for (const span of spans) width += visibleColumns(span.text);
31988
+ return width;
31989
+ }
31990
+ /** Join widths of parts with one fixed separator between neighbors. */
31991
+ function joinWidth(parts, separator) {
31992
+ if (parts.length === 0) return 0;
31993
+ let width = 0;
31994
+ for (const part of parts) width += part;
31995
+ return width + separator * (parts.length - 1);
31996
+ }
31997
+ /** Build every candidate group/span with its drop rank and item id. */
31998
+ function buildCandidates(facts, stats, busy, enabled, contextWidth) {
31999
+ const identity = [{
32000
+ text: busy ? "● " : "○ ",
32001
+ tone: busy ? "live" : "meta"
32002
+ }];
32003
+ const push = (span) => {
32004
+ if (identity.length > 1) identity.push(sep());
32005
+ identity.push(span);
32006
+ };
32007
+ const model = safe(facts.model);
32008
+ if (model !== "" && enabled.has("model")) {
32009
+ const effort = safe(stats.reasoningEffort);
32010
+ push({
32011
+ text: effort === "" ? model : `${model}@${effort}`,
32012
+ tone: "model"
32013
+ });
32014
+ }
32015
+ const cwd = safe(facts.cwd);
32016
+ if (cwd !== "" && enabled.has("cwd")) push({
32017
+ text: cwd,
32018
+ tone: "path"
32019
+ });
32020
+ const mode = safe(facts.mode ?? "");
32021
+ if (mode !== "" && enabled.has("mode")) {
32022
+ push({
32023
+ text: t("status.label.mode") + " ",
32024
+ tone: "label"
32025
+ });
32026
+ identity.push({
32027
+ text: mode,
32028
+ tone: "accent"
32029
+ });
32030
+ }
32031
+ const branch = safe(facts.branch);
32032
+ if (branch !== "" && enabled.has("branch")) push({
32033
+ text: "⑂ " + branch,
32034
+ tone: "branch"
32035
+ });
32036
+ const left = [{
32037
+ group: { spans: identity },
32038
+ rank: RANK_IDENTITY,
32039
+ id: "identity"
32040
+ }];
32041
+ const right = [];
32042
+ const row2 = [];
32043
+ if (stats.turns > 0 || stats.steps > 0) {
32044
+ if (enabled.has("turns")) {
32045
+ const counts = [];
32046
+ const pair = (label, value) => {
32047
+ if (counts.length > 0) counts.push(sep());
32048
+ counts.push({
32049
+ text: label + " ",
32050
+ tone: "label"
32051
+ }, {
32052
+ text: value,
32053
+ tone: "value"
32054
+ });
32055
+ };
32056
+ pair(t("status.label.turns"), String(stats.turns));
32057
+ pair(t("status.label.steps"), String(stats.steps));
32058
+ row2.push({
32059
+ group: { spans: counts },
32060
+ rank: RANK_COUNTS,
32061
+ id: "turns"
32062
+ });
32063
+ }
32064
+ if (enabled.has("durations")) {
32065
+ const durations = [];
32066
+ const pair = (label, value) => {
32067
+ if (durations.length > 0) durations.push(sep());
32068
+ durations.push({
32069
+ text: label + " ",
32070
+ tone: "label"
32071
+ }, {
32072
+ text: value,
32073
+ tone: "value"
32074
+ });
32075
+ };
32076
+ if (stats.llmMs > 0) pair(t("status.label.modelTime"), formatDuration(stats.llmMs));
32077
+ if (stats.ttftSteps > 0) pair(t("status.label.latency"), formatDuration(stats.ttftMs / stats.ttftSteps));
32078
+ if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
32079
+ if (durations.length > 0) durations.push(sep());
32080
+ durations.push({
32081
+ text: formatRate(stats.decodeTokens / (stats.decodeMs / 1e3)),
32082
+ tone: "value"
32083
+ }, {
32084
+ text: t("status.label.tokensPerSec"),
32085
+ tone: "label"
32086
+ });
32087
+ }
32088
+ if (stats.toolMs > 0) pair(t("status.label.tool"), formatDuration(stats.toolMs));
32089
+ if (durations.length > 0) row2.push({
32090
+ group: { spans: durations },
32091
+ rank: RANK2_DURATIONS,
32092
+ id: "durations"
32093
+ });
32094
+ }
32095
+ }
32096
+ const cacheHit = cacheHitPercent(stats.usage);
32097
+ if (cacheHit !== null && enabled.has("cache")) {
32098
+ const spans = [{
32099
+ text: t("status.label.cache") + " ",
32100
+ tone: "label"
32101
+ }];
32102
+ if (stats.usage.cacheReadTokens > 0) spans.push({
32103
+ text: formatTokens(stats.usage.cacheReadTokens),
32104
+ tone: "value"
32105
+ }, sep());
32106
+ spans.push({
32107
+ text: cacheHit + "%",
32108
+ tone: "value"
32109
+ });
32110
+ row2.push({
32111
+ group: { spans },
32112
+ rank: RANK2_CACHE,
32113
+ id: "cache"
32114
+ });
32115
+ }
32116
+ if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has("context")) left.push({
32117
+ group: { spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, "full") },
32118
+ rank: RANK_CONTEXT,
32119
+ id: "context"
32120
+ });
32121
+ if ((stats.usage.uncachedInputTokens > 0 || stats.usage.outputTokens > 0) && enabled.has("tokens")) {
32122
+ const tokens = [];
32123
+ const pair = (label, value) => {
32124
+ if (tokens.length > 0) tokens.push(sep());
32125
+ tokens.push({
32126
+ text: label + " ",
32127
+ tone: "label"
32128
+ }, {
32129
+ text: value,
32130
+ tone: "value"
32131
+ });
32132
+ };
32133
+ pair(t("status.label.in"), formatTokens(stats.usage.uncachedInputTokens));
32134
+ pair(t("status.label.out"), formatTokens(stats.usage.outputTokens));
32135
+ row2.push({
32136
+ group: { spans: tokens },
32137
+ rank: RANK_TOKENS,
32138
+ id: "tokens"
32139
+ });
32140
+ }
32141
+ const label = truncateColumns(safe(facts.title !== void 0 && facts.title !== "" ? facts.title : facts.sessionId), TITLE_BUDGET);
32142
+ if (label !== "" && enabled.has("title")) row2.push({
32143
+ group: { spans: [{
32144
+ text: label,
32145
+ tone: "meta"
32146
+ }] },
32147
+ rank: RANK_TITLE,
32148
+ id: "title"
32149
+ });
32150
+ if (facts.goal !== void 0 && enabled.has("goal")) row2.push({
32151
+ group: { spans: [{
32152
+ text: facts.goal.phase === "active" ? t("status.goal.round", {
32153
+ current: facts.goal.rounds,
32154
+ max: facts.goal.max
32155
+ }) : t("status.goal.phase", { phase: safe(facts.goal.phase) }),
32156
+ tone: "accent"
32157
+ }] },
32158
+ rank: RANK_GOAL,
32159
+ id: "goal"
32160
+ });
32161
+ const sandbox = safe(facts.sandbox ?? "");
32162
+ if (sandbox !== "" && sandbox.toLowerCase() !== facts.permission.toLowerCase() && enabled.has("sandbox")) row2.push({
32163
+ group: { spans: [{
32164
+ text: t("status.label.sandbox") + " " + sandbox,
32165
+ tone: "warn"
32166
+ }] },
32167
+ rank: RANK_SANDBOX,
32168
+ id: "sandbox"
32169
+ });
32170
+ const permission = safe(facts.permission);
32171
+ let badge = -1;
32172
+ const planStation = facts.plan && permissionTone(permission) === "success";
32173
+ if (permission !== "" && enabled.has("permission")) {
32174
+ right.push({
32175
+ span: planStation ? {
32176
+ text: t("status.plan.on"),
32177
+ tone: "plan"
32178
+ } : {
32179
+ text: permission,
32180
+ tone: permissionTone(permission)
32181
+ },
32182
+ rank: RANK_BADGE,
32183
+ id: "permission"
32184
+ });
32185
+ badge = right.length - 1;
32149
32186
  }
32187
+ if (facts.plan && enabled.has("plan")) row2.push({
32188
+ group: { spans: [{
32189
+ text: t("status.plan.mark"),
32190
+ tone: "accent"
32191
+ }] },
32192
+ rank: RANK2_PLAN,
32193
+ id: "plan"
32194
+ });
32195
+ return {
32196
+ left,
32197
+ right,
32198
+ badge,
32199
+ row2
32200
+ };
32150
32201
  }
32151
32202
  /**
32152
- * Tokenize a stdin chunk containing at least one editor-only key. Ink calls
32153
- * `useInput` once for a pasted/batched chunk, so preserving each action here
32154
- * prevents repeated Backspace/Home/End/Delete presses from collapsing into
32155
- * one blurred key event. Unknown escape sequences return undefined and stay
32156
- * under Ink's ownership.
32203
+ * Compose the two-row footer layout under a column budget. Row 1 keeps model,
32204
+ * cwd, mode, branch, context, then the right-pinned permission badge and cycle
32205
+ * hint. It shrinks the context bar, drops the hint, and peels trailing
32206
+ * identity facts before removing the context group or the permission badge.
32207
+ * Row 2 fits all secondary figures and state within its own budget.
32208
+ * @param facts - identity facts resolved by the runner.
32209
+ * @param stats - session figures folded from the durable log.
32210
+ * @param columns - usable columns for each row (before their left padding).
32211
+ * @param options - 'busy' hides the cycle hint while a turn runs (Codex
32212
+ * keeps mode hints idle-only); 'items' is the ordered enabled-item config
32213
+ * from /statusline (defaults to the full catalog). Display order follows the
32214
+ * config per side while the drop ladder keeps its fixed ranks.
32215
+ * @returns the two rows to render; row1.left is never empty.
32157
32216
  */
32158
- function tokenizeRawEditorChunk(chunk) {
32159
- const tokens = [];
32160
- let text = "";
32161
- let special = false;
32162
- const flushText = () => {
32163
- if (text === "") return;
32164
- tokens.push({
32165
- kind: "text",
32166
- text
32167
- });
32168
- text = "";
32217
+ function layoutStatusBar(facts, stats, columns, options = {}) {
32218
+ const busy = options.busy === true;
32219
+ const items = options.items ?? DEFAULT_STATUSLINE_ITEMS;
32220
+ const enabled = new Set(items);
32221
+ const budget = Math.max(1, Math.floor(columns) - WIDTH_SAFETY);
32222
+ const maxContextWidth = Math.max(CONTEXT_MIN_WIDTH, Math.min(budget, Math.floor(options.contextWidth ?? 24)));
32223
+ const { left, right, badge, row2 } = buildCandidates(facts, stats, busy, enabled, maxContextWidth);
32224
+ const groupSeparator = visibleColumns(" | ");
32225
+ const itemSeparator = visibleColumns(STATUS_ITEM_SEPARATOR);
32226
+ const position = new Map(items.map((id, index) => [id, index]));
32227
+ const byPosition = (a, b) => (position.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (position.get(b.id) ?? Number.MAX_SAFE_INTEGER);
32228
+ const orderedLeft = [left[0], ...left.slice(1).sort(byPosition)];
32229
+ const orderedRight = right.slice().sort(byPosition);
32230
+ const orderedRow2 = row2.slice().sort(byPosition);
32231
+ let hint = badge >= 0 && !busy;
32232
+ const leftKept = [...orderedLeft];
32233
+ const rightKept = [...orderedRight];
32234
+ let contextReadout = "full";
32235
+ let contextWidth = maxContextWidth;
32236
+ const rebuildContext = () => {
32237
+ const index = leftKept.findIndex((entry) => entry.id === "context");
32238
+ if (index < 0) return;
32239
+ leftKept[index] = {
32240
+ group: { spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, contextReadout) },
32241
+ rank: RANK_CONTEXT,
32242
+ id: "context"
32243
+ };
32169
32244
  };
32170
- for (let offset = 0; offset < chunk.length;) {
32171
- if (chunk.startsWith("\x1B", offset) || chunk.startsWith("\x1B\b", offset)) {
32172
- flushText();
32173
- tokens.push({ kind: "delete-word-backward" });
32174
- special = true;
32175
- offset += 2;
32176
- continue;
32245
+ const width = () => {
32246
+ const leftWidth = joinWidth(leftKept.map((entry) => spansWidth(entry.group.spans)), groupSeparator);
32247
+ const rightWidth = joinWidth(rightKept.map((entry) => visibleColumns(entry.span.text)), itemSeparator) + (hint ? visibleColumns(statusCycleHint()) : 0);
32248
+ return rightWidth > 0 ? leftWidth + LEFT_RIGHT_GAP + rightWidth : leftWidth;
32249
+ };
32250
+ while (width() > budget) {
32251
+ if (leftKept.some((entry) => entry.id === "context")) {
32252
+ if (contextWidth > CONTEXT_MIN_WIDTH) {
32253
+ const overflow = width() - budget;
32254
+ contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow);
32255
+ rebuildContext();
32256
+ continue;
32257
+ }
32258
+ if (contextReadout === "full") {
32259
+ contextReadout = "percent";
32260
+ rebuildContext();
32261
+ continue;
32262
+ }
32177
32263
  }
32178
- const functional = functionalToken(chunk, offset);
32179
- if (functional !== void 0) {
32180
- flushText();
32181
- tokens.push(functional.token);
32182
- special = true;
32183
- offset += functional.length;
32264
+ if (hint) {
32265
+ hint = false;
32184
32266
  continue;
32185
32267
  }
32186
- const char = chunk[offset];
32187
- if (char === "" || char === "\b") {
32188
- flushText();
32189
- tokens.push({ kind: "delete-backward" });
32190
- special = true;
32191
- offset += 1;
32268
+ const identityEntry = leftKept[0];
32269
+ if (identityEntry?.id === "identity") {
32270
+ const peeled = peelIdentityFact(identityEntry.group.spans);
32271
+ if (peeled !== void 0) {
32272
+ leftKept[0] = {
32273
+ ...identityEntry,
32274
+ group: { spans: peeled }
32275
+ };
32276
+ continue;
32277
+ }
32278
+ }
32279
+ if (leftKept.some((entry) => entry.id === "context")) {
32280
+ leftKept.splice(leftKept.findIndex((entry) => entry.id === "context"), 1);
32192
32281
  continue;
32193
32282
  }
32194
- if (char === "\x1B") return void 0;
32195
- text += char;
32196
- offset += 1;
32283
+ let dropLeft = -1;
32284
+ let dropRight = -1;
32285
+ let dropRank = Number.POSITIVE_INFINITY;
32286
+ for (let index = 0; index < leftKept.length; index += 1) {
32287
+ const rank = leftKept[index].rank;
32288
+ if (rank < dropRank) {
32289
+ dropRank = rank;
32290
+ dropLeft = index;
32291
+ dropRight = -1;
32292
+ }
32293
+ }
32294
+ for (let index = 0; index < rightKept.length; index += 1) {
32295
+ const rank = rightKept[index].rank;
32296
+ if (rank < dropRank) {
32297
+ dropRank = rank;
32298
+ dropRight = index;
32299
+ dropLeft = -1;
32300
+ }
32301
+ }
32302
+ if (dropLeft < 0 && dropRight < 0) break;
32303
+ if (dropLeft >= 0) leftKept.splice(dropLeft, 1);
32304
+ else {
32305
+ rightKept.splice(dropRight, 1);
32306
+ if (dropRight === rightKept.length) hint = false;
32307
+ }
32197
32308
  }
32198
- flushText();
32199
- return special ? tokens : void 0;
32309
+ if (width() > budget) {
32310
+ rightKept.length = 0;
32311
+ hint = false;
32312
+ while (leftKept.length > 1) leftKept.pop();
32313
+ leftKept[0] = {
32314
+ group: { spans: [{
32315
+ text: truncateColumns(leftKept[0].group.spans.map((span) => span.text).join(""), budget),
32316
+ tone: "model"
32317
+ }] },
32318
+ rank: RANK_IDENTITY,
32319
+ id: "identity"
32320
+ };
32321
+ }
32322
+ const row2Kept = [...orderedRow2];
32323
+ const row2Budget = Math.max(1, budget - 2);
32324
+ const row2Width = () => joinWidth(row2Kept.map((entry) => spansWidth(entry.group.spans)), groupSeparator);
32325
+ while (row2Width() > row2Budget && row2Kept.length > 0) {
32326
+ let dropIndex = 0;
32327
+ let dropRank = Number.POSITIVE_INFINITY;
32328
+ for (let index = 0; index < row2Kept.length; index += 1) if (row2Kept[index].rank < dropRank) {
32329
+ dropRank = row2Kept[index].rank;
32330
+ dropIndex = index;
32331
+ }
32332
+ row2Kept.splice(dropIndex, 1);
32333
+ }
32334
+ return {
32335
+ row1: {
32336
+ left: leftKept.map((entry) => entry.group),
32337
+ right: rightKept.map((entry) => entry.span),
32338
+ hint
32339
+ },
32340
+ row2: {
32341
+ left: row2Kept.map((entry) => entry.group),
32342
+ right: [],
32343
+ hint: false
32344
+ }
32345
+ };
32200
32346
  }
32201
32347
  //#endregion
32202
32348
  //#region src/ui/query-editor.ts
@@ -35301,9 +35447,9 @@ function deepseekWaveHues(tier) {
35301
35447
  palette.brandMid
35302
35448
  ];
35303
35449
  }
35304
- /** Original web StateDot chase used by the busy composer marker. */
35450
+ /** Functional busy chase; decorative color flow still follows the preference. */
35305
35451
  function BusyChase({ animated = true }) {
35306
- const tick = useFrames(125, animated);
35452
+ const tick = useFrames(125, true);
35307
35453
  const flow = themeFlow();
35308
35454
  const marker = flow !== void 0 && animated ? flowColor(tick * 125 + flow.phaseMs, flow.anchors) : getPalette().brandBright;
35309
35455
  return (0, import_react.createElement)(Text, { color: inkColor(marker) }, busyChaseFrame(tick) + " ");
@@ -35636,7 +35782,7 @@ function Composer({ active, frozen, frozenHint, busy, descriptors, skills, dispa
35636
35782
  const [preparingImages, setPreparingImages] = (0, import_react.useState)(false);
35637
35783
  const prepareAbortRef = (0, import_react.useRef)(void 0);
35638
35784
  const prepareEpochRef = (0, import_react.useRef)(0);
35639
- const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages && animations);
35785
+ const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages);
35640
35786
  (0, import_react.useEffect)(() => () => {
35641
35787
  prepareEpochRef.current += 1;
35642
35788
  prepareAbortRef.current?.abort();
@@ -38315,6 +38461,37 @@ function QuestionBar({ store, snapshot, locked }) {
38315
38461
  }
38316
38462
  /** The /model panel: a scrolling list over the advisory model directory. */
38317
38463
  //#endregion
38464
+ //#region src/render/transcript-viewport.ts
38465
+ /**
38466
+ * Advance the monotonic physical-row flush cursor.
38467
+ *
38468
+ * Ordinary renders never pull a row back out of native scrollback. A source-
38469
+ * backed replay (epoch change) flushes the complete durable window exactly
38470
+ * once after the caller clears/remounts Static; subsequent durable rows start
38471
+ * a fresh retained tail. Session/reset shrink starts from the new source.
38472
+ */
38473
+ function advanceTranscriptViewport(previous, input) {
38474
+ const total = Math.max(0, Math.floor(input.totalRows));
38475
+ const retained = Math.max(0, Math.floor(input.retainedRows));
38476
+ const desiredStatic = Math.max(0, total - retained);
38477
+ const staticRows = previous.sessionKey !== input.sessionKey ? desiredStatic : previous.epoch !== input.epoch ? total : total < previous.flushedRows ? desiredStatic : Math.max(previous.flushedRows, desiredStatic);
38478
+ return {
38479
+ cursor: {
38480
+ sessionKey: input.sessionKey,
38481
+ epoch: input.epoch,
38482
+ flushedRows: staticRows
38483
+ },
38484
+ staticRows,
38485
+ liveRows: total - staticRows
38486
+ };
38487
+ }
38488
+ /** Real history rows that fit beside the currently painted stream/tool rows. */
38489
+ function visibleTranscriptRows(historyRows, occupiedRows, capacity) {
38490
+ const history = Math.max(0, Math.floor(historyRows));
38491
+ const available = Math.max(0, Math.floor(capacity) - Math.max(0, Math.floor(occupiedRows)));
38492
+ return Math.min(history, available);
38493
+ }
38494
+ //#endregion
38318
38495
  //#region src/app.ts
38319
38496
  /**
38320
38497
  * The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
@@ -38363,26 +38540,23 @@ const TRIM_REFLOW_CLEAR = "\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[H";
38363
38540
  const SYNCHRONIZED_UPDATE_BEGIN = "\x1B[?2026h";
38364
38541
  /** Release the held frame after Ink has replayed the source-backed Static rows. */
38365
38542
  const SYNCHRONIZED_UPDATE_END = "\x1B[?2026l";
38366
- /**
38367
- * The original web StateDot chase used by the busy composer marker. With
38368
- * animations off it freezes on the first frame (still visibly busy).
38369
- */
38370
- function Caret({ animated = true }) {
38371
- const tick = useFrames(530, animated);
38543
+ /** Functional streaming caret: activity remains visible in reduced motion. */
38544
+ function Caret() {
38545
+ const tick = useFrames(530, true);
38372
38546
  return (0, import_react.createElement)(Text, null, caretVisible(tick) ? "▍" : " ");
38373
38547
  }
38374
38548
  /** One resettable input-caret phase shared by the entire composer. */
38375
- function ShimmerLine({ text, animated = true }) {
38376
- const tick = useFrames(33, animated);
38549
+ function ShimmerLine({ text, themeFlowAnimated = true }) {
38550
+ const tick = useFrames(33, true);
38377
38551
  const palette = getPalette();
38378
38552
  const flow = themeFlow();
38379
- const highlight = flow !== void 0 && animated ? flowColor(tick * 33 + flow.phaseMs, flow.anchors) : palette.brandBright;
38553
+ const highlight = flow !== void 0 && themeFlowAnimated ? flowColor(tick * 33 + flow.phaseMs, flow.anchors) : palette.brandBright;
38380
38554
  const graphemes = splitGraphemes(text);
38381
38555
  return (0, import_react.createElement)(Text, { wrap: "truncate-end" }, ...graphemes.map((grapheme, index) => {
38382
38556
  const sparkle = grapheme.text === "✻";
38383
38557
  return (0, import_react.createElement)(Text, {
38384
38558
  key: `${grapheme.start}-${grapheme.end}`,
38385
- color: inkColor(!animated ? sparkle ? palette.brandBright : palette.brandDeep : sparkle ? deepDivingSparkColor(tick, palette.brandDeep, highlight) : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, highlight)),
38559
+ color: inkColor(sparkle ? deepDivingSparkColor(tick, palette.brandDeep, highlight) : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, highlight)),
38386
38560
  bold: sparkle || void 0
38387
38561
  }, grapheme.text);
38388
38562
  }));
@@ -38393,12 +38567,13 @@ function ShimmerLine({ text, animated = true }) {
38393
38567
  * only once the turn has clearly been running (15s) — anchored to `turn/start`
38394
38568
  * so a resumed mid-turn keeps the real time.
38395
38569
  */
38396
- function DeepDivingLine({ since, animated = true }) {
38570
+ function DeepDivingLine({ since, themeFlowAnimated = true }) {
38571
+ useFrames(1e3, true);
38397
38572
  const elapsed = since === 0 ? 0 : Date.now() - since;
38398
38573
  const text = elapsed >= 15e3 ? `✻ Deep diving... ${runClock(elapsed)}` : "✻ Deep diving...";
38399
38574
  return (0, import_react.createElement)(ShimmerLine, {
38400
38575
  text,
38401
- animated
38576
+ themeFlowAnimated
38402
38577
  });
38403
38578
  }
38404
38579
  /**
@@ -38419,11 +38594,25 @@ function streamTailBodyColumns(rowColumns, prefix, continuationPrefix = prefix)
38419
38594
  const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix));
38420
38595
  return Math.max(1, width - prefixColumns);
38421
38596
  }
38422
- function StreamTail({ text, dim, maxRows, prefix = "", continuationPrefix = prefix, children, columns }) {
38597
+ /** Shared physical layout for streaming render and viewport row accounting. */
38598
+ function streamTailLayout(text, columns, maxRows, prefix, continuationPrefix) {
38423
38599
  const safeRows = Math.max(1, maxRows);
38424
38600
  const contentColumns = streamTailBodyColumns(columns, prefix, continuationPrefix);
38425
38601
  const initial = displayTail(text, contentColumns, safeRows);
38426
38602
  const tail = initial.truncated && safeRows > 1 ? displayTail(text, contentColumns, safeRows - 1) : initial;
38603
+ return {
38604
+ text: tail.text,
38605
+ truncated: tail.truncated,
38606
+ rows: tail.text.split("\n").length + (tail.truncated && safeRows > 1 ? 1 : 0)
38607
+ };
38608
+ }
38609
+ function streamTailPhysicalRows(text, columns, maxRows, prefix = "", continuationPrefix = prefix) {
38610
+ if (text === "" || maxRows <= 0) return 0;
38611
+ return streamTailLayout(text, columns, maxRows, prefix, continuationPrefix).rows;
38612
+ }
38613
+ function StreamTail({ text, dim, maxRows, prefix = "", continuationPrefix = prefix, children, columns }) {
38614
+ const safeRows = Math.max(1, maxRows);
38615
+ const tail = streamTailLayout(text, columns, safeRows, prefix, continuationPrefix);
38427
38616
  const rows = tail.text.split("\n");
38428
38617
  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, {
38429
38618
  key: index,
@@ -39003,7 +39192,10 @@ function StatusLine({ facts, stats, busy, columns, items, onRows, animated }) {
39003
39192
  }, (0, import_react.createElement)(Text, { wrap: "truncate-end" }, ...leftParts), rightParts.length > 0 ? (0, import_react.createElement)(Text, { wrap: "truncate-end" }, ...rightParts) : void 0);
39004
39193
  };
39005
39194
  const row2Present = layout.row2.left.length > 0;
39006
- return (0, import_react.createElement)(Box, { flexDirection: "column" }, renderRow(layout.row1, "s1"), row2Present ? renderRow(layout.row2, "s2", 2) : void 0);
39195
+ return (0, import_react.createElement)(Box, {
39196
+ flexDirection: "column",
39197
+ width: columns
39198
+ }, renderRow(layout.row1, "s1"), row2Present ? renderRow(layout.row2, "s2", 2) : void 0);
39007
39199
  }
39008
39200
  /**
39009
39201
  * One fixed-height local feedback row. Errors remain visible while a slash
@@ -39265,21 +39457,32 @@ function StaticTranscript({ items }) {
39265
39457
  });
39266
39458
  }
39267
39459
  const MemoStaticTranscript = (0, import_react.memo)(StaticTranscript);
39268
- /** Build one settled row (row Box plus its roomy-prompt spacers and row count). */
39460
+ /** Build one settled entry as stable physical rows shared by Static and viewport. */
39269
39461
  function buildSettledRow(entry, index, showReasoning, columns) {
39270
- const roomyPrompt = entry.kind === "user" && !entry.notice;
39271
- const lines = settledEntryLines(entry, Math.max(10, columns - 2), showReasoning);
39462
+ const physical = settledEntryLines(entry, Math.max(10, columns - 2), showReasoning).map((line, row) => ({
39463
+ line,
39464
+ element: (0, import_react.createElement)(Box, { key: `entry-${index}-row-${row}` }, (0, import_react.createElement)(StyledRows, { lines: [line] }))
39465
+ }));
39466
+ if (entry.kind === "user" && !entry.notice) {
39467
+ const blank = { segments: [] };
39468
+ physical.unshift({
39469
+ line: blank,
39470
+ element: (0, import_react.createElement)(Box, {
39471
+ key: `prompt-before-${index}`,
39472
+ paddingX: 1
39473
+ }, (0, import_react.createElement)(Text, null, " "))
39474
+ });
39475
+ physical.push({
39476
+ line: blank,
39477
+ element: (0, import_react.createElement)(Box, {
39478
+ key: `prompt-after-${index}`,
39479
+ paddingX: 1
39480
+ }, (0, import_react.createElement)(Text, null, " "))
39481
+ });
39482
+ }
39272
39483
  return {
39273
- box: (0, import_react.createElement)(Box, { key: index }, (0, import_react.createElement)(StyledRows, { lines })),
39274
- before: roomyPrompt ? (0, import_react.createElement)(Box, {
39275
- key: `prompt-before-${index}`,
39276
- paddingX: 1
39277
- }, (0, import_react.createElement)(Text, null, " ")) : void 0,
39278
- after: roomyPrompt ? (0, import_react.createElement)(Box, {
39279
- key: `prompt-after-${index}`,
39280
- paddingX: 1
39281
- }, (0, import_react.createElement)(Text, null, " ")) : void 0,
39282
- rows: lines.length + (roomyPrompt ? 2 : 0)
39484
+ physical,
39485
+ rows: physical.length
39283
39486
  };
39284
39487
  }
39285
39488
  /** The dim hint row placed under the header once the window has dropped entries. */
@@ -39305,8 +39508,9 @@ function settledTrimHint(droppedEntries, columns) {
39305
39508
  *
39306
39509
  * RENDERED-HISTORY CAP: the window holds at most `rowCap` physical rows of
39307
39510
  * settled transcript (header and hint reserved on top). The cap exists only
39308
- * here — the event log, the store projection, /export, Ctrl+O, and /resume
39309
- * keep the full history. Ink 5's <Static> is a consumption counter
39511
+ * here — the event log, the store projection, /export, and /resume keep the
39512
+ * full history; Ctrl+O keeps its inspectable subset. Ink 5's <Static> is a
39513
+ * consumption counter
39310
39514
  * (items.slice(index) keyed on length): deleting head items mid-stream while
39311
39515
  * appending tail items can permanently swallow new rows, so the append branch
39312
39516
  * NEVER drops the head — it only accounts rows and flags `needsTrim` once the
@@ -39328,7 +39532,7 @@ function settledTrimHint(droppedEntries, columns) {
39328
39532
  function computeSettledRows(previous, entries, settled, showReasoning, resumed, epoch, columns = 80, rowCap = SETTLED_ROW_CAP) {
39329
39533
  if (previous === void 0 || previous.epoch !== epoch || previous.resumed !== resumed || settled < previous.entries.length) {
39330
39534
  const records = /* @__PURE__ */ new Map();
39331
- const window = [];
39535
+ const physical = [];
39332
39536
  let windowRows = 0;
39333
39537
  let droppedEntries = 0;
39334
39538
  let index = settled - 1;
@@ -39342,19 +39546,18 @@ function computeSettledRows(previous, entries, settled, showReasoning, resumed,
39342
39546
  }
39343
39547
  records.set(entry, record);
39344
39548
  windowRows += record.rows;
39345
- if (record.after !== void 0) window.unshift(record.after);
39346
- window.unshift(record.box);
39347
- if (record.before !== void 0) window.unshift(record.before);
39549
+ physical.unshift(...record.physical);
39348
39550
  }
39349
39551
  const header = (0, import_react.createElement)(Header, {
39350
39552
  key: "header",
39351
39553
  resumed
39352
39554
  });
39555
+ const rowElements = physical.map((row) => row.element);
39353
39556
  const flat = droppedEntries > 0 ? [
39354
39557
  header,
39355
39558
  settledTrimHint(droppedEntries, columns),
39356
- ...window
39357
- ] : [header, ...window];
39559
+ ...rowElements
39560
+ ] : [header, ...rowElements];
39358
39561
  return {
39359
39562
  cache: {
39360
39563
  entries: entries.slice(droppedEntries, settled),
@@ -39365,6 +39568,7 @@ function computeSettledRows(previous, entries, settled, showReasoning, resumed,
39365
39568
  epoch,
39366
39569
  columns,
39367
39570
  flat,
39571
+ physical,
39368
39572
  droppedEntries,
39369
39573
  totalRows: windowRows,
39370
39574
  needsTrim: false
@@ -39393,9 +39597,7 @@ function computeSettledRows(previous, entries, settled, showReasoning, resumed,
39393
39597
  records.set(entry, record);
39394
39598
  suffix.push(entry);
39395
39599
  deltaRows += record.rows;
39396
- if (record.before !== void 0) added.push(record.before);
39397
- added.push(record.box);
39398
- if (record.after !== void 0) added.push(record.after);
39600
+ added.push(...record.physical);
39399
39601
  }
39400
39602
  const totalRows = previous.totalRows + deltaRows;
39401
39603
  const needsTrim = rowCap > 0 && totalRows > rowCap + Math.floor(rowCap / 4);
@@ -39408,7 +39610,8 @@ function computeSettledRows(previous, entries, settled, showReasoning, resumed,
39408
39610
  showReasoning,
39409
39611
  epoch: previous.epoch,
39410
39612
  columns: previous.columns,
39411
- flat: previous.flat.concat(added),
39613
+ flat: previous.flat.concat(added.map((row) => row.element)),
39614
+ physical: previous.physical.concat(added),
39412
39615
  droppedEntries: previous.droppedEntries,
39413
39616
  totalRows,
39414
39617
  needsTrim
@@ -39797,6 +40000,7 @@ function App(props) {
39797
40000
  panelSurfacesRef.current = panelSurfaces;
39798
40001
  const openPanel = panelSurfaces.find((surface) => surface.open);
39799
40002
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending;
40003
+ const verboseEntries = (0, import_react.useMemo)(() => verboseOpen ? inspectableTranscriptEntries(view.entries) : [], [verboseOpen, view.entries]);
39800
40004
  const modalVisible = openPanel !== void 0 || inspectorVisible || approvalPending || questionPending;
39801
40005
  const inputActive = deleteConfirmId !== void 0 ? !approvalPending && !questionPending : !modalVisible;
39802
40006
  const transcriptVisible = !modalVisible;
@@ -39813,10 +40017,15 @@ function App(props) {
39813
40017
  }));
39814
40018
  const terminalSizeRef = (0, import_react.useRef)(terminalSize);
39815
40019
  const settledRowsCache = (0, import_react.useRef)(void 0);
39816
- const settledRows = (0, import_react.useMemo)(() => {
40020
+ const viewportFlushRef = (0, import_react.useRef)({
40021
+ sessionKey: props.sessionKey,
40022
+ epoch: refreshEpoch,
40023
+ rows: 0
40024
+ });
40025
+ const settledRowsResult = (0, import_react.useMemo)(() => {
39817
40026
  const result = computeSettledRows(settledRowsCache.current, view.entries, settled, showReasoning, props.resumed, refreshEpoch, terminalSize.columns);
39818
40027
  settledRowsCache.current = result.cache;
39819
- return result.cache.flat;
40028
+ return result;
39820
40029
  }, [
39821
40030
  view.entries,
39822
40031
  settled,
@@ -39891,17 +40100,54 @@ function App(props) {
39891
40100
  const deepDivingVisible = busy && !streamingActive;
39892
40101
  const tabTitle = view.title === "" ? DEFAULT_TERMINAL_TITLE : view.title;
39893
40102
  useTerminalTitle(tabTitle);
39894
- const allLiveLines = (0, import_react.useMemo)(() => view.entries.slice(settled).flatMap((entry) => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2), showReasoning)), [
40103
+ const maximumTranscriptRows = liveRegionBudget({
40104
+ terminalRows,
40105
+ composerRows: 1,
40106
+ statusBarRows: 1,
40107
+ menuRows: 0,
40108
+ gutterRows: composerGutterRows,
40109
+ notice: false,
40110
+ todo: false,
40111
+ agents: false
40112
+ });
40113
+ const settledPhysical = settledRowsResult.cache.physical;
40114
+ const viewportStep = advanceTranscriptViewport({
40115
+ sessionKey: viewportFlushRef.current.sessionKey,
40116
+ epoch: viewportFlushRef.current.epoch,
40117
+ flushedRows: viewportFlushRef.current.rows
40118
+ }, {
40119
+ sessionKey: props.sessionKey,
40120
+ epoch: refreshEpoch,
40121
+ totalRows: settledPhysical.length,
40122
+ retainedRows: maximumTranscriptRows
40123
+ });
40124
+ const flushedRows = viewportStep.staticRows;
40125
+ viewportFlushRef.current = {
40126
+ sessionKey: viewportStep.cursor.sessionKey,
40127
+ epoch: viewportStep.cursor.epoch,
40128
+ rows: viewportStep.cursor.flushedRows
40129
+ };
40130
+ const staticPrefixRows = settledRowsResult.cache.flat.length - settledPhysical.length;
40131
+ const settledRows = settledRowsResult.cache.flat.slice(0, staticPrefixRows + flushedRows);
40132
+ const retainedSettledLines = settledPhysical.slice(flushedRows).map((row) => row.line);
40133
+ const mutableLiveLines = (0, import_react.useMemo)(() => view.entries.slice(settled).flatMap((entry) => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2), showReasoning)), [
39895
40134
  view.entries,
39896
40135
  settled,
39897
40136
  terminalColumns,
39898
40137
  showReasoning
39899
40138
  ]);
39900
- const liveBudget = busy || streamingActive ? Math.max(1, Math.floor(dynamicRows / 3)) : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0));
39901
- const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget);
39902
- const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length);
39903
- const reasoningRows = view.streamingReasoning === "" ? 0 : view.streaming === "" ? streamRows : streamRows <= 1 ? 0 : showReasoning ? Math.max(1, Math.floor(streamRows / 3)) : 1;
39904
- const answerRows = view.streaming === "" ? 0 : Math.max(1, streamRows - reasoningRows);
40139
+ const allLiveLines = retainedSettledLines.concat(mutableLiveLines);
40140
+ const provisionalLiveBudget = busy || streamingActive ? Math.max(1, Math.floor(dynamicRows / 3)) : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0));
40141
+ const provisionalLiveRows = Math.min(allLiveLines.length, provisionalLiveBudget);
40142
+ const provisionalStreamRows = Math.max(1, dynamicRows - provisionalLiveRows);
40143
+ const provisionalReasoningRows = view.streamingReasoning === "" ? 0 : view.streaming === "" ? provisionalStreamRows : provisionalStreamRows <= 1 ? 0 : showReasoning ? Math.max(1, Math.floor(provisionalStreamRows / 3)) : 1;
40144
+ const provisionalAnswerRows = view.streaming === "" ? 0 : Math.max(1, provisionalStreamRows - provisionalReasoningRows);
40145
+ const streamColumns = Math.max(1, terminalColumns - 2);
40146
+ const reasoningRows = view.streamingReasoning === "" || provisionalReasoningRows === 0 ? 0 : showReasoning ? streamTailPhysicalRows(view.streamingReasoning, streamColumns, provisionalReasoningRows, "✻ ", " ") : 1;
40147
+ const answerRows = view.streaming === "" || provisionalAnswerRows === 0 ? 0 : streamTailPhysicalRows(view.streaming, streamColumns, provisionalAnswerRows, " ");
40148
+ const nonHistoryRows = reasoningRows + answerRows + (deepDivingVisible ? 1 : 0);
40149
+ const visibleHistoryRows = visibleTranscriptRows(allLiveLines.length, nonHistoryRows, dynamicRows);
40150
+ const visibleLiveLines = visibleHistoryRows === 0 ? [] : allLiveLines.slice(-visibleHistoryRows);
39905
40151
  const liveAudit = clampLiveAllocation({
39906
40152
  live: visibleLiveLines.length,
39907
40153
  reasoning: reasoningRows,
@@ -39914,6 +40160,11 @@ function App(props) {
39914
40160
  const auditedLiveLines = liveAudit.allocation.live === visibleLiveLines.length ? visibleLiveLines : visibleLiveLines.slice(-liveAudit.allocation.live);
39915
40161
  const auditedReasoningRows = liveAudit.allocation.reasoning;
39916
40162
  const auditedAnswerRows = liveAudit.allocation.answer;
40163
+ const transcriptViewportFilled = allLiveLines.length + nonHistoryRows >= dynamicRows;
40164
+ const modalViewportFilled = allLiveLines.length >= dynamicRows;
40165
+ const anchoredSurfaceRows = dynamicRows + (transcriptVisible && view.todos.length > 0 ? 1 : 0) + (transcriptVisible && agentRows.length > 0 ? 1 : 0);
40166
+ const surfaceAnchored = transcriptVisible ? transcriptViewportFilled : modalViewportFilled;
40167
+ const frozenModalLines = surfaceAnchored && !transcriptVisible ? allLiveLines.slice(-dynamicRows) : [];
39917
40168
  const keyboardOwner = approvalPending ? "the approval prompt" : questionPending ? "the question" : openPanel?.hint ?? (inspectorVisible ? "history details" : void 0);
39918
40169
  useStableInput((input, key) => {
39919
40170
  if (!(key.ctrl && input === "c")) return;
@@ -39957,10 +40208,15 @@ function App(props) {
39957
40208
  const attachedView = (0, import_react.useSyncExternalStore)(subscribeAttached, readAttached);
39958
40209
  const attachedSettled = (0, import_react.useMemo)(() => settledEntryCount(attachedView.entries), [attachedView.entries]);
39959
40210
  const attachedRowsCache = (0, import_react.useRef)(void 0);
39960
- const attachedSettledRows = (0, import_react.useMemo)(() => {
40211
+ const attachedViewportFlushRef = (0, import_react.useRef)({
40212
+ sessionKey: "none",
40213
+ epoch: refreshEpoch,
40214
+ rows: 0
40215
+ });
40216
+ const attachedSettledRowsResult = (0, import_react.useMemo)(() => {
39961
40217
  const result = computeSettledRows(attachedRowsCache.current, attachedView.entries, attachedSettled, showReasoning, true, refreshEpoch, terminalSize.columns);
39962
40218
  attachedRowsCache.current = result.cache;
39963
- return result.cache.flat;
40219
+ return result;
39964
40220
  }, [
39965
40221
  attachedView.entries,
39966
40222
  attachedSettled,
@@ -39968,6 +40224,26 @@ function App(props) {
39968
40224
  refreshEpoch,
39969
40225
  terminalSize.columns
39970
40226
  ]);
40227
+ const attachDynamicRows = Math.max(1, terminalRows - 3 - 1 - 1 - 2);
40228
+ const attachedPhysical = attachedSettledRowsResult.cache.physical;
40229
+ const attachedViewportStep = advanceTranscriptViewport({
40230
+ sessionKey: attachedViewportFlushRef.current.sessionKey,
40231
+ epoch: attachedViewportFlushRef.current.epoch,
40232
+ flushedRows: attachedViewportFlushRef.current.rows
40233
+ }, {
40234
+ sessionKey: attachment?.id ?? "none",
40235
+ epoch: refreshEpoch,
40236
+ totalRows: attachedPhysical.length,
40237
+ retainedRows: attachDynamicRows
40238
+ });
40239
+ attachedViewportFlushRef.current = {
40240
+ sessionKey: attachedViewportStep.cursor.sessionKey,
40241
+ epoch: attachedViewportStep.cursor.epoch,
40242
+ rows: attachedViewportStep.cursor.flushedRows
40243
+ };
40244
+ const attachedStaticPrefixRows = attachedSettledRowsResult.cache.flat.length - attachedPhysical.length;
40245
+ const attachedSettledRows = attachedSettledRowsResult.cache.flat.slice(0, attachedStaticPrefixRows + attachedViewportStep.staticRows);
40246
+ const attachedRetainedLines = attachedPhysical.slice(attachedViewportStep.staticRows).map((row) => row.line);
39971
40247
  /** Detach and hand the keyboard back to the /agents list. */
39972
40248
  const detach = (0, import_react.useCallback)(() => {
39973
40249
  setAttachment((current) => {
@@ -40342,25 +40618,33 @@ function App(props) {
40342
40618
  });
40343
40619
  }
40344
40620
  if (attachment !== void 0) {
40345
- const attachDynamicRows = Math.max(1, terminalRows - 3 - 1 - 1 - 2);
40346
40621
  const attachBusy = attachedView.busySince !== void 0;
40347
40622
  const attachStreaming = attachedView.streaming !== "" || attachedView.streamingReasoning !== "";
40348
- const attachAllLiveLines = attachedView.entries.slice(attachedSettled).flatMap((entry) => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2), showReasoning));
40349
- const attachLiveBudget = attachBusy || attachStreaming ? Math.max(1, Math.floor(attachDynamicRows / 3)) : Math.max(0, attachDynamicRows - 1);
40350
- const attachVisibleLive = attachLiveBudget === 0 ? [] : attachAllLiveLines.slice(-attachLiveBudget);
40351
- const attachStreamRows = Math.max(1, attachDynamicRows - attachVisibleLive.length);
40352
- const attachReasoningRows = attachedView.streamingReasoning === "" ? 0 : attachedView.streaming === "" ? attachStreamRows : attachStreamRows <= 1 ? 0 : showReasoning ? Math.max(1, Math.floor(attachStreamRows / 3)) : 1;
40353
- const attachAnswerRows = attachedView.streaming === "" ? 0 : Math.max(1, attachStreamRows - attachReasoningRows);
40623
+ const attachMutableLines = attachedView.entries.slice(attachedSettled).flatMap((entry) => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2), showReasoning));
40624
+ const attachAllLiveLines = attachedRetainedLines.concat(attachMutableLines);
40625
+ const attachProvisionalLiveRows = Math.min(attachAllLiveLines.length, attachBusy || attachStreaming ? Math.max(1, Math.floor(attachDynamicRows / 3)) : attachDynamicRows);
40626
+ const attachStreamRows = Math.max(1, attachDynamicRows - attachProvisionalLiveRows);
40627
+ const attachReasoningLimit = attachedView.streamingReasoning === "" ? 0 : attachedView.streaming === "" ? attachStreamRows : attachStreamRows <= 1 ? 0 : showReasoning ? Math.max(1, Math.floor(attachStreamRows / 3)) : 1;
40628
+ const attachAnswerLimit = attachedView.streaming === "" ? 0 : Math.max(1, attachStreamRows - attachReasoningLimit);
40629
+ const attachReasoningRows = attachedView.streamingReasoning === "" || attachReasoningLimit === 0 ? 0 : showReasoning ? streamTailPhysicalRows(attachedView.streamingReasoning, Math.max(1, terminalColumns - 2), attachReasoningLimit, "✻ ", " ") : 1;
40630
+ const attachAnswerRows = attachedView.streaming === "" || attachAnswerLimit === 0 ? 0 : streamTailPhysicalRows(attachedView.streaming, Math.max(1, terminalColumns - 2), attachAnswerLimit, " ");
40631
+ const attachNonHistoryRows = attachReasoningRows + attachAnswerRows + (attachBusy && !attachStreaming ? 1 : 0);
40632
+ const attachVisibleHistoryRows = visibleTranscriptRows(attachAllLiveLines.length, attachNonHistoryRows, attachDynamicRows);
40633
+ const attachVisibleLive = attachVisibleHistoryRows === 0 ? [] : attachAllLiveLines.slice(-attachVisibleHistoryRows);
40354
40634
  const attachAudit = clampLiveAllocation({
40355
40635
  live: attachVisibleLive.length,
40356
40636
  reasoning: attachReasoningRows,
40357
40637
  answer: attachAnswerRows
40358
40638
  }, attachDynamicRows);
40639
+ const attachViewportFilled = attachAllLiveLines.length + attachNonHistoryRows >= attachDynamicRows;
40359
40640
  const attachTurns = attachedView.entries.filter((entry) => entry.kind === "turn-marker").length;
40360
40641
  return (0, import_react.createElement)(Box, { flexDirection: "column" }, (0, import_react.createElement)(MemoStaticTranscript, {
40361
40642
  key: refreshEpoch,
40362
40643
  items: attachedSettledRows
40363
- }), (0, import_react.createElement)(Box, { flexDirection: "column" }, attachAudit.allocation.live === attachVisibleLive.length && attachVisibleLive.length > 0 ? (0, import_react.createElement)(StyledRows, { lines: attachVisibleLive }) : void 0, attachedView.streamingReasoning !== "" && attachAudit.allocation.reasoning > 0 ? (0, import_react.createElement)(StreamTail, {
40644
+ }), (0, import_react.createElement)(Box, attachViewportFilled ? {
40645
+ flexDirection: "column",
40646
+ height: attachDynamicRows
40647
+ } : { flexDirection: "column" }, attachAudit.allocation.live === attachVisibleLive.length && attachVisibleLive.length > 0 ? (0, import_react.createElement)(StyledRows, { lines: attachVisibleLive }) : void 0, attachedView.streamingReasoning !== "" && attachAudit.allocation.reasoning > 0 ? (0, import_react.createElement)(StreamTail, {
40364
40648
  text: showReasoning ? attachedView.streamingReasoning : "Thinking…",
40365
40649
  prefix: "✻ ",
40366
40650
  continuationPrefix: " ",
@@ -40373,9 +40657,9 @@ function App(props) {
40373
40657
  maxRows: attachAudit.allocation.answer,
40374
40658
  prefix: " ",
40375
40659
  columns: Math.max(1, terminalColumns - 2)
40376
- }, attachBusy ? (0, import_react.createElement)(Caret, { animated: animations }) : void 0) : void 0, attachBusy && attachedView.streaming === "" && attachedView.streamingReasoning === "" ? (0, import_react.createElement)(DeepDivingLine, {
40660
+ }, attachBusy ? (0, import_react.createElement)(Caret) : void 0) : void 0, attachBusy && attachedView.streaming === "" && attachedView.streamingReasoning === "" ? (0, import_react.createElement)(DeepDivingLine, {
40377
40661
  since: attachedView.busySince,
40378
- animated: animations
40662
+ themeFlowAnimated: animations
40379
40663
  }) : void 0), (0, import_react.createElement)(Box, {
40380
40664
  borderStyle: "round",
40381
40665
  borderColor: inkColor(getPalette().brandMid),
@@ -40396,7 +40680,17 @@ function App(props) {
40396
40680
  return (0, import_react.createElement)(Box, { flexDirection: "column" }, (0, import_react.createElement)(MemoStaticTranscript, {
40397
40681
  key: refreshEpoch,
40398
40682
  items: attachment === void 0 ? settledRows : attachedSettledRows
40399
- }), transcriptVisible ? (0, import_react.createElement)(Box, { flexDirection: "column" }, auditedLiveLines.length === 0 ? void 0 : (0, import_react.createElement)(StyledRows, { lines: auditedLiveLines }), view.streamingReasoning !== "" && auditedReasoningRows > 0 ? showReasoning ? (0, import_react.createElement)(StreamTail, {
40683
+ }), (0, import_react.createElement)(Box, surfaceAnchored ? {
40684
+ flexDirection: "column",
40685
+ height: anchoredSurfaceRows
40686
+ } : { flexDirection: "column" }, frozenModalLines.length === 0 ? void 0 : (0, import_react.createElement)(Box, {
40687
+ flexDirection: "column",
40688
+ height: 0,
40689
+ flexGrow: 1,
40690
+ flexShrink: 1,
40691
+ overflow: "hidden",
40692
+ justifyContent: "flex-end"
40693
+ }, (0, import_react.createElement)(StyledRows, { lines: frozenModalLines })), transcriptVisible ? (0, import_react.createElement)(Box, { flexDirection: "column" }, auditedLiveLines.length === 0 ? void 0 : (0, import_react.createElement)(StyledRows, { lines: auditedLiveLines }), view.streamingReasoning !== "" && auditedReasoningRows > 0 ? showReasoning ? (0, import_react.createElement)(StreamTail, {
40400
40694
  text: view.streamingReasoning,
40401
40695
  prefix: "✻ ",
40402
40696
  continuationPrefix: " ",
@@ -40405,7 +40699,7 @@ function App(props) {
40405
40699
  columns: Math.max(1, terminalColumns - 2)
40406
40700
  }) : view.streaming === "" ? (0, import_react.createElement)(ShimmerLine, {
40407
40701
  text: "✻ Thinking… (Ctrl/Alt+R to expand)",
40408
- animated: animations
40702
+ themeFlowAnimated: animations
40409
40703
  }) : (0, import_react.createElement)(StreamTail, {
40410
40704
  text: "Thinking… (Ctrl/Alt+R to expand)",
40411
40705
  prefix: "✻ ",
@@ -40419,9 +40713,9 @@ function App(props) {
40419
40713
  maxRows: auditedAnswerRows,
40420
40714
  prefix: " ",
40421
40715
  columns: Math.max(1, terminalColumns - 2)
40422
- }, busy ? (0, import_react.createElement)(Caret, { animated: animations }) : void 0) : void 0, deepDivingVisible ? (0, import_react.createElement)(DeepDivingLine, {
40716
+ }, busy ? (0, import_react.createElement)(Caret) : void 0) : void 0, deepDivingVisible ? (0, import_react.createElement)(DeepDivingLine, {
40423
40717
  since: view.busySince,
40424
- animated: animations
40718
+ themeFlowAnimated: animations
40425
40719
  }) : void 0) : void 0, transcriptVisible ? (0, import_react.createElement)(TodoPanel, { todos: view.todos }) : void 0, transcriptVisible ? (0, import_react.createElement)(AgentsLine, {
40426
40720
  rows: agentRows,
40427
40721
  total: props.subagents.getTotalSeen()
@@ -40469,7 +40763,7 @@ function App(props) {
40469
40763
  },
40470
40764
  close: () => setReviewPickerOpen(false)
40471
40765
  }) : void 0, verboseOpen && !approvalPending && !questionPending ? (0, import_react.createElement)(MemoVerbosePanel, {
40472
- entries: view.entries,
40766
+ entries: verboseEntries,
40473
40767
  onClose: closeInspector,
40474
40768
  columns: terminalColumns,
40475
40769
  rows: terminalRows
@@ -40606,7 +40900,7 @@ function App(props) {
40606
40900
  setSubagentOpen(false);
40607
40901
  },
40608
40902
  close: () => setSubagentOpen(false)
40609
- }) : void 0, notice === void 0 ? void 0 : (0, import_react.createElement)(NoticeLine, {
40903
+ }) : void 0), notice === void 0 ? void 0 : (0, import_react.createElement)(NoticeLine, {
40610
40904
  text: notice.text,
40611
40905
  tone: notice.tone,
40612
40906
  columns: terminalColumns
@@ -40790,7 +41084,7 @@ function App(props) {
40790
41084
  stats: view.stats,
40791
41085
  busy,
40792
41086
  animated: animations,
40793
- columns: terminalColumns,
41087
+ columns: Math.max(1, terminalColumns - 1),
40794
41088
  items: statuslineItems,
40795
41089
  onRows: handleStatusRows
40796
41090
  })));
@@ -43053,18 +43347,29 @@ function createInputHistory(path, onFailure) {
43053
43347
  flush: () => chain
43054
43348
  };
43055
43349
  }
43056
- //#endregion
43057
- //#region src/runner/session-io.ts
43058
- /** Session directory IO exposed to the picker, /delete, and /export.
43059
- *
43060
- * The kernel persistence seam has NO deletion API by design — logs accumulate
43061
- * "until removed externally" — so removal is planned, layout-checked, and
43062
- * lease-guarded here before any file is touched. Every read goes through the
43063
- * in-process session-query engine, so this module stays free of `app.ts` and
43064
- * of the Ink tree.
43350
+ /**
43351
+ * Map a collection with a fixed worker count while preserving input order.
43352
+ * The picker may own thousands of persisted sessions; an unbounded
43353
+ * `Promise.all` turns those into one filesystem burst and can exhaust handles
43354
+ * on Windows or remote filesystems.
43065
43355
  *
43066
- * @module @deepseek-ai/dsh-code/runner/session-io
43356
+ * @internal Exported for the deterministic concurrency regression.
43067
43357
  */
43358
+ async function mapConcurrent(items, concurrency, mapper) {
43359
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1) throw new RangeError("concurrency must be a positive safe integer");
43360
+ const results = new Array(items.length);
43361
+ let next = 0;
43362
+ const worker = async () => {
43363
+ for (;;) {
43364
+ const index = next;
43365
+ if (index >= items.length) return;
43366
+ next += 1;
43367
+ results[index] = await mapper(items[index], index);
43368
+ }
43369
+ };
43370
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
43371
+ return results;
43372
+ }
43068
43373
  /**
43069
43374
  * Bind the session IO to one runner's services.
43070
43375
  * @param services - the query engine, persistence, and the live-session probe.
@@ -43077,15 +43382,23 @@ function createSessionIo(services) {
43077
43382
  const records = await sessionQuery.listSessions(signal);
43078
43383
  const root = jsonlSessionRoot(persistence);
43079
43384
  const updated = /* @__PURE__ */ new Map();
43080
- if (root !== void 0) await Promise.all(records.map(async (record) => {
43081
- try {
43082
- const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id);
43083
- const entries = await readdir(dir, { withFileTypes: true });
43084
- const stats = await Promise.all(entries.filter((entry) => entry.isFile() && isSessionArtifactName(entry.name)).map((entry) => stat(join(dir, entry.name))));
43085
- const newest = Math.max(...stats.map((info) => info.mtimeMs));
43086
- if (Number.isFinite(newest)) updated.set(record.header.id, newest);
43087
- } catch {}
43088
- }));
43385
+ if (root !== void 0) {
43386
+ const mtimes = await mapConcurrent(records, 16, async (record) => {
43387
+ signal?.throwIfAborted();
43388
+ try {
43389
+ const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id);
43390
+ const entries = await readdir(dir, { withFileTypes: true });
43391
+ const stats = await Promise.all(entries.filter((entry) => entry.isFile() && isSessionArtifactName(entry.name)).map((entry) => stat(join(dir, entry.name))));
43392
+ signal?.throwIfAborted();
43393
+ const newest = Math.max(...stats.map((info) => info.mtimeMs));
43394
+ return Number.isFinite(newest) ? [record.header.id, newest] : void 0;
43395
+ } catch {
43396
+ signal?.throwIfAborted();
43397
+ return;
43398
+ }
43399
+ });
43400
+ for (const pair of mtimes) if (pair !== void 0) updated.set(...pair);
43401
+ }
43089
43402
  const projected = projectSessionRows(records, {
43090
43403
  ...options,
43091
43404
  query: ""