@pi-archimedes/ui 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/package.json +44 -0
  4. package/src/bash/index.ts +10 -0
  5. package/src/bash/renderer.test.ts +424 -0
  6. package/src/bash/renderer.ts +301 -0
  7. package/src/bash/tool.test.ts +26 -0
  8. package/src/bash/tool.ts +17 -0
  9. package/src/codemode/index.ts +20 -0
  10. package/src/codemode/loader.test.ts +139 -0
  11. package/src/codemode/loader.ts +115 -0
  12. package/src/codemode/renderer.test.ts +717 -0
  13. package/src/codemode/renderer.ts +399 -0
  14. package/src/codemode/tool.test.ts +149 -0
  15. package/src/codemode/tool.ts +96 -0
  16. package/src/config.test.ts +199 -0
  17. package/src/config.ts +129 -0
  18. package/src/editor/index.test.ts +820 -0
  19. package/src/editor/index.ts +380 -0
  20. package/src/editor/spin-quips.test.ts +106 -0
  21. package/src/editor/spin-quips.ts +44 -0
  22. package/src/editor/spin.test.ts +763 -0
  23. package/src/editor/spin.ts +493 -0
  24. package/src/index.test.ts +402 -0
  25. package/src/index.ts +235 -0
  26. package/src/migration.test.ts +256 -0
  27. package/src/migration.ts +94 -0
  28. package/src/settings.test.ts +122 -0
  29. package/src/settings.ts +135 -0
  30. package/src/startup/capture.ts +47 -0
  31. package/src/startup/index.test.ts +175 -0
  32. package/src/startup/index.ts +348 -0
  33. package/src/startup/logo.test.ts +260 -0
  34. package/src/startup/logo.ts +85 -0
  35. package/src/startup/sections.test.ts +333 -0
  36. package/src/startup/sections.ts +306 -0
  37. package/src/startup/version.test.ts +98 -0
  38. package/src/startup/version.ts +26 -0
  39. package/src/thinking/patch.test.ts +857 -0
  40. package/src/thinking/patch.ts +407 -0
  41. package/src/thinking/theme.test.ts +216 -0
  42. package/src/thinking/theme.ts +156 -0
  43. package/src/thinking/transform.test.ts +89 -0
  44. package/src/thinking/transform.ts +21 -0
  45. package/src/thinking/unindent.test.ts +74 -0
  46. package/src/thinking/unindent.ts +74 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Cherubini and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # UI Package
