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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli.js +2872 -2908
  3. package/dist/types/config/settings-schema.d.ts +14 -4
  4. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
  5. package/dist/types/modes/components/assistant-message.d.ts +8 -0
  6. package/dist/types/modes/components/cache-invalidation-marker.d.ts +34 -0
  7. package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
  8. package/dist/types/modes/components/index.d.ts +0 -1
  9. package/dist/types/modes/components/message-frame.d.ts +6 -4
  10. package/dist/types/modes/interactive-mode.d.ts +2 -1
  11. package/dist/types/modes/theme/theme.d.ts +7 -1
  12. package/dist/types/modes/types.d.ts +7 -1
  13. package/dist/types/sdk.d.ts +1 -1
  14. package/dist/types/session/agent-session.d.ts +20 -1
  15. package/dist/types/session/session-context.d.ts +7 -0
  16. package/dist/types/session/session-dump-format.d.ts +1 -0
  17. package/dist/types/session/tool-choice-queue.d.ts +14 -0
  18. package/dist/types/system-prompt.d.ts +3 -3
  19. package/dist/types/tools/index.d.ts +4 -0
  20. package/dist/types/tools/resolve.d.ts +15 -5
  21. package/package.json +12 -12
  22. package/src/config/settings-schema.ts +16 -4
  23. package/src/debug/log-viewer.ts +4 -4
  24. package/src/debug/raw-sse.ts +4 -4
  25. package/src/edit/renderer.ts +2 -2
  26. package/src/internal-urls/docs-index.generated.txt +1 -1
  27. package/src/lsp/render.ts +7 -7
  28. package/src/modes/components/__tests__/skill-message.test.ts +92 -0
  29. package/src/modes/components/agent-dashboard.ts +1 -1
  30. package/src/modes/components/assistant-message.ts +21 -0
  31. package/src/modes/components/cache-invalidation-marker.ts +84 -0
  32. package/src/modes/components/chat-transcript-builder.ts +16 -2
  33. package/src/modes/components/compaction-summary-message.ts +29 -1
  34. package/src/modes/components/custom-message.ts +4 -1
  35. package/src/modes/components/dynamic-border.ts +1 -1
  36. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  37. package/src/modes/components/extensions/inspector-panel.ts +5 -5
  38. package/src/modes/components/hook-selector.ts +2 -2
  39. package/src/modes/components/index.ts +0 -1
  40. package/src/modes/components/message-frame.ts +10 -6
  41. package/src/modes/components/model-selector.ts +2 -2
  42. package/src/modes/components/overlay-box.ts +10 -9
  43. package/src/modes/components/skill-message.ts +39 -19
  44. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  45. package/src/modes/components/welcome.ts +1 -1
  46. package/src/modes/controllers/event-controller.ts +14 -0
  47. package/src/modes/controllers/selector-controller.ts +7 -0
  48. package/src/modes/interactive-mode.ts +9 -1
  49. package/src/modes/theme/theme.ts +14 -0
  50. package/src/modes/types.ts +7 -1
  51. package/src/modes/utils/ui-helpers.ts +20 -2
  52. package/src/prompts/steering/user-interjection.md +3 -4
  53. package/src/sdk.ts +8 -6
  54. package/src/session/agent-session.ts +90 -13
  55. package/src/session/messages.ts +7 -9
  56. package/src/session/session-context.ts +54 -7
  57. package/src/session/session-dump-format.ts +3 -1
  58. package/src/session/snapcompact-inline.ts +2 -2
  59. package/src/session/tool-choice-queue.ts +59 -0
  60. package/src/system-prompt.ts +10 -9
  61. package/src/tools/bash-interactive.ts +4 -4
  62. package/src/tools/index.ts +4 -0
  63. package/src/tools/resolve.ts +66 -41
  64. package/src/tui/output-block.ts +9 -9
  65. package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
  66. package/src/modes/components/branch-summary-message.ts +0 -46
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) {
@@ -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,84 @@
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, and turns
29
+ * that reused any cache.
30
+ */
31
+ export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined {
32
+ if (!prev) return undefined;
33
+ const prevFootprint = prev.cacheRead + prev.cacheWrite;
34
+ if (prevFootprint < MIN_CACHE_FOOTPRINT) return undefined;
35
+ // Any cache reuse this turn means the prefix survived (at least partly).
36
+ if (current.cacheRead > 0) return undefined;
37
+ const reprocessedTokens = current.cacheWrite + current.input;
38
+ if (reprocessedTokens < MIN_CACHE_FOOTPRINT) return undefined;
39
+ return { reprocessedTokens };
40
+ }
41
+
42
+ const CACHE_INVALIDATION_RULE_WIDTH = 10;
43
+
44
+ /**
45
+ * Slim left-aligned divider rendered above an assistant turn whose request lost
46
+ * the prompt cache. Mirrors the compaction divider's banner styling but spans
47
+ * only a short rule plus label (not the full width) and carries no expandable
48
+ * detail:
49
+ *
50
+ * ────────── ⊘ cache miss · 50.9k tokens
51
+ */
52
+ export class CacheInvalidationMarkerComponent implements Component {
53
+ #cache?: { width: number; lines: string[] };
54
+
55
+ constructor(private readonly info: CacheInvalidation) {}
56
+
57
+ invalidate(): void {
58
+ this.#cache = undefined;
59
+ }
60
+
61
+ render(width: number): readonly string[] {
62
+ width = Math.max(1, width);
63
+ if (this.#cache?.width === width) {
64
+ return this.#cache.lines;
65
+ }
66
+ const lines = ["", this.#divider(width), ""];
67
+ this.#cache = { width, lines };
68
+ return lines;
69
+ }
70
+
71
+ #divider(width: number): string {
72
+ const icon = theme.icon.cacheMiss;
73
+ const head = icon ? `${icon} cache miss` : "cache miss";
74
+ const tokens = this.info.reprocessedTokens;
75
+ const label = tokens > 0 ? `${head} ${theme.sep.dot.trim()} ${formatNumber(tokens)} tokens` : head;
76
+ const labelWidth = Bun.stringWidth(label, { countAnsiEscapeCodes: false });
77
+ const ruleWidth = Math.min(CACHE_INVALIDATION_RULE_WIDTH, width - labelWidth - 1);
78
+ if (ruleWidth < 1) {
79
+ // Too narrow to frame — emit the bare label.
80
+ return theme.fg("muted", label);
81
+ }
82
+ return `${theme.fg("dim", theme.tree.horizontal.repeat(ruleWidth))} ${theme.fg("muted", label)}`;
83
+ }
84
+ }
@@ -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 {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Shared box-drawing chrome for fullscreen overlays (the `/copy` picker, the
3
- * plan-review overlay, …). Every helper paints with `theme.boxSharp` glyphs and
4
- * the `border`/`accent` theme colors so all outlined overlays read identically.
3
+ * plan-review overlay, …). Every helper paints with `theme.boxRound` glyphs
4
+ * (rounded corners, sharp tee/cross junctions) and the `border`/`accent` theme
5
+ * colors so all outlined overlays read identically.
5
6
  */
6
7
  import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
7
8
  import { theme } from "../theme/theme";
@@ -23,7 +24,7 @@ function paint(s: string): string {
23
24
 
24
25
  /** Top border with an optional accent-colored title inset into the rule. */
25
26
  export function topBorder(width: number, title: string): string {
26
- const box = theme.boxSharp;
27
+ const box = theme.boxRound;
27
28
  const inner = Math.max(0, width - 2);
28
29
  if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight);
29
30
  const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
@@ -37,18 +38,18 @@ export function topBorder(width: number, title: string): string {
37
38
 
38
39
  /** A horizontal rule with left/right tees, splitting overlay sections. */
39
40
  export function divider(width: number): string {
40
- const box = theme.boxSharp;
41
+ const box = theme.boxRound;
41
42
  return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft);
42
43
  }
43
44
 
44
45
  export function bottomBorder(width: number): string {
45
- const box = theme.boxSharp;
46
+ const box = theme.boxRound;
46
47
  return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight);
47
48
  }
48
49
 
49
50
  /** Wrap pre-styled content in vertical borders with single-column insets. */
50
51
  export function row(content: string, width: number): string {
51
- const box = theme.boxSharp;
52
+ const box = theme.boxRound;
52
53
  return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`;
53
54
  }
54
55
 
@@ -70,7 +71,7 @@ export function splitBodyWidth(width: number, sidebarWidth: number): number {
70
71
 
71
72
  /** Top border carrying the title, split by a `┬` over the column divider. */
72
73
  export function topBorderSplit(width: number, title: string, sidebarWidth: number): string {
73
- const box = theme.boxSharp;
74
+ const box = theme.boxRound;
74
75
  const dividerCol = splitDividerCol(sidebarWidth);
75
76
  const leftLen = Math.max(0, dividerCol - 1);
76
77
  const rightLen = Math.max(0, width - 2 - dividerCol);
@@ -90,7 +91,7 @@ export function topBorderSplit(width: number, title: string, sidebarWidth: numbe
90
91
 
91
92
  /** Section rule that closes the sidebar column with a `┴` over the divider. */
92
93
  export function dividerSplit(width: number, sidebarWidth: number): string {
93
- const box = theme.boxSharp;
94
+ const box = theme.boxRound;
94
95
  const dividerCol = splitDividerCol(sidebarWidth);
95
96
  const leftLen = Math.max(0, dividerCol - 1);
96
97
  const rightLen = Math.max(0, width - 2 - dividerCol);
@@ -101,7 +102,7 @@ export function dividerSplit(width: number, sidebarWidth: number): string {
101
102
 
102
103
  /** A two-column content row: `│ sidebar │ body │`, each inset by one column. */
103
104
  export function splitRow(sidebar: string, body: string, width: number, sidebarWidth: number): string {
104
- const box = theme.boxSharp;
105
+ const box = theme.boxRound;
105
106
  const bodyWidth = splitBodyWidth(width, sidebarWidth);
106
107
  const bar = paint(box.vertical);
107
108
  return `${bar} ${fit(sidebar, sidebarWidth)} ${bar} ${fit(body, bodyWidth)} ${bar}`;