@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2

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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Shared rendering for extension/hook custom message frames.
3
+ *
4
+ * Both `CustomMessageComponent` and `HookMessageComponent` wrap a
5
+ * `Spacer(1) + Box` layout, try a user-supplied renderer first, and fall
6
+ * back to a label + markdown body when the renderer returns nothing or
7
+ * throws. The only meaningful difference is that hook messages collapse to
8
+ * the first N lines when not expanded; extension messages render in full.
9
+ */
10
+
11
+ import type { TextContent } from "@oh-my-pi/pi-ai";
12
+ import type { Box, Component } from "@oh-my-pi/pi-tui";
13
+ import { Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
14
+ import { getMarkdownTheme, type Theme, theme } from "../../modes/theme/theme";
15
+
16
+ /** Message shape consumed by the shared frame. */
17
+ export interface FramedMessage {
18
+ customType: string;
19
+ content: string | (TextContent | { type: string })[];
20
+ }
21
+
22
+ /**
23
+ * Callable signature shared by `MessageRenderer` (extensions) and
24
+ * `HookMessageRenderer` (hooks). Both narrow `message` to their own type;
25
+ * this signature is the structural intersection callers can hand off here.
26
+ */
27
+ export type FramedRenderer<M extends FramedMessage> = (
28
+ message: M,
29
+ options: { expanded: boolean },
30
+ theme: Theme,
31
+ ) => Component | undefined;
32
+
33
+ export interface RebuildFrameOptions<M extends FramedMessage> {
34
+ message: M;
35
+ box: Box;
36
+ expanded: boolean;
37
+ /** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */
38
+ collapseAfterLines?: number;
39
+ customRenderer?: FramedRenderer<M>;
40
+ }
41
+
42
+ /**
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.
47
+ */
48
+ export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
49
+ if (opts.customRenderer) {
50
+ try {
51
+ const component = opts.customRenderer(opts.message, { expanded: opts.expanded }, theme);
52
+ if (component) return component;
53
+ } catch {
54
+ // Fall through to default rendering
55
+ }
56
+ }
57
+
58
+ opts.box.clear();
59
+
60
+ const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`));
61
+ opts.box.addChild(new Text(label, 0, 0));
62
+ opts.box.addChild(new Spacer(1));
63
+
64
+ let text: string;
65
+ if (typeof opts.message.content === "string") {
66
+ text = opts.message.content;
67
+ } else {
68
+ text = opts.message.content
69
+ .filter((c): c is TextContent => c.type === "text")
70
+ .map(c => c.text)
71
+ .join("\n");
72
+ }
73
+
74
+ if (!opts.expanded && opts.collapseAfterLines !== undefined) {
75
+ const lines = text.split("\n");
76
+ if (lines.length > opts.collapseAfterLines) {
77
+ text = `${lines.slice(0, opts.collapseAfterLines).join("\n")}\n…`;
78
+ }
79
+ }
80
+
81
+ opts.box.addChild(
82
+ new Markdown(text, 0, 0, getMarkdownTheme(), {
83
+ color: (value: string) => theme.fg("customMessageText", value),
84
+ }),
85
+ );
86
+
87
+ return undefined;
88
+ }
@@ -2,6 +2,7 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
3
3
  import {
4
4
  Container,
5
+ fuzzyFilter,
5
6
  getKeybindings,
6
7
  Input,
7
8
  matchesKey,
@@ -18,7 +19,6 @@ import { resolveModelRoleValue } from "../../config/model-resolver";
18
19
  import type { Settings } from "../../config/settings";
19
20
  import { type ThemeColor, theme } from "../../modes/theme/theme";
20
21
  import { getThinkingLevelMetadata } from "../../thinking";
21
- import { fuzzyFilter } from "../../utils/fuzzy";
22
22
  import { getTabBarTheme } from "../shared";
23
23
  import { DynamicBorder } from "./dynamic-border";
24
24
 
@@ -18,6 +18,7 @@ import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
18
18
  import { Container, Markdown, type MarkdownTheme, matchesKey } from "@oh-my-pi/pi-tui";
19
19
  import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
20
20
  import type { KeyId } from "../../config/keybindings";
21
+ import { isSilentAbort } from "../../session/messages";
21
22
  import type { SessionMessageEntry } from "../../session/session-manager";
22
23
  import { parseSessionEntries } from "../../session/session-manager";
23
24
  import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
@@ -267,7 +268,10 @@ export class SessionObserverOverlayComponent extends Container {
267
268
  if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`);