2
+
3
+ TUI enhancements for the Pi Archimedes monorepo.
4
+
5
+ ## Features
6
+
7
+ ### Bash Tool Styling
8
+ - **Collapsed/Expanded Views**: Toggleable tool execution details.
9
+ - **Live Timer**: Track tool execution duration.
10
+ - **Status Indicators**:
11
+ - `▸` Running (Orange)
12
+ - `✓` Success (Green)
13
+ - `✗` Error (Red)
14
+
15
+ ### Codemode Tool Styling
16
+ - **Styling follows the `toolStyle` master switch**:
17
+ - **Minimal** (default): the override is registered — the collapsed row is the `codemode` header plus the nested tool calls the script made (last five, with an expand hint when there are more) — no script dump, no summary line. A script that made no calls shows a single `<glyph> <duration>` line instead.
18
+ - **Native**: the override is not registered at all — pi's native codemode rendering stands (and no built-in-takeover startup notice).
19
+ - **Expanded View**: The syntax-highlighted script, every nested call (status glyph, args, duration, and cost for `models.*` calls, with a `Model calls: $total` line when several were priced), the script output without the executor's `Script completed/failed` header (image blocks show a `[Image: …]` indicator — the renderer is text-only), the full-output path when truncated, and the timing + final status.
20
+ - **Live Timer**: In-flight calls show the `…` running glyph and update every second.
21
+ - **Native Behaviour Preserved**: The override re-registers pi's own `codemode` tool (executor, dynamic description, activation semantics all intact) and replaces only the presentation — so it takes over pi's replaceable built-in `codemode` extension (pi prints a one-line startup notice, as it does for any extension that replaces a built-in).
22
+ - **Graceful Fallback**: On pi versions without the `codemode` extension the override is a no-op and pi's native rendering stands in.
23
+
24
+ ### Editor
25
+ - **HephaestusEditor**: Advanced text editing capabilities.
26
+ - **Border Spinner Animations**: Visual feedback during operations.
27
+ - **Spin Quips**: Enjoyable messages while waiting for processes.
28
+
29
+ ### Thinking
30
+ - **Full/Compact Output Style**: Full or one-line thinking (`thinkingStyle`). The tool-side master switch is `toolStyle` (Minimal = archimedes styled, Native = no archimedes tool styling).
31
+ - **Collapsible Thinking**: Expandable for deeper inspection.
32
+ - **Theme**: Unified styling for thinking blocks.
33
+
34
+ ### Startup
35
+ - **Splash Logo Reveal**: Engaging animations during initialization.
36
+
37
+ ## Settings (`archimedes.ui`)
38
+
39
+ | Setting | Type | Default | Description |
40
+ | :--- | :--- | :--- | :--- |
41
+ | `bashToolStyling` | boolean | `true` | Custom styled bash tool rendering (applies when `toolStyle` is Minimal) |
42
+ | `codemodeToolStyling` | boolean | `true` | Custom styled codemode tool rendering (applies when `toolStyle` is Minimal) while keeping the native executor |
43
+ | `mutedTheme` | boolean | `false` | Muted theme for thinking blocks |
44
+ | `autoCollapseThinking` | boolean | `false` | Automatically collapse thinking blocks |
45
+ | `thinkingStyle` | "Full" \| "Compact" | `"Full"` | Thinking block display (Full = full text, Compact = one line, click to expand) |
46
+ | `toolStyle` | "Native" \| "Minimal" | `"Minimal"` | Tool rendering master switch (Minimal = archimedes styled, Native = pi native, no archimedes styling) |
47
+ | `codeUnindent` | boolean | `true` | Strip common indentation from code blocks |
48
+ | `labelText` | string | `"Thinking..."` | Text shown before thinking blocks |
49
+ | `labelColor` | string | `"255,215,0"` | RGB color for the thinking label (e.g. `255,215,0`) |
50
+ | `animationStyle` | "diagonal" \| "top-right" \| "bottom-left" \| "bottom-right" \| "center-out" \| "wave" \| "horizontal" \| "vertical" \| "vertical-up" | `"vertical-up"` | Startup logo animation |
51
+ | `editorSpinBorder` | boolean | `true` | Animated spinner border on editor |
52
+ | `editorSpinSpeed` | "slow" \| "normal" \| "fast" | `"normal"` | Editor border spinner speed |
53
+ | `editorSpinStyle` | "typing" \| "pulse" \| "rain" \| "cascade" \| "columns" \| "wave-rows" \| "diagonal-swipe" \| "sparkle" \| "pendulum" \| "marquee" | `"pendulum"` | Editor spinner style |
54
+ | `editorSpinLabel` | string | `"Working"` | Spinner frame label |
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pi install @pi-archimedes/ui
60
+ ```
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@pi-archimedes/ui",
3
+ "version": "2.9.0",
4
+ "description": "Terminal UI enhancements, styled bash tool rendering, and visual presentation components for Pi",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "files": [
8
+ "src"
9
+ ],
10
+ "keywords": [
11
+ "pi-package"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./src/index.ts"
16
+ ]
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/danielcherubini/pi-archimedes.git"
21
+ },
22
+ "exports": {
23
+ ".": "./src/index.ts",
24
+ "./bash": "./src/bash/index.ts",
25
+ "./editor": "./src/editor/index.ts",
26
+ "./thinking": "./src/thinking/patch.ts",
27
+ "./startup": "./src/startup/index.ts",
28
+ "./config": "./src/config.ts"
29
+ },
30
+ "dependencies": {
31
+ "@pi-archimedes/core": "2.9.0"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/pi-ai": "^0.87.0",
35
+ "@earendil-works/pi-coding-agent": "^0.87.0",
36
+ "@earendil-works/pi-tui": "^0.87.0",
37
+ "typescript": "^6.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-ai": ">=0.1.0",
41
+ "@earendil-works/pi-coding-agent": ">=0.1.0",
42
+ "@earendil-works/pi-tui": ">=0.1.0"
43
+ }
44
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ renderBashCall,
3
+ renderBashResult,
4
+ formatBashCommand,
5
+ formatDuration,
6
+ clearActiveBashIntervals,
7
+ TruncatedTextComponent,
8
+ type BashRendererState,
9
+ } from "./renderer.js";
10
+ export { registerBashToolOverride } from "./tool.js";
@@ -0,0 +1,424 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { stripAnsi } from "@pi-archimedes/core/text";
4
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
+ import {
6
+ formatDuration,
7
+ formatBashCommand,
8
+ renderBashCall,
9
+ renderBashResult,
10
+ clearActiveBashIntervals,
11
+ TruncatedTextComponent,
12
+ } from "./renderer.js";
13
+
14
+ // Mock Text from @earendil-works/pi-tui while preserving real utility functions
15
+ vi.mock("@earendil-works/pi-tui", async () => {
16
+ const actual = await vi.importActual<typeof import("@earendil-works/pi-tui")>("@earendil-works/pi-tui");
17
+ class MockText {
18
+ private _content = "";
19
+
20
+ constructor(initial: string = "") {
21
+ this._content = initial;
22
+ }
23
+
24
+ setText(content: string): void {
25
+ this._content = content;
26
+ }
27
+
28
+ getContent(): string {
29
+ return this._content;
30
+ }
31
+ }
32
+ return {
33
+ ...actual,
34
+ Text: MockText,
35
+ };
36
+ });
37
+
38
+ import { Text as MockText } from "@earendil-works/pi-tui";
39
+
40
+ type MockTextShim = { getContent(): string };
41
+ const content = (c: unknown) => (c as unknown as MockTextShim).getContent();
42
+
43
+ // Test theme wrapping in markers
44
+ const theme = {
45
+ fg: (token: string, text?: string) =>
46
+ text === undefined ? `[${token}]` : `[${token}:${text}]`,
47
+ bold: (text: string) => `**${text}**`,
48
+ } as unknown as Theme;
49
+
50
+ describe("formatDuration", () => {
51
+ it("formats under 10 seconds as <X.X>s", () => {
52
+ expect(formatDuration(0)).toBe("0.0s");
53
+ expect(formatDuration(400)).toBe("0.4s");
54
+ expect(formatDuration(1500)).toBe("1.5s");
55
+ expect(formatDuration(9900)).toBe("9.9s");
56
+ });
57
+
58
+ it("formats between 10s and 60s as <X>s", () => {
59
+ expect(formatDuration(10000)).toBe("10s");
60
+ expect(formatDuration(15000)).toBe("15s");
61
+ expect(formatDuration(45800)).toBe("45s");
62
+ expect(formatDuration(59999)).toBe("59s");
63
+ });
64
+
65
+ it("formats minutes and seconds as <M>m <S>s", () => {
66
+ expect(formatDuration(60000)).toBe("1m 0s");
67
+ expect(formatDuration(65000)).toBe("1m 5s");
68
+ expect(formatDuration(125000)).toBe("2m 5s");
69
+ });
70
+
71
+ it("formats hours if duration exceeds 60 minutes", () => {
72
+ expect(formatDuration(3665000)).toBe("1h 1m 5s");
73
+ });
74
+ });
75
+
76
+ describe("formatBashCommand", () => {
77
+ it("returns undefined for undefined or non-string input", () => {
78
+ expect(formatBashCommand(undefined)).toBeUndefined();
79
+ expect(formatBashCommand(null as any)).toBeUndefined();
80
+ expect(formatBashCommand(123 as any)).toBeUndefined();
81
+ });
82
+
83
+ it("returns undefined for empty or whitespace-only commands", () => {
84
+ expect(formatBashCommand("")).toBeUndefined();
85
+ expect(formatBashCommand(" ")).toBeUndefined();
86
+ expect(formatBashCommand("\t\n")).toBeUndefined();
87
+ });
88
+
89
+ it("returns trimmed single-line command", () => {
90
+ expect(formatBashCommand(" echo hi ")).toBe("echo hi");
91
+ });
92
+
93
+ it("normalizes newlines to spaces", () => {
94
+ expect(formatBashCommand("echo 'first'\r\necho 'second'\necho 'third'")).toBe(
95
+ "echo 'first' echo 'second' echo 'third'",
96
+ );
97
+ });
98
+
99
+ it("preserves full command when maxLength is omitted", () => {
100
+ const longCmd = "a".repeat(120);
101
+ expect(formatBashCommand(longCmd)).toBe(longCmd);
102
+ });
103
+
104
+ it("truncates via truncateToWidth when maxLength is provided", () => {
105
+ const longCmd = "a".repeat(80);
106
+ const result = formatBashCommand(longCmd, 70)!;
107
+ expect(stripAnsi(result)).toBe("a".repeat(69) + "…");
108
+ expect(visibleWidth(result)).toBe(70);
109
+ });
110
+ });
111
+
112
+ describe("TruncatedTextComponent", () => {
113
+ it("renders single-line text and pads to width", () => {
114
+ const comp = new TruncatedTextComponent("hello");
115
+ const lines = comp.render(10);
116
+ expect(lines).toHaveLength(1);
117
+ expect(lines[0]).toBe("hello ");
118
+ });
119
+
120
+ it("truncates text wider than width with … via truncateToWidth", () => {
121
+ const comp = new TruncatedTextComponent("a".repeat(50));
122
+ const lines = comp.render(10);
123
+ expect(lines).toHaveLength(1);
124
+ expect(stripAnsi(lines[0]!)).toBe("a".repeat(9) + "…");
125
+ expect(visibleWidth(lines[0]!)).toBe(10);
126
+ });
127
+
128
+ it("styles truncation ellipsis with theme accent when theme is set", () => {
129
+ const comp = new TruncatedTextComponent("a".repeat(50), theme);
130
+ const lines = comp.render(10);
131
+ expect(lines[0]!.includes("[accent:…]")).toBe(true);
132
+ });
133
+
134
+ it("normalizes newlines to spaces during render", () => {
135
+ const comp = new TruncatedTextComponent("hello\nworld");
136
+ const lines = comp.render(20);
137
+ expect(lines).toHaveLength(1);
138
+ expect(lines[0]!.startsWith("hello world")).toBe(true);
139
+ });
140
+
141
+ it("returns single empty padded line when text is empty", () => {
142
+ const comp = new TruncatedTextComponent("");
143
+ expect(comp.render(5)).toEqual([""]);
144
+ });
145
+ });
146
+
147
+ describe("renderBashCall", () => {
148
+ it("renders bold bash header when command is absent", () => {
149
+ const text = renderBashCall({}, theme, {});
150
+ expect(content(text)).toBe("[toolTitle:**bash**]");
151
+ });
152
+
153
+ it("renders bold bash header with orange accent command when command is provided in args", () => {
154
+ const text = renderBashCall({ command: "echo hi" }, theme, {});
155
+ expect(content(text)).toBe("[toolTitle:**bash**] [accent:echo hi]");
156
+ });
157
+
158
+ it("falls back to context.args.command when absent from args", () => {
159
+ const text = renderBashCall({}, theme, { args: { command: "git status" } });
160
+ expect(content(text)).toBe("[toolTitle:**bash**] [accent:git status]");
161
+ });
162
+
163
+ it("normalizes newlines to spaces in command in header", () => {
164
+ const text = renderBashCall({ command: "echo 1\r\necho 2\necho 3" }, theme, {});
165
+ expect(content(text)).toBe("[toolTitle:**bash**] [accent:echo 1 echo 2 echo 3]");
166
+ });
167
+
168
+ it("preserves full command text in component and truncates dynamically at render width", () => {
169
+ const longCmd = "a".repeat(120);
170
+ const comp = renderBashCall({ command: longCmd }, theme, {});
171
+ expect(content(comp)).toBe(`[toolTitle:**bash**] [accent:${longCmd}]`);
172
+ // Render at width 50: dynamically truncated
173
+ const lines50 = comp.render(50);
174
+ expect(lines50[0]!.includes("…")).toBe(true);
175
+ });
176
+
177
+ it("sets startedAt when executionStarted is true and startedAt is undefined", () => {
178
+ const context = { executionStarted: true, state: {} as any };
179
+ renderBashCall({ command: "echo hi" }, theme, context);
180
+ expect(typeof context.state.startedAt).toBe("number");
181
+ });
182
+
183
+ it("reuses context.lastComponent if present and an instance of TruncatedTextComponent", () => {
184
+ const last = new TruncatedTextComponent("stale");
185
+ const out = renderBashCall({ command: "echo hi" }, theme, { lastComponent: last });
186
+ expect(out).toBe(last);
187
+ expect(content(out)).toBe("[toolTitle:**bash**] [accent:echo hi]");
188
+ });
189
+
190
+ it("stores timeout and command in context state when provided", () => {
191
+ const context = { state: {} as any };
192
+ renderBashCall({ command: "echo hi", timeout: 30 }, theme, context);
193
+ expect(context.state.timeout).toBe(30);
194
+ expect(context.state.command).toBe("echo hi");
195
+ });
196
+
197
+ it("does not store timeout when not a finite number", () => {
198
+ const context = { state: {} as any };
199
+ renderBashCall({ command: "echo hi", timeout: Number.NaN }, theme, context);
200
+ expect(context.state.timeout).toBeUndefined();
201
+ });
202
+ });
203
+
204
+ describe("renderBashResult - Collapsed view", () => {
205
+ it("renders running status glyph (warning ▸) and live duration when isPartial is true", () => {
206
+ const context = {
207
+ state: { startedAt: Date.now() - 1500 },
208
+ };
209
+ const out = renderBashResult({}, { isPartial: true, expanded: false }, theme, context);
210
+ expect(content(out)).toBe("[warning:▸] [muted:1.5s]");
211
+ });
212
+
213
+ it("renders success status glyph (success ✓) and duration when exit 0", () => {
214
+ const context = {
215
+ state: { startedAt: 1000, endedAt: 2500 },
216
+ };
217
+ const out = renderBashResult(
218
+ { content: [{ type: "text", text: "On branch main" }] },
219
+ { expanded: false },
220
+ theme,
221
+ context,
222
+ );
223
+ expect(content(out)).toBe("[success:✓] [muted:1.5s]");
224
+ });
225
+
226
+ it("renders error status glyph (error ✗) and duration when context.isError or result.isError", () => {
227
+ const context = {
228
+ isError: true,
229
+ state: { startedAt: 1000, endedAt: 2500 },
230
+ };
231
+ const out = renderBashResult(
232
+ { content: [{ type: "text", text: "command not found" }] },
233
+ { expanded: false },
234
+ theme,
235
+ context,
236
+ );
237
+ expect(content(out)).toBe("[error:✗] [muted:1.5s]");
238
+ });
239
+
240
+ it("appends timeout when timeout is provided in args", () => {
241
+ const context = {
242
+ args: { timeout: 1800 },
243
+ state: { startedAt: 1000, endedAt: 26000 },
244
+ };
245
+ const out = renderBashResult({}, { expanded: false }, theme, context);
246
+ expect(content(out)).toBe("[success:✓] [muted:25s][dim: (timeout: 1800s)]");
247
+ });
248
+
249
+ it("appends timeout when timeout is stored in state", () => {
250
+ const context = {
251
+ state: { timeout: 30, startedAt: 1000, endedAt: 2500 },
252
+ };
253
+ const out = renderBashResult({}, { expanded: false }, theme, context);
254
+ expect(content(out)).toBe("[success:✓] [muted:1.5s][dim: (timeout: 30s)]");
255
+ });
256
+
257
+ it("does not append timeout when timeout is not a finite number", () => {
258
+ const context = {
259
+ args: { timeout: Number.NaN },
260
+ state: { startedAt: 1000, endedAt: 2000 },
261
+ };
262
+ const out = renderBashResult({}, { expanded: false }, theme, context);
263
+ expect(content(out)).toBe("[success:✓] [muted:1.0s]");
264
+ });
265
+
266
+ it("formats running collapsed row with timeout and exact spacing", () => {
267
+ const context = {
268
+ args: { timeout: 1800 },
269
+ state: { startedAt: Date.now() - 25000 },
270
+ };
271
+ const out = renderBashResult({}, { isPartial: true, expanded: false }, theme, context);
272
+ expect(content(out)).toBe("[warning:▸] [muted:25s][dim: (timeout: 1800s)]");
273
+ });
274
+ });
275
+
276
+ describe("renderBashResult - Live timer lifecycle", () => {
277
+ beforeEach(() => {
278
+ vi.useFakeTimers();
279
+ });
280
+
281
+ afterEach(() => {
282
+ vi.useRealTimers();
283
+ });
284
+
285
+ it("starts interval and invalidates context when isPartial is true", () => {
286
+ const invalidate = vi.fn();
287
+ const context = {
288
+ executionStarted: true,
289
+ invalidate,
290
+ state: {} as any,
291
+ };
292
+
293
+ renderBashResult({}, { isPartial: true }, theme, context);
294
+ expect(context.state.interval).toBeDefined();
295
+
296
+ vi.advanceTimersByTime(1000);
297
+ expect(invalidate).toHaveBeenCalledTimes(1);
298
+
299
+ vi.advanceTimersByTime(1000);
300
+ expect(invalidate).toHaveBeenCalledTimes(2);
301
+ });
302
+
303
+ it("clears interval and sets endedAt when execution completes", () => {
304
+ const invalidate = vi.fn();
305
+ const context = {
306
+ executionStarted: true,
307
+ invalidate,
308
+ state: {} as any,
309
+ };
310
+
311
+ renderBashResult({}, { isPartial: true }, theme, context);
312
+ expect(context.state.interval).toBeDefined();
313
+
314
+ renderBashResult({}, { isPartial: false }, theme, context);
315
+ expect(context.state.interval).toBeUndefined();
316
+ expect(context.state.endedAt).toBeDefined();
317
+ });
318
+
319
+ it("clears active bash intervals when clearActiveBashIntervals is called", () => {
320
+ const context = {
321
+ executionStarted: true,
322
+ invalidate: vi.fn(),
323
+ state: {} as any,
324
+ };
325
+
326
+ renderBashResult({}, { isPartial: true }, theme, context);
327
+ expect(context.state.interval).toBeDefined();
328
+
329
+ clearActiveBashIntervals();
330
+ expect(context.state.interval).toBeUndefined();
331
+ // Verify if the interval still triggers
332
+ vi.advanceTimersByTime(1000);
333
+ expect(context.invalidate).not.toHaveBeenCalled();
334
+ });
335
+ });
336
+
337
+ describe("renderBashResult - Expanded view", () => {
338
+ it("renders full command with $ prefix, output lines, duration, and ✓ Done on success", () => {
339
+ const context = {
340
+ args: { command: "git log -n 1 --oneline" },
341
+ state: { startedAt: 1000, endedAt: 2200 },
342
+ };
343
+ const result = {
344
+ content: [
345
+ { type: "text", text: "abc1234 feat: first commit\nsecond line" },
346
+ ],
347
+ };
348
+
349
+ const out = renderBashResult(result, { expanded: true }, theme, context);
350
+ const text = content(out);
351
+
352
+ expect(text).toContain("[dim:$ ][toolOutput:git log -n 1 --oneline]");
353
+ expect(text).toContain("[toolOutput:abc1234 feat: first commit]");
354
+ expect(text).toContain("[toolOutput:second line]");
355
+ expect(text).toContain("[muted:Took 1.2s]");
356
+ expect(text).toContain("[success:✓ Done]");
357
+ });
358
+
359
+ it("renders output lines with error styling and exit code on failure", () => {
360
+ const context = {
361
+ isError: true,
362
+ args: { command: "cat nonexistent.txt" },
363
+ state: { startedAt: 1000, endedAt: 2500 },
364
+ };
365
+ const result = {
366
+ content: [
367
+ {
368
+ type: "text",
369
+ text: "cat: nonexistent.txt: No such file or directory\nCommand exited with code 1",
370
+ },
371
+ ],
372
+ };
373
+
374
+ const out = renderBashResult(result, { expanded: true }, theme, context);
375
+ const text = content(out);
376
+
377
+ expect(text).toContain("[dim:$ ][toolOutput:cat nonexistent.txt]");
378
+ expect(text).toContain("[error:cat: nonexistent.txt: No such file or directory]");
379
+ expect(text).toContain("[muted:Took 1.5s]");
380
+ expect(text).toContain("[error:✗ Command exited with code 1]");
381
+ });
382
+
383
+ it("renders truncation notice if output was truncated", () => {
384
+ const context = {
385
+ args: { command: "find ." },
386
+ state: { startedAt: 1000, endedAt: 2000 },
387
+ };
388
+ const result = {
389
+ content: [{ type: "text", text: "file1\nfile2" }],
390
+ details: {
391
+ truncation: {
392
+ truncated: true,
393
+ outputLines: 2000,
394
+ totalLines: 5000,
395
+ },
396
+ fullOutputPath: "/tmp/pi-bash-123.log",
397
+ },
398
+ };
399
+
400
+ const out = renderBashResult(result, { expanded: true }, theme, context);
401
+ const text = content(out);
402
+
403
+ expect(text).toContain(
404
+ "[warning:[Truncated: showing 2000 of 5000 lines. Full output: /tmp/pi-bash-123.log]]",
405
+ );
406
+ });
407
+
408
+ it("renders Elapsed <duration> without final status while isPartial is true", () => {
409
+ const context = {
410
+ args: { command: "sleep 10" },
411
+ state: { startedAt: Date.now() - 3200 },
412
+ };
413
+ const result = {
414
+ content: [{ type: "text", text: "working..." }],
415
+ };
416
+
417
+ const out = renderBashResult(result, { expanded: true, isPartial: true }, theme, context);
418
+ const text = content(out);
419
+
420
+ expect(text).toContain("[muted:Elapsed 3.2s]");
421
+ expect(text).not.toContain("✓ Done");
422
+ expect(text).not.toContain("✗ Command exited");
423
+ });
424
+ });