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,81 @@
1
+ import React, { useState, useEffect, useRef, useMemo } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { colors } from '../theme.js';
4
+ import { formatResponse } from '../utils/markdown-table.js';
5
+
6
+ interface AnswerBoxProps {
7
+ stream?: AsyncGenerator<string>;
8
+ text?: string;
9
+ onStart?: () => void;
10
+ onComplete?: (answer: string) => void;
11
+ }
12
+
13
+ export const AnswerBox = React.memo(function AnswerBox({ stream, text, onStart, onComplete }: AnswerBoxProps) {
14
+ const [content, setContent] = useState(text || '');
15
+ const [isStreaming, setIsStreaming] = useState(!!stream);
16
+
17
+ // Store callbacks in refs to avoid effect re-runs when references change
18
+ const onStartRef = useRef(onStart);
19
+ const onCompleteRef = useRef(onComplete);
20
+ onStartRef.current = onStart;
21
+ onCompleteRef.current = onComplete;
22
+
23
+ // Sync content with text prop when not streaming (used by V2 CLI)
24
+ useEffect(() => {
25
+ if (!stream && text !== undefined) {
26
+ setContent(text);
27
+ }
28
+ }, [stream, text]);
29
+
30
+ useEffect(() => {
31
+ if (!stream) return;
32
+
33
+ let collected = text || '';
34
+ let started = false;
35
+
36
+ (async () => {
37
+ try {
38
+ for await (const chunk of stream) {
39
+ if (!started && chunk.trim()) {
40
+ started = true;
41
+ onStartRef.current?.();
42
+ }
43
+ collected += chunk;
44
+ setContent(collected);
45
+ }
46
+ } finally {
47
+ setIsStreaming(false);
48
+ onCompleteRef.current?.(collected);
49
+ }
50
+ })();
51
+ }, [stream, text]);
52
+
53
+ // Apply pre-render formatting (tables, bold)
54
+ const displayContent = useMemo(() => formatResponse(content), [content]);
55
+
56
+ return (
57
+ <Box flexDirection="column">
58
+ <Box>
59
+ <Text color={colors.muted}>⏺ </Text>
60
+ <Text>
61
+ {displayContent}
62
+ {isStreaming && '▌'}
63
+ </Text>
64
+ </Box>
65
+ </Box>
66
+ );
67
+ });
68
+
69
+ interface UserQueryProps {
70
+ query: string;
71
+ }
72
+
73
+ export function UserQuery({ query }: UserQueryProps) {
74
+ return (
75
+ <Box marginTop={1} paddingRight={2}>
76
+ <Text color={colors.white} backgroundColor={colors.mutedDark}>
77
+ {'>'} {query}{' '}
78
+ </Text>
79
+ </Box>
80
+ );
81
+ }
@@ -0,0 +1,75 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { colors } from '../theme.js';
4
+
5
+ interface ApiKeyConfirmProps {
6
+ providerName: string;
7
+ onConfirm: (wantsToSet: boolean) => void;
8
+ }
9
+
10
+ export function ApiKeyConfirm({ providerName, onConfirm }: ApiKeyConfirmProps) {
11
+ useInput((input) => {
12
+ const key = input.toLowerCase();
13
+ if (key === 'y') {
14
+ onConfirm(true);
15
+ } else if (key === 'n') {
16
+ onConfirm(false);
17
+ }
18
+ });
19
+
20
+ return (
21
+ <Box flexDirection="column" marginTop={1}>
22
+ <Text color={colors.primary} bold>
23
+ Set API Key
24
+ </Text>
25
+ <Text>
26
+ Would you like to set your {providerName} API key? <Text color={colors.muted}>(y/n)</Text>
27
+ </Text>
28
+ </Box>
29
+ );
30
+ }
31
+
32
+ interface ApiKeyInputProps {
33
+ providerName: string;
34
+ apiKeyName: string;
35
+ onSubmit: (apiKey: string | null) => void;
36
+ }
37
+
38
+ export function ApiKeyInput({ providerName, apiKeyName, onSubmit }: ApiKeyInputProps) {
39
+ const [value, setValue] = useState('');
40
+
41
+ useInput((input, key) => {
42
+ if (key.return) {
43
+ onSubmit(value.trim() || null);
44
+ } else if (key.escape) {
45
+ onSubmit(null);
46
+ } else if (key.backspace || key.delete) {
47
+ setValue((prev) => prev.slice(0, -1));
48
+ } else if (input && !key.ctrl && !key.meta) {
49
+ setValue((prev) => prev + input);
50
+ }
51
+ });
52
+
53
+ // Mask the API key for display
54
+ const maskedValue = value.length > 0 ? '*'.repeat(value.length) : '';
55
+
56
+ return (
57
+ <Box flexDirection="column" marginTop={1}>
58
+ <Text color={colors.primary} bold>
59
+ Enter {providerName} API Key
60
+ </Text>
61
+ <Text color={colors.muted}>
62
+ ({apiKeyName})
63
+ </Text>
64
+ <Box marginTop={1}>
65
+ <Text color={colors.primary}>{'> '}</Text>
66
+ <Text>{maskedValue}</Text>
67
+ <Text color={colors.muted}>█</Text>
68
+ </Box>
69
+ <Box marginTop={1}>
70
+ <Text color={colors.muted}>Enter to confirm · Esc to cancel</Text>
71
+ </Box>
72
+ </Box>
73
+ );
74
+ }
75
+
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import React from 'react';
3
+ import { render, cleanup } from 'ink-testing-library';
4
+ import { CommandMenu } from './CommandMenu.js';
5
+ import type { CommandDef } from '../commands/types.js';
6
+
7
+ afterEach(cleanup);
8
+
9
+ const testCommands: CommandDef[] = [
10
+ { name: 'help', description: 'Show help', type: 'local', execute: () => {} },
11
+ { name: 'clear', description: 'Clear history', type: 'local', execute: () => {} },
12
+ { name: 'model', description: 'Switch model', type: 'local', execute: () => {} },
13
+ ];
14
+
15
+ describe('CommandMenu', () => {
16
+ test('renders nothing when commands list is empty', () => {
17
+ const { lastFrame } = render(
18
+ React.createElement(CommandMenu, { commands: [], selectedIndex: 0 })
19
+ );
20
+ expect(lastFrame()).toBe('');
21
+ });
22
+
23
+ test('renders all commands', () => {
24
+ const { lastFrame } = render(
25
+ React.createElement(CommandMenu, { commands: testCommands, selectedIndex: 0 })
26
+ );
27
+ const frame = lastFrame()!;
28
+ expect(frame).toContain('/help');
29
+ expect(frame).toContain('/clear');
30
+ expect(frame).toContain('/model');
31
+ });
32
+
33
+ test('shows descriptions', () => {
34
+ const { lastFrame } = render(
35
+ React.createElement(CommandMenu, { commands: testCommands, selectedIndex: 0 })
36
+ );
37
+ const frame = lastFrame()!;
38
+ expect(frame).toContain('Show help');
39
+ expect(frame).toContain('Clear history');
40
+ });
41
+
42
+ test('highlights selected command with indicator', () => {
43
+ const { lastFrame } = render(
44
+ React.createElement(CommandMenu, { commands: testCommands, selectedIndex: 1 })
45
+ );
46
+ const frame = lastFrame()!;
47
+ // Selected item (index 1 = /clear) should have the selection indicator
48
+ const lines = frame.split('\n');
49
+ // Find the line with /clear
50
+ const clearLine = lines.find(l => l.includes('/clear'));
51
+ expect(clearLine).toContain('❯');
52
+ });
53
+
54
+ test('shows argHint when present', () => {
55
+ const cmdsWithHint: CommandDef[] = [
56
+ { name: 'history', description: 'Show history', argHint: '[search term]', type: 'local', execute: () => {} },
57
+ ];
58
+ const { lastFrame } = render(
59
+ React.createElement(CommandMenu, { commands: cmdsWithHint, selectedIndex: 0 })
60
+ );
61
+ const frame = lastFrame()!;
62
+ expect(frame).toContain('[search term]');
63
+ });
64
+ });
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { colors } from '../theme.js';
4
+ import type { CommandDef } from '../commands/types.js';
5
+
6
+ interface CommandMenuProps {
7
+ /** Filtered command list to display */
8
+ commands: CommandDef[];
9
+ /** Currently selected index */
10
+ selectedIndex: number;
11
+ }
12
+
13
+ export function CommandMenu({ commands, selectedIndex }: CommandMenuProps) {
14
+ if (commands.length === 0) return null;
15
+
16
+ return (
17
+ <Box flexDirection="column" marginLeft={3} marginBottom={0}>
18
+ {commands.map((cmd, i) => {
19
+ const isSelected = i === selectedIndex;
20
+ return (
21
+ <Box key={cmd.name}>
22
+ <Text
23
+ color={isSelected ? colors.primary : colors.muted}
24
+ bold={isSelected}
25
+ >
26
+ {isSelected ? '❯ ' : ' '}
27
+ {'/' + cmd.name}
28
+ </Text>
29
+ {cmd.argHint && (
30
+ <Text color={colors.mutedDark}>{' ' + cmd.argHint}</Text>
31
+ )}
32
+ <Text color={colors.mutedDark}>{' ' + cmd.description}</Text>
33
+ </Box>
34
+ );
35
+ })}
36
+ </Box>
37
+ );
38
+ }
@@ -0,0 +1,43 @@
1
+ import React from 'react';
2
+ import { Text } from 'ink';
3
+ import chalk from 'chalk';
4
+
5
+ interface CursorTextProps {
6
+ /** The text content to display */
7
+ text: string;
8
+ /** Current cursor position (0-indexed) */
9
+ cursorPosition: number;
10
+ }
11
+
12
+ /**
13
+ * Renders text with a block cursor at the specified position.
14
+ * Uses a single Text element with inline ANSI styling to ensure proper
15
+ * text wrapping across multiple terminal lines.
16
+ * - When cursor is within text, the character at cursor position is highlighted (inverse)
17
+ * - When cursor is at end, displays a block cursor (inverse space)
18
+ * - When cursor is on a newline, displays an inverse space at end of line, then the newline
19
+ */
20
+ export function CursorText({ text, cursorPosition }: CursorTextProps) {
21
+ const beforeCursor = text.slice(0, cursorPosition);
22
+ const charAtCursor = cursorPosition < text.length ? text[cursorPosition] : null;
23
+
24
+ // If cursor is on a newline, display an inverse space at end of line
25
+ // then include the newline for proper line break rendering
26
+ const atCursor = charAtCursor === '\n' || charAtCursor === null ? ' ' : charAtCursor;
27
+
28
+ // If cursor is on newline, we need to include the newline after the inverse space
29
+ const afterCursor =
30
+ charAtCursor === '\n'
31
+ ? '\n' + text.slice(cursorPosition + 1)
32
+ : text.slice(cursorPosition + 1);
33
+
34
+ // Build a single string with ANSI escape codes for the cursor
35
+ // This ensures text wraps correctly across terminal lines
36
+ let displayText = beforeCursor + chalk.inverse(atCursor) + afterCursor;
37
+
38
+ // Indent all lines after the first to align with the "> " prompt offset
39
+ const indent = ' ';
40
+ displayText = displayText.replace(/\n/g, '\n' + indent);
41
+
42
+ return <Text>{displayText}</Text>;
43
+ }
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { useDebugLogs } from '../hooks/useDebugLogs.js';
4
+ import { colors } from '../theme.js';
5
+ import type { LogLevel } from '../utils/logger.js';
6
+
7
+ const levelColors: Record<LogLevel, string> = {
8
+ debug: colors.mutedDark,
9
+ info: colors.info,
10
+ warn: colors.warning,
11
+ error: colors.error,
12
+ };
13
+
14
+ interface DebugPanelProps {
15
+ maxLines?: number;
16
+ show?: boolean;
17
+ }
18
+
19
+ export function DebugPanel({ maxLines = 10, show = true }: DebugPanelProps) {
20
+ const logs = useDebugLogs();
21
+
22
+ if (!show || logs.length === 0) return null;
23
+
24
+ const displayLogs = logs.slice(-maxLines);
25
+
26
+ return (
27
+ <Box
28
+ flexDirection="column"
29
+ borderStyle="single"
30
+ borderColor={colors.mutedDark}
31
+ paddingX={1}
32
+ marginTop={1}
33
+ >
34
+ <Text color={colors.mutedDark} dimColor>─ Debug ─</Text>
35
+ {displayLogs.map(entry => (
36
+ <Box key={entry.id}>
37
+ <Text color={levelColors[entry.level]}>
38
+ [{entry.level.toUpperCase().padEnd(5)}]
39
+ </Text>
40
+ <Text> {entry.message}</Text>
41
+ {entry.data !== undefined && (
42
+ <Text color={colors.mutedDark}> {JSON.stringify(entry.data)}</Text>
43
+ )}
44
+ </Box>
45
+ ))}
46
+ </Box>
47
+ );
48
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import React from 'react';
3
+ import { render, cleanup } from 'ink-testing-library';
4
+ import { ErrorBox } from './ErrorBox.js';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('ErrorBox', () => {
9
+ test('renders error message', () => {
10
+ const { lastFrame } = render(
11
+ React.createElement(ErrorBox, { error: 'Something went wrong' })
12
+ );
13
+ const frame = lastFrame()!;
14
+ expect(frame).toContain('Something went wrong');
15
+ });
16
+
17
+ test('shows correct category for rate limit error', () => {
18
+ const { lastFrame } = render(
19
+ React.createElement(ErrorBox, { error: 'Rate limit exceeded' })
20
+ );
21
+ const frame = lastFrame()!;
22
+ expect(frame).toContain('Rate Limit');
23
+ });
24
+
25
+ test('shows suggestions for rate limit error', () => {
26
+ const { lastFrame } = render(
27
+ React.createElement(ErrorBox, { error: 'Rate limit exceeded' })
28
+ );
29
+ const frame = lastFrame()!;
30
+ expect(frame).toContain('Suggestions');
31
+ expect(frame).toContain('Wait');
32
+ });
33
+
34
+ test('shows correct category for auth error', () => {
35
+ const { lastFrame } = render(
36
+ React.createElement(ErrorBox, { error: 'Unauthorized: invalid API key' })
37
+ );
38
+ const frame = lastFrame()!;
39
+ expect(frame).toContain('Authentication Error');
40
+ });
41
+
42
+ test('shows suggestions for network error', () => {
43
+ const { lastFrame } = render(
44
+ React.createElement(ErrorBox, { error: 'connect ECONNREFUSED 127.0.0.1:11434' })
45
+ );
46
+ const frame = lastFrame()!;
47
+ expect(frame).toContain('Network Error');
48
+ expect(frame).toContain('connection');
49
+ });
50
+
51
+ test('shows Unknown Error for unrecognized errors', () => {
52
+ const { lastFrame } = render(
53
+ React.createElement(ErrorBox, { error: 'xyzzy foobar unknown' })
54
+ );
55
+ const frame = lastFrame()!;
56
+ expect(frame).toContain('Unknown Error');
57
+ });
58
+ });
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { colors } from '../theme.js';
4
+ import { classifyError } from '../utils/error-classifier.js';
5
+
6
+ interface ErrorBoxProps {
7
+ error: string;
8
+ }
9
+
10
+ export function ErrorBox({ error }: ErrorBoxProps) {
11
+ const { category, suggestions } = classifyError(error);
12
+
13
+ return (
14
+ <Box flexDirection="column" marginBottom={1}>
15
+ <Text color={colors.error} bold> {category}: <Text color={colors.error}>{error}</Text></Text>
16
+ {suggestions.length > 0 && (
17
+ <Box flexDirection="column" marginLeft={2} marginTop={0}>
18
+ <Text color={colors.muted} dimColor> Suggestions:</Text>
19
+ {suggestions.map((s, i) => (
20
+ <Text key={i} color={colors.muted}> · {s}</Text>
21
+ ))}
22
+ </Box>
23
+ )}
24
+ </Box>
25
+ );
26
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import React from 'react';
3
+ import { render, cleanup } from 'ink-testing-library';
4
+ import { HelpView, ShortcutsView } from './HelpView.js';
5
+ import { registerBuiltinCommands } from '../commands/builtin.js';
6
+
7
+ // Ensure commands are registered before tests
8
+ registerBuiltinCommands();
9
+
10
+ afterEach(cleanup);
11
+
12
+ describe('HelpView', () => {
13
+ test('renders Commands section', () => {
14
+ const { lastFrame } = render(React.createElement(HelpView));
15
+ const frame = lastFrame()!;
16
+ expect(frame).toContain('Commands');
17
+ });
18
+
19
+ test('shows all registered visible commands', () => {
20
+ const { lastFrame } = render(React.createElement(HelpView));
21
+ const frame = lastFrame()!;
22
+ expect(frame).toContain('/help');
23
+ expect(frame).toContain('/clear');
24
+ expect(frame).toContain('/model');
25
+ expect(frame).toContain('/debug');
26
+ expect(frame).toContain('/cost');
27
+ expect(frame).toContain('/history');
28
+ expect(frame).toContain('/export');
29
+ expect(frame).toContain('/compact');
30
+ });
31
+
32
+ test('renders Keyboard Shortcuts section', () => {
33
+ const { lastFrame } = render(React.createElement(HelpView));
34
+ const frame = lastFrame()!;
35
+ expect(frame).toContain('Keyboard Shortcuts');
36
+ });
37
+
38
+ test('shows common keyboard shortcuts', () => {
39
+ const { lastFrame } = render(React.createElement(HelpView));
40
+ const frame = lastFrame()!;
41
+ expect(frame).toContain('Esc');
42
+ expect(frame).toContain('Ctrl+C');
43
+ expect(frame).toContain('Shift+Enter');
44
+ expect(frame).toContain('Ctrl+D');
45
+ });
46
+
47
+ test('shows tip about typing /', () => {
48
+ const { lastFrame } = render(React.createElement(HelpView));
49
+ const frame = lastFrame()!;
50
+ expect(frame).toContain('Type / to see available commands');
51
+ });
52
+ });
53
+
54
+ describe('ShortcutsView', () => {
55
+ test('renders Keyboard Shortcuts header', () => {
56
+ const { lastFrame } = render(React.createElement(ShortcutsView));
57
+ const frame = lastFrame()!;
58
+ expect(frame).toContain('Keyboard Shortcuts');
59
+ });
60
+
61
+ test('shows all shortcuts', () => {
62
+ const { lastFrame } = render(React.createElement(ShortcutsView));
63
+ const frame = lastFrame()!;
64
+ expect(frame).toContain('Esc');
65
+ expect(frame).toContain('Cancel');
66
+ expect(frame).toContain('Up/Down');
67
+ expect(frame).toContain('Ctrl+A');
68
+ expect(frame).toContain('Ctrl+E');
69
+ });
70
+ });
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { colors } from '../theme.js';
4
+ import { commandRegistry } from '../commands/registry.js';
5
+
6
+ const KEYBOARD_SHORTCUTS = [
7
+ { keys: 'Esc', description: 'Cancel/interrupt agent' },
8
+ { keys: 'Ctrl+C', description: 'Cancel or exit' },
9
+ { keys: 'Shift+Enter', description: 'New line (multi-line input)' },
10
+ { keys: 'Up/Down', description: 'Navigate input history' },
11
+ { keys: 'Ctrl+A / Ctrl+E', description: 'Start/end of line' },
12
+ { keys: 'Opt+Left/Right', description: 'Word navigation' },
13
+ { keys: 'Opt+Backspace', description: 'Delete word' },
14
+ { keys: 'Ctrl+D', description: 'Toggle debug panel' },
15
+ ];
16
+
17
+ export function HelpView() {
18
+ const commands = commandRegistry.getVisible();
19
+
20
+ return (
21
+ <Box flexDirection="column" marginY={1}>
22
+ <Text color={colors.primary} bold>Commands</Text>
23
+ <Box flexDirection="column" marginLeft={2} marginBottom={1}>
24
+ {commands.map(cmd => (
25
+ <Box key={cmd.name}>
26
+ <Text color={colors.accent}>{'/' + cmd.name.padEnd(12)}</Text>
27
+ <Text color={colors.muted}>{cmd.description}</Text>
28
+ </Box>
29
+ ))}
30
+ </Box>
31
+
32
+ <Text color={colors.primary} bold>Keyboard Shortcuts</Text>
33
+ <Box flexDirection="column" marginLeft={2} marginBottom={1}>
34
+ {KEYBOARD_SHORTCUTS.map(s => (
35
+ <Box key={s.keys}>
36
+ <Text color={colors.accent}>{s.keys.padEnd(20)}</Text>
37
+ <Text color={colors.muted}>{s.description}</Text>
38
+ </Box>
39
+ ))}
40
+ </Box>
41
+
42
+ <Text color={colors.muted}>Type / to see available commands</Text>
43
+ </Box>
44
+ );
45
+ }
46
+
47
+ export function ShortcutsView() {
48
+ return (
49
+ <Box flexDirection="column" marginY={1}>
50
+ <Text color={colors.primary} bold>Keyboard Shortcuts</Text>
51
+ <Box flexDirection="column" marginLeft={2}>
52
+ {KEYBOARD_SHORTCUTS.map(s => (
53
+ <Box key={s.keys}>
54
+ <Text color={colors.accent}>{s.keys.padEnd(20)}</Text>
55
+ <Text color={colors.muted}>{s.description}</Text>
56
+ </Box>
57
+ ))}
58
+ </Box>
59
+ </Box>
60
+ );
61
+ }
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { colors } from '../theme.js';
4
+ import { EventListView } from './AgentEventView.js';
5
+ import type { DisplayEvent } from './AgentEventView.js';
6
+ import { AnswerBox } from './AnswerBox.js';
7
+ import type { TokenUsage } from '../agent/types.js';
8
+
9
+ /**
10
+ * Format duration in milliseconds to human-readable string
11
+ * e.g., "1m 31s", "45s", "500ms"
12
+ */
13
+ function formatDuration(ms: number): string {
14
+ // Show milliseconds for sub-second durations
15
+ if (ms < 1000) {
16
+ return `${Math.round(ms)}ms`;
17
+ }
18
+
19
+ const totalSeconds = Math.round(ms / 1000);
20
+ const minutes = Math.floor(totalSeconds / 60);
21
+ const seconds = totalSeconds % 60;
22
+
23
+ if (minutes === 0) {
24
+ return `${seconds}s`;
25
+ }
26
+
27
+ return `${minutes}m ${seconds}s`;
28
+ }
29
+
30
+ /**
31
+ * Format performance stats into a single string
32
+ */
33
+ function formatPerformanceStats(
34
+ duration?: number,
35
+ tokenUsage?: TokenUsage,
36
+ tokensPerSecond?: number
37
+ ): string {
38
+ const parts: string[] = [];
39
+ if (duration !== undefined) parts.push(formatDuration(duration));
40
+ if (tokenUsage) parts.push(`${tokenUsage.totalTokens.toLocaleString()} tokens`);
41
+ if (tokensPerSecond !== undefined) parts.push(`(${tokensPerSecond.toFixed(1)} tok/s)`);
42
+ return parts.join(' · ');
43
+ }
44
+
45
+ export type HistoryItemStatus = 'processing' | 'complete' | 'error' | 'interrupted';
46
+
47
+ export interface HistoryItem {
48
+ id: string;
49
+ query: string;
50
+ events: DisplayEvent[];
51
+ answer: string;
52
+ status: HistoryItemStatus;
53
+ activeToolId?: string;
54
+ /** Timestamp when this query started processing */
55
+ startTime?: number;
56
+ /** Total duration in milliseconds (set when complete) */
57
+ duration?: number;
58
+ /** Token usage statistics */
59
+ tokenUsage?: TokenUsage;
60
+ /** Tokens per second throughput */
61
+ tokensPerSecond?: number;
62
+ }
63
+
64
+ interface HistoryItemViewProps {
65
+ item: HistoryItem;
66
+ }
67
+
68
+ export function HistoryItemView({ item }: HistoryItemViewProps) {
69
+ // Add spacing after completed items, but not during processing
70
+ const isComplete = item.status === 'complete' || item.status === 'error' || item.status === 'interrupted';
71
+
72
+ return (
73
+ <Box flexDirection="column" marginBottom={isComplete ? 1 : 0}>
74
+ {/* Query */}
75
+ <Box>
76
+ <Text color={colors.muted} backgroundColor={colors.queryBg}>{'❯ '}</Text>
77
+ <Text color={colors.white} backgroundColor={colors.queryBg}>{`${item.query} `}</Text>
78
+ </Box>
79
+
80
+ {/* Interrupted indicator */}
81
+ {item.status === 'interrupted' && (
82
+ <Box marginLeft={2}>
83
+ <Text color={colors.muted}>⎿ Interrupted · What should Brownian do instead?</Text>
84
+ </Box>
85
+ )}
86
+
87
+ {/* Events (tool calls, thinking) */}
88
+ <EventListView
89
+ events={item.events}
90
+ activeToolId={item.status === 'processing' ? item.activeToolId : undefined}
91
+ />
92
+
93
+ {/* Answer - only show if we have one */}
94
+ {item.answer && (
95
+ <Box>
96
+ <AnswerBox text={item.answer} />
97
+ </Box>
98
+ )}
99
+
100
+ {/* Performance stats - only show when token data is present */}
101
+ {item.status === 'complete' && item.tokenUsage && (
102
+ <Box marginTop={1}>
103
+ <Text color={colors.muted}>✻ {formatPerformanceStats(item.duration, item.tokenUsage, item.tokensPerSecond)}</Text>
104
+ </Box>
105
+ )}
106
+ </Box>
107
+ );
108
+ }