brownian-code 2026.2.10

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/bin/brownian +25 -0
  4. package/env.example +21 -0
  5. package/package.json +87 -0
  6. package/src/agent/agent.test.ts +414 -0
  7. package/src/agent/agent.ts +385 -0
  8. package/src/agent/index.ts +27 -0
  9. package/src/agent/prompts.ts +271 -0
  10. package/src/agent/scratchpad.test.ts +482 -0
  11. package/src/agent/scratchpad.ts +526 -0
  12. package/src/agent/token-counter.test.ts +59 -0
  13. package/src/agent/token-counter.ts +33 -0
  14. package/src/agent/types.ts +137 -0
  15. package/src/cli.tsx +385 -0
  16. package/src/commands/builtin.test.ts +271 -0
  17. package/src/commands/builtin.ts +200 -0
  18. package/src/commands/registry.test.ts +188 -0
  19. package/src/commands/registry.ts +111 -0
  20. package/src/commands/types.ts +64 -0
  21. package/src/components/AgentEventView.tsx +487 -0
  22. package/src/components/AnswerBox.tsx +81 -0
  23. package/src/components/ApiKeyPrompt.tsx +75 -0
  24. package/src/components/CommandMenu.test.tsx +64 -0
  25. package/src/components/CommandMenu.tsx +38 -0
  26. package/src/components/CursorText.tsx +43 -0
  27. package/src/components/DebugPanel.tsx +48 -0
  28. package/src/components/ErrorBox.test.tsx +58 -0
  29. package/src/components/ErrorBox.tsx +26 -0
  30. package/src/components/HelpView.test.tsx +70 -0
  31. package/src/components/HelpView.tsx +61 -0
  32. package/src/components/HistoryItemView.tsx +108 -0
  33. package/src/components/Input.tsx +193 -0
  34. package/src/components/Intro.test.tsx +59 -0
  35. package/src/components/Intro.tsx +35 -0
  36. package/src/components/ModelSelector.tsx +288 -0
  37. package/src/components/StatusBar.test.tsx +78 -0
  38. package/src/components/StatusBar.tsx +56 -0
  39. package/src/components/WorkingIndicator.tsx +133 -0
  40. package/src/components/index.ts +23 -0
  41. package/src/e2e/agent-flow.test.ts +378 -0
  42. package/src/evals/components/EvalApp.tsx +206 -0
  43. package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
  44. package/src/evals/components/EvalProgress.tsx +33 -0
  45. package/src/evals/components/EvalRecentResults.tsx +63 -0
  46. package/src/evals/components/EvalStats.tsx +49 -0
  47. package/src/evals/components/index.ts +5 -0
  48. package/src/evals/dataset/crypto_agent.csv +16 -0
  49. package/src/evals/run.ts +355 -0
  50. package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
  51. package/src/gateway/channels/whatsapp/inbound.ts +86 -0
  52. package/src/gateway/channels/whatsapp/login.ts +28 -0
  53. package/src/gateway/channels/whatsapp/outbound.ts +27 -0
  54. package/src/gateway/channels/whatsapp/session.ts +69 -0
  55. package/src/gateway/config.ts +81 -0
  56. package/src/gateway/index.ts +62 -0
  57. package/src/hooks/useAgentRunner.ts +317 -0
  58. package/src/hooks/useDebugLogs.ts +22 -0
  59. package/src/hooks/useInputHistory.ts +106 -0
  60. package/src/hooks/useModelSelection.ts +249 -0
  61. package/src/hooks/useTextBuffer.test.ts +121 -0
  62. package/src/hooks/useTextBuffer.ts +97 -0
  63. package/src/index.tsx +74 -0
  64. package/src/mcp/cache.ts +205 -0
  65. package/src/mcp/client.test.ts +126 -0
  66. package/src/mcp/client.ts +145 -0
  67. package/src/mcp/index.ts +2 -0
  68. package/src/model/llm.test.ts +158 -0
  69. package/src/model/llm.ts +233 -0
  70. package/src/providers.ts +94 -0
  71. package/src/skills/index.ts +17 -0
  72. package/src/skills/loader.ts +73 -0
  73. package/src/skills/registry.ts +125 -0
  74. package/src/skills/types.ts +31 -0
  75. package/src/test-utils/mocks.ts +110 -0
  76. package/src/theme.ts +21 -0
  77. package/src/tools/browser/browser.ts +357 -0
  78. package/src/tools/browser/index.ts +1 -0
  79. package/src/tools/crypto/hive-tools.ts +171 -0
  80. package/src/tools/crypto/index.ts +1 -0
  81. package/src/tools/descriptions/browser.ts +105 -0
  82. package/src/tools/descriptions/crypto-search.ts +58 -0
  83. package/src/tools/descriptions/index.ts +8 -0
  84. package/src/tools/descriptions/web-fetch.ts +44 -0
  85. package/src/tools/descriptions/web-search.ts +26 -0
  86. package/src/tools/fetch/cache.ts +95 -0
  87. package/src/tools/fetch/external-content.ts +200 -0
  88. package/src/tools/fetch/index.ts +1 -0
  89. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  90. package/src/tools/fetch/web-fetch.ts +371 -0
  91. package/src/tools/index.ts +12 -0
  92. package/src/tools/registry.ts +130 -0
  93. package/src/tools/search/exa.ts +43 -0
  94. package/src/tools/search/index.ts +2 -0
  95. package/src/tools/search/tavily.ts +35 -0
  96. package/src/tools/skill.ts +62 -0
  97. package/src/tools/types.ts +53 -0
  98. package/src/utils/ai-message.ts +26 -0
  99. package/src/utils/config.ts +54 -0
  100. package/src/utils/cost-calculator.test.ts +101 -0
  101. package/src/utils/cost-calculator.ts +74 -0
  102. package/src/utils/env.ts +101 -0
  103. package/src/utils/error-classifier.test.ts +146 -0
  104. package/src/utils/error-classifier.ts +91 -0
  105. package/src/utils/in-memory-chat-history.test.ts +291 -0
  106. package/src/utils/in-memory-chat-history.ts +224 -0
  107. package/src/utils/index.ts +19 -0
  108. package/src/utils/input-key-handlers.test.ts +155 -0
  109. package/src/utils/input-key-handlers.ts +64 -0
  110. package/src/utils/logger.ts +67 -0
  111. package/src/utils/long-term-chat-history.ts +138 -0
  112. package/src/utils/markdown-table.ts +227 -0
  113. package/src/utils/ollama.ts +37 -0
  114. package/src/utils/progress-channel.ts +84 -0
  115. package/src/utils/text-navigation.test.ts +222 -0
  116. package/src/utils/text-navigation.ts +81 -0
  117. package/src/utils/thinking-verbs.ts +29 -0
  118. package/src/utils/tokens.test.ts +163 -0
  119. package/src/utils/tokens.ts +67 -0
  120. package/src/utils/tool-description.ts +88 -0
