@bubblebrain-ai/bubble 0.0.8 → 0.0.9

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 (74) hide show
  1. package/dist/agent/categories.d.ts +34 -0
  2. package/dist/agent/categories.js +98 -0
  3. package/dist/agent/profiles.d.ts +4 -0
  4. package/dist/agent/profiles.js +2 -3
  5. package/dist/agent/subagent-control.d.ts +5 -0
  6. package/dist/agent/subagent-control.js +4 -0
  7. package/dist/agent/subagent-lifecycle-reminder.d.ts +3 -0
  8. package/dist/agent/subagent-lifecycle-reminder.js +102 -0
  9. package/dist/agent/subagent-route-format.d.ts +8 -0
  10. package/dist/agent/subagent-route-format.js +18 -0
  11. package/dist/agent/subtask-policy.d.ts +0 -1
  12. package/dist/agent/subtask-policy.js +0 -4
  13. package/dist/agent.d.ts +12 -0
  14. package/dist/agent.js +152 -13
  15. package/dist/config.d.ts +23 -3
  16. package/dist/config.js +59 -6
  17. package/dist/context/budget.d.ts +3 -3
  18. package/dist/context/budget.js +29 -15
  19. package/dist/context/compact.d.ts +23 -0
  20. package/dist/context/compact.js +129 -0
  21. package/dist/context/llm-compactor.d.ts +19 -0
  22. package/dist/context/llm-compactor.js +200 -0
  23. package/dist/context/projector.js +28 -12
  24. package/dist/context/token-estimator.d.ts +14 -0
  25. package/dist/context/token-estimator.js +106 -0
  26. package/dist/context/tool-output-truncate.d.ts +8 -0
  27. package/dist/context/tool-output-truncate.js +59 -0
  28. package/dist/context/usage.js +9 -9
  29. package/dist/main.js +43 -6
  30. package/dist/model-catalog.d.ts +9 -0
  31. package/dist/model-catalog.js +16 -0
  32. package/dist/orchestrator/default-hooks.js +18 -0
  33. package/dist/provider-openai-codex.d.ts +13 -2
  34. package/dist/provider-openai-codex.js +81 -32
  35. package/dist/provider-registry.js +20 -4
  36. package/dist/slash-commands/commands.js +24 -0
  37. package/dist/slash-commands/types.d.ts +7 -0
  38. package/dist/tools/agent-lifecycle.js +22 -4
  39. package/dist/tools/edit.js +2 -2
  40. package/dist/tools/glob.js +2 -1
  41. package/dist/tools/grep.js +2 -2
  42. package/dist/tools/lsp.js +2 -2
  43. package/dist/tools/path-utils.d.ts +2 -0
  44. package/dist/tools/path-utils.js +16 -0
  45. package/dist/tools/read.js +117 -5
  46. package/dist/tools/write.js +3 -2
  47. package/dist/tui-ink/app.d.ts +11 -2
  48. package/dist/tui-ink/app.js +191 -78
  49. package/dist/tui-ink/approval/approval-dialog.js +4 -1
  50. package/dist/tui-ink/approval/diff-view.js +2 -1
  51. package/dist/tui-ink/approval/select.js +2 -1
  52. package/dist/tui-ink/code-highlight.d.ts +2 -0
  53. package/dist/tui-ink/code-highlight.js +30 -2
  54. package/dist/tui-ink/detect-theme.d.ts +19 -0
  55. package/dist/tui-ink/detect-theme.js +123 -0
  56. package/dist/tui-ink/footer.js +4 -3
  57. package/dist/tui-ink/input-box.js +83 -26
  58. package/dist/tui-ink/input-history.d.ts +16 -0
  59. package/dist/tui-ink/input-history.js +81 -0
  60. package/dist/tui-ink/markdown.js +30 -20
  61. package/dist/tui-ink/message-list.js +112 -16
  62. package/dist/tui-ink/model-picker.js +6 -1
  63. package/dist/tui-ink/plan-confirm.js +2 -1
  64. package/dist/tui-ink/question-dialog.js +2 -1
  65. package/dist/tui-ink/run.d.ts +5 -1
  66. package/dist/tui-ink/run.js +30 -2
  67. package/dist/tui-ink/theme.d.ts +64 -35
  68. package/dist/tui-ink/theme.js +81 -8
  69. package/dist/tui-ink/todos.js +5 -3
  70. package/dist/tui-ink/trace-groups.d.ts +3 -1
  71. package/dist/tui-ink/trace-groups.js +93 -14
  72. package/dist/tui-ink/welcome.js +23 -4
  73. package/dist/types.d.ts +6 -0
  74. package/package.json +2 -1
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Box, Static, Text } from "ink";
4
- import { theme } from "./theme.js";
4
+ import { useTheme } from "./theme.js";
5
5
  import { highlightCode, inferLang } from "./code-highlight.js";
6
6
  import { MarkdownContent } from "./markdown.js";
7
7
  import { buildTraceGroups, formatElapsed, formatTracePath, traceGroupLabel } from "./trace-groups.js";
8
8
  import { EDIT_COLLAPSED_DIFF_LINES, formatEditSuccessSummary, getEditDiffDetails } from "./edit-diff.js";
9
+ import { formatSubagentRoute } from "../agent/subagent-route-format.js";
9
10
  export function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, verboseTrace, pendingApproval, nowTick, welcomeBanner, }) {
10
11
  const hasStreaming = !!(streamingContent ||
11
12
  streamingReasoning ||
@@ -39,6 +40,7 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
39
40
  } }), hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick }))] }));