268
269
  if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`);
269
270
  if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
270
- return stats.length > 0 ? theme.fg("dim", stats.join(theme.sep.dot)) : "";
271
+ const parts: string[] = [];
272
+ if (stats.length > 0) parts.push(theme.fg("dim", stats.join(theme.sep.dot)));
273
+ if (progress.cost > 0) parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`));
274
+ return parts.join(theme.sep.dot);
271
275
  }
272
276
 
273
277
  #buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void {
@@ -285,7 +289,7 @@ export class SessionObserverOverlayComponent extends Container {
285
289
 
286
290
  if (msg.role === "assistant") {
287
291
  // Handle error messages with empty content
288
- if (msg.content.length === 0 && msg.errorMessage) {
292
+ if (msg.content.length === 0 && msg.errorMessage && !isSilentAbort(msg.errorMessage)) {
289
293
  const startLine = lines.length;
290
294
  const isSelected = entryIndex === this.#selectedEntryIndex;
291
295
  const cursor = isSelected ? theme.fg("accent", "▶") : " ";
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type Component,
3
3
  Container,
4
+ fuzzyFilter,
4
5
  Input,
5
6
  matchesKey,
6
7
  padding,
@@ -14,7 +15,6 @@ import { formatBytes } from "@oh-my-pi/pi-utils";
14
15
  import { theme } from "../../modes/theme/theme";
15
16
  import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
16
17
  import type { SessionInfo } from "../../session/session-manager";
17
- import { fuzzyFilter } from "../../utils/fuzzy";
18
18
  import { DynamicBorder } from "./dynamic-border";
19
19
  import { HookSelectorComponent } from "./hook-selector";
20
20
 
@@ -2,8 +2,8 @@ import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
4
  import { TERMINAL } from "@oh-my-pi/pi-tui";
5
- import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
6
- import { theme } from "../../../modes/theme/theme";
5
+ import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
6
+ import { type ThemeColor, theme } from "../../../modes/theme/theme";
7
7
  import { shortenPath } from "../../../tools/render-utils";
8
8
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
9
9
  import { sanitizeStatusText } from "../../shared";
@@ -32,6 +32,33 @@ function normalizePremiumRequests(value: number): number {
32
32
  return Math.round((value + Number.EPSILON) * 100) / 100;
33
33
  }
34
34
 
35
+ const SCRATCH_ROOTS: readonly string[] = (() => {
36
+ const roots = new Set<string>([os.tmpdir(), path.join(os.homedir(), "tmp")]);
37
+ if (process.platform === "win32") {
38
+ const { TEMP, TMP, SystemRoot } = process.env;
39
+ if (TEMP) roots.add(TEMP);
40
+ if (TMP) roots.add(TMP);
41
+ if (SystemRoot) roots.add(path.join(SystemRoot, "Temp"));
42
+ } else {
43
+ roots.add("/tmp");
44
+ roots.add("/var/tmp");
45
+ if (process.platform === "darwin") {
46
+ roots.add("/private/tmp");
47
+ roots.add("/private/var/tmp");
48
+ }
49
+ }
50
+ return [...roots];
51
+ })();
52
+
53
+ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string | null } {
54
+ for (const root of SCRATCH_ROOTS) {
55
+ if (pathIsWithin(root, pwd)) {
56
+ return { scratch: true, relative: relativePathWithinRoot(root, pwd) };
57
+ }
58
+ }
59
+ return { scratch: false, relative: null };
60
+ }
61
+
35
62
  // ═══════════════════════════════════════════════════════════════════════════
36
63
  // Segment Implementations
37
64
  // ═══════════════════════════════════════════════════════════════════════════
@@ -76,17 +103,65 @@ const modelSegment: StatusLineSegment = {
76
103
  },
77
104
  };
78
105
 
106
+ function formatGoalBudget(current: number, budget?: number): string {
107
+ const used = formatNumber(current);
108
+ if (budget === undefined) return used;
109
+ return `${used}/${formatNumber(budget)}`;
110
+ }
111
+
112
+ function renderGoalMode(ctx: SegmentContext, mode: { enabled: boolean; paused: boolean }): RenderedSegment {
113
+ const goal = ctx.session.getGoalModeState()?.goal;
114
+ const status = goal?.status ?? (mode.paused ? "paused" : "active");
115
+
116
+ let icon: string = theme.icon.goal;
117
+ let color: ThemeColor = "accent";
118
+ switch (status) {
119
+ case "paused":
120
+ icon = theme.icon.pause || theme.symbol("status.pending");
121
+ color = "warning";
122
+ break;
123
+ case "complete":
124
+ icon = theme.symbol("status.success");
125
+ color = "success";
126
+ break;
127
+ case "budget-limited":
128
+ icon = theme.symbol("status.warning");
129
+ color = "warning";
130
+ break;
131
+ case "dropped":
132
+ icon = theme.symbol("status.aborted");
133
+ color = "dim";
134
+ break;
135
+ default:
136
+ break;
137
+ }
138
+
139
+ const parts: string[] = [withIcon(icon, "Goal")];
140
+ const showBudget = ctx.session.settings.get("goal.statusInFooter") === true;
141
+ if (showBudget && goal) {
142
+ parts.push(formatGoalBudget(goal.tokensUsed, goal.tokenBudget));
143
+ }
144
+ return { content: theme.fg(color, parts.join(" ")), visible: true };
145
+ }
146
+
79
147
  const modeSegment: StatusLineSegment = {
80
148
  id: "mode",
81
149
  render(ctx) {
150
+ const pauseSuffix = theme.icon.pause ? ` ${theme.icon.pause}` : " (paused)";
151
+
82
152
  const plan = ctx.planMode;
83
153
  if (plan && (plan.enabled || plan.paused)) {
84
- const label = plan.paused ? "Plan ⏸" : "Plan";
154
+ const label = plan.paused ? `Plan${pauseSuffix}` : "Plan";
85
155
  const content = withIcon(theme.icon.plan, label);
86
156
  const color = plan.paused ? "warning" : "accent";
87
157
  return { content: theme.fg(color, content), visible: true };
88
158
  }
89
159
 
160
+ const goal = ctx.goalMode;
161
+ if (goal && (goal.enabled || goal.paused)) {
162
+ return renderGoalMode(ctx, goal);
163
+ }
164
+
90
165
  const loop = ctx.loopMode;
91
166
  if (loop?.enabled) {
92
167
  const content = withIcon(theme.icon.loop, "Loop");
@@ -102,10 +177,16 @@ const pathSegment: StatusLineSegment = {
102
177
  render(ctx) {
103
178
  const opts = ctx.options.path ?? {};
104
179
 
105
- let pwd = getProjectDir();
180
+ const projectDir = getProjectDir();
181
+ const { scratch, relative } = classifyProjectDir(projectDir);
182
+ let pwd = projectDir;
106
183
 
107
184
  if (opts.stripWorkPrefix !== false) {
108
- pwd = stripDisplayRoot(pwd);
185
+ if (scratch) {
186
+ if (relative) pwd = relative;
187
+ } else {
188
+ pwd = stripDisplayRoot(pwd);
189
+ }
109
190
  }
110
191
  if (opts.abbreviate !== false) {
111
192
  pwd = shortenPath(pwd);
@@ -118,7 +199,8 @@ const pathSegment: StatusLineSegment = {
118
199
  pwd = `${ellipsis}${pwd.slice(-sliceLen)}`;
119
200
  }
120
201
 
121
- const content = withIcon(theme.icon.folder, pwd);
202
+ const icon = scratch ? theme.icon.scratchFolder : theme.icon.folder;
203
+ const content = withIcon(icon, pwd);
122
204
  return { content: theme.fg("statusLinePath", content), visible: true };
123
205
  },
124
206
  };
@@ -216,8 +298,11 @@ const tokenOutSegment: StatusLineSegment = {
216
298
  const tokenTotalSegment: StatusLineSegment = {
217
299
  id: "token_total",
218
300
  render(ctx) {
219
- const { input, output, cacheRead, cacheWrite } = ctx.usageStats;
220
- const total = input + output + cacheRead + cacheWrite;
301
+ // Excludes cacheRead: that field re-reads the full cached context every
302
+ // turn, making the cumulative sum N×context_size. The dedicated cache_read
303
+ // segment handles cache monitoring; the cost segment handles billing.
304
+ const { input, output, cacheWrite } = ctx.usageStats;
305
+ const total = input + output + cacheWrite;
221
306
  if (!total) return { content: "", visible: false };
222
307
 
223
308
  const content = withIcon(theme.icon.tokens, formatNumber(total));
@@ -27,6 +27,10 @@ export interface SegmentContext {
27
27
  loopMode: {
28
28
  enabled: boolean;
29
29
  } | null;
30
+ goalMode: {
31
+ enabled: boolean;
32
+ paused: boolean;
33
+ } | null;
30
34
  // Cached values for performance (computed once per render)
31
35
  usageStats: {
32
36
  input: number;
@@ -1,5 +1,4 @@
1
1
  import * as fs from "node:fs";
2
- import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
2
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
4
3
  import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils";
5
4
  import { $ } from "bun";
@@ -7,10 +6,10 @@ import { settings } from "../../config/settings";
7
6
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
8
7
  import { theme } from "../../modes/theme/theme";
9
8
  import type { AgentSession } from "../../session/agent-session";
10
- import { calculatePromptTokens } from "../../session/compaction/compaction";
11
9
  import * as git from "../../utils/git";
12
10
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color";
13
11
  import { sanitizeStatusText } from "../shared";
12
+ import { computeContextBreakdown } from "../utils/context-usage";
14
13
  import {
15
14
  canReuseCachedPr,
16
15
  createPrCacheContext,
@@ -59,6 +58,7 @@ export class StatusLineComponent implements Component {
59
58
  #sessionStartTime: number = Date.now();
60
59
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
61
60
  #loopModeStatus: { enabled: boolean } | null = null;
61
+ #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
62
62
 
63
63
  // Git status caching (1s TTL)
64
64
  #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
@@ -73,6 +73,10 @@ export class StatusLineComponent implements Component {
73
73
  #lastTokensPerSecond: number | null = null;
74
74
  #lastTokensPerSecondTimestamp: number | null = null;
75
75
 
76
+ // Context breakdown caching (2s TTL — aligns with /context command output)
77
+ #cachedBreakdown: { usedTokens: number; contextWindow: number } | null = null;
78
+ #breakdownFetchedAt = 0;
79
+
76
80
  constructor(private readonly session: AgentSession) {
77
81
  this.#settings = {
78
82
  preset: settings.get("statusLine.preset"),
@@ -109,6 +113,10 @@ export class StatusLineComponent implements Component {
109
113
  this.#loopModeStatus = status ?? null;
110
114
  }
111
115
 
116
+ setGoalModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
117
+ this.#goalModeStatus = status ?? null;
118
+ }
119
+
112
120
  setHookStatus(key: string, text: string | undefined): void {
113
121
  if (text === undefined) {
114
122
  this.#hookStatuses.delete(key);
@@ -301,6 +309,19 @@ export class StatusLineComponent implements Component {
301
309
  return null;
302
310
  }
303
311
 
312
+ #getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
313
+ const now = Date.now();
314
+ if (!this.#cachedBreakdown || now - this.#breakdownFetchedAt > 2_000) {
315
+ const breakdown = computeContextBreakdown(this.session);
316
+ this.#cachedBreakdown = {
317
+ usedTokens: breakdown.usedTokens,
318
+ contextWindow: breakdown.contextWindow,
319
+ };
320
+ this.#breakdownFetchedAt = now;
321
+ }
322
+ return this.#cachedBreakdown;
323
+ }
324
+
304
325
  #buildSegmentContext(width: number): SegmentContext {
305
326
  const state = this.session.state;
306
327
 
@@ -318,14 +339,10 @@ export class StatusLineComponent implements Component {
318
339
  tokensPerSecond: this.#getTokensPerSecond(),
319
340
  };
320
341
 
321
- // Get context percentage
322
- const lastAssistantMessage = state.messages
323
- .slice()
324
- .reverse()
325
- .find(m => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
326
-
327
- const contextTokens = lastAssistantMessage ? calculatePromptTokens(lastAssistantMessage.usage) : 0;
328
- const contextWindow = state.model?.contextWindow || 0;
342
+ // Context usage — aligned with /context command so both surfaces report the same value
343
+ const breakdown = this.#getCachedContextBreakdown();
344
+ const contextTokens = breakdown.usedTokens;
345
+ const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
329
346
  const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
330
347
 
331
348
  return {
@@ -334,6 +351,7 @@ export class StatusLineComponent implements Component {
334
351
  options: this.#resolveSettings().segmentOptions ?? {},
335
352
  planMode: this.#planModeStatus,
336
353
  loopMode: this.#loopModeStatus,
354
+ goalMode: this.#goalModeStatus,
337
355
  usageStats,
338
356
  contextPercent,
339
357
  contextWindow,
@@ -32,7 +32,6 @@ import {
32
32
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
33
33
  import { toolRenderers } from "../../tools/renderers";
34
34
  import { renderStatusLine } from "../../tui";
35
- import { convertToPng } from "../../utils/image-convert";
36
35
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
37
36
  import { renderDiff } from "./diff";
38
37
 
@@ -295,13 +294,13 @@ export class ToolExecutionComponent extends Container {
295
294
 
296
295
  // Convert async - catch errors from processing
297
296
  const index = i;
298
- convertToPng(img.data, img.mimeType)
299
- .then(converted => {
300
- if (converted) {
301
- this.#convertedImages.set(index, converted);
302
- this.#updateDisplay();
303
- this.#ui.requestRender();
304
- }
297
+ new Bun.Image(Buffer.from(img.data, "base64"))
298
+ .png()
299
+ .toBase64()
300
+ .then(data => {
301
+ this.#convertedImages.set(index, { data, mimeType: "image/png" });
302
+ this.#updateDisplay();
303
+ this.#ui.requestRender();
305
304
  })
306
305
  .catch(() => {
307
306
  // Ignore conversion failures - display will use original image format
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared helpers for /mcp and /ssh command controllers.
3
+ *
4
+ * Captures argument parsing, source grouping, and chat-message rendering that
5
+ * was duplicated between mcp-command-controller and ssh-command-controller.
6
+ * Intentionally kept narrow: subcommand routing, help text, success/error
7
+ * wording, and add-flow logic stay in the per-controller files because they
8
+ * diverge in workflow.
9
+ */
10
+ import { Spacer, Text } from "@oh-my-pi/pi-tui";
11
+ import type { SourceMeta } from "../../capability/types";
12
+ import { shortenPath } from "../../tools/render-utils";
13
+ import { DynamicBorder } from "../components/dynamic-border";
14
+ import { parseCommandArgs } from "../shared";
15
+ import type { InteractiveModeContext } from "../types";
16
+
17
+ export type ScopeValue = "project" | "user";
18
+
19
+ export type ScopeFlagResult = { ok: true; scope: ScopeValue } | { ok: false; error: string };
20
+
21
+ /**
22
+ * Validate the value following a `--scope` flag.
23
+ */
24
+ export function readScopeFlag(value: string | undefined): ScopeFlagResult {
25
+ if (!value || (value !== "project" && value !== "user")) {
26
+ return { ok: false, error: "Invalid --scope value. Use project or user." };
27
+ }
28
+ return { ok: true, scope: value };
29
+ }
30
+
31
+ export type RemoveArgs = { name: string | undefined; scope: ScopeValue };
32
+
33
+ export type ParseRemoveResult = { ok: true; value: RemoveArgs } | { ok: false; error: string };
34
+
35
+ /**
36
+ * Parse the argument tail of `/<cmd> remove <name> [--scope project|user]`.
37
+ *
38
+ * `rest` is the text after the subcommand keyword. The caller is responsible
39
+ * for emitting the command-specific "<entity> name required" usage hint when
40
+ * `value.name` is undefined.
41
+ */
42
+ export function parseRemoveArgs(rest: string): ParseRemoveResult {
43
+ const tokens = parseCommandArgs(rest);
44
+
45
+ let name: string | undefined;
46
+ let scope: ScopeValue = "project";
47
+ let i = 0;
48
+
49
+ if (tokens.length > 0 && !tokens[0].startsWith("-")) {
50
+ name = tokens[0];
51
+ i = 1;
52
+ }
53
+
54
+ while (i < tokens.length) {
55
+ const token = tokens[i];
56
+ if (token === "--scope") {
57
+ const r = readScopeFlag(tokens[i + 1]);
58
+ if (!r.ok) return { ok: false, error: r.error };
59
+ scope = r.scope;
60
+ i += 2;
61
+ continue;
62
+ }
63
+ return { ok: false, error: `Unknown option: ${token}` };
64
+ }
65
+
66
+ return { ok: true, value: { name, scope } };
67
+ }
68
+
69
+ /**
70
+ * Group capability-loaded items by their source provider+path, yielding each
71
+ * group with a display-ready `shortPath`.
72
+ */
73
+ export function* groupBySource<T>(
74
+ items: Iterable<T>,
75
+ getSource: (item: T) => SourceMeta,
76
+ ): Iterable<{ providerName: string; shortPath: string; items: T[] }> {
77
+ const groups = new Map<string, T[]>();
78
+ for (const item of items) {
79
+ const src = getSource(item);
80
+ const key = `${src.providerName}|${src.path}`;
81
+ let group = groups.get(key);
82
+ if (!group) {
83
+ group = [];
84
+ groups.set(key, group);
85
+ }
86
+ group.push(item);
87
+ }
88
+ for (const [key, grouped] of groups) {
89
+ const sepIdx = key.indexOf("|");
90
+ yield {
91
+ providerName: key.slice(0, sepIdx),
92
+ shortPath: shortenPath(key.slice(sepIdx + 1)),
93
+ items: grouped,
94
+ };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Render a message block (DynamicBorder / Text / DynamicBorder) into the chat
100
+ * container and request a render.
101
+ */
102
+ export function showCommandMessage(ctx: InteractiveModeContext, text: string): void {
103
+ ctx.chatContainer.addChild(new Spacer(1));
104
+ ctx.chatContainer.addChild(new DynamicBorder());
105
+ ctx.chatContainer.addChild(new Text(text, 1, 1));
106
+ ctx.chatContainer.addChild(new DynamicBorder());
107
+ ctx.ui.requestRender();
108
+ }
@@ -256,12 +256,21 @@ export class CommandController {
256
256
  }
257
257
 
258
258
  #copyLastMessage() {
259
- const text = this.ctx.session.getLastAssistantText();
260
- if (!text) {
261
- this.ctx.showError("No agent messages to copy yet.");
259
+ const assistantText = this.ctx.session.getLastAssistantText();
260
+ if (assistantText) {
261
+ this.#doCopy(assistantText, "Copied last agent message to clipboard");
262
262
  return;
263
263
  }
264
- this.#doCopy(text, "Copied last agent message to clipboard");
264
+
265
+ if (!this.ctx.session.hasCopyCandidateAssistantMessage()) {
266
+ const handoffText = this.ctx.session.getLastVisibleHandoffText();
267
+ if (handoffText) {
268
+ this.#doCopy(handoffText, "Copied handoff context to clipboard");
269
+ return;
270
+ }
271
+ }
272
+
273
+ this.ctx.showError("No agent messages to copy yet.");
265
274
  }
266
275
 
267
276
  #copyCode() {
@@ -13,9 +13,11 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
13
13
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
14
14
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
15
15
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
16
+ import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
16
17
  import type { AgentSessionEvent } from "../../session/agent-session";
17
18
  import { calculatePromptTokens } from "../../session/compaction/compaction";
18
- import type { ExitPlanModeDetails } from "../../tools";
19
+ import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
20
+ import type { ResolveToolDetails } from "../../tools/resolve";
19
21
 
20
22
  type AgentSessionEventKind = AgentSessionEvent["type"];
21
23
 
@@ -61,6 +63,8 @@ export class EventController {
61
63
  todo_auto_clear: e => this.#handleTodoAutoClear(e),
62
64
  irc_message: e => this.#handleIrcMessage(e),
63
65
  notice: e => this.#handleNotice(e),
66
+ thinking_level_changed: async () => {},
67
+ goal_updated: async () => {},
64
68
  } satisfies AgentSessionEventHandlers;
65
69
  }
66
70
 
@@ -178,6 +182,17 @@ export class EventController {
178
182
  this.#renderedCustomMessages.add(signature);
179
183
  this.#resetReadGroup();
180
184
  this.ctx.addMessageToChat(event.message);
185
+ // Tag-keyed pending-bar refresh: when AgentSession.#handleAgentEvent
186
+ // spliced this dequeued custom message out of #steeringMessages /
187
+ // #followUpMessages (it ran before this emit), the array state is
188
+ // already correct — pendingMessagesContainer just needs to be
189
+ // re-rendered to match. Gated on tag presence so non-queued customs
190
+ // (ttsr-injection, irc:*, async-result, hookMessage) skip the
191
+ // rebuild; their dispatch path never registered a pending chip.
192
+ // Mirrors the user-role refresh at the bottom of this function.
193
+ if (event.message.role === "custom" && readPendingDisplayTag(event.message.details)) {
194
+ this.ctx.updatePendingMessagesDisplay();
195
+ }
181
196
  this.ctx.ui.requestRender();
182
197
  } else if (event.message.role === "user") {
183
198
  const textContent = this.ctx.getUserMessageText(event.message);
@@ -364,7 +379,15 @@ export class EventController {
364
379
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
365
380
  this.ctx.streamingMessage = event.message;
366
381
  let errorMessage: string | undefined;
367
- if (this.ctx.streamingMessage.stopReason === "aborted" && !this.ctx.session.isTtsrAbortPending) {
382
+ const aborted = this.ctx.streamingMessage.stopReason === "aborted";
383
+ const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage);
384
+ const ttsrSilenced = aborted && this.ctx.session.isTtsrAbortPending;
385
+ if (aborted && !silentlyAborted && !ttsrSilenced) {
386
+ // Real user-cancel / network / provider abort: surface the standard
387
+ // operator-facing label. AgentSession.#handleAgentEvent already stamped
388
+ // SILENT_ABORT_MARKER for the plan-compact transition before this
389
+ // controller ran, so reaching this branch implies the abort was NOT a
390
+ // silent internal transition.
368
391
  const retryAttempt = this.ctx.session.retryAttempt;
369
392
  errorMessage =
370
393
  retryAttempt > 0
@@ -372,7 +395,10 @@ export class EventController {
372
395
  : "Operation aborted";
373
396
  this.ctx.streamingMessage.errorMessage = errorMessage;
374
397
  }
375
- if (this.ctx.session.isTtsrAbortPending && this.ctx.streamingMessage.stopReason === "aborted") {
398
+ if (silentlyAborted || ttsrSilenced) {
399
+ // Silence the streaming render by downgrading stopReason to "stop" for
400
+ // display only — does NOT mutate the persisted message's stopReason
401
+ // (the marker on errorMessage drives replay-side suppression).
376
402
  const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
377
403
  this.ctx.streamingComponent.updateContent(msgWithoutAbort);
378
404
  } else {
@@ -522,10 +548,13 @@ export class EventController {
522
548
  `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
523
549
  );
524
550
  }
525
- if (event.toolName === "exit_plan_mode" && !event.isError) {
526
- const details = event.result.details as ExitPlanModeDetails | undefined;
527
- if (details) {
528
- await this.ctx.handleExitPlanModeTool(details);
551
+ if (event.toolName === "resolve" && !event.isError) {
552
+ const details = event.result.details as ResolveToolDetails | undefined;
553
+ if (details?.sourceToolName === "plan_approval" && details.action === "apply") {
554
+ const planDetails = details.sourceResultDetails as PlanApprovalDetails | undefined;
555
+ if (planDetails) {
556
+ await this.ctx.handlePlanApproval(planDetails);
557
+ }
529
558
  }
530
559
  }
531
560
  }