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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/bin/brownian +25 -0
- package/env.example +21 -0
- package/package.json +87 -0
- package/src/agent/agent.test.ts +414 -0
- package/src/agent/agent.ts +385 -0
- package/src/agent/index.ts +27 -0
- package/src/agent/prompts.ts +271 -0
- package/src/agent/scratchpad.test.ts +482 -0
- package/src/agent/scratchpad.ts +526 -0
- package/src/agent/token-counter.test.ts +59 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/types.ts +137 -0
- package/src/cli.tsx +385 -0
- package/src/commands/builtin.test.ts +271 -0
- package/src/commands/builtin.ts +200 -0
- package/src/commands/registry.test.ts +188 -0
- package/src/commands/registry.ts +111 -0
- package/src/commands/types.ts +64 -0
- package/src/components/AgentEventView.tsx +487 -0
- package/src/components/AnswerBox.tsx +81 -0
- package/src/components/ApiKeyPrompt.tsx +75 -0
- package/src/components/CommandMenu.test.tsx +64 -0
- package/src/components/CommandMenu.tsx +38 -0
- package/src/components/CursorText.tsx +43 -0
- package/src/components/DebugPanel.tsx +48 -0
- package/src/components/ErrorBox.test.tsx +58 -0
- package/src/components/ErrorBox.tsx +26 -0
- package/src/components/HelpView.test.tsx +70 -0
- package/src/components/HelpView.tsx +61 -0
- package/src/components/HistoryItemView.tsx +108 -0
- package/src/components/Input.tsx +193 -0
- package/src/components/Intro.test.tsx +59 -0
- package/src/components/Intro.tsx +35 -0
- package/src/components/ModelSelector.tsx +288 -0
- package/src/components/StatusBar.test.tsx +78 -0
- package/src/components/StatusBar.tsx +56 -0
- package/src/components/WorkingIndicator.tsx +133 -0
- package/src/components/index.ts +23 -0
- package/src/e2e/agent-flow.test.ts +378 -0
- package/src/evals/components/EvalApp.tsx +206 -0
- package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
- package/src/evals/components/EvalProgress.tsx +33 -0
- package/src/evals/components/EvalRecentResults.tsx +63 -0
- package/src/evals/components/EvalStats.tsx +49 -0
- package/src/evals/components/index.ts +5 -0
- package/src/evals/dataset/crypto_agent.csv +16 -0
- package/src/evals/run.ts +355 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
- package/src/gateway/channels/whatsapp/inbound.ts +86 -0
- package/src/gateway/channels/whatsapp/login.ts +28 -0
- package/src/gateway/channels/whatsapp/outbound.ts +27 -0
- package/src/gateway/channels/whatsapp/session.ts +69 -0
- package/src/gateway/config.ts +81 -0
- package/src/gateway/index.ts +62 -0
- package/src/hooks/useAgentRunner.ts +317 -0
- package/src/hooks/useDebugLogs.ts +22 -0
- package/src/hooks/useInputHistory.ts +106 -0
- package/src/hooks/useModelSelection.ts +249 -0
- package/src/hooks/useTextBuffer.test.ts +121 -0
- package/src/hooks/useTextBuffer.ts +97 -0
- package/src/index.tsx +74 -0
- package/src/mcp/cache.ts +205 -0
- package/src/mcp/client.test.ts +126 -0
- package/src/mcp/client.ts +145 -0
- package/src/mcp/index.ts +2 -0
- package/src/model/llm.test.ts +158 -0
- package/src/model/llm.ts +233 -0
- package/src/providers.ts +94 -0
- package/src/skills/index.ts +17 -0
- package/src/skills/loader.ts +73 -0
- package/src/skills/registry.ts +125 -0
- package/src/skills/types.ts +31 -0
- package/src/test-utils/mocks.ts +110 -0
- package/src/theme.ts +21 -0
- package/src/tools/browser/browser.ts +357 -0
- package/src/tools/browser/index.ts +1 -0
- package/src/tools/crypto/hive-tools.ts +171 -0
- package/src/tools/crypto/index.ts +1 -0
- package/src/tools/descriptions/browser.ts +105 -0
- package/src/tools/descriptions/crypto-search.ts +58 -0
- package/src/tools/descriptions/index.ts +8 -0
- package/src/tools/descriptions/web-fetch.ts +44 -0
- package/src/tools/descriptions/web-search.ts +26 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +371 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/registry.ts +130 -0
- package/src/tools/search/exa.ts +43 -0
- package/src/tools/search/index.ts +2 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/skill.ts +62 -0
- package/src/tools/types.ts +53 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/config.ts +54 -0
- package/src/utils/cost-calculator.test.ts +101 -0
- package/src/utils/cost-calculator.ts +74 -0
- package/src/utils/env.ts +101 -0
- package/src/utils/error-classifier.test.ts +146 -0
- package/src/utils/error-classifier.ts +91 -0
- package/src/utils/in-memory-chat-history.test.ts +291 -0
- package/src/utils/in-memory-chat-history.ts +224 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/input-key-handlers.test.ts +155 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/text-navigation.test.ts +222 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +29 -0
- package/src/utils/tokens.test.ts +163 -0
- package/src/utils/tokens.ts +67 -0
- 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
|
+
}
|