40
41
  }
41
42
  function MessageItem({ message, terminalColumns, verboseTrace, showExpandHint, nowTick, }) {
43
+ const theme = useTheme();
42
44
  if (message.role === "user") {
43
45
  return _jsx(UserMessageBlock, { content: message.content, terminalColumns: terminalColumns });
44
46
  }
@@ -72,6 +74,7 @@ function MessageParts({ parts, terminalColumns, verboseTrace, pendingApproval, s
72
74
  }) }));
73
75
  }
74
76
  function TimelineText({ content, compactTop, terminalColumns, }) {
77
+ const theme = useTheme();
75
78
  if (!content.trim())
76
79
  return null;
77
80
  // marginLeft (2) + "⛬ " glyph (3 visual cells) = 5 cells consumed by the
@@ -115,6 +118,7 @@ function TraceGroupList({ toolCalls, terminalColumns, pendingApproval, nowTick,
115
118
  return (_jsxs(Box, { flexDirection: "column", children: [activeGroup && (_jsx(TraceActivityLine, { group: activeGroup, pendingApproval: pendingApproval, nowTick: nowTick, terminalColumns: terminalColumns })), groups.map((group, idx) => (_jsx(TraceGroupBlock, { group: group, terminalColumns: terminalColumns, pendingApproval: pendingApproval, compactTop: idx === 0 && compactTop, nowTick: nowTick }, group.raw.map((tool) => tool.id).join(":"))))] }));
116
119
  }
117
120
  function TraceActivityLine({ group, pendingApproval, nowTick, terminalColumns, }) {
121
+ const theme = useTheme();
118
122
  const waiting = isTraceGroupWaitingForApproval(group, pendingApproval);
119
123
  const elapsed = formatElapsed(group.startedAt, nowTick);
120
124
  const labelWidth = Math.max(20, terminalColumns - 26);
@@ -122,34 +126,39 @@ function TraceActivityLine({ group, pendingApproval, nowTick, terminalColumns, }
122
126
  return (_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { color: waiting ? theme.warning : theme.tracePending, children: "\u25CF " }), _jsxs(Text, { color: theme.traceDetail, children: [waiting ? "Waiting for approval" : "Working on", " "] }), _jsx(Text, { color: theme.traceAction, children: label }), elapsed && _jsxs(Text, { color: theme.traceDetail, children: [" \u00B7 ", elapsed] })] }));
123
127
  }
124
128
  function TraceGroupBlock({ group, terminalColumns, pendingApproval, compactTop, nowTick, }) {
129
+ const theme = useTheme();
125
130
  const waiting = isTraceGroupWaitingForApproval(group, pendingApproval);
126
- const status = traceGroupStatus(group, waiting, nowTick);
131
+ const status = traceGroupStatus(group, waiting, theme, nowTick);
127
132
  const editTool = group.kind === "edit" && group.raw.length === 1 ? group.raw[0] : undefined;
128
133
  const editDetails = editTool && !group.pending && !group.hasError ? getEditDiffDetails(editTool) : null;
129
134
  if (editTool && editDetails) {
130
135
  return (_jsx(EditTraceBlock, { tool: editTool, details: editDetails, terminalColumns: terminalColumns, compactTop: compactTop, status: status }));
131
136
  }
132
- const titleColor = group.hasError ? theme.error : theme.traceAction;
133
- const detailColor = group.hasError ? theme.error : theme.traceDetail;
137
+ const allErrored = group.hasError && group.errorCount >= group.raw.length && !group.pending;
138
+ const titleColor = allErrored ? theme.error : theme.traceAction;
139
+ const detailColor = allErrored ? theme.error : theme.traceDetail;
134
140
  const commandWidth = Math.max(14, terminalColumns - group.title.length - 16);
135
141
  const detailWidth = Math.max(20, terminalColumns - 8);
136
142
  const detailLines = group.previewLines.length > 0 ? group.previewLines : group.items;
137
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: titleColor, children: group.title }), group.command ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: theme.traceCommand, children: truncateVisual(group.command, commandWidth) })] })) : group.count !== undefined && group.noun ? (_jsxs(Text, { color: theme.traceCount, children: [" ", group.count, " ", group.noun] })) : null, status && _jsxs(Text, { color: status.color, children: [" ", status.text] })] }), detailLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: detailLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: detailColor, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, index))) })), group.omitted > 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.traceDetail, children: ["... ", group.omitted, " more, Ctrl+O to view"] }) }))] }));
143
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: titleColor, children: group.title }), group.command ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: theme.traceCommand, children: truncateVisual(group.command, commandWidth) })] })) : group.count !== undefined && group.noun ? (_jsxs(Text, { color: theme.traceCount, children: [" ", group.count, " ", group.noun] })) : null, status && _jsxs(Text, { color: status.color, children: [" ", status.text] })] }), detailLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: detailLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: detailColor, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, index))) })), group.errorLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: group.errorLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: theme.error, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, `error-${index}`))) })), group.omitted > 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.traceDetail, children: ["... ", group.omitted, " more, Ctrl+O to view"] }) }))] }));
138
144
  }
