@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
@@ -7,19 +7,23 @@ import {
7
7
  Container,
8
8
  Ellipsis,
9
9
  ImageProtocol,
10
- Loader,
11
- Spacer,
10
+ type Loader,
12
11
  TERMINAL,
13
12
  Text,
14
13
  type TUI,
15
14
  truncateToWidth,
16
15
  visibleWidth,
17
16
  } from "@oh-my-pi/pi-tui";
18
- import { getSymbolTheme, theme } from "../../modes/theme/theme";
19
- import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
17
+ import { theme } from "../../modes/theme/theme";
18
+ import type { TruncationMeta } from "../../tools/output-meta";
20
19
  import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
21
- import { DynamicBorder } from "./dynamic-border";
22
- import { truncateToVisualLines } from "./visual-truncate";
20
+ import {
21
+ buildExecutionFrame,
22
+ buildStatusFooter,
23
+ createCollapsedPreview,
24
+ type ExecutionStatus,
25
+ resolveExecutionStatus,
26
+ } from "./execution-shared";
23
27
 
24
28
  // Preview line limit when not expanded (matches tool execution behavior)
25
29
  const PREVIEW_LINES = 20;
@@ -31,7 +35,7 @@ const CHUNK_THROTTLE_MS = 50;
31
35
 
32
36
  export class BashExecutionComponent extends Container {
33
37
  #outputLines: string[] = [];
34
- #status: "running" | "complete" | "cancelled" | "error" = "running";
38
+ #status: ExecutionStatus = "running";
35
39
  #exitCode: number | undefined = undefined;
36
40
  #loader: Loader;
37
41
  #truncation?: TruncationMeta;
@@ -50,34 +54,14 @@ export class BashExecutionComponent extends Container {
50
54
 
51
55
  // Use dim border for excluded-from-context commands (!! prefix)
52
56
  const colorKey = excludeFromContext ? "dim" : "bashMode";
53
- const borderColor = (str: string) => theme.fg(colorKey, str);
54
-
55
- // Add spacer
56
- this.addChild(new Spacer(1));
57
-
58
- // Top border
59
- this.addChild(new DynamicBorder(borderColor));
60
-
61
- // Content container (holds dynamic content between borders)
62
- this.#contentContainer = new Container();
63
- this.addChild(this.#contentContainer);
57
+ const { contentContainer, loader } = buildExecutionFrame(this, ui, colorKey);
58
+ this.#contentContainer = contentContainer;
59
+ this.#loader = loader;
64
60
 
65
61
  // Command header
66
62
  this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
67
63
  this.#contentContainer.addChild(this.#headerText);
68
-
69
- // Loader
70
- this.#loader = new Loader(
71
- ui,
72
- spinner => theme.fg(colorKey, spinner),
73
- text => theme.fg("muted", text),
74
- `Running… (esc to cancel)`,
75
- getSymbolTheme().spinnerFrames,
76
- );
77
64
  this.#contentContainer.addChild(this.#loader);
78
-
79
- // Bottom border
80
- this.addChild(new DynamicBorder(borderColor));
81
65
  }
82
66
 
83
67
  /**
@@ -130,11 +114,7 @@ export class BashExecutionComponent extends Container {
130
114
  options?: { output?: string; truncation?: TruncationMeta },
131
115
  ): void {
132
116
  this.#exitCode = exitCode;
133
- this.#status = cancelled
134
- ? "cancelled"
135
- : exitCode !== 0 && exitCode !== undefined && exitCode !== null
136
- ? "error"
137
- : "complete";
117
+ this.#status = resolveExecutionStatus(exitCode, cancelled);
138
118
  this.#truncation = options?.truncation;
139
119
  if (options?.output !== undefined) {
140
120
  this.#setOutput(options.output);
@@ -182,14 +162,7 @@ export class BashExecutionComponent extends Container {
182
162
  } else {
183
163
  // Use shared visual truncation utility, recomputed per render width
184
164
  const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n");
185
- const previewText = `\n${styledOutput}`;
186
- this.#contentContainer.addChild({
187
- render: (width: number) => {
188
- const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1);
189
- return visualLines;
190
- },
191
- invalidate: () => {},
192
- });
165
+ this.#contentContainer.addChild(createCollapsedPreview(`\n${styledOutput}`, PREVIEW_LINES));
193
166
  }
194
167
  }
195
168
 
@@ -197,26 +170,14 @@ export class BashExecutionComponent extends Container {
197
170
  if (this.#status === "running") {
198
171
  this.#contentContainer.addChild(this.#loader);
199
172
  } else {
200
- const statusParts: string[] = [];
201
-
202
- // Show how many lines are hidden (collapsed preview)
203
- if (hiddenLineCount > 0 && !hasSixelOutput) {
204
- statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
205
- }
206
-
207
- if (this.#status === "cancelled") {
208
- statusParts.push(theme.fg("warning", "(cancelled)"));
209
- } else if (this.#status === "error") {
210
- statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`));
211
- }
212
-
213
- if (this.#truncation) {
214
- statusParts.push(theme.fg("warning", formatTruncationMetaNotice(this.#truncation)));
215
- }
216
-
217
- if (statusParts.length > 0) {
218
- this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
219
- }
173
+ const footer = buildStatusFooter({
174
+ status: this.#status,
175
+ exitCode: this.#exitCode,
176
+ truncation: this.#truncation,
177
+ hiddenLineCount,
178
+ suppressHiddenCount: hasSixelOutput,
179
+ });
180
+ if (footer) this.#contentContainer.addChild(footer);
220
181
  }
221
182
  }
222
183
 
@@ -1,9 +1,9 @@
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 { MessageRenderer } from "../../extensibility/extensions/types";
5
- import { getMarkdownTheme, theme } from "../../modes/theme/theme";
4
+ import { theme } from "../../modes/theme/theme";
6
5
  import type { CustomMessage } from "../../session/messages";
6
+ import { renderFramedMessage } from "./message-frame";
7
7
 
8
8
  /**
9
9
  * Component that renders a custom message entry from extensions.
@@ -41,51 +41,25 @@ export class CustomMessageComponent extends Container {
41
41
  }
42
42
 
43
43
  #rebuild(): void {
44
- // Remove previous content component
45
44
  if (this.#customComponent) {
46
45
  this.removeChild(this.#customComponent);
47
46
  this.#customComponent = undefined;
48
47
  }
49
48
  this.removeChild(this.#box);
50
49
 
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
- this.#customComponent = component;
57
- this.addChild(component);
58
- return;
59
- }
60
- } catch {
61
- // Fall through to default rendering
62
- }
63
- }
64
-
65
- // Default rendering uses our box
66
- this.addChild(this.#box);
67
- this.#box.clear();
50
+ const custom = renderFramedMessage({
51
+ message: this.message,
52
+ box: this.#box,
53
+ expanded: this.#expanded,
54
+ customRenderer: this.customRenderer,
55
+ // Extension messages render full content; no collapse-on-fold behaviour.
56
+ });
68
57
 
69
- // Default rendering: label + content
70
- const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
71
- this.#box.addChild(new Text(label, 0, 0));
72
- this.#box.addChild(new Spacer(1));
73
-
74
- // Extract text content
75
- let text: string;
76
- if (typeof this.message.content === "string") {
77
- text = this.message.content;
58
+ if (custom) {
59
+ this.#customComponent = custom;
60
+ this.addChild(custom);
78
61
  } else {
79
- text = this.message.content
80
- .filter((c): c is TextContent => c.type === "text")
81
- .map(c => c.text)
82
- .join("\n");
62
+ this.addChild(this.#box);
83
63
  }
84
-
85
- this.#box.addChild(
86
- new Markdown(text, 0, 0, getMarkdownTheme(), {
87
- color: (value: string) => theme.fg("customMessageText", value),
88
- }),
89
- );
90
64
  }
91
65
  }
@@ -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();