@oh-my-pi/pi-coding-agent 16.0.10 โ†’ 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 (135) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/cli.js +3344 -3371
  3. package/dist/types/advisor/index.d.ts +1 -0
  4. package/dist/types/advisor/transcript-recorder.d.ts +52 -0
  5. package/dist/types/commit/agentic/agent.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +14 -8
  7. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  9. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
  10. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  11. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +8 -0
  13. package/dist/types/modes/components/cache-invalidation-marker.d.ts +34 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
  16. package/dist/types/modes/components/index.d.ts +0 -1
  17. package/dist/types/modes/components/message-frame.d.ts +6 -4
  18. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  19. package/dist/types/modes/interactive-mode.d.ts +4 -2
  20. package/dist/types/modes/theme/theme.d.ts +7 -1
  21. package/dist/types/modes/types.d.ts +9 -2
  22. package/dist/types/registry/agent-registry.d.ts +10 -3
  23. package/dist/types/sdk.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +20 -1
  25. package/dist/types/session/compact-modes.d.ts +60 -0
  26. package/dist/types/session/session-context.d.ts +7 -0
  27. package/dist/types/session/session-dump-format.d.ts +1 -0
  28. package/dist/types/session/streaming-output.d.ts +0 -2
  29. package/dist/types/session/tool-choice-queue.d.ts +14 -0
  30. package/dist/types/system-prompt.d.ts +3 -3
  31. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/resolve.d.ts +15 -5
  34. package/package.json +12 -12
  35. package/src/advisor/index.ts +1 -0
  36. package/src/advisor/transcript-recorder.ts +136 -0
  37. package/src/cli/stats-cli.ts +2 -11
  38. package/src/collab/host.ts +25 -13
  39. package/src/commit/agentic/agent.ts +2 -1
  40. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  41. package/src/commit/changelog/index.ts +1 -1
  42. package/src/commit/map-reduce/map-phase.ts +1 -1
  43. package/src/commit/map-reduce/utils.ts +1 -1
  44. package/src/config/settings-schema.ts +16 -9
  45. package/src/config/settings.ts +0 -6
  46. package/src/debug/log-viewer.ts +4 -4
  47. package/src/debug/raw-sse.ts +4 -4
  48. package/src/edit/file-snapshot-store.ts +1 -1
  49. package/src/edit/renderer.ts +9 -9
  50. package/src/eval/js/tool-bridge.ts +3 -2
  51. package/src/eval/py/prelude.py +3 -2
  52. package/src/export/html/tool-views.generated.js +28 -28
  53. package/src/extensibility/extensions/types.ts +7 -0
  54. package/src/hindsight/mental-models.ts +1 -1
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/history-protocol.ts +8 -3
  57. package/src/irc/bus.ts +8 -0
  58. package/src/lsp/index.ts +2 -2
  59. package/src/lsp/render.ts +7 -7
  60. package/src/main.ts +4 -1
  61. package/src/modes/acp/acp-agent.ts +63 -0
  62. package/src/modes/components/__tests__/skill-message.test.ts +92 -0
  63. package/src/modes/components/agent-dashboard.ts +1 -1
  64. package/src/modes/components/agent-hub.ts +97 -920
  65. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  66. package/src/modes/components/assistant-message.ts +21 -0
  67. package/src/modes/components/cache-invalidation-marker.ts +84 -0
  68. package/src/modes/components/chat-transcript-builder.ts +476 -0
  69. package/src/modes/components/compaction-summary-message.ts +29 -1
  70. package/src/modes/components/custom-message.ts +4 -1
  71. package/src/modes/components/diff.ts +12 -35
  72. package/src/modes/components/dynamic-border.ts +1 -1
  73. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  74. package/src/modes/components/extensions/inspector-panel.ts +5 -5
  75. package/src/modes/components/hook-selector.ts +2 -2
  76. package/src/modes/components/index.ts +0 -1
  77. package/src/modes/components/message-frame.ts +10 -6
  78. package/src/modes/components/model-selector.ts +2 -2
  79. package/src/modes/components/overlay-box.ts +10 -9
  80. package/src/modes/components/skill-message.ts +39 -19
  81. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  82. package/src/modes/components/welcome.ts +1 -1
  83. package/src/modes/controllers/command-controller.ts +12 -2
  84. package/src/modes/controllers/event-controller.ts +15 -1
  85. package/src/modes/controllers/input-controller.ts +8 -1
  86. package/src/modes/controllers/selector-controller.ts +11 -1
  87. package/src/modes/interactive-mode.ts +13 -3
  88. package/src/modes/theme/theme.ts +14 -0
  89. package/src/modes/types.ts +9 -2
  90. package/src/modes/utils/ui-helpers.ts +20 -2
  91. package/src/prompts/steering/user-interjection.md +3 -4
  92. package/src/prompts/tools/read.md +1 -1
  93. package/src/registry/agent-registry.ts +13 -4
  94. package/src/sdk.ts +9 -7
  95. package/src/session/agent-session.ts +182 -16
  96. package/src/session/compact-modes.ts +105 -0
  97. package/src/session/messages.ts +7 -9
  98. package/src/session/session-context.ts +54 -7
  99. package/src/session/session-dump-format.ts +4 -2
  100. package/src/session/session-history-format.ts +1 -1
  101. package/src/session/snapcompact-inline.ts +2 -2
  102. package/src/session/streaming-output.ts +5 -5
  103. package/src/session/tool-choice-queue.ts +59 -0
  104. package/src/slash-commands/builtin-registry.ts +16 -4
  105. package/src/system-prompt.ts +10 -9
  106. package/src/task/executor.ts +1 -1
  107. package/src/task/output-manager.ts +5 -0
  108. package/src/tools/__tests__/json-tree.test.ts +35 -0
  109. package/src/tools/approval.ts +1 -1
  110. package/src/tools/bash-interactive.ts +4 -4
  111. package/src/tools/bash.ts +0 -1
  112. package/src/tools/browser.ts +0 -1
  113. package/src/tools/eval.ts +1 -1
  114. package/src/tools/gh.ts +1 -1
  115. package/src/tools/index.ts +4 -0
  116. package/src/tools/irc.ts +1 -1
  117. package/src/tools/json-tree.ts +22 -5
  118. package/src/tools/read.ts +5 -6
  119. package/src/tools/resolve.ts +66 -41
  120. package/src/tui/output-block.ts +9 -9
  121. package/src/web/scrapers/firefox-addons.ts +1 -1
  122. package/src/web/scrapers/github.ts +1 -1
  123. package/src/web/scrapers/go-pkg.ts +2 -2
  124. package/src/web/scrapers/metacpan.ts +2 -2
  125. package/src/web/scrapers/nvd.ts +2 -2
  126. package/src/web/scrapers/ollama.ts +1 -1
  127. package/src/web/scrapers/opencorporates.ts +1 -1
  128. package/src/web/scrapers/pub-dev.ts +1 -1
  129. package/src/web/scrapers/repology.ts +1 -1
  130. package/src/web/scrapers/sourcegraph.ts +1 -1
  131. package/src/web/scrapers/terraform.ts +6 -6
  132. package/src/web/scrapers/wikidata.ts +2 -2
  133. package/src/workspace-tree.ts +1 -1
  134. package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
  135. package/src/modes/components/branch-summary-message.ts +0 -46