139
145
  function EditTraceBlock({ tool, details, terminalColumns, compactTop, status, }) {
146
+ const theme = useTheme();
140
147
  const path = formatTracePath(details.path ?? tool.args.path ?? "");
141
148
  const pathWidth = Math.max(14, terminalColumns - 12);
142
149
  return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.traceAction, children: "Edit" }), path && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: theme.traceCommand, children: truncateVisual(path, pathWidth) })] })), status && _jsxs(Text, { color: status.color, children: [" ", status.text] })] }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { color: theme.traceDetail, children: "\u23BF " }), _jsx(Text, { color: theme.success, children: formatEditSuccessSummary(details) })] }), _jsx(DiffBlock, { diff: details.diff, terminalColumns: terminalColumns, maxLines: EDIT_COLLAPSED_DIFF_LINES, verbose: false, showExpandHint: true })] }));
143
150
  }
144
- function traceGroupStatus(group, waitingApproval, nowTick) {
151
+ function traceGroupStatus(group, waitingApproval, theme, nowTick) {
145
152
  if (waitingApproval)
146
153
  return { text: "waiting for approval", color: theme.warning };
147
154
  if (group.pending) {
148
155
  const elapsed = formatElapsed(group.startedAt, nowTick);
149
156
  return { text: elapsed ? `running · ${elapsed}` : "running", color: theme.tracePending };
150
157
  }
151
- if (group.hasError)
152
- return { text: "error", color: theme.error };
158
+ if (group.hasError) {
159
+ const count = group.errorCount || 1;
160
+ return { text: count === 1 ? "1 error" : `${count} errors`, color: theme.error };
161
+ }
153
162
  return null;
154
163
  }
155
164
  function findActiveTraceGroup(groups, pendingApproval) {
@@ -173,16 +182,18 @@ function approvalMatchesTool(hint, tc) {
173
182
  return !hint.path || hint.path === tc.args.path;
174
183
  }
175
184
  function ReasoningTraceBlock({ reasoning }) {
185
+ const theme = useTheme();
176
186
  const lines = React.useMemo(() => reasoning.split("\n").filter((l) => l.trim() !== ""), [reasoning]);
177
187
  return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: theme.thinkingDim, dimColor: true, children: ["\u273B Reasoning trace", lines.length > 0 ? ` · ${lines.length} line${lines.length === 1 ? "" : "s"}` : ""] }), lines.map((line, i) => (_jsx(Text, { color: theme.thinkingDim, dimColor: true, italic: true, children: line }, i)))] }));
178
188
  }
179
189
  function UserMessageBlock({ content, terminalColumns }) {
180
- const horizontalRoom = Math.max(20, terminalColumns - 4);
181
- const maxBubbleTextWidth = Math.max(1, horizontalRoom - 4);
190
+ const theme = useTheme();
191
+ // Rail ( + space) takes 2 cols; reserve 2 cols inside the fill for left/right gutters.
192
+ const horizontalRoom = Math.max(20, terminalColumns - 2);
193
+ const bubbleTextWidth = Math.max(1, horizontalRoom - 2);
182
194
  const wrappedLines = content
183
195
  .split("\n")
184
- .flatMap((line) => wrapByVisualWidth(line, maxBubbleTextWidth));
185
- const bubbleTextWidth = Math.min(maxBubbleTextWidth, Math.max(8, ...wrappedLines.map((line) => visualWidth(line))));
196
+ .flatMap((line) => wrapByVisualWidth(line, bubbleTextWidth));
186
197
  return (_jsx(Box, { flexDirection: "column", children: wrappedLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.userRail, children: "\u258C " }), _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: ` ${padVisual(line || " ", bubbleTextWidth)} ` })] }, index))) }));
187
198
  }
188
199
  const TOOL_DISPLAY_NAMES = {
@@ -276,9 +287,72 @@ function summarizeToolResult(tc) {
276
287
  return lineCount > 0 ? p(lineCount, "line", "lines") : "Done";
277
288
  }
278
289
  }
290
+ function subagentsFrom(toolCall) {
291
+ const raw = toolCall.metadata?.subagents;
292
+ if (!Array.isArray(raw))
293
+ return [];
294
+ return raw.filter((item) => typeof item === "object" && item !== null);
295
+ }
296
+ function latestSubagentNote(subagent) {
297
+ const note = subagent.error
298
+ || subagent.toolNotes?.filter(Boolean).at(-1)
299
+ || subagent.summary
300
+ || subagent.task
301
+ || "";
302
+ return note.replace(/\r\n/g, "\n").split("\n").map((line) => line.trim()).find(Boolean) ?? "";
303
+ }
304
+ function subagentLabel(subagent) {
305
+ return subagent.nickname ?? subagent.agentName ?? "subagent";
306
+ }
307
+ function subagentRole(subagent) {
308
+ return [subagent.agentName, subagent.category ? `/${subagent.category}` : ""].join("") || "default";
309
+ }
310
+ function subagentDescriptor(subagent, includeThinking = false) {
311
+ const route = formatSubagentRoute(subagent.route, { includeThinking });
312
+ const role = subagentRole(subagent);
313
+ return route ? `${role} @ ${route}` : role;
314
+ }
315
+ function subagentStatusColor(status, theme) {
316
+ if (status === "completed")
317
+ return theme.success;
318
+ if (status === "failed" || status === "blocked" || status === "cancelled")
319
+ return theme.error;
320
+ if (status === "queued")
321
+ return theme.muted;
322
+ return theme.toolPending;
323
+ }
324
+ function subagentSummary(subagents) {
325
+ if (subagents.length === 0)
326
+ return "no subagents";
327
+ const counts = new Map();
328
+ for (const subagent of subagents) {
329
+ const status = subagent.status ?? "running";
330
+ counts.set(status, (counts.get(status) ?? 0) + 1);
331
+ }
332
+ const order = ["running", "queued", "completed", "blocked", "failed", "cancelled"];
333
+ return order
334
+ .filter((status) => counts.has(status))
335
+ .map((status) => `${counts.get(status)} ${status}`)
336
+ .join(" ");
337
+ }
338
+ function sortSubagents(subagents) {
339
+ const rank = {
340
+ running: 0,
341
+ blocked: 1,
342
+ failed: 2,
343
+ queued: 3,
344
+ cancelled: 4,
345
+ completed: 5,
346
+ };
347
+ return [...subagents].sort((a, b) => (rank[a.status ?? "running"] ?? 9) - (rank[b.status ?? "running"] ?? 9));
348
+ }
279
349
  const COLLAPSED_PREVIEW_LINES = 10;