@@ -0,0 +1,111 @@
1
+ import type { CommandDef, CommandContext, CommandResult } from './types.js';
2
+
3
+ /**
4
+ * Command registry - manages slash command registration, lookup, and fuzzy search.
5
+ */
6
+ class CommandRegistry {
7
+ private commands = new Map<string, CommandDef>();
8
+
9
+ /** Register a command (keyed by name + all aliases) */
10
+ register(cmd: CommandDef): void {
11
+ this.commands.set(cmd.name, cmd);
12
+ if (cmd.aliases) {
13
+ for (const alias of cmd.aliases) {
14
+ this.commands.set(alias, cmd);
15
+ }
16
+ }
17
+ }
18
+
19
+ /** Register multiple commands */
20
+ registerAll(cmds: CommandDef[]): void {
21
+ for (const cmd of cmds) {
22
+ this.register(cmd);
23
+ }
24
+ }
25
+
26
+ /** Get a command by exact name */
27
+ get(name: string): CommandDef | undefined {
28
+ return this.commands.get(name);
29
+ }
30
+
31
+ /** Get all unique, non-hidden commands (for help/autocomplete) */
32
+ getVisible(): CommandDef[] {
33
+ const seen = new Set<string>();
34
+ const result: CommandDef[] = [];
35
+ for (const cmd of this.commands.values()) {
36
+ if (!cmd.isHidden && !seen.has(cmd.name)) {
37
+ seen.add(cmd.name);
38
+ result.push(cmd);
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+
44
+ /** Get all unique commands including hidden */
45
+ getAll(): CommandDef[] {
46
+ const seen = new Set<string>();
47
+ const result: CommandDef[] = [];
48
+ for (const cmd of this.commands.values()) {
49
+ if (!seen.has(cmd.name)) {
50
+ seen.add(cmd.name);
51
+ result.push(cmd);
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /** Fuzzy search for commands matching a prefix (for autocomplete) */
58
+ search(prefix: string): CommandDef[] {
59
+ const lower = prefix.toLowerCase();
60
+ const seen = new Set<string>();
61
+ const result: CommandDef[] = [];
62
+
63
+ for (const cmd of this.commands.values()) {
64
+ if (seen.has(cmd.name) || cmd.isHidden) continue;
65
+
66
+ // Match against name or aliases
67
+ const matchesName = cmd.name.toLowerCase().includes(lower);
68
+ const matchesAlias = cmd.aliases?.some(a => a.toLowerCase().includes(lower));
69
+
70
+ if (matchesName || matchesAlias) {
71
+ seen.add(cmd.name);
72
+ result.push(cmd);
73
+ }
74
+ }
75
+
76
+ // Sort: exact prefix matches first, then alphabetical
77
+ return result.sort((a, b) => {
78
+ const aStarts = a.name.toLowerCase().startsWith(lower);
79
+ const bStarts = b.name.toLowerCase().startsWith(lower);
80
+ if (aStarts && !bStarts) return -1;
81
+ if (!aStarts && bStarts) return 1;
82
+ return a.name.localeCompare(b.name);
83
+ });
84
+ }
85
+ }
86
+
87
+ /** Parse a slash command string into name and args */
88
+ export function parseCommand(input: string): { name: string; args: string[] } | null {
89
+ const trimmed = input.trim();
90
+ if (!trimmed.startsWith('/')) return null;
91
+
92
+ const parts = trimmed.slice(1).split(/\s+/);
93
+ const name = parts[0]?.toLowerCase();
94
+ if (!name) return null;
95
+
96
+ return { name, args: parts.slice(1) };
97
+ }
98
+
99
+ /** Execute a parsed command from the registry */
100
+ export async function executeCommand(
101
+ name: string,
102
+ args: string[],
103
+ ctx: CommandContext
104
+ ): Promise<CommandResult> {
105
+ const cmd = commandRegistry.get(name);
106
+ if (!cmd) return undefined;
107
+ return cmd.execute(args, ctx);
108
+ }
109
+
110
+ /** Global command registry singleton */
111
+ export const commandRegistry = new CommandRegistry();
@@ -0,0 +1,64 @@
1
+ import type React from 'react';
2
+ import type { HistoryItem } from '../components/HistoryItemView.js';
3
+ import type { InMemoryChatHistory } from '../utils/in-memory-chat-history.js';
4
+
5
+ /**
6
+ * Context passed to command execute functions.
7
+ * Provides access to app state and actions.
8
+ */
9
+ export interface CommandContext {
10
+ /** Current provider ID (e.g., 'anthropic') */
11
+ provider: string;
12
+ /** Current model ID (e.g., 'claude-sonnet-4-5') */
13
+ model: string;
14
+ /** Current conversation history items */
15
+ history: HistoryItem[];
16
+ /** In-memory chat history ref for multi-turn context */
17
+ inMemoryChatHistoryRef: React.RefObject<InMemoryChatHistory>;
18
+ /** Set an error message (displayed in error area) */
19
+ setError: (error: string | null) => void;
20
+ /** Clear conversation history (UI + in-memory) */
21
+ clearHistory: () => void;
22
+ /** Start model selection flow */
23
+ startSelection: () => void;
24
+ /** Toggle debug panel */
25
+ toggleDebug: () => void;
26
+ /** Show debug panel state */
27
+ showDebug: boolean;
28
+ /** Cumulative session token count */
29
+ cumulativeTokens: number;
30
+ /** Cumulative session cost */
31
+ cumulativeCost: number;
32
+ /** Turn count */
33
+ turnCount: number;
34
+ /** Run a query through the agent (for /compact) */
35
+ runCompactQuery?: (instructions?: string) => Promise<void>;
36
+ }
37
+
38
+ /**
39
+ * Result of executing a command.
40
+ * - void: command handled silently (e.g., /clear)
41
+ * - React.ReactNode: render as JSX in history (e.g., /help)
42
+ * - string: display as text message
43
+ */
44
+ export type CommandResult = void | React.ReactNode | string;
45
+
46
+ /**
47
+ * Definition of a slash command.
48
+ */
49
+ export interface CommandDef {
50
+ /** Primary command name (without slash) */
51
+ name: string;
52
+ /** Short description shown in autocomplete */
53
+ description: string;
54
+ /** Alternative names for the command */
55
+ aliases?: string[];
56
+ /** 'local' runs a function, 'local-jsx' renders a component */
57
+ type: 'local' | 'local-jsx';
58
+ /** Hidden from autocomplete/help (e.g., aliases) */
59
+ isHidden?: boolean;
60
+ /** Argument hint shown dimmed after command name */
61
+ argHint?: string;
62
+ /** Execute the command */
63
+ execute: (args: string[], ctx: CommandContext) => CommandResult | Promise<CommandResult>;
64
+ }
@@ -0,0 +1,487 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { colors } from '../theme.js';
5
+ import type { AgentEvent } from '../agent/types.js';
6
+
7
+ /**
8
+ * Format tool name from snake_case to Title Case
9
+ * e.g., get_market_and_price_endpoints -> Get Market And Price Endpoints
10
+ */
11
+ function formatToolName(name: string): string {
12
+ return name
13
+ .split('_')
14
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
15
+ .join(' ');
16
+ }
17
+
18
+ /**
19
+ * Truncate string at word boundary (before exceeding maxLength)
20
+ */
21
+ function truncateAtWord(str: string, maxLength: number): string {
22
+ if (str.length <= maxLength) return str;
23
+
24
+ // Find last space before maxLength
25
+ const lastSpace = str.lastIndexOf(' ', maxLength);
26
+
27
+ // If there's a space in a reasonable position (at least 50% of maxLength), use it
28
+ if (lastSpace > maxLength * 0.5) {
29
+ return str.slice(0, lastSpace) + '...';
30
+ }
31
+
32
+ // No good word boundary - truncate at maxLength
33
+ return str.slice(0, maxLength) + '...';
34
+ }
35
+
36
+ /**
37
+ * Format tool arguments for display - truncate long values at word boundaries
38
+ */
39
+ function formatArgs(args: Record<string, unknown>): string {
40
+ // For tools with a single 'query' arg, show it in a clean format
41
+ if (Object.keys(args).length === 1 && 'query' in args) {
42
+ const query = String(args.query);
43
+ return `"${truncateAtWord(query, 60)}"`;
44
+ }
45
+
46
+ // For other tools, format key=value pairs with truncation
47
+ return Object.entries(args)
48
+ .map(([key, value]) => {
49
+ const strValue = String(value);
50
+ return `${key}=${truncateAtWord(strValue, 60)}`;
51
+ })
52
+ .join(', ');
53
+ }
54
+
55
+ /**
56
+ * Format duration in human-readable form
57
+ */
58
+ function formatDuration(ms: number): string {
59
+ if (ms < 1000) {
60
+ return `${ms}ms`;
61
+ }
62
+ return `${(ms / 1000).toFixed(1)}s`;
63
+ }
64
+
65
+ /**
66
+ * Truncate result for display
67
+ */
68
+ function truncateResult(result: string, maxLength: number = 100): string {
69
+ if (result.length <= maxLength) {
70
+ return result;
71
+ }
72
+ return result.slice(0, maxLength) + '...';
73
+ }
74
+
75
+ /**
76
+ * Truncate URL to hostname + path for display
77
+ */
78
+ function truncateUrl(url: string, maxLen = 45): string {
79
+ try {
80
+ const parsed = new URL(url);
81
+ const display = parsed.hostname + parsed.pathname;
82
+ return display.length <= maxLen ? display : display.slice(0, maxLen) + '...';
83
+ } catch {
84
+ return url.length > maxLen ? url.slice(0, maxLen) + '...' : url;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Format browser step for consolidated display.
90
+ * Returns null for actions that should not be shown (act steps like click, type).
91
+ */
92
+ function formatBrowserStep(args: Record<string, unknown>): string | null {
93
+ const action = args.action as string;
94
+ const url = args.url as string | undefined;
95
+
96
+ switch (action) {
97
+ case 'open':
98
+ return `Opening ${truncateUrl(url || '')}`;
99
+ case 'navigate':
100
+ return `Navigating to ${truncateUrl(url || '')}`;
101
+ case 'snapshot':
102
+ return 'Reading page structure';
103
+ case 'read':
104
+ return 'Extracting page text';
105
+ case 'close':
106
+ return 'Closing browser';
107
+ case 'act':
108
+ return null; // Don't show act steps (click, type, etc.)
109
+ default:
110
+ return null;
111
+ }
112
+ }
113
+
114
+ interface ThinkingViewProps {
115
+ message: string;
116
+ }
117
+
118
+ export function ThinkingView({ message }: ThinkingViewProps) {
119
+ const trimmedMessage = message.trim();
120
+ if (!trimmedMessage) return null;
121
+
122
+ const displayMessage = trimmedMessage.length > 200
123
+ ? trimmedMessage.slice(0, 200) + '...'
124
+ : trimmedMessage;
125
+
126
+ return (
127
+ <Box>
128
+ <Text>{displayMessage}</Text>
129
+ </Box>
130
+ );
131
+ }
132
+
133
+ interface ToolStartViewProps {
134
+ tool: string;
135
+ args: Record<string, unknown>;
136
+ isActive?: boolean;
137
+ progressMessage?: string;
138
+ }
139
+
140
+ export function ToolStartView({ tool, args, isActive = false, progressMessage }: ToolStartViewProps) {
141
+ return (
142
+ <Box flexDirection="column">
143
+ <Box>
144
+ <Text>⏺ </Text>
145
+ <Text>{formatToolName(tool)}</Text>
146
+ <Text color={colors.muted}>({formatArgs(args)})</Text>
147
+ </Box>
148
+ {isActive && (
149
+ <Box marginLeft={2}>
150
+ <Text color={colors.muted}>⎿ </Text>
151
+ <Text color={colors.muted}>
152
+ <Spinner type="dots" />
153
+ </Text>
154
+ <Text> {progressMessage || 'Searching...'}</Text>
155
+ </Box>
156
+ )}
157
+ </Box>
158
+ );
159
+ }
160
+
161
+ interface ToolEndViewProps {
162
+ tool: string;
163
+ args: Record<string, unknown>;
164
+ result: string;
165
+ duration: number;
166
+ }
167
+
168
+ export function ToolEndView({ tool, args, result, duration }: ToolEndViewProps) {
169
+ // Parse result to get a summary
170
+ let summary = 'Received data';
171
+
172
+ // Special handling for skill tool
173
+ if (tool === 'skill') {
174
+ const skillName = args.skill as string;
175
+ summary = `Loaded ${skillName} skill`;
176
+ } else if (tool === 'get_api_endpoint_schema') {
177
+ summary = `Got schema for ${args.endpoint_name || 'endpoint'}`;
178
+ } else if (tool === 'invoke_api_endpoint') {
179
+ const endpointName = String(args.endpoint_name || 'endpoint')
180
+ .replace(/_/g, ' ')
181
+ .replace(/\b\w/g, (c: string) => c.toUpperCase());
182
+ summary = `Fetched ${endpointName}`;
183
+ } else if (tool.startsWith('get_') && tool.endsWith('_endpoints')) {
184
+ summary = 'Discovered endpoints';
185
+ } else {
186
+ try {
187
+ const parsed = JSON.parse(result);
188
+ if (parsed.data) {
189
+ if (Array.isArray(parsed.data)) {
190
+ summary = `Received ${parsed.data.length} items`;
191
+ } else if (typeof parsed.data === 'object') {
192
+ const keys = Object.keys(parsed.data).filter(k => !k.startsWith('_'));
193
+ if (tool === 'web_search') {
194
+ summary = `Did 1 search`;
195
+ } else {
196
+ summary = `Received ${keys.length} fields`;
197
+ }
198
+ }
199
+ }
200
+ } catch {
201
+ // Not JSON, use truncated result
202
+ summary = truncateResult(result, 50);
203
+ }
204
+ }
205
+
206
+ return (
207
+ <Box flexDirection="column">
208
+ <Box>
209
+ <Text>⏺ </Text>
210
+ <Text>{formatToolName(tool)}</Text>
211
+ <Text color={colors.muted}>({formatArgs(args)})</Text>
212
+ </Box>
213
+ <Box marginLeft={2}>
214
+ <Text color={colors.muted}>⎿ </Text>
215
+ <Text>{summary}</Text>
216
+ <Text color={colors.muted}> in {formatDuration(duration)}</Text>
217
+ </Box>
218
+ </Box>
219
+ );
220
+ }
221
+
222
+ interface ToolErrorViewProps {
223
+ tool: string;
224
+ error: string;
225
+ }
226
+
227
+ export function ToolErrorView({ tool, error }: ToolErrorViewProps) {
228
+ return (
229
+ <Box flexDirection="column">
230
+ <Box>
231
+ <Text>⏺ </Text>
232
+ <Text>{formatToolName(tool)}</Text>
233
+ </Box>
234
+ <Box marginLeft={2}>
235
+ <Text color={colors.muted}>⎿ </Text>
236
+ <Text color={colors.error}>Error: {truncateResult(error, 80)}</Text>
237
+ </Box>
238
+ </Box>
239
+ );
240
+ }
241
+
242
+ interface ToolLimitViewProps {
243
+ tool: string;
244
+ warning?: string;
245
+ }
246
+
247
+ export function ToolLimitView({ tool, warning }: ToolLimitViewProps) {
248
+ return (
249
+ <Box flexDirection="column">
250
+ <Box>
251
+ <Text>⏺ </Text>
252
+ <Text>{formatToolName(tool)}</Text>
253
+ <Text color={colors.warning}> [NOTE]</Text>
254
+ </Box>
255
+ <Box marginLeft={2}>
256
+ <Text color={colors.muted}>⎿ </Text>
257
+ <Text color={colors.warning}>
258
+ {truncateResult(warning || 'Approaching suggested limit', 100)}
259
+ </Text>
260
+ </Box>
261
+ </Box>
262
+ );
263
+ }
264
+
265
+ interface ContextClearedViewProps {
266
+ clearedCount: number;
267
+ keptCount: number;
268
+ }
269
+
270
+ export function ContextClearedView({ clearedCount, keptCount }: ContextClearedViewProps) {
271
+ return (
272
+ <Box>
273
+ <Text>⏺ </Text>
274
+ <Text color={colors.muted}>Context threshold reached - cleared {clearedCount} old tool result{clearedCount !== 1 ? 's' : ''}, kept {keptCount} most recent</Text>
275
+ </Box>
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Accumulated event for display
281
+ * Combines tool_start and tool_end into a single view
282
+ */
283
+ export interface DisplayEvent {
284
+ id: string;
285
+ event: AgentEvent;
286
+ completed?: boolean;
287
+ endEvent?: AgentEvent;
288
+ progressMessage?: string;
289
+ }
290
+
291
+ /**
292
+ * Find the current displayable browser step from a list of browser events.
293
+ * Skips 'act' actions and returns the most recent displayable step.
294
+ */
295
+ function findCurrentBrowserStep(events: DisplayEvent[], activeStepId?: string): string | null {
296
+ // If there's an active step, try to show it
297
+ if (activeStepId) {
298
+ const activeEvent = events.find(e => e.id === activeStepId);
299
+ if (activeEvent?.event.type === 'tool_start') {
300
+ const step = formatBrowserStep(activeEvent.event.args);
301
+ if (step) return step;
302
+ }
303
+ }
304
+
305
+ // Otherwise, find the most recent displayable step (working backwards)
306
+ for (let i = events.length - 1; i >= 0; i--) {
307
+ const event = events[i];
308
+ if (event.event.type === 'tool_start') {
309
+ const step = formatBrowserStep(event.event.args);
310
+ if (step) return step;
311
+ }
312
+ }
313
+
314
+ return null;
315
+ }
316
+
317
+ interface BrowserSessionViewProps {
318
+ events: DisplayEvent[];
319
+ activeStepId?: string;
320
+ }
321
+
322
+ /**
323
+ * Renders a consolidated browser session showing the current step.
324
+ */
325
+ function BrowserSessionView({ events, activeStepId }: BrowserSessionViewProps) {
326
+ // Find current displayable step (skip 'act' actions)
327
+ const currentStep = findCurrentBrowserStep(events, activeStepId);
328
+
329
+ return (
330
+ <Box flexDirection="column">
331
+ <Box>
332
+ <Text>⏺ </Text>
333
+ <Text>Browser</Text>
334
+ </Box>
335
+ <Box marginLeft={2}>
336
+ <Text color={colors.muted}>⎿ </Text>
337
+ {activeStepId && (
338
+ <Text color={colors.muted}><Spinner type="dots" /></Text>
339
+ )}
340
+ {currentStep && <Text>{activeStepId ? ' ' : ''}{currentStep}</Text>}
341
+ </Box>
342
+ </Box>
343
+ );
344
+ }
345
+
346
+ interface AgentEventViewProps {
347
+ event: AgentEvent;
348
+ isActive?: boolean;
349
+ progressMessage?: string;
350
+ }
351
+
352
+ /**
353
+ * Renders a single agent event in Claude Code style
354
+ */
355
+ export function AgentEventView({ event, isActive = false, progressMessage }: AgentEventViewProps) {
356
+ switch (event.type) {
357
+ case 'thinking':
358
+ return <ThinkingView message={event.message} />;
359
+
360
+ case 'tool_start':
361
+ return <ToolStartView tool={event.tool} args={event.args} isActive={isActive} progressMessage={progressMessage} />;
362
+
363
+ case 'tool_end':
364
+ return <ToolEndView tool={event.tool} args={event.args} result={event.result} duration={event.duration} />;
365
+
366
+ case 'tool_error':
367
+ return <ToolErrorView tool={event.tool} error={event.error} />;
368
+
369
+ case 'tool_limit':
370
+ return <ToolLimitView tool={event.tool} warning={event.warning} />;
371
+
372
+ case 'context_cleared':
373
+ return <ContextClearedView clearedCount={event.clearedCount} keptCount={event.keptCount} />;
374
+
375
+ case 'answer_start':
376
+ case 'done':
377
+ // These are handled separately by the parent component
378
+ return null;
379
+
380
+ default:
381
+ return null;
382
+ }
383
+ }
384
+
385
+ // Event grouping types for consolidated display
386
+ type EventGroup =
387
+ | { type: 'browser_session'; id: string; events: DisplayEvent[]; activeStepId?: string }
388
+ | { type: 'single'; displayEvent: DisplayEvent };
389
+
390
+ /**
391
+ * Groups consecutive browser events into sessions for consolidated display.
392
+ * Non-browser events are kept as single events.
393
+ */
394
+ function groupBrowserEvents(events: DisplayEvent[], activeToolId?: string): EventGroup[] {
395
+ const groups: EventGroup[] = [];
396
+ let browserGroup: DisplayEvent[] = [];
397
+
398
+ const flushBrowserGroup = () => {
399
+ if (browserGroup.length > 0) {
400
+ const isActive = browserGroup.some(e => e.id === activeToolId);
401
+ groups.push({
402
+ type: 'browser_session',
403
+ id: `browser-${browserGroup[0].id}`,
404
+ events: browserGroup,
405
+ activeStepId: isActive ? activeToolId : undefined,
406
+ });
407
+ browserGroup = [];
408
+ }
409
+ };
410
+
411
+ for (const event of events) {
412
+ if (event.event.type === 'tool_start' && event.event.tool === 'browser') {
413
+ browserGroup.push(event);
414
+ } else {
415
+ flushBrowserGroup();
416
+ groups.push({ type: 'single', displayEvent: event });
417
+ }
418
+ }
419
+ flushBrowserGroup();
420
+
421
+ return groups;
422
+ }
423
+
424
+ interface EventListViewProps {
425
+ events: DisplayEvent[];
426
+ activeToolId?: string;
427
+ }
428
+
429
+ /**
430
+ * Renders a list of agent events with browser events consolidated into sessions
431
+ */
432
+ export function EventListView({ events, activeToolId }: EventListViewProps) {
433
+ const groupedEvents = groupBrowserEvents(events, activeToolId);
434
+
435
+ return (
436
+ <Box flexDirection="column" gap={0} marginTop={1}>
437
+ {groupedEvents.map((group) => {
438
+ // Render browser sessions with consolidated view
439
+ if (group.type === 'browser_session') {
440
+ return (
441
+ <Box key={group.id} marginBottom={1}>
442
+ <BrowserSessionView
443
+ events={group.events}
444
+ activeStepId={group.activeStepId}
445
+ />
446
+ </Box>
447
+ );
448
+ }
449
+
450
+ // Render single events as before
451
+ const { id, event, completed, endEvent, progressMessage } = group.displayEvent;
452
+
453
+ // For tool events, show the end state if completed
454
+ if (event.type === 'tool_start' && completed && endEvent?.type === 'tool_end') {
455
+ return (
456
+ <Box key={id} marginBottom={1}>
457
+ <ToolEndView
458
+ tool={endEvent.tool}
459
+ args={event.args}
460
+ result={endEvent.result}
461
+ duration={endEvent.duration}
462
+ />
463
+ </Box>
464
+ );
465
+ }
466
+
467
+ if (event.type === 'tool_start' && completed && endEvent?.type === 'tool_error') {
468
+ return (
469
+ <Box key={id} marginBottom={1}>
470
+ <ToolErrorView tool={endEvent.tool} error={endEvent.error} />
471
+ </Box>
472
+ );
473
+ }
474
+
475
+ return (
476
+ <Box key={id} marginBottom={1}>
477
+ <AgentEventView
478
+ event={event}
479
+ isActive={!completed && id === activeToolId}
480
+ progressMessage={progressMessage}
481
+ />
482
+ </Box>
483
+ );
484
+ })}
485
+ </Box>
486
+ );
487
+ }