@bubblebrain-ai/bubble 0.0.28 → 0.0.29

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 (59) hide show
  1. package/README.md +21 -0
  2. package/dist/agent/categories.d.ts +2 -0
  3. package/dist/agent/categories.js +4 -0
  4. package/dist/agent/child-runner.d.ts +5 -1
  5. package/dist/agent/child-runner.js +35 -2
  6. package/dist/agent/profiles.js +3 -0
  7. package/dist/agent/structured-output.d.ts +37 -0
  8. package/dist/agent/structured-output.js +193 -0
  9. package/dist/agent/subagent-control.d.ts +3 -0
  10. package/dist/agent/subagent-scheduler.d.ts +10 -0
  11. package/dist/agent/subagent-scheduler.js +31 -0
  12. package/dist/agent/workflow/control.d.ts +37 -0
  13. package/dist/agent/workflow/control.js +20 -0
  14. package/dist/agent/workflow/errors.d.ts +16 -0
  15. package/dist/agent/workflow/errors.js +24 -0
  16. package/dist/agent/workflow/runtime.d.ts +75 -0
  17. package/dist/agent/workflow/runtime.js +237 -0
  18. package/dist/agent.d.ts +105 -0
  19. package/dist/agent.js +425 -17
  20. package/dist/context/compact-llm.d.ts +10 -1
  21. package/dist/context/compact-llm.js +13 -5
  22. package/dist/context/compact.d.ts +30 -0
  23. package/dist/context/compact.js +34 -17
  24. package/dist/network/provider-transport.d.ts +9 -0
  25. package/dist/network/provider-transport.js +19 -1
  26. package/dist/provider.d.ts +14 -0
  27. package/dist/provider.js +24 -0
  28. package/dist/session.d.ts +16 -0
  29. package/dist/session.js +33 -1
  30. package/dist/slash-commands/commands.js +47 -1
  31. package/dist/slash-commands/types.d.ts +16 -1
  32. package/dist/tools/agent-lifecycle.d.ts +6 -0
  33. package/dist/tools/agent-lifecycle.js +285 -0
  34. package/dist/tools/child-tools.d.ts +10 -0
  35. package/dist/tools/child-tools.js +12 -0
  36. package/dist/tools/read.d.ts +1 -1
  37. package/dist/tools/read.js +9 -0
  38. package/dist/tui/image-display.d.ts +6 -0
  39. package/dist/tui/image-display.js +26 -1
  40. package/dist/tui-ink/app.js +84 -6
  41. package/dist/tui-ink/compaction-progress.d.ts +19 -0
  42. package/dist/tui-ink/compaction-progress.js +74 -0
  43. package/dist/tui-ink/input-box.d.ts +7 -1
  44. package/dist/tui-ink/input-box.js +48 -15
  45. package/dist/tui-ink/markdown.d.ts +18 -0
  46. package/dist/tui-ink/markdown.js +172 -16
  47. package/dist/tui-ink/message-list.js +38 -94
  48. package/dist/tui-ink/run.js +5 -0
  49. package/dist/tui-ink/subagent-inspector.d.ts +17 -0
  50. package/dist/tui-ink/subagent-inspector.js +189 -0
  51. package/dist/tui-ink/subagent-view.d.ts +47 -0
  52. package/dist/tui-ink/subagent-view.js +163 -0
  53. package/dist/tui-ink/terminal-env.d.ts +15 -0
  54. package/dist/tui-ink/terminal-env.js +22 -0
  55. package/dist/tui-ink/use-terminal-size.js +33 -6
  56. package/dist/tui-ink/width.d.ts +18 -0
  57. package/dist/tui-ink/width.js +130 -0
  58. package/dist/types.d.ts +35 -0
  59. package/package.json +2 -1
@@ -4,10 +4,11 @@ import { Box, Static, Text, measureElement } from "ink";
4
4
  import { useTheme } from "./theme.js";
5
5
  import { highlightCode, inferLang } from "./code-highlight.js";
6
6
  import { MarkdownContent, StreamingMarkdown } from "./markdown.js";
7
+ import { visualWidth, graphemeWidth, ambiguousIsWide } from "./width.js";
7
8
  import { userInputStatusBadgeLabel, } from "./display-history.js";
8
9
  import { buildTraceGroups, executeCommandBlock, formatTracePath, shouldInlineExecuteCommand, traceGroupLabel, } from "./trace-groups.js";
9
10
  import { EDIT_COLLAPSED_DIFF_LINES, formatEditSuccessSummary, getEditDiffDetails } from "./edit-diff.js";
10
- import { formatSubagentRoute } from "../agent/subagent-route-format.js";
11
+ import { latestSubagentNote, sortSubagents, subagentDescriptor, subagentLabel, subagentStatusColor, subagentSummary, } from "./subagent-view.js";
11
12
  import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
12
13
  import { splitImageDisplayContent } from "../tui/image-display.js";
13
14
  const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
