@oh-my-pi/pi-coding-agent 16.0.11 → 16.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cli.js +3166 -3202
  3. package/dist/types/config/settings-schema.d.ts +40 -39
  4. package/dist/types/lsp/types.d.ts +5 -3
  5. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
  6. package/dist/types/modes/components/assistant-message.d.ts +8 -0
  7. package/dist/types/modes/components/cache-invalidation-marker.d.ts +39 -0
  8. package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
  9. package/dist/types/modes/components/index.d.ts +0 -1
  10. package/dist/types/modes/components/message-frame.d.ts +6 -4
  11. package/dist/types/modes/interactive-mode.d.ts +2 -1
  12. package/dist/types/modes/theme/theme.d.ts +7 -1
  13. package/dist/types/modes/types.d.ts +7 -1
  14. package/dist/types/sdk.d.ts +1 -1
  15. package/dist/types/session/agent-session.d.ts +20 -1
  16. package/dist/types/session/session-context.d.ts +7 -0
  17. package/dist/types/session/session-dump-format.d.ts +1 -0
  18. package/dist/types/session/tool-choice-queue.d.ts +14 -0
  19. package/dist/types/system-prompt.d.ts +3 -3
  20. package/dist/types/tools/index.d.ts +4 -0
  21. package/dist/types/tools/resolve.d.ts +15 -5
  22. package/package.json +12 -12
  23. package/src/config/settings-schema.ts +48 -39
  24. package/src/config/settings.ts +40 -0
  25. package/src/debug/log-viewer.ts +4 -4
  26. package/src/debug/raw-sse.ts +4 -4
  27. package/src/edit/renderer.ts +2 -2
  28. package/src/internal-urls/docs-index.generated.txt +1 -1
  29. package/src/lsp/client.ts +9 -9
  30. package/src/lsp/render.ts +7 -7
  31. package/src/lsp/types.ts +6 -3
  32. package/src/modes/components/__tests__/skill-message.test.ts +92 -0
  33. package/src/modes/components/agent-dashboard.ts +1 -1
  34. package/src/modes/components/assistant-message.ts +21 -0
  35. package/src/modes/components/cache-invalidation-marker.ts +94 -0
  36. package/src/modes/components/chat-transcript-builder.ts +16 -2
  37. package/src/modes/components/compaction-summary-message.ts +29 -1
  38. package/src/modes/components/custom-message.ts +4 -1
  39. package/src/modes/components/dynamic-border.ts +1 -1
  40. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  41. package/src/modes/components/extensions/inspector-panel.ts +5 -5
  42. package/src/modes/components/hook-selector.ts +2 -2
  43. package/src/modes/components/index.ts +0 -1
  44. package/src/modes/components/message-frame.ts +10 -6
  45. package/src/modes/components/model-selector.ts +2 -2
  46. package/src/modes/components/overlay-box.ts +10 -9
  47. package/src/modes/components/settings-defs.ts +7 -0
  48. package/src/modes/components/skill-message.ts +39 -19
  49. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  50. package/src/modes/components/welcome.ts +1 -1
  51. package/src/modes/controllers/event-controller.ts +14 -0
  52. package/src/modes/controllers/selector-controller.ts +7 -0
  53. package/src/modes/interactive-mode.ts +9 -1
  54. package/src/modes/theme/theme.ts +14 -0
  55. package/src/modes/types.ts +7 -1
  56. package/src/modes/utils/ui-helpers.ts +20 -2
  57. package/src/prompts/steering/user-interjection.md +3 -4
  58. package/src/sdk.ts +8 -6
  59. package/src/session/agent-session.ts +96 -23
  60. package/src/session/messages.ts +7 -9
  61. package/src/session/session-context.ts +54 -7
  62. package/src/session/session-dump-format.ts +3 -1
  63. package/src/session/snapcompact-inline.ts +2 -2
  64. package/src/session/tool-choice-queue.ts +59 -0
  65. package/src/system-prompt.ts +10 -9
  66. package/src/tools/bash-interactive.ts +4 -4
  67. package/src/tools/index.ts +4 -0
  68. package/src/tools/resolve.ts +66 -41
  69. package/src/tui/output-block.ts +9 -9
  70. package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
  71. package/src/modes/components/branch-summary-message.ts +0 -46
