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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  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/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
@@ -4,11 +4,17 @@
4
4
  */
5
5
 
6
6
  import { sanitizeText } from "@oh-my-pi/pi-natives";
7
- import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
8
- import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
9
- import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
10
- import { DynamicBorder } from "./dynamic-border";
11
- import { truncateToVisualLines } from "./visual-truncate";
7
+ import { Container, type Loader, Text, type TUI } from "@oh-my-pi/pi-tui";
8
+ import { highlightCode, theme } from "../../modes/theme/theme";
9
+ import type { TruncationMeta } from "../../tools/output-meta";
10
+ import {
11
+ buildExecutionFrame,
12
+ buildStatusFooter,
13
+ createCollapsedPreview,
14
+ type ExecutionColorKey,
15
+ type ExecutionStatus,
16
+ resolveExecutionStatus,
17
+ } from "./execution-shared";
12
18
 
13
19
  const PREVIEW_LINES = 20;
14
20
  const MAX_DISPLAY_LINE_CHARS = 4000;
@@ -17,7 +23,7 @@ export type EvalExecutionLanguage = "python" | "js";
17
23
 
18
24
  export class EvalExecutionComponent extends Container {
19
25
  #outputLines: string[] = [];
20
- #status: "running" | "complete" | "cancelled" | "error" = "running";
26
+ #status: ExecutionStatus = "running";
21
27
  #exitCode: number | undefined = undefined;
22
28
  #loader: Loader;
23
29
  #truncation?: TruncationMeta;
@@ -28,7 +34,7 @@ export class EvalExecutionComponent extends Container {
28
34
  return this.language === "js" ? "javascript" : "python";
29
35
  }
30
36
 
31
- #formatHeader(colorKey: "dim" | "pythonMode"): Text {
37
+ #formatHeader(colorKey: ExecutionColorKey): Text {
32
38
  const prompt = theme.fg(colorKey, theme.bold(">>>"));
33
39
  const continuation = theme.fg(colorKey, " ");
34
40
  const codeLines = highlightCode(this.code, this.#highlightLang());
@@ -46,26 +52,13 @@ export class EvalExecutionComponent extends Container {
46
52
  ) {
47
53
  super();
48
54
 
49
- const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
50
- const borderColor = (str: string) => theme.fg(colorKey, str);
55
+ const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
56
+ const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
57
+ this.#contentContainer = contentContainer;
58
+ this.#loader = loader;
51
59
 
52
- this.addChild(new Spacer(1));
53
- this.addChild(new DynamicBorder(borderColor));
54
-
55
- this.#contentContainer = new Container();
56
- this.addChild(this.#contentContainer);
57
60
  this.#contentContainer.addChild(this.#formatHeader(colorKey));
58
-
59
- this.#loader = new Loader(
60
- ui,
61
- spinner => theme.fg(colorKey, spinner),
62
- text => theme.fg("muted", text),
63
- `Running… (esc to cancel)`,
64
- getSymbolTheme().spinnerFrames,
65
- );
66
61
  this.#contentContainer.addChild(this.#loader);
67
-
68
- this.addChild(new DynamicBorder(borderColor));
69
62
  }
70
63
 
71
64
  setExpanded(expanded: boolean): void {
@@ -99,11 +92,7 @@ export class EvalExecutionComponent extends Container {
99
92
  options?: { output?: string; truncation?: TruncationMeta },
100
93
  ): void {
101
94
  this.#exitCode = exitCode;
102
- this.#status = cancelled
103
- ? "cancelled"
104
- : exitCode !== 0 && exitCode !== undefined && exitCode !== null
105
- ? "error"
106
- : "complete";
95
+ this.#status = resolveExecutionStatus(exitCode, cancelled);
107
96
  this.#truncation = options?.truncation;
108
97
  if (options?.output !== undefined) {
109
98
  this.#setOutput(options.output);
@@ -120,7 +109,7 @@ export class EvalExecutionComponent extends Container {
120
109
 
121
110
  this.#contentContainer.clear();
122
111
 
123
- const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
112
+ const colorKey: ExecutionColorKey = this.excludeFromContext ? "dim" : "pythonMode";
124
113
  this.#contentContainer.addChild(this.#formatHeader(colorKey));
125
114
 
126
115
  if (availableLines.length > 0) {
@@ -129,39 +118,20 @@ export class EvalExecutionComponent extends Container {
129
118
  this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
130
119
  } else {
131
120
  const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
132
- const previewText = `\n${styledOutput}`;
133
- this.#contentContainer.addChild({
134
- render: (width: number) => {
135
- const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
136
- return visualLines;
137
- },
138
- invalidate: () => {},
139
- });
121
+ this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
140
122
  }
141
123
  }
142
124
 
143
125
  if (this.#status === "running") {
144
126
  this.#contentContainer.addChild(this.#loader);
145
127
  } else {
146
- const statusParts: string[] = [];
147
-
148
- if (hiddenLineCount > 0) {
149
- statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
150
- }
151
-
152
- if (this.#status === "cancelled") {
153
- statusParts.push(theme.fg("warning", "(cancelled)"));
154
- } else if (this.#status === "error") {
155
- statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
156
- }
157
-
158
- if (this.#truncation) {
159
- statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
160
- }
161
-
162
- if (statusParts.length > 0) {
163
- this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
164
- }
128
+ const footer = buildStatusFooter({
129
+ status: this.#status,
130
+ exitCode: this.#exitCode,
131
+ truncation: this.#truncation,
132
+ hiddenLineCount,
133
+ });
134
+ if (footer) this.#contentContainer.addChild(footer);
165
135
  }
166
136
  }
167
137
 
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Shared rendering primitives for bash/eval execution components.
3
+ *
4
+ * Each helper isolates a piece of structure both components share verbatim
5
+ * (frame layout, collapsed preview, post-run status line). Differences in
6
+ * how each component prepares its header, output lines, or sixel masking
7
+ * stay in their respective files.
8
+ */
9
+
10
+ import { type Component, Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
11
+ import { getSymbolTheme, theme } from "../../modes/theme/theme";
12
+ import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
13
+ import { DynamicBorder } from "./dynamic-border";
14
+ import { truncateToVisualLines } from "./visual-truncate";
15
+
16
+ export type ExecutionStatus = "running" | "complete" | "cancelled" | "error";
17
+
18
+ /** Theme color keys valid for an execution frame. */
19
+ export type ExecutionColorKey = "dim" | "bashMode" | "pythonMode";
20
+
21
+ /**
22
+ * Build the spacer + top border + content container + bottom border scaffold
23
+ * that bash and eval execution components share. The caller appends the
24
+ * header (command vs `>>>` prompt) and the returned loader to
25
+ * `contentContainer` so per-mode order is preserved.
26
+ */
27
+ export function buildExecutionFrame(
28
+ parent: Container,
29
+ ui: TUI,
30
+ colorKey: ExecutionColorKey,
31
+ ): { contentContainer: Container; loader: Loader } {
32
+ const borderColor = (str: string) => theme.fg(colorKey, str);
33
+
34
+ parent.addChild(new Spacer(1));
35
+ parent.addChild(new DynamicBorder(borderColor));
36
+
37
+ const contentContainer = new Container();
38
+ parent.addChild(contentContainer);
39
+
40
+ const loader = new Loader(
41
+ ui,
42
+ spinner => theme.fg(colorKey, spinner),
43
+ text => theme.fg("muted", text),
44
+ `Running… (esc to cancel)`,
45
+ getSymbolTheme().spinnerFrames,
46
+ );
47
+
48
+ parent.addChild(new DynamicBorder(borderColor));
49
+ return { contentContainer, loader };
50
+ }
51
+
52
+ /**
53
+ * Wrap a styled preview block in a render-time visual-line truncator.
54
+ * Recomputed per render width so wrapping stays in sync with terminal size.
55
+ */
56
+ export function createCollapsedPreview(previewText: string, previewLines: number): Component {
57
+ return {
58
+ render: (width: number) => truncateToVisualLines(previewText, previewLines, width, 1).visualLines,
59
+ invalidate: () => {},
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Build the post-run status block (hidden-line hint, exit/cancel marker,
65
+ * truncation notice). Returns undefined when there is nothing to display so
66
+ * callers can skip appending a stray Text child.
67
+ */
68
+ export function buildStatusFooter(opts: {
69
+ status: ExecutionStatus;
70
+ exitCode: number | undefined;
71
+ truncation: TruncationMeta | undefined;
72
+ hiddenLineCount: number;
73
+ /** Suppress the "… N more lines" hint (used when sixel passthrough renders the full output). */
74
+ suppressHiddenCount?: boolean;
75
+ }): Text | undefined {
76
+ const parts: string[] = [];
77
+
78
+ if (opts.hiddenLineCount > 0 && !opts.suppressHiddenCount) {
79
+ parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (ctrl+o to expand)`));
80
+ }
81
+ if (opts.status === "cancelled") {
82
+ parts.push(theme.fg("warning", "(cancelled)"));
83
+ } else if (opts.status === "error") {
84
+ parts.push(theme.fg("error", `(exit ${opts.exitCode})`));
85
+ }
86
+ if (opts.truncation) {
87
+ parts.push(theme.fg("warning", formatTruncationMetaNotice(opts.truncation)));
88
+ }
89
+
90
+ if (parts.length === 0) return undefined;
91
+ return new Text(`\n${parts.join("\n")}`, 1, 0);
92
+ }
93
+
94
+ /**
95
+ * Derive the post-run status from an exit code + cancellation flag using the
96
+ * same precedence both execution components apply.
97
+ */
98
+ export function resolveExecutionStatus(exitCode: number | undefined, cancelled: boolean): ExecutionStatus {
99
+ if (cancelled) return "cancelled";
100
+ if (exitCode !== 0 && exitCode !== undefined && exitCode !== null) return "error";
101
+ return "complete";
102
+ }
@@ -1,9 +1,12 @@
1
- import type { TextContent } from "@oh-my-pi/pi-ai";
2
1
  import type { Component } from "@oh-my-pi/pi-tui";
3
- import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
2
+ import { Box, Container, Spacer } from "@oh-my-pi/pi-tui";
4
3
  import type { HookMessageRenderer } from "../../extensibility/hooks/types";
5
- import { getMarkdownTheme, theme } from "../../modes/theme/theme";
4
+ import { theme } from "../../modes/theme/theme";
6
5
  import type { HookMessage } from "../../session/messages";
6
+ import { renderFramedMessage } from "./message-frame";
7
+
8
+ /** Lines of default markdown body shown before the "…" fold when collapsed. */
9
+ const HOOK_COLLAPSED_LINES = 5;
7
10
 
8
11
  /**
9
12
  * Component that renders a custom message entry from hooks.
@@ -41,60 +44,25 @@ export class HookMessageComponent extends Container {
41
44
  }
42
45
 
43
46
  #rebuild(): void {
44
- // Remove previous content component
45
47
  if (this.#customComponent) {
46
48
  this.removeChild(this.#customComponent);
47
49
  this.#customComponent = undefined;
48
50
  }
49
51
  this.removeChild(this.#box);
50
52
 
51
- // Try custom renderer first - it handles its own styling
52
- if (this.customRenderer) {
53
- try {
54
- const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme);
55
- if (component) {
56
- // Custom renderer provides its own styled component
57
- this.#customComponent = component;
58
- this.addChild(component);
59
- return;
60
- }
61
- } catch {
62
- // Fall through to default rendering
63
- }
64
- }
65
-
66
- // Default rendering uses our box
67
- this.addChild(this.#box);
68
- this.#box.clear();
69
-
70
- // Default rendering: label + content
71
- const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
72
- this.#box.addChild(new Text(label, 0, 0));
73
- this.#box.addChild(new Spacer(1));
53
+ const custom = renderFramedMessage({
54
+ message: this.message,
55
+ box: this.#box,
56
+ expanded: this.#expanded,
57
+ customRenderer: this.customRenderer,
58
+ collapseAfterLines: HOOK_COLLAPSED_LINES,
59
+ });
74
60
 
75
- // Extract text content
76
- let text: string;
77
- if (typeof this.message.content === "string") {
78
- text = this.message.content;
61
+ if (custom) {
62
+ this.#customComponent = custom;
63
+ this.addChild(custom);
79
64
  } else {
80
- text = this.message.content
81
- .filter((c): c is TextContent => c.type === "text")
82
- .map(c => c.text)
83
- .join("\n");
65
+ this.addChild(this.#box);
84
66
  }
85
-
86
- // Limit lines when collapsed
87
- if (!this.#expanded) {
88
- const lines = text.split("\n");
89
- if (lines.length > 5) {
90
- text = `${lines.slice(0, 5).join("\n")}\n…`;
91
- }
92
- }
93
-
94
- this.#box.addChild(
95
- new Markdown(text, 0, 0, getMarkdownTheme(), {
96
- color: (text: string) => theme.fg("customMessageText", text),
97
- }),
98
- );
99
67
  }
100
68
  }
@@ -47,6 +47,18 @@ type WizardStep =
47
47
  | "scope"
48
48
  | "confirm";
49
49
 
50
+ /**
51
+ * Result of the wizard's OAuth callback. `credentialId` is mandatory;
52
+ * `clientId`/`clientSecret` are populated when the OAuth provider performed
53
+ * dynamic client registration (or when the caller pre-supplied them) so the
54
+ * wizard can fold them into the final `mcp.json` entry for refresh.
55
+ */
56
+ export interface MCPAddWizardOAuthResult {
57
+ credentialId: string;
58
+ clientId?: string;
59
+ clientSecret?: string;
60
+ }
61
+
50
62
  interface WizardState {
51
63
  name: string;
52
64
  transport: TransportType | null;
@@ -104,7 +116,13 @@ export class MCPAddWizard extends Container {
104
116
  #onCompleteCallback: (name: string, config: MCPServerConfig, scope: Scope) => void;
105
117
  #onCancelCallback: () => void;
106
118
  #onOAuthCallback:
107
- | ((authUrl: string, tokenUrl: string, clientId: string, clientSecret: string, scopes: string) => Promise<string>)
119
+ | ((
120
+ authUrl: string,
121
+ tokenUrl: string,
122
+ clientId: string,
123
+ clientSecret: string,
124
+ scopes: string,
125
+ ) => Promise<MCPAddWizardOAuthResult>)
108
126
  | null = null;
109
127
  #onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
110
128
  #onRenderCallback: (() => void) | null = null;
@@ -118,7 +136,7 @@ export class MCPAddWizard extends Container {
118
136
  clientId: string,
119
137
  clientSecret: string,
120
138
  scopes: string,
121
- ) => Promise<string>,
139
+ ) => Promise<MCPAddWizardOAuthResult>,
122
140
  onTestConnection?: (config: MCPServerConfig) => Promise<void>,
123
141
  onRender?: () => void,
124
142
  initialName?: string,
@@ -1120,7 +1138,7 @@ export class MCPAddWizard extends Container {
1120
1138
 
1121
1139
  try {
1122
1140
  // Call OAuth handler
1123
- const credentialId = await this.#onOAuthCallback(
1141
+ const oauthResult = await this.#onOAuthCallback(
1124
1142
  this.#state.oauthAuthUrl,
1125
1143
  this.#state.oauthTokenUrl,
1126
1144
  this.#state.oauthClientId,
@@ -1128,8 +1146,11 @@ export class MCPAddWizard extends Container {
1128
1146
  this.#state.oauthScopes,
1129
1147
  );
1130
1148
 
1131
- // Store credential ID
1132
- this.#state.oauthCredentialId = credentialId;
1149
+ // Store credential ID + any dynamically-registered client credentials,
1150
+ // so the final mcp.json entry persists everything needed for refresh.
1151
+ this.#state.oauthCredentialId = oauthResult.credentialId;
1152
+ if (oauthResult.clientId) this.#state.oauthClientId = oauthResult.clientId;
1153
+ if (oauthResult.clientSecret) this.#state.oauthClientSecret = oauthResult.clientSecret;
1133
1154
 
1134
1155
  // Show success message
1135
1156
  this.#contentContainer.clear();
@@ -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
 
@@ -3,7 +3,7 @@ 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
5
  import { formatDuration, formatNumber, getProjectDir, relativePathWithinRoot } from "@oh-my-pi/pi-utils";
6
- import { theme } from "../../../modes/theme/theme";
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";
@@ -76,17 +76,65 @@ const modelSegment: StatusLineSegment = {
76
76
  },
77
77
  };
78
78
 
79
+ function formatGoalBudget(current: number, budget?: number): string {
80
+ const used = formatNumber(current);
81
+ if (budget === undefined) return used;
82
+ return `${used}/${formatNumber(budget)}`;
83
+ }
84
+
85
+ function renderGoalMode(ctx: SegmentContext, mode: { enabled: boolean; paused: boolean }): RenderedSegment {
86
+ const goal = ctx.session.getGoalModeState()?.goal;
87
+ const status = goal?.status ?? (mode.paused ? "paused" : "active");
88
+
89
+ let icon: string = theme.icon.goal;
90
+ let color: ThemeColor = "accent";
91
+ switch (status) {
92
+ case "paused":
93
+ icon = theme.icon.pause || theme.symbol("status.pending");
94
+ color = "warning";
95
+ break;
96
+ case "complete":
97
+ icon = theme.symbol("status.success");
98
+ color = "success";
99
+ break;
100
+ case "budget-limited":
101
+ icon = theme.symbol("status.warning");
102
+ color = "warning";
103
+ break;
104
+ case "dropped":
105
+ icon = theme.symbol("status.aborted");
106
+ color = "dim";
107
+ break;
108
+ default:
109
+ break;
110
+ }
111
+
112
+ const parts: string[] = [withIcon(icon, "Goal")];
113
+ const showBudget = ctx.session.settings.get("goal.statusInFooter") === true;
114
+ if (showBudget && goal) {
115
+ parts.push(formatGoalBudget(goal.tokensUsed, goal.tokenBudget));
116
+ }
117
+ return { content: theme.fg(color, parts.join(" ")), visible: true };
118
+ }
119
+
79
120
  const modeSegment: StatusLineSegment = {
80
121
  id: "mode",
81
122
  render(ctx) {
123
+ const pauseSuffix = theme.icon.pause ? ` ${theme.icon.pause}` : " (paused)";
124
+
82
125
  const plan = ctx.planMode;
83
126
  if (plan && (plan.enabled || plan.paused)) {
84
- const label = plan.paused ? "Plan ⏸" : "Plan";
127
+ const label = plan.paused ? `Plan${pauseSuffix}` : "Plan";
85
128
  const content = withIcon(theme.icon.plan, label);
86
129
  const color = plan.paused ? "warning" : "accent";
87
130
  return { content: theme.fg(color, content), visible: true };
88
131
  }
89
132
 
133
+ const goal = ctx.goalMode;
134
+ if (goal && (goal.enabled || goal.paused)) {
135
+ return renderGoalMode(ctx, goal);
136
+ }
137
+
90
138
  const loop = ctx.loopMode;
91
139
  if (loop?.enabled) {
92
140
  const content = withIcon(theme.icon.loop, "Loop");
@@ -216,8 +264,11 @@ const tokenOutSegment: StatusLineSegment = {
216
264
  const tokenTotalSegment: StatusLineSegment = {
217
265
  id: "token_total",
218
266
  render(ctx) {
219
- const { input, output, cacheRead, cacheWrite } = ctx.usageStats;
220
- const total = input + output + cacheRead + cacheWrite;
267
+ // Excludes cacheRead: that field re-reads the full cached context every
268
+ // turn, making the cumulative sum N×context_size. The dedicated cache_read
269
+ // segment handles cache monitoring; the cost segment handles billing.
270
+ const { input, output, cacheWrite } = ctx.usageStats;
271
+ const total = input + output + cacheWrite;
221
272
  if (!total) return { content: "", visible: false };
222
273
 
223
274
  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;