@@ -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}`;
@@ -3,6 +3,8 @@ import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
4
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
5
5
  import type { CustomMessage, SkillPromptDetails } from "../../session/messages";
6
+ import { shortenPath } from "../../tools/render-utils";
7
+ import { fileHyperlink } from "../../tui";
6
8
 
7
9
  export class SkillMessageComponent extends Container {
8
10
  #box: Box;
@@ -38,25 +40,26 @@ export class SkillMessageComponent extends Container {
38
40
  this.removeChild(this.#box);
39
41
  this.addChild(this.#box);
40
42
  this.#box.clear();
41
-
42
- const label = theme.fg("customMessageLabel", theme.bold("[skill]"));
43
- this.#box.addChild(new Text(label, 0, 0));
44
- this.#box.addChild(new Spacer(1));
43
+ // Re-read symbols every rebuild so a runtime theme/preset switch refreshes the outline.
44
+ this.#box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) });
45
45
 
46
46
  const details = this.message.details;
47
- const args = details?.args?.trim();
48
- const infoLines = [
49
- `Skill: ${details?.name ?? "unknown"}`,
50
- args ? `Args: ${args}` : undefined,
51
- details?.path ? `Path: ${details.path}` : undefined,
52
- typeof details?.lineCount === "number" ? `Prompt: ${details.lineCount} lines` : undefined,
53
- ].filter((line): line is string => Boolean(line));
54
-
55
- this.#box.addChild(
56
- new Markdown(infoLines.join("\n"), 0, 0, getMarkdownTheme(), {
57
- color: (value: string) => theme.fg("customMessageText", value),
58
- }),
59
- );
47
+ const name = details?.name?.trim() || "unknown";
48
+ // Collapse args to one line: a stray newline/tab in user-supplied args would split the header.
49
+ const args = details?.args?.replace(/\s+/g, " ").trim() ?? "";
50
+
51
+ // Header: icon-tag + skill name, with the invocation args trailing dimmed.
52
+ const tag = theme.fg("customMessageLabel", theme.bold(`${theme.icon.extensionSkill} skill`));
53
+ let header = `${tag} ${theme.fg("customMessageText", theme.bold(name))}`;
54
+ if (args) {
55
+ header += ` ${theme.fg("dim", args)}`;
56
+ }
57
+ this.#box.addChild(new Text(header, 0, 0));
58
+
59
+ const meta = this.#metaLine(details);
60
+ if (meta) {
61
+ this.#box.addChild(new Text(meta, 0, 0));
62
+ }
60
63
 
61
64
  if (!this.#expanded) {
62
65
  return;
@@ -68,8 +71,7 @@ export class SkillMessageComponent extends Container {
68
71
  }
69
72
 
70
73
  this.#box.addChild(new Spacer(1));
71
- const promptHeader = theme.fg("customMessageLabel", theme.bold("Prompt"));
72
- this.#box.addChild(new Text(promptHeader, 0, 0));
74
+ this.#box.addChild(new Text(theme.fg("muted", "prompt"), 0, 0));
73
75
  this.#box.addChild(new Spacer(1));
74
76
 
75
77
  this.#contentComponent = new Markdown(text, 0, 0, getMarkdownTheme(), {
@@ -78,6 +80,24 @@ export class SkillMessageComponent extends Container {
78
80
  this.#box.addChild(this.#contentComponent);
79
81
  }
80
82
 
83
+ /** Sub-line under the header: home-shortened (clickable) accent path ยท muted prompt size. */
84
+ #metaLine(details: SkillPromptDetails | undefined): string | undefined {
85
+ const parts: string[] = [];
86
+
87
+ const filePath = details?.path;
88
+ if (filePath) {
89
+ parts.push(fileHyperlink(filePath, theme.fg("accent", shortenPath(filePath)), { line: 1 }));
90
+ }
91
+ if (typeof details?.lineCount === "number") {
92
+ parts.push(theme.fg("muted", `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`));
93
+ }
94
+
95
+ if (parts.length === 0) {
96
+ return undefined;
97
+ }
98
+ return ` ${parts.join(theme.fg("muted", theme.sep.dot))}`;
99
+ }
100
+
81
101
  #extractText(): string {
82
102
  if (typeof this.message.content === "string") {
83
103
  return this.message.content;
@@ -74,7 +74,7 @@ export class TinyTitleDownloadProgressComponent implements Component {
74
74
  render(width: number): readonly string[] {
75
75
  width = Math.max(1, width);
76
76
  const spec = getTinyTitleModelSpec(this.#modelKey);
77
- const border = theme.fg("border", theme.boxSharp.horizontal.repeat(width));
77
+ const border = theme.fg("border", theme.boxRound.horizontal.repeat(width));
78
78
  const status = statusLabel(this.#event);
79
79
  const file = currentFile(this.#event);
80
80
  const pct =
@@ -308,7 +308,7 @@ export class WelcomeComponent implements Component {
308
308
  }
309
309
  // Bottom border
310
310
  if (showRightColumn) {
311
- lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxSharp.teeUp) + h.repeat(rightCol) + br);
311
+ lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br);
312
312
  } else {
313
313
  lines.push(bl + h.repeat(leftCol) + br);
314
314
  }
@@ -38,6 +38,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
38
38
  import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
39
39
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
40
40
  import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
41
+ import type { CompactMode } from "../../session/compact-modes";
41
42
  import type { NewSessionOptions } from "../../session/session-entries";
42
43
  import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
43
44
  import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
@@ -1034,6 +1035,7 @@ export class CommandController {
1034
1035
 
1035
1036
  async handleCompactCommand(
1036
1037
  customInstructions?: string,
1038
+ mode?: CompactMode,
1037
1039
  beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
1038
1040
  ): Promise<CompactionOutcome> {
1039
1041
  const entries = this.ctx.sessionManager.getEntries();
@@ -1044,7 +1046,7 @@ export class CommandController {
1044
1046
  return "ok";
1045
1047
  }
1046
1048
 
1047
- return this.executeCompaction(customInstructions, false, beforeFlush);
1049
+ return this.executeCompaction(customInstructions, false, beforeFlush, mode);
1048
1050
  }
1049
1051
 
1050
1052
  /**
@@ -1090,6 +1092,7 @@ export class CommandController {
1090
1092
  customInstructionsOrOptions?: string | CompactOptions,
1091
1093
  isAuto = false,
1092
1094
  beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
1095
+ mode?: CompactMode,
1093
1096
  ): Promise<CompactionOutcome> {
1094
1097
  if (this.ctx.loadingAnimation) {
1095
1098
  this.ctx.loadingAnimation.stop();
@@ -1111,10 +1114,17 @@ export class CommandController {
1111
1114
  let outcome: CompactionOutcome = "ok";
1112
1115
  try {
1113
1116
  const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined;
1114
- const options =
1117
+ const baseOptions =
1115
1118
  customInstructionsOrOptions && typeof customInstructionsOrOptions === "object"
1116
1119
  ? customInstructionsOrOptions
1117
1120
  : undefined;
1121
+ // The slash path passes `mode` positionally; the extension path carries
1122
+ // it inside the options object. Either source wins over no mode.
1123
+ const effectiveMode = mode ?? baseOptions?.mode;
1124
+ const options =
1125
+ baseOptions || effectiveMode
1126
+ ? { ...baseOptions, ...(effectiveMode ? { mode: effectiveMode } : {}) }
1127
+ : undefined;
1118
1128
  await this.ctx.session.compact(instructions, options);
1119
1129
 
1120
1130
  compactingLoader.stop();
@@ -1,10 +1,11 @@
1
- import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
2
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
3
2
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
3
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
4
4
  import { extractTextContent } from "../../commit/utils";
5
5
  import { settings } from "../../config/settings";
6
6
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
7
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
8
+ import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
8
9
  import {
9
10
  ReadToolGroupComponent,
10
11
  readArgsHaveTarget,
@@ -659,6 +660,16 @@ export class EventController {
659
660
  // waiting poll cannot be displaced anymore โ€” freeze it in place.
660
661
  this.#resolveDisplaceablePoll();
661
662
  }
663
+ // Surface a prompt-cache invalidation: if the previous turn cached a
664
+ // meaningful prefix and this request read none of it back, flag the turn.
665
+ const usage = event.message.usage;
666
+ if (usage.cacheRead + usage.cacheWrite + usage.input > 0) {
667
+ if (settings.get("display.cacheMissMarker")) {
668
+ const invalidation = detectCacheInvalidation(this.ctx.lastAssistantUsage, usage);
669
+ if (invalidation) this.ctx.streamingComponent.setCacheInvalidation(invalidation);
670
+ }
671
+ this.ctx.lastAssistantUsage = usage;
672
+ }
662
673
  this.#lastAssistantComponent = this.ctx.streamingComponent;
663
674
  this.#lastAssistantComponent.markTranscriptBlockFinalized();
664
675
  if (settings.get("display.showTokenUsage")) {
@@ -969,12 +980,14 @@ export class EventController {
969
980
  }
970
981
  this.ctx.showWarning(event.errorMessage);
971
982
  } else if (!event.skipped) {
983
+ this.ctx.lastAssistantUsage = undefined;
972
984
  this.ctx.rebuildChatFromMessages();
973
985
  this.ctx.statusLine.invalidate();
974
986
  this.ctx.updateEditorTopBorder();
975
987
  this.ctx.showStatus("Auto-shake completed");
976
988
  }
977
989
  } else if (event.result) {
990
+ this.ctx.lastAssistantUsage = undefined;
978
991
  this.ctx.rebuildChatFromMessages();
979
992
  this.ctx.statusLine.invalidate();
980
993
  this.ctx.updateEditorTopBorder();
@@ -982,6 +995,7 @@ export class EventController {
982
995
  this.ctx.showWarning(event.errorMessage);
983
996
  } else if (isHandoffAction) {
984
997
  this.ctx.chatContainer.clear();
998
+ this.ctx.lastAssistantUsage = undefined;
985
999
  this.ctx.rebuildChatFromMessages();
986
1000
  this.ctx.statusLine.invalidate();
987
1001
  this.ctx.updateEditorTopBorder();
@@ -1529,7 +1529,6 @@ export class InputController {
1529
1529
  for (const child of this.ctx.chatContainer.children) {
1530
1530
  if (child instanceof AssistantMessageComponent) {
1531
1531
  child.setHideThinkingBlock(this.ctx.hideThinkingBlock);
1532
- child.invalidate();
1533
1532
  }
1534
1533
  }
1535
1534
 
@@ -1538,6 +1537,14 @@ export class InputController {
1538
1537
  this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
1539
1538
  }
1540
1539
 
1540
+ // Every block now carries the new flag, but on ED3-risk terminals the
1541
+ // blocks that scrolled past the live region are frozen snapshots in
1542
+ // committed scrollback โ€” a plain repaint replays them stale, so scrolling
1543
+ // up still shows the old thinking expanded. resetDisplay() retires those
1544
+ // snapshots (it invalidates every block) and forces a full clear + replay
1545
+ // of the whole transcript, matching setToolsExpanded()'s redraw.
1546
+ this.ctx.ui.resetDisplay();
1547
+
1541
1548
  this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`);