@@ -116,10 +117,26 @@ const MessageItem = React.memo(function MessageItem({ message, terminalColumns,
116
117
  // Same defense as reasoning: strip any internal reminder markup the model
117
118
  // echoed back into its visible answer so it never reaches the transcript.
118
119
  const visibleContent = sanitizeInternalReminderBlocks(message.content ?? "");
119
- const hasVisibleAssistantContent = !!visibleContent.trim() ||
120
- (message.toolCalls?.length ?? 0) > 0 ||
121
- (message.parts?.length ?? 0) > 0 ||
122
- (!!visibleReasoning && (showThinking || verboseTrace));
120
+ // Decide visibility by what will ACTUALLY render below, not by raw array
121
+ // lengths. A turn whose only text part is an echoed <bubble_internal_*>
122
+ // reminder sanitizes to empty (TimelineText returns null), but the wrapper's
123
+ // marginTop/marginBottom would still emit a blank band — and long sessions
124
+ // inject more reminders, so consecutive empty turns stack into a large gap
125
+ // after the tool rows. Mirror the render: parts path → MessageParts; non-parts
126
+ // path → toolCalls/visibleContent; verbose adds a TurnDigest from toolCalls.
127
+ const hasParts = (message.parts?.length ?? 0) > 0;
128
+ const hasVisibleParts = hasParts &&
129
+ (message.parts ?? []).some((part) => part.type === "tools"
130
+ ? part.toolCalls.length > 0
131
+ : sanitizeInternalReminderBlocks(part.content).trim() !== "");
132
+ const toolCallCount = message.toolCalls?.length ?? 0;
133
+ const hasVisibleAssistantContent = (hasParts ? hasVisibleParts : (!!visibleContent.trim() || toolCallCount > 0)) ||
134
+ (!!visibleReasoning && (showThinking || verboseTrace)) ||
135
+ // A finalized turn carries taskElapsedMs and renders a TaskDurationLine even
136
+ // when its text/parts are empty — mirror that so the duration isn't dropped.
137
+ // (The verbose TurnDigest needs no term: whenever it renders, the same
138
+ // toolCalls already make the turn visible via the parts/non-parts branch.)
139
+ message.taskElapsedMs !== undefined;
123
140
  if (!hasVisibleAssistantContent)
124
141
  return null;
125
142
  return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && (showThinking || verboseTrace) && _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), visibleContent.trim() && _jsx(MarkdownContent, { content: visibleContent })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
@@ -167,10 +184,14 @@ function TimelineText({ content, compactTop, terminalColumns, streaming = false,
167
184
  const visible = sanitizeInternalReminderBlocks(content);
168
185
  if (!visible.trim())
169
186
  return null;
170
- // marginLeft (2) + "● " marker (3 visual cells) = 5 cells consumed by the
171
- // timeline gutter; pass the remaining width so wide blocks like tables size
172
- // themselves against the actual content area instead of the raw terminal.
173
- const available = terminalColumns ? Math.max(20, terminalColumns - 5) : undefined;
187
+ // Timeline gutter = marginLeft (2) + "● " marker. The (U+25CF) is itself
188
+ // an ambiguous-width glyph, so the marker is 3 cells on a narrow terminal but
189
+ // 4 on an ambiguous-wide one Ink lays it out as 3 either way (it measures
190
+ // ●=1), so on a wide terminal the first line of every message gets shoved 1
191
+ // cell right and would overflow. Reserve that extra cell up front so the
192
+ // pre-wrap never packs a line the terminal then hard-wraps.
193
+ const gutter = ambiguousIsWide() ? 6 : 5;
194
+ const available = terminalColumns ? Math.max(20, terminalColumns - gutter) : undefined;
174
195
  const trimmed = visible.trim();
175
196
  return (_jsxs(Box, { marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsx(Text, { color: theme.agent, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: streaming ? (_jsx(StreamingMarkdown, { content: trimmed, maxWidth: available })) : (_jsx(MarkdownContent, { content: trimmed, maxWidth: available })) })] }));
176
197
  }
@@ -300,7 +321,11 @@ function CompactionSummaryBlock({ message }) {
300
321
  const theme = useTheme();
301
322
  const rawStatus = message.content.replace(/^✓\s*/, "").trim();
302
323
  const status = rawStatus.replace(/^Compaction complete\s*(?:·\s*)?/i, "").trim() || "Session compacted";
303
- const summary = message.compactionSummary?.trim();
324
+ // Same defense as every other visible-text path: strip any internal reminder
325
+ // markup before rendering, so a summary that echoed it never reaches the
326
+ // transcript. Belt-and-suspenders — the summarizer is fed sanitized history,
327
+ // but the summary is model-generated and also re-injected as context.
328
+ const summary = sanitizeInternalReminderBlocks(message.compactionSummary ?? "").trim() || undefined;
304
329
  return (_jsxs(Box, { marginTop: 1, marginBottom: 1, paddingX: 1, flexDirection: "column", borderStyle: "round", borderColor: theme.borderActive, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.success, bold: true, children: "\u2713 " }), _jsx(Text, { color: theme.accent, bold: true, children: "Compaction checkpoint" }), _jsxs(Text, { color: theme.muted, children: [" \u00B7 ", status] })] }), summary && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.muted, dimColor: true, children: "Preserved context summary" }), _jsx(Box, { paddingLeft: 2, flexDirection: "column", children: _jsx(MarkdownContent, { content: summary }) })] }))] }));