280
350
  const EXPANDED_PREVIEW_LINES = 50;
281
351
  function ToolCallDisplay({ toolCall, isStreaming, verbose, terminalColumns, showExpandHint = false, waitingApproval = false, compactTop = false, nowTick, }) {
352
+ const theme = useTheme();
353
+ if (toolCall.metadata?.kind === "subagent") {
354
+ return (_jsx(SubagentToolDisplay, { toolCall: toolCall, verbose: verbose, terminalColumns: terminalColumns, compactTop: compactTop }));
355
+ }
282
356
  // Show raw output immediately, then upgrade to highlighted ANSI when shiki
283
357
  // resolves. Avoids a noticeable "flash" where the line jumps from empty/raw
284
358
  // to colorized after a tick.
@@ -357,7 +431,25 @@ function ToolCallDisplay({ toolCall, isStreaming, verbose, terminalColumns, show
357
431
  const isWritePreview = toolCall.name === "write" && !toolCall.isError && toolCall.result !== undefined;
358
432
  return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: bulletColor, children: [glyph, " "] }), _jsx(Text, { bold: true, color: theme.toolName, children: name }), header && _jsxs(Text, { color: theme.muted, children: ["(", header, ")"] })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: summaryColor, children: ["\u23BF ", summary] }) }), toolCall.isError && toolCall.result && (_jsx(Box, { marginLeft: 4, flexDirection: "column", children: toolCall.result.replace(/\r\n/g, "\n").split("\n").slice(0, 6).map((line, i) => (_jsx(Text, { color: theme.error, children: line }, i))) })), isEditDiff && (_jsx(DiffBlock, { diff: editDetails.diff, terminalColumns: terminalColumns, maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint })), isWritePreview && (_jsx(WritePreview, { content: String(toolCall.args.content || ""), maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint })), !toolCall.isError && !isEditDiff && !isWritePreview && highlighted && (_jsx(OutputPreview, { text: highlighted, maxLines: maxLines, verbose: verbose, showExpandHint: showExpandHint }))] }));
359
433
  }
360
- function renderTruncationHint(remaining, verbose, showExpandHint) {
434
+ function SubagentToolDisplay({ toolCall, verbose, terminalColumns, compactTop, }) {
435
+ const theme = useTheme();
436
+ const subagents = subagentsFrom(toolCall);
437
+ const hasError = toolCall.isError || subagents.some((subagent) => (subagent.status === "failed" || subagent.status === "blocked" || subagent.status === "cancelled"));
438
+ const bulletColor = hasError ? theme.error : toolCall.result === undefined ? theme.toolPending : theme.user;
439
+ const detailWidth = Math.max(24, terminalColumns - 10);
440
+ const rows = verbose ? sortSubagents(subagents) : sortSubagents(subagents).slice(0, 4);
441
+ const omitted = Math.max(0, subagents.length - rows.length);
442
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: bulletColor, children: "\u21B3 " }), _jsx(Text, { bold: true, color: theme.toolName, children: "Subagents" }), subagents.length > 0 && _jsxs(Text, { color: theme.muted, children: [" ", subagentSummary(subagents)] })] }), rows.length > 0 && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [rows.map((subagent, index) => {
443
+ const status = subagent.status ?? "running";
444
+ const label = padVisual(truncateVisual(subagentLabel(subagent), 10), 10);
445
+ const descriptorWidth = verbose ? 42 : 32;
446
+ const descriptor = padVisual(truncateVisual(subagentDescriptor(subagent), descriptorWidth), descriptorWidth);
447
+ const note = truncateVisual(latestSubagentNote(subagent), Math.max(12, detailWidth - 16 - descriptorWidth - 10));
448
+ return (_jsxs(Box, { children: [_jsx(Text, { color: subagentStatusColor(status, theme), children: label }), _jsxs(Text, { color: theme.traceAction, children: [" ", descriptor] }), _jsxs(Text, { color: subagentStatusColor(status, theme), children: [" ", padVisual(status, 9)] }), note && _jsxs(Text, { color: subagent.error ? theme.error : theme.traceDetail, children: [" ", note] })] }, subagent.subAgentId ?? `${subagentLabel(subagent)}-${index}`));
449
+ }), omitted > 0 && (_jsxs(Text, { color: theme.muted, children: ["... ", omitted, " more, Ctrl+O to view"] }))] })), subagents.length === 0 && toolCall.result && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: hasError ? theme.error : theme.muted, children: summarizeToolResult(toolCall) }) }))] }));
450
+ }
451
+ function TruncationHint({ remaining, verbose, showExpandHint, }) {
452
+ const theme = useTheme();
361
453
  if (remaining <= 0)
362
454
  return null;
363
455
  const noun = `line${remaining === 1 ? "" : "s"}`;
@@ -367,19 +459,21 @@ function renderTruncationHint(remaining, verbose, showExpandHint) {
367
459
  return (_jsxs(Text, { color: theme.muted, children: ["\u2026 +", remaining, " ", noun, showExpandHint ? " (ctrl+o to expand)" : ""] }));
368
460
  }
369
461
  function OutputPreview({ text, maxLines, verbose, showExpandHint, }) {
462
+ const theme = useTheme();
370
463
  const lines = text.split("\n");
371
464
  const shown = lines.slice(0, maxLines);
372
465
  const remaining = Math.max(0, lines.length - maxLines);
373
466
  if (shown.length === 0 || (shown.length === 1 && shown[0] === ""))
374
467
  return null;
375
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 4, children: [shown.map((line, i) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "\u2502 " }), _jsx(Text, { children: line })] }, i))), renderTruncationHint(remaining, verbose, showExpandHint)] }));
468
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 4, children: [shown.map((line, i) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: "\u2502 " }), _jsx(Text, { children: line })] }, i))), _jsx(TruncationHint, { remaining: remaining, verbose: verbose, showExpandHint: showExpandHint })] }));
376
469
  }