package/src/lsp/client.ts CHANGED
@@ -5,6 +5,7 @@ import { applyWorkspaceEdit } from "./edits";
5
5
  import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
6
6
  import type {
7
7
  LspClient,
8
+ LspJsonRpcId,
8
9
  LspJsonRpcNotification,
9
10
  LspJsonRpcRequest,
10
11
  LspJsonRpcResponse,
@@ -416,7 +417,6 @@ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name:
416
417
  * Handle workspace/workspaceFolders requests from the server.
417
418
  */
418
419
  async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
419
- if (typeof message.id !== "number") return;
420
420
  await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
421
421
  }
422
422
 
@@ -424,7 +424,6 @@ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJson
424
424
  * Handle workspace/configuration requests from the server.
425
425
  */
426
426
  async function handleConfigurationRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
427
- if (typeof message.id !== "number") return;
428
427
  const params = message.params as { items?: Array<{ section?: string }> };
429
428
  const items = params?.items ?? [];
430
429
  const result = items.map(item => {
@@ -438,7 +437,6 @@ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpc
438
437
  * Handle workspace/applyEdit requests from the server.
439
438
  */
440
439
  async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
441
- if (typeof message.id !== "number") return;
442
440
  const params = message.params as { edit?: WorkspaceEdit };
443
441
  if (!params?.edit) {
444
442
  await sendResponse(
@@ -475,13 +473,15 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
475
473
  return;
476
474
  }
477
475
  if (message.method === "window/workDoneProgress/create") {
478
- // Accept progress token registration from the server
479
- if (typeof message.id === "number") {
480
- await sendResponse(client, message.id, null, message.method);
481
- }
476
+ // Accept progress token registration from the server.
477
+ await sendResponse(client, message.id, null, message.method);
478
+ return;
479
+ }
480
+ if (message.method === "client/registerCapability" || message.method === "client/unregisterCapability") {
481
+ // Some servers block semantic requests until dynamic registration succeeds.
482
+ await sendResponse(client, message.id, null, message.method);
482
483
  return;
483
484
  }
484
- if (typeof message.id !== "number") return;
485
485
  await sendResponse(client, message.id, null, message.method, {
486
486
  code: -32601,
487
487
  message: `Method not found: ${message.method}`,
@@ -493,7 +493,7 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
493
493
  */
494
494
  async function sendResponse(
495
495
  client: LspClient,
496
- id: number,
496
+ id: LspJsonRpcId,
497
497
  result: unknown,
498
498
  method: string,
499
499
  error?: { code: number; message: string; data?: unknown },
package/src/lsp/render.ts CHANGED
@@ -223,10 +223,10 @@ function renderHover(
223
223
  const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : "";
224
224
 
225
225
  if (expanded) {
226
- const h = theme.boxSharp.horizontal;
227
- const v = theme.boxSharp.vertical;
228
- const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`;
229
- const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
226
+ const h = theme.boxRound.horizontal;
227
+ const v = theme.boxRound.vertical;
228
+ const top = `${theme.boxRound.topLeft}${h.repeat(3)}`;
229
+ const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`;
230
230
  let output = `${icon}${langLabel}`;
231
231
  if (beforeCode) {
232
232
  for (const line of beforeCode.split("\n")) {
@@ -254,9 +254,9 @@ function renderHover(
254
254
  const preview = truncateToWidth(beforeCode, TRUNCATE_LENGTHS.TITLE);
255
255
  output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`;
256
256
  }
257
- const h = theme.boxSharp.horizontal;
258
- const v = theme.boxSharp.vertical;
259
- const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`;
257
+ const h = theme.boxRound.horizontal;
258
+ const v = theme.boxRound.vertical;
259
+ const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`;
260
260
  output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`;
261
261
 
262
262
  if (codeLines.length > 1) {
package/src/lsp/types.ts CHANGED
@@ -399,7 +399,7 @@ export interface LspClient {
399
399
  diagnostics: Map<string, PublishedDiagnostics>;
400
400
  diagnosticsVersion: number;
401
401
  openFiles: Map<string, OpenFile>;
402
- pendingRequests: Map<number, PendingRequest>;
402
+ pendingRequests: Map<number | string, PendingRequest>;
403
403
  messageBuffer: Uint8Array;
404
404
  isReading: boolean;
405
405
  /** Lifecycle state: "connecting" until initialize completes, then "ready"; "error" on init failure or reader death. */
@@ -420,16 +420,19 @@ export interface LspClient {
420
420
  // JSON-RPC Protocol Types
421
421
  // =============================================================================
422
422
 
423
+ /** JSON-RPC request/response identifier accepted by LSP peers. */
424
+ export type LspJsonRpcId = number | string;
425
+
423
426
  export interface LspJsonRpcRequest {
424
427
  jsonrpc: "2.0";
425
- id: number;
428
+ id: LspJsonRpcId;
426
429
  method: string;
427
430
  params: unknown;
428
431
  }
429
432
 
430
433
  export interface LspJsonRpcResponse {
431
434
  jsonrpc: "2.0";
432
- id?: number;
435
+ id?: LspJsonRpcId;
433
436
  result?: unknown;
434
437
  error?: { code: number; message: string; data?: unknown };
435
438
  }
@@ -0,0 +1,92 @@
1
+ import { beforeAll, describe, expect, it } from "bun:test";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { Settings } from "../../../config/settings";
5
+ import type { CustomMessage, SkillPromptDetails } from "../../../session/messages";
6
+ import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
7
+ import { SkillMessageComponent } from "../skill-message";
8
+
9
+ // Drop SGR colors and OSC 8 hyperlink wrappers so assertions see the visible text only.
10
+ const strip = (lines: readonly string[]): string =>
11
+ lines
12
+ .join("\n")
13
+ .replace(/\x1b\]8;[^\x1b\x07]*(?:\x07|\x1b\\)/g, "")
14
+ .replace(/\x1b\[[0-9;]*m/g, "");
15
+
16
+ function makeMessage(
17
+ details: SkillPromptDetails,
18
+ content = "Use the atomic-commit workflow.",
19
+ ): CustomMessage<SkillPromptDetails> {
20
+ return { role: "custom", customType: "skill-prompt", content, display: true, details, timestamp: Date.now() };
21
+ }
22
+
23
+ describe("SkillMessageComponent", () => {
24
+ let uiTheme: Theme;
25
+
26
+ beforeAll(async () => {
27
+ await Settings.init({ inMemory: true });
28
+ const loaded = await getThemeByName("dark");
29
+ if (!loaded) throw new Error("theme unavailable");
30
+ uiTheme = loaded;
31
+ setThemeInstance(uiTheme);
32
+ });
33
+
34
+ const skillPath = path.join(os.homedir(), ".agent/skills/atomic-commit/SKILL.md");
35
+
36
+ it("renders a compact, outlined card instead of the archaic key:value dump", () => {
37
+ const component = new SkillMessageComponent(
38
+ makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88 }),
39
+ );
40
+ const text = strip(component.render(80));
41
+
42
+ // New look: an icon-tagged "skill" header with the name and a single meta line.
43
+ expect(text).toContain("skill");
44
+ expect(text).toContain("atomic-commit");
45
+ expect(text).toContain("88 lines");
46
+
47
+ // The card is drawn with an outline.
48
+ expect(text).toContain(uiTheme.boxRound.topLeft);
49
+ expect(text).toContain(uiTheme.boxRound.bottomRight);
50
+
51
+ // Path is home-shortened and never leaks the absolute home dir.
52
+ expect(text).toContain("~/.agent/skills/atomic-commit/SKILL.md");
53
+ expect(text).not.toContain(os.homedir());
54
+
55
+ // The old archaic framing is gone.
56
+ expect(text).not.toContain("[skill]");
57
+ expect(text).not.toContain("Skill:");
58
+ expect(text).not.toContain("Path:");
59
+ expect(text).not.toContain("Prompt:");
60
+ });
61
+
62
+ it("flattens multi-line args onto the single-line header", () => {
63
+ const component = new SkillMessageComponent(
64
+ makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88, args: "stage all\nthen split" }),
65
+ );
66
+ const text = strip(component.render(80));
67
+ // Whitespace (including the newline) collapsed to single spaces so the header can't break.
68
+ expect(text).toContain("stage all then split");
69
+ expect(text).not.toContain("stage all\nthen split");
70
+ });
71
+
72
+ it("uses a singular unit for a one-line prompt", () => {
73
+ const component = new SkillMessageComponent(makeMessage({ name: "tiny", path: skillPath, lineCount: 1 }));
74
+ const text = strip(component.render(80));
75
+ expect(text).toContain("1 line");
76
+ expect(text).not.toContain("1 lines");
77
+ });
78
+
79
+ it("reveals the prompt body under a calm subheader only when expanded", () => {
80
+ const details: SkillPromptDetails = { name: "atomic-commit", path: skillPath, lineCount: 88 };
81
+ const body = "Step one: stage hunks.";
82
+
83
+ const collapsed = new SkillMessageComponent(makeMessage(details, body));
84
+ expect(strip(collapsed.render(80))).not.toContain(body);
85
+
86
+ const expanded = new SkillMessageComponent(makeMessage(details, body));
87
+ expanded.setExpanded(true);
88
+ const text = strip(expanded.render(80));
89
+ expect(text).toContain("prompt");
90
+ expect(text).toContain(body);
91
+ });
92
+ });
@@ -321,7 +321,7 @@ class TwoColumnBody implements Component {
321
321
  const rightLines = this.rightPane.render(rightWidth);
322
322
  const lineCount = this.maxHeight;
323
323
  const out: string[] = [];
324
- const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
324
+ const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `);
325
325
 
326
326
  for (let i = 0; i < lineCount; i++) {
327
327
  const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
@@ -5,6 +5,7 @@ import { getMarkdownTheme, theme } from "../../modes/theme/theme";
5
5
  import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages";
6
6
  import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
7
7
  import { canonicalizeMessage } from "../../utils/thinking-display";
8
+ import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";
8
9
 
9
10
  /**
10
11
  * Max lines of a turn-ending provider error rendered inline in the transcript.
@@ -29,6 +30,7 @@ const THINKING_DOTS_FRAME_MS = 320;
29
30
  */
30
31
  export class AssistantMessageComponent extends Container {
31
32
  #contentContainer: Container;
33
+ #markerSlot: Container;
32
34
  #lastMessage?: AssistantMessage;
33
35
  #toolImagesByCallId = new Map<string, ImageContent[]>();
34
36
  #convertedKittyImages = new Map<string, ImageContent>();
@@ -75,6 +77,11 @@ export class AssistantMessageComponent extends Container {
75
77
  super();
76
78
  this.#transcriptBlockFinalized = message !== undefined;
77
79
 
80
+ // Slim cache-invalidation divider, populated above the content when this
81
+ // turn's request lost the prompt cache (see setCacheInvalidation).
82
+ this.#markerSlot = new Container();
83
+ this.addChild(this.#markerSlot);
84
+
78
85
  // Container for text/thinking content
79
86
  this.#contentContainer = new Container();
80
87
  this.addChild(this.#contentContainer);
@@ -84,6 +91,20 @@ export class AssistantMessageComponent extends Container {
84
91
  }
85
92
  }
86
93
 
94
+ /**
95
+ * Show or clear the slim cache-invalidation divider above this turn. Set at
96
+ * `message_end` (live) or during rebuild, once the turn's usage is known and
97
+ * compared against the previous turn's cache footprint. Bumps the transcript
98
+ * block version so the change repaints even after content finalized.
99
+ */
100
+ setCacheInvalidation(info: CacheInvalidation | undefined): void {
101
+ this.#markerSlot.clear();
102
+ if (info) {
103
+ this.#markerSlot.addChild(new CacheInvalidationMarkerComponent(info));
104
+ }
105
+ this.#blockVersion++;
106
+ }
107
+
87
108
  override invalidate(): void {
88
109
  super.invalidate();
89
110
  // Theme/symbol changes arrive via invalidate(). Fast-path children captured
@@ -0,0 +1,94 @@
1
+ import type { Usage } from "@oh-my-pi/pi-ai";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { formatNumber } from "@oh-my-pi/pi-utils";
4
+ import { theme } from "../../modes/theme/theme";
5
+
6
+ /**
7
+ * Minimum cached prefix (read + write) the previous turn must have established
8
+ * before a collapse on the current turn counts as an invalidation. Filters out
9
+ * tiny contexts and providers below the cacheable-prefix floor, where a zero
10
+ * `cacheRead` is expected rather than a reset.
11
+ */
12
+ const MIN_CACHE_FOOTPRINT = 2048;
13
+
14
+ /** A prompt-cache invalidation detected from a turn's usage. */
15
+ export interface CacheInvalidation {
16
+ /** Prompt tokens the cold turn had to (re)process instead of reading from cache. */
17
+ reprocessedTokens: number;
18
+ }
19
+
20
+ /**
21
+ * Decide whether `current` turn lost the prompt cache that `prev` established.
22
+ *
23
+ * The provider reports a warm prefix as `cacheRead`; a model/thinking/tool/
24
+ * system-prompt change (or a history rewrite) breaks the prefix, so the next
25
+ * request reads nothing from cache and re-pays for the whole prompt. We detect
26
+ * that as: the previous turn cached a meaningful prefix, yet this turn's
27
+ * `cacheRead` collapsed to zero while it still reprocessed a non-trivial prompt.
28
+ * Returns `undefined` (no marker) for the first turn, tiny contexts, turns
29
+ * that reused any cache, and — crucially — turns on providers with *implicit*
30
+ * best-effort caching. Only an explicit, prefix-controlled cache (Anthropic /
31
+ * Bedrock `cache_control`) re-creates the prefix on a cold turn (`cacheWrite >
32
+ * 0`); implicit caches (Google / OpenAI / Fireworks) report `cacheWrite: 0` and
33
+ * drop `cacheRead` to zero intermittently as routine propagation noise that
34
+ * self-heals the next turn, so flagging it would be a false positive.
35
+ */
36
+ export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined {
37
+ if (!prev) return undefined;
38
+ const prevFootprint = prev.cacheRead + prev.cacheWrite;
39
+ if (prevFootprint < MIN_CACHE_FOOTPRINT) return undefined;
40
+ // Any cache reuse this turn means the prefix survived (at least partly).
41
+ if (current.cacheRead > 0) return undefined;
42
+ // Only an explicit, prefix-controlled cache re-creates the prefix on a cold
43
+ // turn — Anthropic/Bedrock report that as `cacheWrite`. Implicit best-effort
44
+ // caches (Google/OpenAI/Fireworks) report `cacheWrite: 0` and drop `cacheRead`
45
+ // to zero intermittently as propagation noise, not a real invalidation.
46
+ if (current.cacheWrite <= 0) return undefined;
47
+ const reprocessedTokens = current.cacheWrite + current.input;
48
+ if (reprocessedTokens < MIN_CACHE_FOOTPRINT) return undefined;
49
+ return { reprocessedTokens };
50
+ }
51
+
52
+ const CACHE_INVALIDATION_RULE_WIDTH = 10;
53
+
54
+ /**
55
+ * Slim left-aligned divider rendered above an assistant turn whose request lost
56
+ * the prompt cache. Mirrors the compaction divider's banner styling but spans
57
+ * only a short rule plus label (not the full width) and carries no expandable
58
+ * detail:
59
+ *
60
+ * ────────── ⊘ cache miss · 50.9k tokens
61
+ */
62
+ export class CacheInvalidationMarkerComponent implements Component {
63
+ #cache?: { width: number; lines: string[] };
64
+
65
+ constructor(private readonly info: CacheInvalidation) {}
66
+
67
+ invalidate(): void {
68
+ this.#cache = undefined;
69
+ }
70
+
71
+ render(width: number): readonly string[] {
72
+ width = Math.max(1, width);
73
+ if (this.#cache?.width === width) {
74
+ return this.#cache.lines;
75
+ }
76
+ const lines = ["", this.#divider(width), ""];
77
+ this.#cache = { width, lines };
78
+ return lines;
79
+ }
80
+
81
+ #divider(width: number): string {
82
+ const icon = theme.icon.cacheMiss;
83
+ const head = icon ? `${icon} cache miss` : "cache miss";
84
+ const tokens = this.info.reprocessedTokens;
85
+ const label = tokens > 0 ? `${head} ${theme.sep.dot.trim()} ${formatNumber(tokens)} tokens` : head;
86
+ const labelWidth = Bun.stringWidth(label, { countAnsiEscapeCodes: false });
87
+ const ruleWidth = Math.min(CACHE_INVALIDATION_RULE_WIDTH, width - labelWidth - 1);
88
+ if (ruleWidth < 1) {
89
+ // Too narrow to frame — emit the bare label.
90
+ return theme.fg("muted", label);
91
+ }
92
+ return `${theme.fg("dim", theme.tree.horizontal.repeat(ruleWidth))} ${theme.fg("muted", label)}`;
93
+ }
94
+ }
@@ -36,9 +36,13 @@ import { createAdvisorMessageCard } from "./advisor-message";
36
36
  import { AssistantMessageComponent } from "./assistant-message";
37
37
  import { createBackgroundTanDispatchBlock } from "./background-tan-message";
38
38
  import { BashExecutionComponent } from "./bash-execution";
39
- import { BranchSummaryMessageComponent } from "./branch-summary-message";
39
+ import { detectCacheInvalidation } from "./cache-invalidation-marker";
40
40
  import { CollabPromptMessageComponent } from "./collab-prompt-message";
41
- import { CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent } from "./compaction-summary-message";
41
+ import {
42
+ BranchSummaryMessageComponent,
43
+ CompactionSummaryMessageComponent,
44
+ createHandoffSummaryMessageComponent,
45
+ } from "./compaction-summary-message";
42
46
  import { CustomMessageComponent } from "./custom-message";
43
47
  import { EvalExecutionComponent } from "./eval-execution";
44
48
  import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
@@ -73,6 +77,7 @@ export class ChatTranscriptBuilder {
73
77
  #readArgs = new Map<string, Record<string, unknown>>();
74
78
  #readGroup: ReadToolGroupComponent | null = null;
75
79
  #pendingUsage: Usage | undefined;
80
+ #lastAssistantUsage: Usage | undefined;
76
81
  #waitingPoll: ToolExecutionComponent | null = null;
77
82
  #expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
78
83
  #expanded = false;
@@ -111,6 +116,7 @@ export class ChatTranscriptBuilder {
111
116
  this.#readArgs.clear();
112
117
  this.#readGroup = null;
113
118
  this.#pendingUsage = undefined;
119
+ this.#lastAssistantUsage = undefined;
114
120
  this.#waitingPoll = null;
115
121
  this.#expandables = [];
116
122
  this.container.dispose();
@@ -246,6 +252,14 @@ export class ChatTranscriptBuilder {
246
252
  );
247
253
  this.container.addChild(assistantComponent);
248
254
 
255
+ if (settings.get("display.cacheMissMarker")) {
256
+ const invalidation = detectCacheInvalidation(this.#lastAssistantUsage, message.usage);
257
+ if (invalidation) assistantComponent.setCacheInvalidation(invalidation);
258
+ }
259
+ if (message.usage.cacheRead + message.usage.cacheWrite + message.usage.input > 0) {
260
+ this.#lastAssistantUsage = message.usage;
261
+ }
262
+
249
263
  const hasVisibleAssistantContent = message.content.some(
250
264
  content =>
251
265
  (content.type === "text" && canonicalizeMessage(content.text)) ||
@@ -1,6 +1,6 @@
1
1
  import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
- import type { CompactionSummaryMessage, CustomMessage } from "../../session/messages";
3
+ import type { BranchSummaryMessage, CompactionSummaryMessage, CustomMessage } from "../../session/messages";
4
4
 
5
5
  interface SummaryDividerOptions {
6
6
  label: () => string;
@@ -156,6 +156,34 @@ export function createHandoffSummaryMessageComponent(
156
156
  return component;
157
157
  }
158
158
 
159
+ /**
160
+ * A branch summary collapses a side branch back into the main line. Render it
161
+ * with the same slim divider as `/compact` and handoff rather than a `[branch]`
162
+ * box, so every history-collapse point reads as one consistent banner.
163
+ */
164
+ export class BranchSummaryMessageComponent implements Component {
165
+ #divider: SummaryDividerComponent;
166
+
167
+ constructor(private readonly message: BranchSummaryMessage) {
168
+ this.#divider = new SummaryDividerComponent({
169
+ label: () => `${theme.icon.branch} branch`,
170
+ detailMarkdown: () => `**Branch summary**\n\n${this.message.summary}`,
171
+ });
172
+ }
173
+
174
+ setExpanded(expanded: boolean): void {
175
+ this.#divider.setExpanded(expanded);
176
+ }
177
+
178
+ invalidate(): void {
179
+ this.#divider.invalidate();
180
+ }
181
+
182
+ render(width: number): readonly string[] {
183
+ return this.#divider.render(width);
184
+ }
185
+ }
186
+
159
187
  function getCustomMessageText(message: CustomMessage<unknown>): string {
160
188
  if (typeof message.content === "string") return message.content;
161
189
  let firstText: string | undefined;
@@ -46,12 +46,15 @@ export class CustomMessageComponent extends Container {
46
46
  }
47
47
  this.removeChild(this.#box);
48
48
 
49
+ // The transcript dispatch routes both `custom` and legacy `hookMessage` roles here:
50
+ // tag hooks with the hook glyph, other injected messages with a neutral package.
51
+ const isHook = (this.message.role as string) === "hookMessage";
49
52
  const custom = renderFramedMessage({
50
53
  message: this.message,
51
54
  box: this.#box,
52
55
  expanded: this.#expanded,
53
56
  customRenderer: this.customRenderer,
54
- // Extension messages render full content; no collapse-on-fold behaviour.
57
+ icon: isHook ? theme.icon.extensionHook : theme.icon.package,
55
58
  });
56
59
 
57
60
  if (custom) {
@@ -26,7 +26,7 @@ export class DynamicBorder implements Component {
26
26
  if (this.#cachedLines && this.#cachedWidth === width) {
27
27
  return this.#cachedLines;
28
28
  }
29
- const lines = [this.#color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))];
29
+ const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))];
30
30
  this.#cachedWidth = width;
31
31
  this.#cachedLines = lines;
32
32
  return lines;
@@ -380,7 +380,7 @@ class TwoColumnBody implements Component {
380
380
  // Fill the full body height so the dashboard reads as a full-screen view.
381
381
  const numLines = this.maxHeight;
382
382
  const combined: string[] = [];
383
- const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
383
+ const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `);
384
384
 
385
385
  for (let i = 0; i < numLines; i++) {
386
386
  const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
@@ -107,7 +107,7 @@ export class InspectorPanel implements Component {
107
107
  #renderFilePreview(raw: unknown, width: number): string[] {
108
108
  const lines: string[] = [];
109
109
  lines.push(theme.fg("muted", "Preview:"));
110
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
110
+ lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
111
111
 
112
112
  const content = this.#getContextFileContent(raw);
113
113
  if (!content) {
@@ -165,7 +165,7 @@ export class InspectorPanel implements Component {
165
165
  #renderToolArgs(raw: unknown, width: number): string[] {
166
166
  const lines: string[] = [];
167
167
  lines.push(theme.fg("muted", "Arguments:"));
168
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
168
+ lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
169
169
 
170
170
  try {
171
171
  const tool = raw as any;
@@ -207,7 +207,7 @@ export class InspectorPanel implements Component {
207
207
  #renderSkillContent(raw: unknown, width: number): string[] {
208
208
  const lines: string[] = [];
209
209
  lines.push(theme.fg("muted", "Instruction:"));
210
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
210
+ lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
211
211
 
212
212
  try {
213
213
  const skill = raw as any;
@@ -236,7 +236,7 @@ export class InspectorPanel implements Component {
236
236
  #renderMcpDetails(raw: unknown, width: number): string[] {
237
237
  const lines: string[] = [];
238
238
  lines.push(theme.fg("muted", "Connection:"));
239
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
239
+ lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
240
240
 
241
241
  try {
242
242
  const mcp = raw as any;
@@ -275,7 +275,7 @@ export class InspectorPanel implements Component {
275
275
  // Show trigger pattern if present
276
276
  if (ext.trigger) {
277
277
  lines.push(theme.fg("muted", "Trigger:"));
278
- lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
278
+ lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
279
279
  lines.push(` ${theme.fg("accent", ext.trigger)}`);
280
280
  lines.push("");
281
281
  }
@@ -123,7 +123,7 @@ class OutlinedList extends Container {
123
123
 
124
124
  render(width: number): readonly string[] {
125
125
  const borderColor = (text: string) => theme.fg("border", text);
126
- const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
126
+ const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width)));
127
127
  const innerWidth = Math.max(1, width - 2);
128
128
  const content: string[] = [];
129
129
  for (const line of this.#lines) {
@@ -134,7 +134,7 @@ class OutlinedList extends Container {
134
134
  const wrappedLine = `${indent}${wrappedBody}`;
135
135
  const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine));
136
136
  content.push(
137
- `${borderColor(theme.boxSharp.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`,
137
+ `${borderColor(theme.boxRound.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxRound.vertical)}`,
138
138
  );
139
139
  }
140
140
  }
@@ -2,7 +2,6 @@
2
2
  export * from "./assistant-message";
3
3
  export * from "./bash-execution";
4
4
  export * from "./bordered-loader";
5
- export * from "./branch-summary-message";
6
5
  export * from "./compaction-summary-message";
7
6
  export * from "./countdown-timer";
8
7
  export * from "./custom-editor";
@@ -34,16 +34,18 @@ export interface RebuildFrameOptions<M extends FramedMessage> {
34
34
  message: M;
35
35
  box: Box;
36
36
  expanded: boolean;
37
+ /** Icon glyph shown before the customType in the default header (e.g. a hook/extension icon). */
38
+ icon?: string;
37
39
  /** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */
38
40
  collapseAfterLines?: number;
39
41
  customRenderer?: FramedRenderer<M>;
40
42
  }
41
43
 
42
44
  /**
43
- * Attempt the custom renderer; on failure or undefined return, populate
44
- * `box` with the default `[customType]` label + markdown body and return
45
- * undefined. When the custom renderer succeeds, return its Component so the
46
- * caller can mount it and skip the default box.
45
+ * Attempt the custom renderer; on failure or undefined return, populate `box`
46
+ * with the default outlined card — an `icon customType` header + markdown body
47
+ * and return undefined. When the custom renderer succeeds, return its Component
48
+ * so the caller can mount it and skip the default box.
47
49
  */
48
50
  export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
49
51
  if (opts.customRenderer) {
@@ -56,9 +58,11 @@ export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameO
56
58
  }
57
59
 
58
60
  opts.box.clear();
61
+ // Match the skill card: a subtle rounded outline so injected messages read as cards.
62
+ opts.box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) });
59
63
 
60
- const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`));
61
- opts.box.addChild(new Text(label, 0, 0));
64
+ const tag = opts.icon ? `${opts.icon} ${opts.message.customType}` : opts.message.customType;
65
+ opts.box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(tag)), 0, 0));
62
66
  opts.box.addChild(new Spacer(1));
63
67
 
64
68
  let text: string;
@@ -1112,7 +1112,7 @@ export class ModelSelectorComponent extends Container {
1112
1112
  const menuWidth = contentWidth + (needsScroll ? 1 : 0);
1113
1113
 
1114
1114
  this.#menuContainer.addChild(new Spacer(1));
1115
- this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
1115
+ this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0));
1116
1116
  if (showingThinking && this.#menuSelectedRole) {
1117
1117
  this.#menuContainer.addChild(
1118
1118
  new Text(
@@ -1152,7 +1152,7 @@ export class ModelSelectorComponent extends Container {
1152
1152
 
1153
1153
  this.#menuContainer.addChild(new Spacer(1));
1154
1154
  this.#menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0));
1155
- this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
1155
+ this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0));
1156
1156
  }
1157
1157
 
1158
1158
  #getMenuVisibleCount(optionCount: number): number {