@sns-myagent/cli 0.2.0 → 0.3.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 (61) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +7 -8
  3. package/package.json +3 -3
  4. package/scripts/apply-pi-natives-patch.js +82 -56
  5. package/src/adapters/telegram/bot.ts +41 -1
  6. package/src/adapters/telegram/bridge.ts +186 -0
  7. package/src/adapters/telegram/handler.ts +138 -13
  8. package/src/adapters/telegram/index.ts +12 -2
  9. package/src/agents/__tests__/config.test.ts +79 -0
  10. package/src/agents/__tests__/resilience.test.ts +119 -0
  11. package/src/agents/__tests__/strategies.test.ts +83 -0
  12. package/src/agents/config.ts +367 -0
  13. package/src/agents/ensemble.ts +224 -0
  14. package/src/agents/resilience.ts +332 -0
  15. package/src/agents/strategies/best-of-n.ts +108 -0
  16. package/src/agents/strategies/consensus.ts +105 -0
  17. package/src/agents/strategies/critic.ts +131 -0
  18. package/src/agents/strategies/types.ts +68 -0
  19. package/src/async/__tests__/task-runner.test.ts +162 -0
  20. package/src/async/__tests__/task-store.test.ts +146 -0
  21. package/src/async/index.ts +28 -1
  22. package/src/async/notifier.ts +133 -0
  23. package/src/async/task-runner.ts +175 -0
  24. package/src/async/task-store.ts +170 -0
  25. package/src/async/types.ts +70 -0
  26. package/src/cli/entry.ts +3 -1
  27. package/src/cli/index.ts +69 -54
  28. package/src/config/index.ts +1 -1
  29. package/src/debug/index.ts +1 -1
  30. package/src/modes/components/welcome.ts +13 -6
  31. package/src/modes/controllers/event-controller.ts +1 -1
  32. package/src/modes/setup-wizard/scenes/splash.ts +1 -1
  33. package/src/session/agent-session.ts +1 -1
  34. package/src/slash-commands/builtin-registry.ts +63 -0
  35. package/src/slash-commands/helpers/task.ts +181 -0
  36. package/src/tbm/__tests__/tbm.test.ts +660 -0
  37. package/src/tbm/comm-modes.ts +165 -0
  38. package/src/tbm/config.ts +136 -0
  39. package/src/tbm/context-delta.ts +146 -0
  40. package/src/tbm/context-pyramid.ts +202 -0
  41. package/src/tbm/dashboard.ts +131 -0
  42. package/src/tbm/index.ts +247 -0
  43. package/src/tbm/lazy-skills.ts +182 -0
  44. package/src/tbm/response-cache.ts +220 -0
  45. package/src/tbm/tombstone.ts +189 -0
  46. package/src/tbm/tool-compress.ts +230 -0
  47. package/src/tools/ask.ts +1 -1
  48. package/src/tui/chat-blocks.ts +205 -0
  49. package/src/tui/chat-ui.ts +270 -0
  50. package/src/tui/code-cell.ts +90 -1
  51. package/src/tui/command-palette.ts +189 -0
  52. package/src/tui/index.ts +4 -0
  53. package/src/tui/splash.ts +130 -0
  54. package/src/ui/banner.ts +69 -29
  55. package/src/ui/colors.ts +24 -0
  56. package/src/ui/error-display.ts +130 -0
  57. package/src/ui/gradient.ts +104 -0
  58. package/src/ui/index.ts +15 -0
  59. package/src/ui/memory-toast.ts +102 -0
  60. package/src/ui/status-bar.ts +36 -30
  61. package/bin/snscoder.js +0 -98
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Chat message block renderers — bordered terminal blocks for each message role.
3
+ * User, assistant, tool, system messages each get distinct gradient border treatment.
4
+ * Premium branded terminal UI for SNS-MyAgent.
5
+ */
6
+ import chalk from "chalk";
7
+ import gradient from "gradient-string";
8
+ import { visibleWidth } from "@oh-my-pi/pi-tui";
9
+ import { BRAND_GRADIENT, ROLE_HEX } from "#src/ui/colors.js";
10
+
11
+ // ── Box-drawing chars (rounded) ──
12
+ const BOX = {
13
+ topLeft: "╭",
14
+ topRight: "╮",
15
+ bottomLeft: "╰",
16
+ bottomRight: "╯",
17
+ horizontal: "─",
18
+ vertical: "│",
19
+ teeRight: "├",
20
+ teeLeft: "┤",
21
+ } as const;
22
+
23
+ // ── Color themes per role ──
24
+ const ROLE_COLORS = {
25
+ user: { border: chalk.cyan, label: chalk.cyan.bold, gradient: [ROLE_HEX.user, "#0099cc"] },
26
+ assistant: { border: chalk.magenta, label: chalk.magenta.bold, gradient: [ROLE_HEX.assistant, "#9b59b6"] },
27
+ tool: { border: chalk.yellow, label: chalk.yellow, gradient: [ROLE_HEX.tool, "#e6a700"] },
28
+ system: { border: chalk.dim, label: chalk.dim, gradient: [ROLE_HEX.system, "#555555"] },
29
+ error: { border: chalk.red, label: chalk.red.bold, gradient: [ROLE_HEX.error, "#cc0000"] },
30
+ } as const;
31
+
32
+ export type MessageRole = keyof typeof ROLE_COLORS;
33
+
34
+ export interface ChatBlockOptions {
35
+ role: MessageRole;
36
+ label?: string;
37
+ content: string;
38
+ width?: number;
39
+ meta?: string;
40
+ streaming?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Word-wrap text respecting ANSI escape sequences.
45
+ * Returns array of lines fitting within `maxWidth` visible columns.
46
+ */
47
+ function wrapAnsi(text: string, maxWidth: number): string[] {
48
+ const lines: string[] = [];
49
+ for (const rawLine of text.split("\n")) {
50
+ if (visibleWidth(rawLine) <= maxWidth) {
51
+ lines.push(rawLine);
52
+ continue;
53
+ }
54
+ // Simple word-wrap
55
+ let current = "";
56
+ let currentWidth = 0;
57
+ const words = rawLine.split(/(\s+)/);
58
+ for (const word of words) {
59
+ const wWidth = visibleWidth(word);
60
+ if (currentWidth + wWidth > maxWidth && current.length > 0) {
61
+ lines.push(current);
62
+ current = word.trimStart();
63
+ currentWidth = visibleWidth(current);
64
+ } else {
65
+ current += word;
66
+ currentWidth += wWidth;
67
+ }
68
+ }
69
+ if (current.length > 0) lines.push(current);
70
+ }
71
+ return lines;
72
+ }
73
+
74
+ /**
75
+ * Render a single chat message as a bordered block with gradient border.
76
+ */
77
+ export function renderChatBlock(opts: ChatBlockOptions): string {
78
+ const cols = process.stdout.columns ?? 80;
79
+ const width = Math.min(opts.width ?? cols, cols) - 2; // margin
80
+ const colors = ROLE_COLORS[opts.role];
81
+ const border = colors.border;
82
+
83
+ const pad = 1;
84
+ const innerWidth = width - 2 - pad * 2; // borders + padding
85
+
86
+ const lines: string[] = [];
87
+
88
+ // ── Top bar with gradient label ──
89
+ const label = opts.label ?? opts.role.toUpperCase();
90
+ const labelText = ` ${label} `;
91
+ const topFill = BOX.horizontal.repeat(
92
+ Math.max(0, innerWidth - visibleWidth(labelText) + pad * 2)
93
+ );
94
+ const gradBorder = gradient(colors.gradient as [string, string]);
95
+ lines.push(
96
+ gradBorder(BOX.topLeft + BOX.horizontal.repeat(2)) +
97
+ colors.label(labelText) +
98
+ gradBorder(topFill + BOX.topRight)
99
+ );
100
+
101
+ // ── Content lines with gradient side borders ──
102
+ const contentLines = wrapAnsi(opts.content, innerWidth);
103
+ const padStr = " ".repeat(pad);
104
+ for (const line of contentLines) {
105
+ const vis = visibleWidth(line);
106
+ const fill = " ".repeat(Math.max(0, innerWidth - vis));
107
+ lines.push(
108
+ gradBorder(BOX.vertical) + padStr + line + fill + padStr + gradBorder(BOX.vertical)
109
+ );
110
+ }
111
+
112
+ // ── Meta line (timestamp, tokens, etc.) ──
113
+ if (opts.meta) {
114
+ const metaText = chalk.dim(opts.meta);
115
+ const metaFill = " ".repeat(Math.max(0, innerWidth - visibleWidth(opts.meta)));
116
+ lines.push(
117
+ gradBorder(BOX.vertical) + padStr + metaText + metaFill + padStr + gradBorder(BOX.vertical)
118
+ );
119
+ }
120
+
121
+ // ── Streaming indicator (animated) ──
122
+ if (opts.streaming) {
123
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
124
+ const frameIdx = Date.now() % (frames.length * 80);
125
+ const frame = frames[Math.floor(frameIdx / 80)];
126
+ const spinnerText = `${chalk.cyan(frame)} ${chalk.dim("thinking...")}`;
127
+ const spinFill = " ".repeat(Math.max(0, innerWidth - visibleWidth(spinnerText)));
128
+ lines.push(
129
+ gradBorder(BOX.vertical) + padStr + spinnerText + spinFill + padStr + gradBorder(BOX.vertical)
130
+ );
131
+ }
132
+
133
+ // ── Bottom bar ──
134
+ lines.push(
135
+ gradBorder(BOX.bottomLeft + BOX.horizontal.repeat(width - 2) + BOX.bottomRight)
136
+ );
137
+
138
+ return lines.join("\n");
139
+ }
140
+
141
+ /**
142
+ * Render a compact inline message (no box, just colored prefix).
143
+ */
144
+ export function renderInline(role: MessageRole, text: string): string {
145
+ const colors = ROLE_COLORS[role];
146
+ const prefix = colors.label(`[${role}]`);
147
+ return `${prefix} ${text}`;
148
+ }
149
+
150
+ /**
151
+ * Render a separator/divider line with gradient accent.
152
+ */
153
+ export function renderDivider(label?: string): string {
154
+ const cols = process.stdout.columns ?? 80;
155
+ const grad = gradient(BRAND_GRADIENT);
156
+ if (!label) {
157
+ return grad("─".repeat(cols - 2));
158
+ }
159
+ const labelText = ` ${label} `;
160
+ const fill = BOX.horizontal.repeat(Math.max(0, cols - 4 - visibleWidth(labelText)));
161
+ return grad(`${fill.slice(0, Math.floor(fill.length / 2))}`) +
162
+ chalk.dim(labelText) +
163
+ grad(`${fill.slice(Math.floor(fill.length / 2))}`);
164
+ }
165
+
166
+ /**
167
+ * Render a tool-call status block (compact, with gradient border).
168
+ */
169
+ export function renderToolBlock(
170
+ toolName: string,
171
+ status: "running" | "done" | "error",
172
+ detail?: string,
173
+ ): string {
174
+ const cols = process.stdout.columns ?? 80;
175
+ const width = Math.min(cols - 2, 72);
176
+ const gradColors = status === "error"
177
+ ? ["#ff4444", "#cc0000"]
178
+ : status === "running"
179
+ ? ["#ffd700", "#e6a700"]
180
+ : ["#00d2ff", "#7b2ff7"];
181
+ const grad = gradient(gradColors as [string, string]);
182
+
183
+ const icon = status === "running" ? chalk.yellow("⚙") : status === "done" ? chalk.green("✓") : chalk.red("✗");
184
+ const label = ` ${icon} tool:${toolName} `;
185
+ const detailText = detail ? chalk.dim(` ${detail}`) : "";
186
+ const fill = BOX.horizontal.repeat(
187
+ Math.max(0, width - 2 - visibleWidth(` ⚙ tool:${toolName} `) - visibleWidth(detail ?? ""))
188
+ );
189
+
190
+ return grad(BOX.topLeft + BOX.horizontal) + label + detailText + grad(fill + BOX.topRight);
191
+ }
192
+
193
+ /**
194
+ * Build a gradient header line for the top of the session.
195
+ */
196
+ export function renderSessionHeader(model: string, version: string): string {
197
+ const cols = process.stdout.columns ?? 80;
198
+ const left = gradient(["#00d2ff", "#7b2ff7"])(" SnsCoder");
199
+ const ver = chalk.dim(` v${version}`);
200
+ const modelStr = chalk.cyan(` ${model}`);
201
+ const sep = chalk.dim(" │ ");
202
+ const line = `${left}${ver}${sep}${modelStr}`;
203
+ const fill = chalk.dim("─".repeat(Math.max(0, cols - visibleWidth(line) - 2)));
204
+ return `\n${line}\n${fill}\n`;
205
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Chat UI orchestrator — block-style terminal chat loop.
3
+ * Renders messages as bordered blocks, handles /commands, manages conversation.
4
+ * Lightweight: no pi-agent-core dependency. Pure terminal rendering.
5
+ */
6
+ import chalk from "chalk";
7
+ import gradient from "gradient-string";
8
+ import ora from "ora";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { stdin, stdout } from "node:process";
11
+ import { platform } from "node:os";
12
+ import { cwd } from "node:process";
13
+ import { renderSplash, type SplashInfo } from "./splash.js";
14
+ import { renderStatusBar, clearStatusBar, type StatusBarState } from "../ui/status-bar.js";
15
+ import {
16
+ renderChatBlock,
17
+ renderToolBlock,
18
+ renderDivider,
19
+ renderSessionHeader,
20
+ type MessageRole,
21
+ } from "./chat-blocks.js";
22
+
23
+ // ── Types ──
24
+
25
+ interface ChatMessage {
26
+ role: MessageRole;
27
+ content: string;
28
+ timestamp: Date;
29
+ meta?: string;
30
+ }
31
+
32
+ interface ChatSessionConfig {
33
+ model?: string;
34
+ provider?: string;
35
+ version: string;
36
+ agentName?: string;
37
+ tokensUsed?: number;
38
+ memoryHits?: number;
39
+ onUserMessage?: (msg: string) => Promise<string | null>;
40
+ }
41
+
42
+ // ── Helpers ──
43
+
44
+ function formatTime(d: Date): string {
45
+ return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
46
+ }
47
+
48
+ function clearScreen(): void {
49
+ stdout.write("\x1b[2J\x1b[H");
50
+ }
51
+
52
+ function moveCursor(row: number, col: number): void {
53
+ stdout.write(`\x1b[${row};${col}H`);
54
+ }
55
+
56
+ // ── Slash commands ──
57
+
58
+ interface SlashCommand {
59
+ name: string;
60
+ description: string;
61
+ handler: (args: string[], ctx: ChatContext) => void | Promise<void>;
62
+ }
63
+
64
+ interface ChatContext {
65
+ messages: ChatMessage[];
66
+ config: ChatSessionConfig;
67
+ print: (text: string) => void;
68
+ }
69
+
70
+ const BUILTIN_COMMANDS: SlashCommand[] = [
71
+ {
72
+ name: "/exit",
73
+ description: "Quit the chat",
74
+ handler: (_args, ctx) => {
75
+ ctx.print(chalk.dim("\n Goodbye. 👋\n"));
76
+ process.exit(0);
77
+ },
78
+ },
79
+ {
80
+ name: "/quit",
81
+ description: "Quit the chat",
82
+ handler: (_args, ctx) => {
83
+ ctx.print(chalk.dim("\n Goodbye. 👋\n"));
84
+ process.exit(0);
85
+ },
86
+ },
87
+ {
88
+ name: "/clear",
89
+ description: "Clear screen and show header",
90
+ handler: (_args, ctx) => {
91
+ clearScreen();
92
+ const model = ctx.config.model ? `${ctx.config.provider ?? "?"}/${ctx.config.model}` : "none";
93
+ ctx.print(renderSessionHeader(model, ctx.config.version));
94
+ },
95
+ },
96
+ {
97
+ name: "/help",
98
+ description: "Show available commands",
99
+ handler: (_args, ctx) => {
100
+ const lines = BUILTIN_COMMANDS.map(
101
+ c => ` ${chalk.cyan(c.name.padEnd(14))}${chalk.dim(c.description)}`
102
+ ).join("\n");
103
+ ctx.print(renderChatBlock({
104
+ role: "system",
105
+ label: "HELP",
106
+ content: lines,
107
+ }));
108
+ },
109
+ },
110
+ {
111
+ name: "/history",
112
+ description: "Show conversation history",
113
+ handler: (_args, ctx) => {
114
+ if (ctx.messages.length === 0) {
115
+ ctx.print(chalk.dim(" No messages yet."));
116
+ return;
117
+ }
118
+ for (const msg of ctx.messages) {
119
+ ctx.print(renderChatBlock({
120
+ role: msg.role,
121
+ content: msg.content,
122
+ meta: formatTime(msg.timestamp),
123
+ }));
124
+ }
125
+ },
126
+ },
127
+ {
128
+ name: "/model",
129
+ description: "Show current model info",
130
+ handler: (_args, ctx) => {
131
+ const model = ctx.config.model ?? "not set";
132
+ const provider = ctx.config.provider ?? "not set";
133
+ ctx.print(renderChatBlock({
134
+ role: "system",
135
+ label: "MODEL",
136
+ content: `Provider: ${provider}\nModel: ${model}\nAgent: ${ctx.config.agentName ?? "SnsCoder"}`,
137
+ }));
138
+ },
139
+ },
140
+ ];
141
+
142
+ // ── Main chat loop ──
143
+
144
+ export async function runChatSession(config: ChatSessionConfig): Promise<void> {
145
+ const messages: ChatMessage[] = [];
146
+ const model = config.model ? `${config.provider ?? "??"}/${config.model}` : "defaults";
147
+ const sessionStarted = Date.now();
148
+ const statusState: StatusBarState = {
149
+ model: config.model ?? "none",
150
+ tokensUsed: config.tokensUsed ?? 0,
151
+ sessionStarted,
152
+ memoryHits: config.memoryHits ?? 0,
153
+ };
154
+
155
+ const updateStatus = (): void => {
156
+ clearStatusBar();
157
+ renderStatusBar(statusState);
158
+ };
159
+ // Print splash
160
+ const splashInfo: SplashInfo = {
161
+ model: config.model,
162
+ provider: config.provider,
163
+ cwd: cwd(),
164
+ platform: platform(),
165
+ };
166
+ clearScreen();
167
+ stdout.write(renderSplash(splashInfo) + "\n\n");
168
+ updateStatus();
169
+ const print = (text: string): void => {
170
+ clearStatusBar();
171
+ stdout.write(text + "\n");
172
+ updateStatus();
173
+ };
174
+
175
+ const ctx: ChatContext = { messages, config, print };
176
+
177
+ // Readline loop
178
+ while (true) {
179
+ const promptStr = chalk.cyan("you") + chalk.dim(" › ");
180
+ let line: string;
181
+ try {
182
+ const rl = createInterface({ input: stdin, output: stdout });
183
+ try {
184
+ line = await rl.question(promptStr);
185
+ } finally {
186
+ rl.close();
187
+ }
188
+ } catch {
189
+ // EOF / Ctrl+C
190
+ print(chalk.dim("\n Goodbye. 👋\n"));
191
+ process.exit(0);
192
+ }
193
+
194
+ const trimmed = line.trim();
195
+ if (trimmed === "") continue;
196
+
197
+ // Handle slash commands
198
+ if (trimmed.startsWith("/")) {
199
+ const [cmd, ...args] = trimmed.split(/\s+/);
200
+ const command = BUILTIN_COMMANDS.find(c => c.name === cmd);
201
+ if (command) {
202
+ await command.handler(args, ctx);
203
+ continue;
204
+ }
205
+ print(chalk.yellow(` Unknown command: ${cmd}. Type /help for available commands.`));
206
+ continue;
207
+ }
208
+
209
+ // User message
210
+ const userMsg: ChatMessage = { role: "user", content: trimmed, timestamp: new Date() };
211
+ messages.push(userMsg);
212
+ statusState.tokensUsed += trimmed.split(/\s+/).length; // rough word count
213
+ updateStatus();
214
+ print(renderChatBlock({
215
+ role: "user",
216
+ content: trimmed,
217
+ meta: formatTime(userMsg.timestamp),
218
+ }));
219
+
220
+ // Assistant response
221
+ let response: string | null = null;
222
+ if (config.onUserMessage) {
223
+ // Show spinner while waiting
224
+ const spinner = ora({
225
+ text: chalk.dim("thinking..."),
226
+ spinner: "dots",
227
+ color: "cyan",
228
+ }).start();
229
+
230
+ try {
231
+ response = await config.onUserMessage(trimmed);
232
+ } catch (err) {
233
+ spinner.fail(chalk.red("error"));
234
+ const errMsg = err instanceof Error ? err.message : String(err);
235
+ print(renderChatBlock({
236
+ role: "error",
237
+ label: "ERROR",
238
+ content: errMsg,
239
+ }));
240
+ continue;
241
+ }
242
+ spinner.stop();
243
+ }
244
+
245
+ // Display response
246
+ const reply = response ?? `echo: ${trimmed}`;
247
+ const assistantMsg: ChatMessage = { role: "assistant", content: reply, timestamp: new Date() };
248
+ messages.push(assistantMsg);
249
+ statusState.tokensUsed += reply.split(/\s+/).length;
250
+ statusState.memoryHits += 1;
251
+ updateStatus();
252
+ print(renderChatBlock({
253
+ role: "assistant",
254
+ content: reply,
255
+ meta: `${formatTime(assistantMsg.timestamp)} │ ${config.agentName ?? "SnsCoder"}`,
256
+ }));
257
+ print(""); // spacing
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Minimal chat session with echo fallback (no LLM connected).
263
+ * Used when no onUserMessage handler is provided.
264
+ */
265
+ export async function runEchoChat(config: Omit<ChatSessionConfig, "onUserMessage">): Promise<void> {
266
+ return runChatSession({
267
+ ...config,
268
+ onUserMessage: async (msg: string) => `echo: ${msg}`,
269
+ });
270
+ }
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Render a code or markdown cell with optional output section.
3
3
  */
4
- import { Markdown } from "@oh-my-pi/pi-tui";
4
+ import { Markdown, visibleWidth } from "@oh-my-pi/pi-tui";
5
+ import chalk from "chalk";
6
+ import gradient from "gradient-string";
5
7
  import { getMarkdownTheme, highlightCode, type Theme } from "../modes/theme/theme";
6
8
  import {
7
9
  formatDuration,
@@ -215,6 +217,93 @@ export interface MarkdownCellOptions {
215
217
  width: number;
216
218
  }
217
219
 
220
+ // ── Collapsible tool output helpers ──
221
+
222
+ const COLLAPSE_BORDER = {
223
+ topLeft: "╭",
224
+ topRight: "╮",
225
+ bottomLeft: "╰",
226
+ bottomRight: "╯",
227
+ horizontal: "─",
228
+ vertical: "│",
229
+ } as const;
230
+
231
+ /**
232
+ * Render a collapsible section for tool output.
233
+ * When collapsed: shows header + line count hint.
234
+ * When expanded: shows full output in bordered block.
235
+ */
236
+ export function renderCollapsibleOutput(
237
+ label: string,
238
+ content: string,
239
+ expanded: boolean,
240
+ width: number,
241
+ theme: Theme,
242
+ maxLines: number = 8,
243
+ ): string[] {
244
+ const grad = theme.fg;
245
+ const lines: string[] = [];
246
+ const inner = Math.max(20, width - 4);
247
+
248
+ if (!expanded) {
249
+ // Collapsed: just header + hint
250
+ const lineCount = content.split("\n").length;
251
+ const hint = chalk.dim(` (${lineCount} lines, click to expand)`);
252
+ lines.push(` ${grad("accent", "▸")} ${grad("toolTitle", label)}${hint}`);
253
+ return lines;
254
+ }
255
+
256
+ // Expanded: bordered block
257
+ const gradColors = ["#00d2ff", "#7b2ff7"] as [string, string];
258
+ const g = gradient(gradColors);
259
+
260
+ // Header
261
+ const headerText = ` ${label} `;
262
+ const headerFill = COLLAPSE_BORDER.horizontal.repeat(
263
+ Math.max(0, inner - headerText.length - 2),
264
+ );
265
+ lines.push(g(COLLAPSE_BORDER.topLeft + COLLAPSE_BORDER.horizontal) + grad("toolTitle", headerText) + g(headerFill + COLLAPSE_BORDER.topRight));
266
+
267
+ // Content
268
+ const rawLines = content.split("\n");
269
+ const visibleLines = rawLines.slice(0, maxLines);
270
+ for (const line of visibleLines) {
271
+ const visLen = visibleWidth(line);
272
+ const fill = " ".repeat(Math.max(0, inner - visLen));
273
+ lines.push(g(COLLAPSE_BORDER.vertical) + " " + line + fill + " " + g(COLLAPSE_BORDER.vertical));
274
+ }
275
+
276
+ // Hidden lines hint
277
+ const remaining = rawLines.length - maxLines;
278
+ if (remaining > 0) {
279
+ const moreText = ` ... +${remaining} more lines`;
280
+ const moreFill = " ".repeat(Math.max(0, inner - moreText.length));
281
+ lines.push(g(COLLAPSE_BORDER.vertical) + chalk.dim(moreText) + moreFill + g(COLLAPSE_BORDER.vertical));
282
+ }
283
+
284
+ // Footer
285
+ lines.push(g(COLLAPSE_BORDER.bottomLeft + COLLAPSE_BORDER.horizontal.repeat(inner) + COLLAPSE_BORDER.bottomRight));
286
+
287
+ return lines;
288
+ }
289
+
290
+ /**
291
+ * Render a collapsible tool output cell with gradient border.
292
+ * Wraps renderCodeCell with collapsible behavior.
293
+ */
294
+ export function renderCollapsibleCodeCell(options: {
295
+ code: string;
296
+ language?: string;
297
+ title?: string;
298
+ status?: "pending" | "running" | "warning" | "complete" | "error";
299
+ output?: string;
300
+ expanded?: boolean;
301
+ width: number;
302
+ }, theme: Theme): string[] {
303
+ const { expanded = false, ...rest } = options;
304
+ return renderCodeCell({ ...rest, codeMaxLines: expanded ? 200 : 8, outputMaxLines: expanded ? 50 : 4, expanded }, theme);
305
+ }
306
+
218
307
  export function renderMarkdownCell(options: MarkdownCellOptions, theme: Theme): string[] {
219
308
  const { content, output, expanded = false, outputMaxLines = 6, contentMaxLines = 12, width } = options;
220
309
  const codeOptions: CodeCellOptions = {