1542
1549
  }
1543
1550
 
@@ -320,9 +320,19 @@ export class SelectorController {
320
320
  for (const child of this.ctx.chatContainer.children) {
321
321
  if (child instanceof AssistantMessageComponent) {
322
322
  child.setHideThinkingBlock(value as boolean);
323
- child.invalidate();
324
323
  }
325
324
  }
325
+ // Full clear + replay so blocks frozen in committed scrollback on
326
+ // ED3-risk terminals retire their stale snapshots too (see
327
+ // InputController.toggleThinkingBlockVisibility).
328
+ this.ctx.ui.resetDisplay();
329
+ break;
330
+ case "display.cacheMissMarker":
331
+ // Rebuild re-runs the usage-based detection under the new setting so
332
+ // markers appear/disappear; full reset retires any already committed
333
+ // to native scrollback (mirrors hideThinking).
334
+ this.ctx.rebuildChatFromMessages();
335
+ this.ctx.ui.resetDisplay();
326
336
  break;
327
337
  case "tui.tight":
328
338
  setTuiTight(value as boolean);
@@ -12,7 +12,7 @@ import {
12
12
  ThinkingLevel,
13
13
  } from "@oh-my-pi/pi-agent-core";
14
14
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
15
- import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
15
+ import type { AssistantMessage, ImageContent, Message, Model, Usage, UsageReport } from "@oh-my-pi/pi-ai";
16
16
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
17
17
  import type {
18
18
  Component,
@@ -82,6 +82,7 @@ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compa
82
82
  type: "text",
83
83
  };
84
84
  import type { AgentSession, AgentSessionEvent, ResolvedRoleModel } from "../session/agent-session";
85
+ import type { CompactMode } from "../session/compact-modes";
85
86
  import { HistoryStorage } from "../session/history-storage";
86
87
  import type { SessionContext } from "../session/session-context";
87
88
  import { getRecentSessions } from "../session/session-listing";
@@ -411,6 +412,7 @@ export class InteractiveMode implements InteractiveModeContext {
411
412
  isPythonMode = false;
412
413
  streamingComponent: AssistantMessageComponent | undefined = undefined;
413
414
  streamingMessage: AssistantMessage | undefined = undefined;
415
+ lastAssistantUsage: Usage | undefined = undefined;
414
416
  loadingAnimation: Loader | undefined = undefined;
415
417
  autoCompactionLoader: Loader | undefined = undefined;
416
418
  retryLoader: Loader | undefined = undefined;
@@ -511,6 +513,7 @@ export class InteractiveMode implements InteractiveModeContext {
511
513
  this.compactionQueuedMessages = [];
512
514
  this.streamingComponent = undefined;
513
515
  this.streamingMessage = undefined;
516
+ this.lastAssistantUsage = undefined;
514
517
  this.pendingTools.clear();
515
518
  }
516
519
  readonly #uiHelpers: UiHelpers;
@@ -1857,6 +1860,9 @@ export class InteractiveMode implements InteractiveModeContext {
1857
1860
  this.#planModePreviousTools = previousTools;
1858
1861
  this.planModePlanFilePath = planFilePath;
1859
1862
  this.planModeEnabled = true;
1863
+ // Suppress cache-miss marker on the next turn: plan mode changes the system
1864
+ // prompt, which predictably invalidates the cache.
1865
+ this.lastAssistantUsage = undefined;
1860
1866
 
1861
1867
  await this.session.setActiveToolsByName(uniquePlanTools);
1862
1868
  this.session.setPlanModeState({
@@ -1974,6 +1980,9 @@ export class InteractiveMode implements InteractiveModeContext {
1974
1980
  this.session.setStandingResolveHandler?.(null);
1975
1981
  this.session.setPlanModeState(undefined);
1976
1982
  this.planModeEnabled = false;
1983
+ // Suppress cache-miss marker on the next turn: plan exit changes the system
1984
+ // prompt, which predictably invalidates the cache.
1985
+ this.lastAssistantUsage = undefined;
1977
1986
  this.planModePaused = options?.paused ?? false;
1978
1987
  this.planModePlanFilePath = undefined;
1979
1988
  this.#planModePreviousTools = undefined;
@@ -2350,7 +2359,7 @@ export class InteractiveMode implements InteractiveModeContext {
2350
2359
  // the try/finally is idempotent and kept for the !compactBeforeExecute
2351
2360
  // branch.
2352
2361
  this.session.setPlanReferencePath(options.planFilePath);
2353
- compactOutcome = await this.handleCompactCommand(compactionPrompt, outcome =>
2362
+ compactOutcome = await this.handleCompactCommand(compactionPrompt, undefined, outcome =>
2354
2363
  this.#applyDeferredPlanModelTransition(outcome, options.executionModel),
2355
2364
  );
2356
2365
  }
@@ -3616,9 +3625,10 @@ export class InteractiveMode implements InteractiveModeContext {
3616
3625
 
3617
3626
  handleCompactCommand(
3618
3627
  customInstructions?: string,
3628
+ mode?: CompactMode,
3619
3629
  beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
3620
3630
  ): Promise<CompactionOutcome> {
3621
- return this.#commandController.handleCompactCommand(customInstructions, beforeFlush);
3631
+ return this.#commandController.handleCompactCommand(customInstructions, mode, beforeFlush);
3622
3632
  }
3623
3633
 
3624
3634
  handleHandoffCommand(customInstructions?: string): Promise<void> {
@@ -111,6 +111,7 @@ export type SymbolKey =
111
111
  | "icon.agents"
112
112
  | "icon.job"
113
113
  | "icon.cache"
114
+ | "icon.cacheMiss"
114
115
  | "icon.input"
115
116
  | "icon.output"
116
117
  | "icon.host"
@@ -310,6 +311,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
310
311
  "icon.agents": "๐Ÿ‘ฅ",
311
312
  "icon.job": "โš™",
312
313
  "icon.cache": "๐Ÿ’พ",
314
+ "icon.cacheMiss": "โŠ˜",
313
315
  "icon.input": "โคต",
314
316
  "icon.output": "โคด",
315
317
  "icon.host": "๐Ÿ–ฅ",
@@ -579,6 +581,8 @@ const NERD_SYMBOLS: SymbolMap = {
579
581
  "icon.job": "\uf013",
580
582
  // pick: ๏‡€ | alt: ๏‚  ๏ƒ‡
581
583
  "icon.cache": "\uf1c0",
584
+ // pick: (fa-ban) | alt: โŠ˜
585
+ "icon.cacheMiss": "\uf05e",
582
586
  // pick: ๏‚ | alt: ๏ก โ†’
583
587
  "icon.input": "\uf090",
584
588
  // pick: ๏‚‹ | alt: ๏ก โ†’
@@ -810,6 +814,7 @@ const ASCII_SYMBOLS: SymbolMap = {
810
814
  "icon.agents": "AG",
811
815
  "icon.job": "bg",
812
816
  "icon.cache": "cache",
817
+ "icon.cacheMiss": "!",
813
818
  "icon.input": "in:",
814
819
  "icon.output": "out:",
815
820
  "icon.host": "host",
@@ -1711,6 +1716,14 @@ export class Theme {
1711
1716
  bottomRight: this.#symbols["boxRound.bottomRight"],
1712
1717
  horizontal: this.#symbols["boxRound.horizontal"],
1713
1718
  vertical: this.#symbols["boxRound.vertical"],
1719
+ // Junctions have no rounded Unicode variant, so a rounded box reuses the
1720
+ // sharp tee/cross glyphs. Sourcing them from the boxSharp.* tokens keeps a
1721
+ // theme's `boxSharp.tee*` overrides effective for rounded-box dividers.
1722
+ cross: this.#symbols["boxSharp.cross"],
1723
+ teeDown: this.#symbols["boxSharp.teeDown"],
1724
+ teeUp: this.#symbols["boxSharp.teeUp"],
1725
+ teeRight: this.#symbols["boxSharp.teeRight"],
1726
+ teeLeft: this.#symbols["boxSharp.teeLeft"],
1714
1727
  };
1715
1728
  }
1716
1729
 
@@ -1770,6 +1783,7 @@ export class Theme {
1770
1783
  agents: this.#symbols["icon.agents"],
1771
1784
  job: this.#symbols["icon.job"],
1772
1785
  cache: this.#symbols["icon.cache"],
1786
+ cacheMiss: this.#symbols["icon.cacheMiss"],
1773
1787
  input: this.#symbols["icon.input"],
1774
1788
  output: this.#symbols["icon.output"],
1775
1789
  host: this.#symbols["icon.host"],
@@ -1,6 +1,6 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
3
- import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai";
3
+ import type { AssistantMessage, ImageContent, Message, Usage, UsageReport } from "@oh-my-pi/pi-ai";
4
4
  import type { Component, Container, EditorTheme, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
5
5
  import type { CollabGuestLink } from "../collab/guest";
6
6
  import type { CollabHost } from "../collab/host";
@@ -17,6 +17,7 @@ import type { CompactOptions } from "../extensibility/extensions/types";
17
17
  import type { MCPManager } from "../mcp";
18
18
  import type { PlanApprovalDetails } from "../plan-mode/approved-plan";
19
19
  import type { AgentSession } from "../session/agent-session";
20
+ import type { CompactMode } from "../session/compact-modes";
20
21
  import type { HistoryStorage } from "../session/history-storage";
21
22
  import type { SessionContext } from "../session/session-context";
22
23
  import type { SessionManager } from "../session/session-manager";
@@ -158,6 +159,12 @@ export interface InteractiveModeContext {
158
159
  isPythonMode: boolean;
159
160
  streamingComponent: AssistantMessageComponent | undefined;
160
161
  streamingMessage: AssistantMessage | undefined;
162
+ /**
163
+ * Usage of the most recently rendered assistant turn, used to detect a
164
+ * prompt-cache invalidation on the next turn (cache footprint collapse).
165
+ * Reseeded by `renderSessionContext` on every rebuild/session switch.
166
+ */
167
+ lastAssistantUsage: Usage | undefined;
161
168
  loadingAnimation: Loader | undefined;
162
169
  autoCompactionLoader: Loader | undefined;
163
170
  retryLoader: Loader | undefined;
@@ -293,7 +300,7 @@ export interface InteractiveModeContext {
293
300
  handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
294
301
  handleMCPCommand(text: string): Promise<void>;
295
302
  handleSSHCommand(text: string): Promise<void>;
296
- handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome>;
303
+ handleCompactCommand(customInstructions?: string, mode?: CompactMode): Promise<CompactionOutcome>;
297
304
  handleHandoffCommand(customInstructions?: string): Promise<void>;
298
305
  handleShakeCommand(mode: ShakeMode): Promise<void>;
299
306
  handleMoveCommand(targetPath: string): Promise<void>;