377
470
  function WritePreview({ content, maxLines, verbose, showExpandHint, }) {
471
+ const theme = useTheme();
378
472
  const lines = content.split("\n");
379
473
  const shown = lines.slice(0, maxLines);
380
474
  const remaining = Math.max(0, lines.length - maxLines);
381
475
  const numWidth = Math.max(2, String(lines.length).length);
382
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 4, children: [shown.map((line, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: theme.muted, children: [String(i + 1).padStart(numWidth, " "), " "] }), _jsx(Text, { children: line })] }, i))), renderTruncationHint(remaining, verbose, showExpandHint)] }));
476
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 4, children: [shown.map((line, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: theme.muted, children: [String(i + 1).padStart(numWidth, " "), " "] }), _jsx(Text, { children: line })] }, i))), _jsx(TruncationHint, { remaining: remaining, verbose: verbose, showExpandHint: showExpandHint })] }));
383
477
  }
384
478
  function parseDiffLines(body) {
385
479
  const result = [];
@@ -420,6 +514,7 @@ function parseDiffLines(body) {
420
514
  return result;
421
515
  }
422
516
  function DiffBlock({ diff, terminalColumns, maxLines, verbose, showExpandHint, }) {
517
+ const theme = useTheme();
423
518
  const lines = parseDiffLines(diff);
424
519
  const shown = lines.slice(0, maxLines);
425
520
  const remaining = Math.max(0, lines.length - maxLines);
@@ -446,13 +541,14 @@ function DiffBlock({ diff, terminalColumns, maxLines, verbose, showExpandHint, }
446
541
  const padded = padVisual(truncated, contentWidth);
447
542
  const lineText = ` ${numStr} ${sign} ${padded}`;
448
543
  return (_jsx(Text, { backgroundColor: bg, color: theme.userMessageText, children: lineText }, i));
449
- }), renderTruncationHint(remaining, verbose, showExpandHint)] }));
544
+ }), _jsx(TruncationHint, { remaining: remaining, verbose: verbose, showExpandHint: showExpandHint })] }));
450
545
  }
451
546
  /**
452
547
  * "Edited 3 files (+42 -8) — a.ts, b.ts" digest below the assistant turn.
453
548
  * Surfaces only when there is at least one file-mutating tool call.
454
549
  */
455
550
  function TurnDigest({ toolCalls }) {
551
+ const theme = useTheme();
456
552
  const digest = React.useMemo(() => buildDigest(toolCalls), [toolCalls]);
457
553
  if (!digest)
458
554
  return null;
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useMemo } from "react";
3
3
  import { Box, Text, useInput, usePaste, useStdout } from "ink";
4
- import { theme } from "./theme.js";
4
+ import { useTheme } from "./theme.js";
5
5
  import { encodeModel, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
6
6
  import { listBuiltinModels } from "../model-catalog.js";
7
7
  export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
8
+ const theme = useTheme();
8
9
  const { stdout } = useStdout();
9
10
  const termHeight = stdout?.rows || 24;
10
11
  const maxVisible = Math.max(5, termHeight - 10);
@@ -122,6 +123,7 @@ export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
122
123
  })] })] }));
123
124
  }
124
125
  function SearchField({ query, placeholder }) {
126
+ const theme = useTheme();
125
127
  const [cursorVisible, setCursorVisible] = useState(true);
126
128
  useEffect(() => {
127
129
  const t = setInterval(() => setCursorVisible((v) => !v), 500);
@@ -189,6 +191,7 @@ function preferredModelIndex(options, current) {
189
191
  return idx >= 0 ? idx : 0;
190
192
  }
191
193
  export function ProviderPicker({ providers, current, onSelect, onCancel, title }) {
194
+ const theme = useTheme();
192
195
  const { stdout } = useStdout();
193
196
  const termHeight = stdout?.rows || 24;
194
197
  const maxVisible = Math.max(5, termHeight - 8);
@@ -240,6 +243,7 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title }
240
243
  }) })] }));
241
244
  }