305
330
  }
306
331
  function UserMessageBlock({ content, terminalColumns, inputStatus, separateFromPrevious = false, }) {
@@ -427,59 +452,6 @@ function subagentsFrom(toolCall) {
427
452
  return [];
428
453
  return raw.filter((item) => typeof item === "object" && item !== null);
429
454
  }
430
- function latestSubagentNote(subagent) {
431
- const note = subagent.error
432
- || subagent.toolNotes?.filter(Boolean).at(-1)
433
- || subagent.summary
434
- || subagent.task
435
- || "";
436
- return note.replace(/\r\n/g, "\n").split("\n").map((line) => line.trim()).find(Boolean) ?? "";
437
- }
438
- function subagentLabel(subagent) {
439
- return subagent.nickname ?? subagent.agentName ?? "subagent";
440
- }
441
- function subagentRole(subagent) {
442
- return [subagent.agentName, subagent.category ? `/${subagent.category}` : ""].join("") || "default";
443
- }
444
- function subagentDescriptor(subagent, includeThinking = false) {
445
- const route = formatSubagentRoute(subagent.route, { includeThinking });
446
- const role = subagentRole(subagent);
447
- return route ? `${role} @ ${route}` : role;
448
- }
449
- function subagentStatusColor(status, theme) {
450
- if (status === "completed")
451
- return theme.success;
452
- if (status === "failed" || status === "blocked" || status === "cancelled")
453
- return theme.error;
454
- if (status === "queued")
455
- return theme.muted;
456
- return theme.toolPending;
457
- }
458
- function subagentSummary(subagents) {
459
- if (subagents.length === 0)
460
- return "no subagents";
461
- const counts = new Map();
462
- for (const subagent of subagents) {
463
- const status = subagent.status ?? "running";
464
- counts.set(status, (counts.get(status) ?? 0) + 1);
465
- }
466
- const order = ["running", "queued", "completed", "blocked", "failed", "cancelled"];
467
- return order
468
- .filter((status) => counts.has(status))
469
- .map((status) => `${counts.get(status)} ${status}`)
470
- .join(" ");
471
- }
472
- function sortSubagents(subagents) {
473
- const rank = {
474
- running: 0,
475
- blocked: 1,
476
- failed: 2,
477
- queued: 3,
478
- cancelled: 4,
479
- completed: 5,
480
- };
481
- return [...subagents].sort((a, b) => (rank[a.status ?? "running"] ?? 9) - (rank[b.status ?? "running"] ?? 9));
482
- }
483
455
  const COLLAPSED_PREVIEW_LINES = 10;
484
456
  const EXPANDED_PREVIEW_LINES = 50;
485
457
  function ToolCallDisplay({ toolCall, isStreaming, verbose, terminalColumns, showExpandHint = false, waitingApproval = false, compactTop = false, nowTick, }) {
@@ -581,7 +553,7 @@ function SubagentToolDisplay({ toolCall, verbose, terminalColumns, compactTop, }
581
553
  const descriptor = padVisual(truncateVisual(subagentDescriptor(subagent), descriptorWidth), descriptorWidth);
582
554
  const note = truncateVisual(latestSubagentNote(subagent), Math.max(12, detailWidth - 16 - descriptorWidth - 10));
583
555
  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}`));
584
- }), 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) }) }))] }));
556
+ }), omitted > 0 && (_jsxs(Text, { color: theme.muted, children: ["... ", omitted, " more \u00B7 Ctrl+O to expand \u00B7 Ctrl+G to inspect traces"] }))] })), subagents.length === 0 && toolCall.result && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: hasError ? theme.error : theme.muted, children: summarizeToolResult(toolCall) }) }))] }));
585
557
  }
586
558
  function TruncationHint({ remaining, verbose, showExpandHint, }) {
587
559
  const theme = useTheme();
@@ -766,7 +738,7 @@ function truncateVisual(str, maxWidth) {
766
738
  let out = "";
767
739
  let width = 0;
768
740
  for (const char of str) {
769
- const w = charVisualWidth(char);
741
+ const w = graphemeWidth(char);
770
742
  if (width + w > maxWidth)
771
743
  break;
772
744
  out += char;
@@ -774,38 +746,10 @@ function truncateVisual(str, maxWidth) {
774
746
  }
775
747
  return out;
776
748
  }
777
- function visualWidth(str) {
778
- let width = 0;
779
- for (const char of str) {
780
- const code = char.codePointAt(0) || 0;
781
- if ((code >= 0x4e00 && code <= 0x9fff) ||
782
- (code >= 0x3000 && code <= 0x303f) ||
783
- (code >= 0xff00 && code <= 0xffef) ||
784
- (code >= 0x3040 && code <= 0x309f) ||
785
- (code >= 0x30a0 && code <= 0x30ff)) {
786
- width += 2;
787
- }
788
- else {
789
- width += 1;
790
- }
791
- }
792
- return width;
793
- }
794
749
  function padVisual(str, width) {
795
750
  const currentWidth = visualWidth(str);
796
751
  return str + " ".repeat(Math.max(0, width - currentWidth));
797
752
  }
798
- function charVisualWidth(char) {
799
- const code = char.codePointAt(0) || 0;
800
- if ((code >= 0x4e00 && code <= 0x9fff) ||
801
- (code >= 0x3000 && code <= 0x303f) ||
802
- (code >= 0xff00 && code <= 0xffef) ||
803
- (code >= 0x3040 && code <= 0x309f) ||
804
- (code >= 0x30a0 && code <= 0x30ff)) {
805
- return 2;
806
- }
807
- return 1;
808
- }
809
753
  function wrapByVisualWidth(line, maxWidth) {
810
754
  if (maxWidth <= 0)
811
755
  return [line];
@@ -815,7 +759,7 @@ function wrapByVisualWidth(line, maxWidth) {
815
759
  let current = "";
816
760
  let currentWidth = 0;
817
761
  for (const char of line) {
818
- const w = charVisualWidth(char);
762
+ const w = graphemeWidth(char);
819
763
  if (currentWidth + w > maxWidth) {
820
764
  result.push(current);
821
765
  current = char;
@@ -28,6 +28,11 @@ export async function runTui(agent, args, options = {}) {
28
28
  // forget — CodeBlock's lazy init falls back to raw lines if this isn't ready
29
29
  // yet, so callers don't need to await it.
30
30
  warmHighlighter();
31
+ // NOTE: the CSI 6n ambiguous-width probe is intentionally NOT run here. Doing
32
+ // raw-mode stdin I/O before Ink mounts left stdin in a state Bun's TTY compat
33
+ // didn't hand cleanly back to Ink, swallowing all composer keystrokes. The
34
+ // verdict now comes from `BUBBLE_AMBIGUOUS_WIDTH` / locale only (see width.ts);
35
+ // a safe Ink-mounted probe can be reintroduced later behind a flag.
31
36
  let exitSummary;
32
37
  const onFatalError = (err) => {
33
38
  restoreTerminal();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Full-screen subagent inspector (Ctrl+G / /agents).
3
+ *
4
+ * Two-level drill-in modeled on Claude Code's workflow view: a grouped list of
5
+ * subagents (each spawn_agent is one member; each agent_team/agent_batch is a
6
+ * group of members) → a per-member working-trace detail (its task, every tool
7
+ * step it ran, and its final summary/error). Data is live: app.tsx derives the
8
+ * groups from the message state each render, so the inspector reflects running
9
+ * members as their events stream in.
10
+ */
11
+ import { type SubagentGroup } from "./subagent-view.js";
12
+ export type { SubagentGroup };
13
+ export interface SubagentInspectorProps {
14
+ groups: SubagentGroup[];
15
+ onCancel: () => void;
16
+ }
17
+ export declare function SubagentInspector({ groups, onCancel }: SubagentInspectorProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,189 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Full-screen subagent inspector (Ctrl+G / /agents).
4
+ *
5
+ * Two-level drill-in modeled on Claude Code's workflow view: a grouped list of
6
+ * subagents (each spawn_agent is one member; each agent_team/agent_batch is a
7
+ * group of members) → a per-member working-trace detail (its task, every tool
8
+ * step it ran, and its final summary/error). Data is live: app.tsx derives the
9
+ * groups from the message state each render, so the inspector reflects running
10
+ * members as their events stream in.
11
+ */
12
+ import { useMemo, useState } from "react";
13
+ import { Box, Text, useInput, useStdout } from "ink";
14
+ import { isKeyReleaseEvent } from "./key-events.js";
15
+ import { useTheme } from "./theme.js";
16
+ import { padVisual, truncateVisual } from "../text-display.js";
17
+ import { latestSubagentNote, subagentDescriptor, subagentLabel, subagentStatusColor, subagentSummary, } from "./subagent-view.js";
18
+ const STATUS_FILTERS = [null, "running", "queued", "completed", "failed"];
19
+ export function SubagentInspector({ groups, onCancel }) {
20
+ const theme = useTheme();
21
+ const { stdout } = useStdout();
22
+ const termHeight = stdout?.rows || 24;
23
+ const termWidth = stdout?.columns || 80;
24
+ const maxVisible = Math.max(6, termHeight - 12);
25
+ const [view, setView] = useState("list");
26
+ const [selectedIdx, setSelectedIdx] = useState(0);
27
+ const [detailScroll, setDetailScroll] = useState(0);
28
+ const [filterIdx, setFilterIdx] = useState(0);
29
+ const statusFilter = STATUS_FILTERS[filterIdx];
30
+ const allMembers = useMemo(() => groups.flatMap((g) => g.members), [groups]);
31
+ // Flat row list: a header per multi-member group, then its (filtered) members.
32
+ const rows = useMemo(() => {
33
+ const out = [];
34
+ for (const group of groups) {
35
+ const members = statusFilter
36
+ ? group.members.filter((m) => (m.status ?? "running") === statusFilter)
37
+ : group.members;
38
+ if (members.length === 0)
39
+ continue;
40
+ if (group.kind !== "single")
41
+ out.push({ type: "header", group });
42
+ members.forEach((member, i) => {
43
+ out.push({ type: "member", group, member, key: member.subAgentId ?? `${group.id}:${i}` });
44
+ });
45
+ }
46
+ return out;
47
+ }, [groups, statusFilter]);
48
+ const memberRowIndices = useMemo(() => rows.map((row, i) => (row.type === "member" ? i : -1)).filter((i) => i >= 0), [rows]);
49
+ const clampedIdx = memberRowIndices.length === 0 ? 0 : Math.min(selectedIdx, memberRowIndices.length - 1);
50
+ const selectedRowIndex = memberRowIndices[clampedIdx] ?? -1;
51
+ const selectedRow = rows[selectedRowIndex];
52
+ const selectedMember = selectedRow?.type === "member" ? selectedRow.member : undefined;
53
+ useInput((input, key) => {
54
+ if (isKeyReleaseEvent(key))
55
+ return;
56
+ if (view === "detail") {
57
+ if (key.escape || key.leftArrow) {
58
+ setView("list");
59
+ return;
60
+ }
61
+ if (key.upArrow || input === "k") {
62
+ setDetailScroll((s) => Math.max(0, s - 1));
63
+ return;
64
+ }
65
+ if (key.downArrow || input === "j") {
66
+ setDetailScroll((s) => s + 1);
67
+ return;
68
+ }
69
+ return;
70
+ }
71
+ // list view
72
+ if (key.escape) {
73
+ onCancel();
74
+ return;
75
+ }
76
+ if (input === "f") {
77
+ setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
78
+ setSelectedIdx(0);
79
+ return;
80
+ }
81
+ if (key.upArrow) {
82
+ setSelectedIdx((i) => Math.max(0, i - 1));
83
+ return;
84
+ }
85
+ if (key.downArrow) {
86
+ setSelectedIdx((i) => Math.min(Math.max(0, memberRowIndices.length - 1), i + 1));
87
+ return;
88
+ }
89
+ if ((key.return || key.rightArrow) && selectedMember) {
90
+ setDetailScroll(0);
91
+ setView("detail");
92
+ return;
93
+ }
94
+ });
95
+ if (groups.length === 0) {
96
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Subagents" }), _jsx(Text, { color: theme.muted, children: "No subagents have been spawned yet. Esc to close." })] }));
97
+ }
98
+ if (view === "detail" && selectedMember) {
99
+ return (_jsx(SubagentDetail, { member: selectedMember, group: selectedRow?.type === "member" ? selectedRow.group : undefined, scroll: detailScroll, maxVisible: maxVisible, termWidth: termWidth }));
100
+ }
101
+ // ---- list view ----
102
+ const start = clampWindowStart(rows, selectedRowIndex, maxVisible);
103
+ const visible = rows.slice(start, start + maxVisible);
104
+ const labelWidth = 12;
105
+ const descriptorWidth = Math.max(20, Math.min(46, termWidth - 48));
106
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Subagents \u00B7 working traces" }), _jsxs(Text, { color: theme.muted, children: [allMembers.length, " member", allMembers.length === 1 ? "" : "s", " \u00B7 ", subagentSummary(allMembers), statusFilter ? ` · filter: ${statusFilter}` : ""] }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 select \u00B7 Enter/\u2192 open trace \u00B7 f filter status \u00B7 Esc close" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [rows.length === 0 && (_jsx(Text, { color: theme.muted, children: "No members match the current filter." })), visible.map((row, i) => {
107
+ const actualIndex = start + i;
108
+ if (row.type === "header") {
109
+ return (_jsx(Box, { marginTop: i === 0 ? 0 : 1, children: _jsxs(Text, { bold: true, color: theme.muted, children: ["\u25A6 ", row.group.kind, " \u00B7 ", truncateVisual(row.group.label, termWidth - 18), " (", row.group.members.length, ")"] }) }, `h-${actualIndex}`));
110
+ }
111
+ const member = row.member;
112
+ const status = member.status ?? "running";
113
+ const isSelected = actualIndex === selectedRowIndex;
114
+ const note = truncateVisual(latestSubagentNote(member), Math.max(12, termWidth - labelWidth - descriptorWidth - 18));
115
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? theme.accent : undefined, children: isSelected ? "> " : " " }), _jsx(Text, { color: subagentStatusColor(status, theme), children: padVisual(truncateVisual(subagentLabel(member), labelWidth), labelWidth) }), _jsxs(Text, { color: theme.traceAction, children: [" ", padVisual(truncateVisual(subagentDescriptor(member), descriptorWidth), descriptorWidth)] }), _jsxs(Text, { color: subagentStatusColor(status, theme), children: [" ", padVisual(status, 9)] }), note && _jsxs(Text, { color: member.error ? theme.error : theme.traceDetail, children: [" ", note] })] }, row.key));
116
+ })] })] }));
117
+ }
118
+ function SubagentDetail({ member, group, scroll, maxVisible, termWidth, }) {
119
+ const theme = useTheme();
120
+ const status = member.status ?? "running";
121
+ const wrapWidth = Math.max(20, termWidth - 6);
122
+ // Build the scrollable body: task → working trace (every tool step) → summary/error.
123
+ const body = [];
124
+ if (member.task) {
125
+ body.push({ text: "Task", color: theme.muted });
126
+ for (const line of wrapText(member.task, wrapWidth))
127
+ body.push({ text: ` ${line}` });
128
+ body.push({ text: "" });
129
+ }
130
+ body.push({ text: `Working trace (${member.toolNotes?.length ?? 0} steps)`, color: theme.muted });
131
+ const notes = member.toolNotes?.filter(Boolean) ?? [];
132
+ if (notes.length === 0) {
133
+ body.push({ text: " (no tool steps recorded yet)", dim: true });
134
+ }
135
+ else {
136
+ notes.forEach((noteRaw, i) => {
137
+ const note = noteRaw.replace(/\r\n/g, "\n").split("\n").map((l) => l.trim()).filter(Boolean).join(" ");
138
+ const wrapped = wrapText(`${String(i + 1).padStart(2, " ")}. ${note}`, wrapWidth);
139
+ wrapped.forEach((line, j) => body.push({ text: ` ${j === 0 ? line : ` ${line}`}`, color: theme.traceDetail }));
140
+ });
141
+ }
142
+ if (member.error) {
143
+ body.push({ text: "" });
144
+ body.push({ text: "Error", color: theme.error });
145
+ for (const line of wrapText(member.error, wrapWidth))
146
+ body.push({ text: ` ${line}`, color: theme.error });
147
+ }
148
+ else if (member.summary) {
149
+ body.push({ text: "" });
150
+ body.push({ text: "Summary", color: theme.muted });
151
+ for (const line of wrapText(member.summary, wrapWidth))
152
+ body.push({ text: ` ${line}` });
153
+ }
154
+ const maxScroll = Math.max(0, body.length - maxVisible);
155
+ const clampedScroll = Math.min(scroll, maxScroll);
156
+ const visible = body.slice(clampedScroll, clampedScroll + maxVisible);
157
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.accent, children: subagentLabel(member) }), _jsxs(Text, { color: theme.traceAction, children: [" ", subagentDescriptor(member, true)] }), _jsxs(Text, { color: subagentStatusColor(status, theme), children: [" ", status] }), group && group.kind !== "single" && _jsxs(Text, { color: theme.muted, children: [" \u00B7 ", group.kind, " \u201C", truncateVisual(group.label, 28), "\u201D"] })] }), _jsxs(Text, { color: theme.muted, children: ["\u2191/\u2193 or j/k scroll \u00B7 \u2190/Esc back", maxScroll > 0 ? ` · ${clampedScroll + 1}-${Math.min(clampedScroll + maxVisible, body.length)}/${body.length}` : ""] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visible.map((line, i) => (_jsx(Text, { color: line.color, dimColor: line.dim, children: line.text || " " }, i))) })] }));
158
+ }
159
+ function wrapText(text, width) {
160
+ const out = [];
161
+ for (const rawLine of text.replace(/\r\n/g, "\n").split("\n")) {
162
+ let line = rawLine;
163
+ if (line.length === 0) {
164
+ out.push("");
165
+ continue;
166
+ }
167
+ while (line.length > width) {
168
+ // Prefer breaking at the last space within the width window.
169
+ let cut = line.lastIndexOf(" ", width);
170
+ if (cut <= 0)
171
+ cut = width;
172
+ out.push(line.slice(0, cut).trimEnd());
173
+ line = line.slice(cut).trimStart();
174
+ }
175
+ out.push(line);
176
+ }
177
+ return out;
178
+ }
179
+ function clampWindowStart(rows, selectedRowIndex, maxVisible) {
180
+ if (rows.length <= maxVisible)
181
+ return 0;
182
+ if (selectedRowIndex < 0)
183
+ return 0;
184
+ const half = Math.floor(maxVisible / 2);
185
+ let start = Math.max(0, selectedRowIndex - half);
186
+ if (start + maxVisible > rows.length)
187
+ start = rows.length - maxVisible;
188
+ return Math.max(0, start);
189
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared subagent view-model + presentation helpers, used by both the inline
3
+ * Subagents block (message-list.tsx) and the full-screen inspector
4
+ * (subagent-inspector.tsx). Pure functions only — no React.
5
+ */
6
+ import { type SubagentRouteLike } from "../agent/subagent-route-format.js";
7
+ import type { Theme } from "./theme.js";
8
+ import type { DisplayMessage, DisplayToolCall } from "./display-history.js";
9
+ export interface SubagentDisplay {
10
+ subAgentId?: string;
11
+ agentName?: string;
12
+ nickname?: string;
13
+ status?: string;
14
+ category?: string;
15
+ route?: SubagentRouteLike;
16
+ profileSource?: string;
17
+ task?: string;
18
+ summary?: string;
19
+ toolNotes?: string[];
20
+ error?: string;
21
+ }
22
+ export declare function latestSubagentNote(subagent: SubagentDisplay): string;
23
+ export declare function subagentLabel(subagent: SubagentDisplay): string;
24
+ export declare function subagentRole(subagent: SubagentDisplay): string;
25
+ export declare function subagentDescriptor(subagent: SubagentDisplay, includeThinking?: boolean): string;
26
+ export declare function subagentStatusColor(status: string | undefined, theme: Theme): string;
27
+ export declare function subagentSummary(subagents: SubagentDisplay[]): string;
28
+ export declare function sortSubagents(subagents: SubagentDisplay[]): SubagentDisplay[];
29
+ /** A spawn_agent (one member), an agent_team/agent_batch, or a run_workflow (a group of members). */
30
+ export interface SubagentGroup {
31
+ id: string;
32
+ kind: "single" | "team" | "batch" | "workflow";
33
+ label: string;
34
+ members: SubagentDisplay[];
35
+ }
36
+ /**
37
+ * Collects every spawned subagent from the live transcript + streaming tools,
38
+ * grouped by their originating tool call, for the inspector. Pure.
39
+ *
40
+ * The same subagent is echoed by MULTIPLE lifecycle tool calls — its spawn_agent
41
+ * (a stale snapshot) plus every wait_agent/list_agents that observed it (later
42
+ * snapshots), all carrying metadata.subagents (agent-lifecycle formatLifecycleResult).
43
+ * So we dedupe by subAgentId, keep the freshest snapshot, group team/batch members
44
+ * by their originating tool call, and collapse a single agent's many lifecycle
45
+ * echoes into one "single" group keyed by the agent itself.
46
+ */
47
+ export declare function collectSubagentGroups(messages: DisplayMessage[], streamingTools: DisplayToolCall[]): SubagentGroup[];
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Shared subagent view-model + presentation helpers, used by both the inline
3
+ * Subagents block (message-list.tsx) and the full-screen inspector
4
+ * (subagent-inspector.tsx). Pure functions only — no React.
5
+ */
6
+ import { formatSubagentRoute } from "../agent/subagent-route-format.js";
7
+ export function latestSubagentNote(subagent) {
8
+ const note = subagent.error
9
+ || subagent.toolNotes?.filter(Boolean).at(-1)
10
+ || subagent.summary
11
+ || subagent.task
12
+ || "";
13
+ return note.replace(/\r\n/g, "\n").split("\n").map((line) => line.trim()).find(Boolean) ?? "";
14
+ }
15
+ export function subagentLabel(subagent) {
16
+ return subagent.nickname ?? subagent.agentName ?? "subagent";
17
+ }
18
+ export function subagentRole(subagent) {
19
+ return [subagent.agentName, subagent.category ? `/${subagent.category}` : ""].join("") || "default";
20
+ }
21
+ export function subagentDescriptor(subagent, includeThinking = false) {
22
+ const route = formatSubagentRoute(subagent.route, { includeThinking });
23
+ const role = subagentRole(subagent);
24
+ return route ? `${role} @ ${route}` : role;
25
+ }
26
+ export function subagentStatusColor(status, theme) {
27
+ if (status === "completed")
28
+ return theme.success;
29
+ if (status === "failed" || status === "blocked" || status === "cancelled")
30
+ return theme.error;
31
+ if (status === "queued")
32
+ return theme.muted;
33
+ return theme.toolPending;
34
+ }
35
+ export function subagentSummary(subagents) {
36
+ if (subagents.length === 0)
37
+ return "no subagents";
38
+ const counts = new Map();
39
+ for (const subagent of subagents) {
40
+ const status = subagent.status ?? "running";
41
+ counts.set(status, (counts.get(status) ?? 0) + 1);
42
+ }
43
+ const order = ["running", "queued", "completed", "blocked", "failed", "cancelled"];
44
+ return order
45
+ .filter((status) => counts.has(status))
46
+ .map((status) => `${counts.get(status)} ${status}`)
47
+ .join(" ");
48
+ }
49
+ export function sortSubagents(subagents) {
50
+ const rank = {
51
+ running: 0,
52
+ blocked: 1,
53
+ failed: 2,
54
+ queued: 3,
55
+ cancelled: 4,
56
+ completed: 5,
57
+ };
58
+ return [...subagents].sort((a, b) => (rank[a.status ?? "running"] ?? 9) - (rank[b.status ?? "running"] ?? 9));
59
+ }
60
+ function subagentStatusRank(status) {
61
+ if (status === "completed" || status === "failed" || status === "blocked" || status === "cancelled" || status === "closed")
62
+ return 3;
63
+ if (status === "running")
64
+ return 2;
65
+ if (status === "queued")
66
+ return 1;
67
+ return 0;
68
+ }
69
+ /** Higher = more "complete"/recent snapshot of the same subagent. */
70
+ function subagentFreshness(member) {
71
+ return subagentStatusRank(member.status) * 100_000
72
+ + (member.toolNotes?.length ?? 0) * 10
73
+ + (member.summary ? 1 : 0);
74
+ }
75
+ function memberKey(member) {
76
+ return member.subAgentId || `${member.nickname ?? ""}|${member.task ?? ""}`;
77
+ }
78
+ /**
79
+ * Collects every spawned subagent from the live transcript + streaming tools,
80
+ * grouped by their originating tool call, for the inspector. Pure.
81
+ *
82
+ * The same subagent is echoed by MULTIPLE lifecycle tool calls — its spawn_agent
83
+ * (a stale snapshot) plus every wait_agent/list_agents that observed it (later
84
+ * snapshots), all carrying metadata.subagents (agent-lifecycle formatLifecycleResult).
85
+ * So we dedupe by subAgentId, keep the freshest snapshot, group team/batch members
86
+ * by their originating tool call, and collapse a single agent's many lifecycle
87
+ * echoes into one "single" group keyed by the agent itself.
88
+ */
89
+ export function collectSubagentGroups(messages, streamingTools) {
90
+ const toolCalls = [];
91
+ const ingest = (tcs) => {
92
+ if (!tcs)
93
+ return;
94
+ for (const tc of tcs)
95
+ if (tc.metadata?.kind === "subagent")
96
+ toolCalls.push(tc);
97
+ };
98
+ for (const message of messages) {
99
+ ingest(message.toolCalls);
100
+ if (message.parts) {
101
+ for (const part of message.parts) {
102
+ if (part.type === "tools")
103
+ ingest(part.toolCalls);
104
+ }
105
+ }
106
+ }
107
+ ingest(streamingTools);
108
+ const freshest = new Map();
109
+ const memberToGroup = new Map();
110
+ const groups = new Map();
111
+ let order = 0;
112
+ for (const tc of toolCalls) {
113
+ const rawMembers = Array.isArray(tc.metadata?.subagents) ? tc.metadata.subagents : [];
114
+ const members = rawMembers.filter((m) => typeof m === "object" && m !== null);
115
+ if (members.length === 0)
116
+ continue;
117
+ // Track the freshest snapshot seen for each subagent.
118
+ for (const m of members) {
119
+ const key = memberKey(m);
120
+ const prev = freshest.get(key);
121
+ if (!prev || subagentFreshness(m) >= subagentFreshness(prev))
122
+ freshest.set(key, m);
123
+ }
124
+ const mode = tc.metadata.mode;
125
+ if (mode === "team" || mode === "batch" || mode === "workflow") {
126
+ // A team/batch/workflow tool call is the canonical group for its members.
127
+ const groupKey = tc.id;
128
+ if (!groups.has(groupKey)) {
129
+ const description = typeof tc.args?.description === "string" ? tc.args.description.trim()
130
+ : typeof tc.args?.title === "string" ? tc.args.title.trim() : "";
131
+ groups.set(groupKey, { kind: mode, label: description || mode, memberKeys: [], order: order++ });
132
+ }
133
+ const group = groups.get(groupKey);
134
+ for (const m of members) {
135
+ const key = memberKey(m);
136
+ if (!memberToGroup.has(key)) {
137
+ memberToGroup.set(key, groupKey);
138
+ group.memberKeys.push(key);
139
+ }
140
+ }
141
+ }
142
+ else {
143
+ // Lifecycle echo (spawn/wait/list/...): one "single" group per agent,
144
+ // collapsing all its echoes; skip any already claimed by a team/batch.
145
+ for (const m of members) {
146
+ const key = memberKey(m);
147
+ if (memberToGroup.has(key))
148
+ continue;
149
+ const groupKey = `single:${key}`;
150
+ memberToGroup.set(key, groupKey);
151
+ groups.set(groupKey, { kind: "single", label: m.nickname ?? m.task ?? "subagent", memberKeys: [key], order: order++ });
152
+ }
153
+ }
154
+ }
155
+ return [...groups.entries()]
156
+ .sort((a, b) => a[1].order - b[1].order)
157
+ .map(([id, g]) => ({
158
+ id,
159
+ kind: g.kind,
160
+ label: g.label,
161
+ members: g.memberKeys.map((k) => freshest.get(k)).filter((m) => !!m),
162
+ }));
163
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Whether we're running inside a terminal multiplexer (tmux or GNU screen).
3
+ *
4
+ * Ink commits settled transcript rows to native scrollback via <Static> and
5
+ * repaints only the short live region in place. When that live region SHRINKS
6
+ * (a turn settles, a steer commits, a run is interrupted), Ink erases the prior
7
+ * frame with a cursor-up + clear. Under a multiplexer that erase cannot reach
8
+ * rows that have already scrolled out of the pane, leaving a blank gap — so
9
+ * those transitions fall back to a full screen+scrollback reprint to stay clean.
10
+ *
11
+ * On a normal terminal that reprint is unnecessary (Ink's in-place erase works)
12
+ * and visible as a one-frame full-screen flash, so we skip it. This predicate is
13
+ * the gate. Pure + injectable for tests.
14
+ */
15
+ export declare function isMultiplexedTerminal(env?: NodeJS.ProcessEnv): boolean;