242
245
  export function KeyPicker({ providerName, onSubmit, onCancel }) {
246
+ const theme = useTheme();
243
247
  const [value, setValue] = useState("");
244
248
  useInput((input, key) => {
245
249
  if (key.escape) {
@@ -270,6 +274,7 @@ export function KeyPicker({ providerName, onSubmit, onCancel }) {
270
274
  return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsxs(Text, { bold: true, color: theme.accent, children: ["Enter API Key for ", providerName] }), _jsx(Text, { color: theme.muted, children: "Paste or type the key \u00B7 Enter to submit \u00B7 Esc to cancel" }), _jsx(SearchField, { query: value.replace(/./g, "*"), placeholder: "Paste your key here..." })] }));
271
275
  }
272
276
  export function SkillPicker({ skills, onSelect, onCancel }) {
277
+ const theme = useTheme();
273
278
  const { stdout } = useStdout();
274
279
  const termHeight = stdout?.rows || 24;
275
280
  const maxVisible = Math.max(5, termHeight - 8);
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
- import { theme } from "./theme.js";
4
+ import { useTheme } from "./theme.js";
5
5
  import { MarkdownContent } from "./markdown.js";
6
6
  export function PlanConfirm({ initialPlan, onApprove, onReject }) {
7
+ const theme = useTheme();
7
8
  const [stage, setStage] = useState("view");
8
9
  const [draft, setDraft] = useState(initialPlan);
9
10
  const [cursor, setCursor] = useState(initialPlan.length);
@@ -1,8 +1,9 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useMemo, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
- import { theme } from "./theme.js";
4
+ import { useTheme } from "./theme.js";
5
5
  export function QuestionDialog({ request, onSubmit, onCancel }) {
6
+ const theme = useTheme();
6
7
  const [index, setIndex] = useState(0);
7
8
  const [selected, setSelected] = useState(0);
8
9
  const [custom, setCustom] = useState("");
@@ -11,6 +11,7 @@ import type { McpManager } from "../mcp/manager.js";
11
11
  import type { LspService } from "../lsp/index.js";
12
12
  import type { QuestionController } from "../question/index.js";
13
13
  import type { MemoryScope } from "../memory/index.js";
14
+ import type { ResolvedTheme, ThemeMode } from "./theme.js";
14
15
  export interface RunTuiOptions {
15
16
  sessionManager?: SessionManager;
16
17
  createProvider?: (providerId: string, apiKey: string, baseURL: string) => Provider;
@@ -23,7 +24,10 @@ export interface RunTuiOptions {
23
24
  settingsManager?: SettingsManager;
24
25
  lspService?: LspService;
25
26
  mcpManager?: McpManager;
26
- theme?: Record<string, string>;
27
+ themeMode?: ThemeMode;
28
+ themeOverrides?: Record<string, string>;
29
+ detectedTheme?: ResolvedTheme;
30
+ onThemeModeChange?: (mode: ThemeMode) => void;
27
31
  flushMemory?: () => Promise<void>;
28
32
  runMemoryCompaction?: () => Promise<string>;
29
33
  runMemorySummary?: (scope?: MemoryScope) => Promise<string>;
@@ -1,13 +1,22 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
+ import chalk from "chalk";
3
4
  import { App } from "./app.js";
5
+ import { warmHighlighter } from "./code-highlight.js";
4
6
  export async function runTui(agent, args, options = {}) {
5
- const instance = render(_jsx(App, { agent: agent, args: args, sessionManager: options.sessionManager, createProvider: options.createProvider, registry: options.registry, skillRegistry: options.skillRegistry, planHandlerRef: options.planHandlerRef, approvalHandlerRef: options.approvalHandlerRef, questionController: options.questionController, bashAllowlist: options.bashAllowlist, settingsManager: options.settingsManager, lspService: options.lspService, mcpManager: options.mcpManager, flushMemory: options.flushMemory, runMemoryCompaction: options.runMemoryCompaction, runMemorySummary: options.runMemorySummary, runMemoryRefresh: options.runMemoryRefresh, bypassEnabled: options.bypassEnabled, onExit: () => {
7
+ // Kick off shiki load before the first code block reaches <Static>. Fire and
8
+ // forget — CodeBlock's lazy init falls back to raw lines if this isn't ready
9
+ // yet, so callers don't need to await it.
10
+ warmHighlighter();
11
+ let exitSummary;
12
+ const instance = render(_jsx(App, { agent: agent, args: args, sessionManager: options.sessionManager, createProvider: options.createProvider, registry: options.registry, skillRegistry: options.skillRegistry, planHandlerRef: options.planHandlerRef, approvalHandlerRef: options.approvalHandlerRef, questionController: options.questionController, bashAllowlist: options.bashAllowlist, settingsManager: options.settingsManager, lspService: options.lspService, mcpManager: options.mcpManager, themeMode: options.themeMode, themeOverrides: options.themeOverrides, detectedTheme: options.detectedTheme, onThemeModeChange: options.onThemeModeChange, flushMemory: options.flushMemory, runMemoryCompaction: options.runMemoryCompaction, runMemorySummary: options.runMemorySummary, runMemoryRefresh: options.runMemoryRefresh, bypassEnabled: options.bypassEnabled, onExit: (summary) => {
6
13
  // The app already called useApp().exit() inside requestExit, which
7
14
  // triggers Ink's own unmount + TTY restore. waitUntilExit() below is
8
15
  // the canonical signal that we're done — we deliberately do *not*
9
16
  // call instance.unmount() again here to avoid double-teardown
10
- // warnings on React 19.
17
+ // warnings on React 19. We capture the summary and render it after
18
+ // teardown so it lands in the real shell scrollback (Claude-Code style).
19
+ exitSummary = summary;
11
20
  } }), {
12
21
  kittyKeyboard: {
13
22
  mode: "enabled",
@@ -21,5 +30,24 @@ export async function runTui(agent, args, options = {}) {
21
30
  // the cursor to column 0 before handing control back to the shell.
22
31
  if (process.stdout.isTTY) {
23
32
  process.stdout.write("\n");
33
+ if (exitSummary) {
34
+ process.stdout.write(formatExitSummary(exitSummary) + "\n");
35
+ }
24
36
  }
25
37
  }
38
+ function formatExitSummary(summary) {
39
+ const label = "Total duration (wall):";
40
+ return chalk.dim(`${label} ${formatWallMs(summary.wallMs)}`);
41
+ }
42
+ function formatWallMs(ms) {
43
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
44
+ if (totalSeconds < 60)
45
+ return `${totalSeconds}s`;
46
+ const minutes = Math.floor(totalSeconds / 60);
47
+ const seconds = totalSeconds % 60;
48
+ if (minutes < 60)
49
+ return `${minutes}m ${seconds}s`;
50
+ const hours = Math.floor(minutes / 60);
51
+ const minutesRest = minutes % 60;
52
+ return `${hours}h ${minutesRest}m ${seconds}s`;
53
+ }
@@ -1,37 +1,66 @@
1
1
  /**
2
- * Lightweight color theme for the TUI.
2
+ * Color themes for the TUI.
3
+ *
4
+ * Two base palettes are shipped: `darkTheme` for dark terminal backgrounds and
5
+ * `lightTheme` for light ones. The shape is identical so consumers depend on
6
+ * `Theme` rather than either palette directly. Active palette is provided
7
+ * through `ThemeContext` so components re-render automatically when the
8
+ * user switches via `/theme` at runtime.
3
9
  */
4
- export declare const theme: {
5
- readonly user: "green";
6
- readonly agent: "blue";
7
- readonly error: "red";
8
- readonly warning: "yellow";
9
- readonly success: "green";
10
- readonly accent: "cyan";
11
- readonly border: "gray";
12
- readonly borderActive: "cyan";
13
- readonly inputBorder: "#8A7FC6";
14
- readonly muted: "gray";
15
- readonly dim: "gray";
16
- readonly thinking: "magenta";
17
- readonly thinkingDim: "gray";
18
- readonly toolName: "cyan";
19
- readonly toolResult: "gray";
20
- readonly toolError: "red";
21
- readonly toolPending: "yellow";
22
- readonly code: "yellow";
23
- readonly traceAction: "#E89A6B";
24
- readonly traceCount: "#c9c1bd";
25
- readonly traceDetail: "gray";
26
- readonly traceCommand: "#59BCE8";
27
- readonly tracePending: "yellow";
28
- readonly userMessageBorder: "#8A7FC6";
29
- readonly userMessageBg: "#2a2a34";
30
- readonly userMessageText: "#f3f3f7";
31
- readonly userRail: "#8A7FC6";
32
- readonly diffAdd: "#1a3d1a";
33
- readonly diffRemove: "#3d1a1a";
34
- readonly diffAddFg: "#9CDCFE";
35
- readonly diffRemoveFg: "#F48771";
36
- };
37
- export type ThemeColor = (typeof theme)[keyof typeof theme];
10
+ export type ResolvedTheme = "light" | "dark";
11
+ export type ThemeMode = "auto" | ResolvedTheme;
12
+ export interface Theme {
13
+ user: string;
14
+ agent: string;
15
+ error: string;
16
+ warning: string;
17
+ success: string;
18
+ accent: string;
19
+ border: string;
20
+ borderActive: string;
21
+ inputBorder: string;
22
+ inputBorderDisabled: string;
23
+ inputBg: string;
24
+ inputBgDisabled: string;
25
+ inputText: string;
26
+ inputPlaceholder: string;
27
+ muted: string;
28
+ dim: string;
29
+ thinking: string;
30
+ thinkingDim: string;
31
+ toolName: string;
32
+ toolResult: string;
33
+ toolError: string;
34
+ toolPending: string;
35
+ code: string;
36
+ traceAction: string;
37
+ traceCount: string;
38
+ traceDetail: string;
39
+ traceCommand: string;
40
+ tracePending: string;
41
+ userMessageBorder: string;
42
+ userMessageBg: string;
43
+ userMessageText: string;
44
+ userRail: string;
45
+ diffAdd: string;
46
+ diffRemove: string;
47
+ diffAddFg: string;
48
+ diffRemoveFg: string;
49
+ }
50
+ export declare const darkTheme: Theme;
51
+ /**
52
+ * Light palette. Two ground rules drove the color choices:
53
+ * 1. Named ANSI colors that render OK on both backgrounds (red/green/blue)
54
+ * are kept by name so the user's terminal palette overrides remain
55
+ * effective.
56
+ * 2. Specific hex values are used wherever the dark palette assumed a dark
57
+ * background (notably accent/code/trace colors and message bubbles).
58
+ * Each hex was picked to clear WCAG AA contrast (4.5:1) against a near-
59
+ * white background (#fafafa) or, when applicable, against the explicit
60
+ * surface color in the same palette (e.g. diffAddFg vs diffAdd).
61
+ */
62
+ export declare const lightTheme: Theme;
63
+ export declare const ThemeProvider: import("react").Provider<Theme>;
64
+ export declare function useTheme(): Theme;
65
+ /** Build the active palette given a resolved mode and optional overrides. */
66
+ export declare function paletteFor(mode: ResolvedTheme, overrides?: Record<string, string>): Theme;
@@ -1,21 +1,30 @@
1
1
  /**
2
- * Lightweight color theme for the TUI.
2
+ * Color themes for the TUI.
3
+ *
4
+ * Two base palettes are shipped: `darkTheme` for dark terminal backgrounds and
5
+ * `lightTheme` for light ones. The shape is identical so consumers depend on
6
+ * `Theme` rather than either palette directly. Active palette is provided
7
+ * through `ThemeContext` so components re-render automatically when the
8
+ * user switches via `/theme` at runtime.
3
9
  */
4
- export const theme = {
5
- // Actors
10
+ import { createContext, useContext } from "react";
11
+ export const darkTheme = {
6
12
  user: "green",
7
13
  agent: "blue",
8
14
  error: "red",
9
15
  warning: "yellow",
10
16
  success: "green",
11
- // UI chrome
12
17
  accent: "cyan",
13
18
  border: "gray",
14
19
  borderActive: "cyan",
15
20
  inputBorder: "#8A7FC6",
21
+ inputBorderDisabled: "#4a4754",
22
+ inputBg: "#1c1c24",
23
+ inputBgDisabled: "#161620",
24
+ inputText: "#f3f3f7",
25
+ inputPlaceholder: "#6c6a78",
16
26
  muted: "gray",
17
27
  dim: "gray",
18
- // Content
19
28
  thinking: "magenta",
20
29
  thinkingDim: "gray",
21
30
  toolName: "cyan",
@@ -28,15 +37,79 @@ export const theme = {
28
37
  traceDetail: "gray",
29
38
  traceCommand: "#59BCE8",
30
39
  tracePending: "yellow",
31
- // Message surfaces — user input uses a subtle fill plus a left rail so it is
32
- // visually separate from assistant/tool trace output without becoming noisy.
33
40
  userMessageBorder: "#8A7FC6",
34
41
  userMessageBg: "#2a2a34",
35
42
  userMessageText: "#f3f3f7",
36
43
  userRail: "#8A7FC6",
37
- // Diff
38
44
  diffAdd: "#1a3d1a",
39
45
  diffRemove: "#3d1a1a",
40
46
  diffAddFg: "#9CDCFE",
41
47
  diffRemoveFg: "#F48771",
42
48
  };
49
+ /**
50
+ * Light palette. Two ground rules drove the color choices:
51
+ * 1. Named ANSI colors that render OK on both backgrounds (red/green/blue)
52
+ * are kept by name so the user's terminal palette overrides remain
53
+ * effective.
54
+ * 2. Specific hex values are used wherever the dark palette assumed a dark
55
+ * background (notably accent/code/trace colors and message bubbles).
56
+ * Each hex was picked to clear WCAG AA contrast (4.5:1) against a near-
57
+ * white background (#fafafa) or, when applicable, against the explicit
58
+ * surface color in the same palette (e.g. diffAddFg vs diffAdd).
59
+ */
60
+ export const lightTheme = {
61
+ user: "green",
62
+ agent: "blue",
63
+ error: "red",
64
+ warning: "#9A6500", // ANSI yellow is invisible on white — go to dark amber.
65
+ success: "green",
66
+ accent: "#0E5A85", // dark teal — replaces "cyan" which washes out on white.
67
+ border: "gray",
68
+ borderActive: "#0E5A85",
69
+ inputBorder: "#6B5FB8",
70
+ inputBorderDisabled: "#c5c3d0",
71
+ inputBg: "#f5f5fa",
72
+ inputBgDisabled: "#ebebf2",
73
+ inputText: "#1c1c24",
74
+ inputPlaceholder: "#7a7886",
75
+ muted: "gray",
76
+ dim: "gray",
77
+ thinking: "magenta",
78
+ thinkingDim: "gray",
79
+ toolName: "#0E5A85",
80
+ toolResult: "gray",
81
+ toolError: "red",
82
+ toolPending: "#9A6500",
83
+ code: "#9A6500",
84
+ traceAction: "#B85A20",
85
+ traceCount: "#5a5a5a",
86
+ traceDetail: "gray",
87
+ traceCommand: "#1A5FA0",
88
+ tracePending: "#9A6500",
89
+ userMessageBorder: "#6B5FB8",
90
+ userMessageBg: "#e8e6f4",
91
+ userMessageText: "#1c1c24",
92
+ userRail: "#6B5FB8",
93
+ diffAdd: "#d4f4d4",
94
+ diffRemove: "#f4d4d4",
95
+ diffAddFg: "#1c1c24",
96
+ diffRemoveFg: "#1c1c24",
97
+ };
98
+ const ThemeContext = createContext(darkTheme);
99
+ export const ThemeProvider = ThemeContext.Provider;
100
+ export function useTheme() {
101
+ return useContext(ThemeContext);
102
+ }
103
+ /** Build the active palette given a resolved mode and optional overrides. */
104
+ export function paletteFor(mode, overrides) {
105
+ const base = mode === "light" ? lightTheme : darkTheme;
106
+ if (!overrides)
107
+ return base;
108
+ const filtered = {};
109
+ for (const [key, value] of Object.entries(overrides)) {
110
+ if (typeof value === "string" && key in base) {
111
+ filtered[key] = value;
112
+ }
113
+ }
114
+ return { ...base, ...filtered };
115
+ }