apex-dev 2.0.0 → 3.0.0

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 (77) hide show
  1. package/.local/share/amp/history.jsonl +33 -0
  2. package/.local/share/amp/session.json +3 -3
  3. package/.local/share/amp/threads/T-019c9761-858c-719b-911f-bc2e4c8cbdde.json +188 -0
  4. package/.local/share/amp/threads/T-019c9761-f5f3-7606-a900-ebe7f10d6e37.json +121 -0
  5. package/.local/share/amp/threads/T-019c9763-b1ae-729d-90aa-f59938ce912e.json +799 -0
  6. package/.local/share/amp/threads/T-019c9769-4a8a-77b8-beab-f48973276f9a.json +1541 -0
  7. package/.local/share/amp/threads/T-019c9772-edac-7075-b26e-0ada1f8697d2.json +7 -0
  8. package/.local/share/amp/threads/T-019c97e8-a9ab-71a1-a8f9-109c540c98bf.json +111 -0
  9. package/.local/share/amp/threads/T-019c97e9-2277-753c-8c5d-df745fa6cfff.json +7 -0
  10. package/.local/share/amp/threads/T-019c97e9-f28e-758d-9663-e37047a8ed95.json +111 -0
  11. package/.local/share/amp/threads/T-019c97ea-17c7-77b8-92b2-f641c069bcc9.json +71 -0
  12. package/.local/share/amp/threads/T-019c97ea-44c6-75b8-88bc-d88113194f6a.json +1611 -0
  13. package/.local/share/amp/threads/T-019c97ec-abae-7251-a5f6-693adf496a1c.json +7 -0
  14. package/.local/share/amp/threads/T-019c97f5-8e61-73ad-8c5d-2637abedcde6.json +1341 -0
  15. package/.local/share/amp/threads/T-019c989d-4f4e-7249-bde0-21d19455ccae.json +163 -0
  16. package/.local/share/amp/threads/T-019c989d-9024-73c4-bee8-e2ae45028a39.json +124 -0
  17. package/.local/share/amp/threads/T-019c989e-1394-74ad-8234-ac573fcdb4c7.json +1260 -0
  18. package/.local/share/amp/threads/T-019c989f-e3dd-772e-85ac-525d0fc88fda.json +403 -0
  19. package/.local/share/amp/threads/T-019c98a1-7b0c-778a-b311-2e1cff85d710.json +3422 -0
  20. package/.local/share/amp/threads/T-019c98c5-4b7f-7284-99e9-88aa8c18ba66.json +1830 -0
  21. package/.local/share/amp/threads/T-019c98d0-f27f-76ec-be10-6df96f22be99.json +4061 -0
  22. package/.local/share/amp/threads/T-019c98f9-d031-704d-a0c2-f2f395f68f2b.json +509 -0
  23. package/.local/share/amp/threads/T-019c9919-f9ee-766c-90be-af7a07f6a4c6.json +2075 -0
  24. package/.local/share/amp/threads/T-019c991c-b98b-7158-9083-cc52408beb13.json +7 -0
  25. package/.local/share/amp/threads/T-019c991d-66d6-72aa-a9a1-105f7df0ea06.json +7 -0
  26. package/.local/share/amp/threads/T-019c9c2e-71a4-77ff-bd7f-b053da7f9000.json +1637 -0
  27. package/.local/share/amp/threads/T-019c9c45-27ca-728b-ba77-835115dfa9b2.json +3893 -0
  28. package/.local/share/amp/threads/T-019c9c48-45dc-736a-9752-e4119fe698f9.json +7 -0
  29. package/.local/share/amp/threads/T-019c9c4d-266b-72d0-b56e-74a5777e6583.json +7 -0
  30. package/.local/share/amp/threads/T-019c9c52-ab89-758f-9178-bda99c39d10b.json +7 -0
  31. package/.local/share/opencode/opencode.db +0 -0
  32. package/.local/share/opencode/opencode.db-shm +0 -0
  33. package/.local/share/opencode/opencode.db-wal +0 -0
  34. package/.local/share/opencode/storage/agent-usage-reminder/ses_36870ea98ffe8S5ZOCE4F11yFh.json +6 -0
  35. package/.local/share/opencode/storage/agent-usage-reminder/ses_3687a3e9affewUnHBzvpiPR6df.json +6 -0
  36. package/.local/share/opencode/storage/agent-usage-reminder/ses_36886e68dffeKVgUWf6lzXdEEt.json +6 -0
  37. package/.local/share/opencode/storage/session_diff/ses_36870ea98ffe8S5ZOCE4F11yFh.json +1 -0
  38. package/.local/share/opencode/storage/session_diff/ses_3687a3e9affewUnHBzvpiPR6df.json +1 -0
  39. package/.local/share/opencode/storage/session_diff/ses_36886e68dffeKVgUWf6lzXdEEt.json +1 -0
  40. package/.local/state/replit/log-query.db +0 -0
  41. package/.local/state/replit/log-query.db-shm +0 -0
  42. package/.local/state/replit/log-query.db-wal +0 -0
  43. package/.upm/store.json +1 -1
  44. package/AGENTS.md +32 -0
  45. package/bun.lock +137 -103
  46. package/index.jsx +24 -0
  47. package/package.json +9 -9
  48. package/src/agent.js +252 -169
  49. package/src/app.jsx +96 -0
  50. package/src/commands.js +66 -38
  51. package/src/components/AssistantMessage.jsx +83 -0
  52. package/src/components/ChatArea.jsx +84 -0
  53. package/src/components/DiffView.jsx +26 -0
  54. package/src/components/Divider.jsx +8 -0
  55. package/src/components/Header.jsx +44 -0
  56. package/src/components/HelpModal.jsx +81 -0
  57. package/src/components/InputBar.jsx +32 -0
  58. package/src/components/Spinner.jsx +23 -0
  59. package/src/components/StatusBar.jsx +44 -0
  60. package/src/components/SystemMessage.jsx +31 -0
  61. package/src/components/ThinkBlock.jsx +29 -0
  62. package/src/components/ToolCallItem.jsx +43 -0
  63. package/src/components/UserMessage.jsx +11 -0
  64. package/src/components/Welcome.jsx +14 -0
  65. package/src/config.js +118 -2
  66. package/src/hooks/useLayout.js +15 -0
  67. package/src/hooks/useStore.js +6 -0
  68. package/src/prompt.js +67 -48
  69. package/src/store.js +99 -0
  70. package/src/theme.js +19 -0
  71. package/src/thinking.js +0 -24
  72. package/src/toolExecutors.js +580 -23
  73. package/src/tools.js +146 -4
  74. package/src/utils.js +32 -0
  75. package/tsconfig.json +10 -0
  76. package/index.js +0 -92
  77. package/src/ui.js +0 -269
package/src/app.jsx ADDED
@@ -0,0 +1,96 @@
1
+ import { useCallback } from 'react';
2
+ import { useKeyboard } from '@opentui/react';
3
+ import { useStore } from './hooks/useStore.js';
4
+ import { setState, addMessage, clearMessages, getRenderer } from './store.js';
5
+ import { session } from './config.js';
6
+ import { handleUserInput } from './agent.js';
7
+ import { handleSlashCommand } from './commands.js';
8
+ import Header from './components/Header.jsx';
9
+ import Divider from './components/Divider.jsx';
10
+ import ChatArea from './components/ChatArea.jsx';
11
+ import InputBar from './components/InputBar.jsx';
12
+ import StatusBar from './components/StatusBar.jsx';
13
+ import HelpModal from './components/HelpModal.jsx';
14
+
15
+ function exitApp() {
16
+ const renderer = getRenderer();
17
+ if (renderer) renderer.destroy();
18
+
19
+ const elapsed = ((Date.now() - session.startTime) / 1000 / 60).toFixed(1);
20
+ const parts = [
21
+ `${elapsed} min`,
22
+ `${session.turnCount} turns`,
23
+ `${session.toolCallCount} tool calls`,
24
+ `${session.totalTokens.toLocaleString()} tokens`,
25
+ `$${session.totalCost.toFixed(4)}`,
26
+ ];
27
+ if (session.filesModified.size > 0) parts.push(`${session.filesModified.size} files modified`);
28
+ if (session.commandsRun.length > 0) parts.push(`${session.commandsRun.length} commands`);
29
+
30
+ console.log(`\n Session: ${parts.join(' · ')}\n`);
31
+ console.log(' Goodbye! ✦\n');
32
+ process.exit(0);
33
+ }
34
+
35
+ export default function App() {
36
+ const state = useStore();
37
+
38
+ useKeyboard((key) => {
39
+ if (key.ctrl && key.name === 'c') {
40
+ exitApp();
41
+ }
42
+ });
43
+
44
+ const handleInput = useCallback(async (value) => {
45
+ if (value === 'exit' || value === 'quit') {
46
+ exitApp();
47
+ return;
48
+ }
49
+
50
+ if (value.startsWith('/')) {
51
+ const result = await handleSlashCommand(value);
52
+ if (result?.action === 'quit') {
53
+ exitApp();
54
+ }
55
+ return;
56
+ }
57
+
58
+ handleUserInput(value).catch(err => {
59
+ addMessage({ role: 'system', content: `Error: ${err.message}` });
60
+ setState({ isProcessing: false });
61
+ });
62
+ }, []);
63
+
64
+ const handleHelpCommand = useCallback((cmd) => {
65
+ if (cmd) {
66
+ handleSlashCommand(cmd).then(result => {
67
+ if (result?.action === 'quit') exitApp();
68
+ });
69
+ }
70
+ }, []);
71
+
72
+ return (
73
+ <box style={{ flexDirection: 'column', flexGrow: 1 }}>
74
+ <Header />
75
+ <Divider />
76
+ <ChatArea
77
+ messages={state.messages}
78
+ streamingContent={state.streamingContent}
79
+ streamingThinking={state.streamingThinking}
80
+ isProcessing={state.isProcessing}
81
+ />
82
+ <Divider />
83
+ <StatusBar isProcessing={state.isProcessing} />
84
+ <InputBar
85
+ disabled={state.isProcessing || state.showHelp}
86
+ onSubmit={handleInput}
87
+ />
88
+ {state.showHelp ? (
89
+ <HelpModal
90
+ onClose={() => setState({ showHelp: false })}
91
+ onCommand={handleHelpCommand}
92
+ />
93
+ ) : null}
94
+ </box>
95
+ );
96
+ }
package/src/commands.js CHANGED
@@ -3,103 +3,131 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execSync } = require('child_process');
6
- const { PROJECT_ROOT, session, resolvePath } = require('./config');
7
- const { t, indent, showHeader, showSessionSummary, showHelpMenu } = require('./ui');
6
+ const { PROJECT_ROOT, session, resolvePath, getMode, setMode } = require('./config');
8
7
  const { executeTool } = require('./toolExecutors');
8
+ const store = require('./store');
9
9
 
10
- async function handleSlashCommand(input, rl) {
10
+ async function handleSlashCommand(input) {
11
11
  const [cmd, ...rest] = input.split(' ');
12
12
  const arg = rest.join(' ');
13
13
 
14
14
  switch (cmd) {
15
15
  case '/help':
16
- showHelpMenu();
16
+ store.setState({ showHelp: true });
17
17
  break;
18
18
 
19
19
  case '/clear':
20
20
  session.conversationHistory = [];
21
- showHeader();
22
- console.log(indent(t.green('✓ ') + t.muted('Conversation cleared.')));
23
- console.log();
21
+ store.clearMessages();
22
+ store.addMessage({ role: 'system', content: 'Conversation cleared.' });
24
23
  break;
25
24
 
26
25
  case '/files':
27
26
  case '/ls': {
28
27
  const dirPath = arg ? resolvePath(arg) : PROJECT_ROOT;
29
- console.log();
30
- console.log(indent(t.text.bold('Project Files')));
31
- console.log();
28
+ store.addMessage({ role: 'system', content: 'Loading file tree...', label: 'Project Files' });
32
29
  const result = await executeTool('ListDir', { path: dirPath, recursive: true });
33
- console.log(indent(t.dim(result), 4));
34
- console.log();
30
+ store.addMessage({ role: 'system', content: result, label: 'Project Files' });
35
31
  break;
36
32
  }
37
33
 
38
34
  case '/cost':
39
35
  case '/status': {
40
36
  const elapsed = ((Date.now() - session.startTime) / 1000 / 60).toFixed(1);
41
- console.log();
42
- console.log(indent(` ${t.dim('Session')} ${t.text(elapsed + ' min')} ${t.dim('Turns')} ${t.text(String(session.turnCount))} ${t.dim('Tools')} ${t.text(String(session.toolCallCount))} ${t.dim('Tokens')} ${t.text(session.totalTokens.toLocaleString())} ${t.dim('Cost')} ${t.text('$' + session.totalCost.toFixed(4))}`));
43
- console.log();
37
+ const parts = [
38
+ `Session: ${elapsed} min`,
39
+ `Turns: ${session.turnCount}`,
40
+ `Tools: ${session.toolCallCount}`,
41
+ `Tokens: ${session.totalTokens.toLocaleString()}`,
42
+ `Cost: $${session.totalCost.toFixed(4)}`,
43
+ ];
44
+ if (session.filesModified.size > 0) parts.push(`Files modified: ${session.filesModified.size}`);
45
+ if (session.commandsRun.length > 0) parts.push(`Commands: ${session.commandsRun.length}`);
46
+ store.addMessage({ role: 'system', content: parts.join('\n'), label: 'Session Stats' });
44
47
  break;
45
48
  }
46
49
 
47
50
  case '/undo': {
48
51
  if (session.editHistory.length === 0) {
49
- console.log(indent(t.yellow('No edits to undo.')));
52
+ store.addMessage({ role: 'system', content: 'No edits to undo.' });
50
53
  } else {
51
54
  const last = session.editHistory[session.editHistory.length - 1];
52
55
  fs.writeFileSync(last.path, last.before, 'utf-8');
53
56
  session.editHistory.pop();
54
- console.log(indent(t.green('✓ ') + t.muted(`Undone last edit to ${path.basename(last.path)}`)));
57
+ store.addMessage({ role: 'system', content: `Undone last edit to ${path.basename(last.path)}` });
55
58
  }
56
- console.log();
57
59
  break;
58
60
  }
59
61
 
60
62
  case '/diff': {
61
63
  try {
62
64
  const diff = execSync('git diff --stat 2>/dev/null', { encoding: 'utf-8', cwd: PROJECT_ROOT });
63
- console.log();
64
- console.log(indent(t.text.bold('Git Diff')));
65
- console.log(indent(t.dim(diff || '(no changes)'), 4));
66
- console.log();
65
+ store.addMessage({ role: 'system', content: diff || '(no changes)', label: 'Git Diff' });
67
66
  } catch {
68
- console.log(indent(t.yellow('Not a git repository.')));
69
- console.log();
67
+ store.addMessage({ role: 'system', content: 'Not a git repository.' });
70
68
  }
71
69
  break;
72
70
  }
73
71
 
74
72
  case '/git': {
75
73
  if (!arg) {
76
- console.log(indent(t.yellow('Usage: /git <command>')));
77
- console.log();
74
+ store.addMessage({ role: 'system', content: 'Usage: /git <command>' });
78
75
  break;
79
76
  }
80
77
  try {
81
78
  const output = execSync(`git ${arg}`, { encoding: 'utf-8', cwd: PROJECT_ROOT });
82
- console.log();
83
- console.log(indent(t.dim(output), 4));
84
- console.log();
79
+ store.addMessage({ role: 'system', content: output || '(no output)', label: `git ${arg}` });
85
80
  } catch (err) {
86
- console.log(indent(t.red(err.stderr || err.message)));
87
- console.log();
81
+ store.addMessage({ role: 'system', content: err.stderr || err.message });
82
+ }
83
+ break;
84
+ }
85
+
86
+ case '/mode': {
87
+ if (!arg) {
88
+ const current = getMode();
89
+ const modes = ['default', 'max', 'lite'];
90
+ const modeDescriptions = {
91
+ default: 'Single agent pass with auto code review',
92
+ max: 'Multi-strategy editing, best-of-N thinking, multi-perspective review, auto context pruning',
93
+ lite: 'Fast mode — skips validation and review steps',
94
+ };
95
+ const lines = modes.map(m =>
96
+ `${m === current ? '→ ' : ' '}${m.padEnd(10)} ${modeDescriptions[m]}`
97
+ );
98
+ store.addMessage({ role: 'system', content: `Current mode: ${current}\n\n${lines.join('\n')}`, label: 'Mode' });
99
+ } else {
100
+ const success = setMode(arg.trim());
101
+ if (success) {
102
+ store.addMessage({ role: 'system', content: `Mode set to: ${arg.trim()}`, label: 'Mode' });
103
+ } else {
104
+ store.addMessage({ role: 'system', content: `Invalid mode: ${arg}. Use default, max, or lite.` });
105
+ }
106
+ }
107
+ break;
108
+ }
109
+
110
+ case '/compact': {
111
+ const pruneId = store.addMessage({ role: 'system', content: 'Compacting conversation...', label: 'Context Pruner' });
112
+ try {
113
+ const result = await executeTool('ContextPruner', {}, (partial) => {
114
+ store.updateMessage(pruneId, { content: partial, label: 'Context Pruner' });
115
+ });
116
+ store.updateMessage(pruneId, { content: result, label: 'Context Pruner' });
117
+ } catch (err) {
118
+ store.updateMessage(pruneId, { content: `Compaction failed: ${err.message}` });
88
119
  }
89
120
  break;
90
121
  }
91
122
 
92
123
  case '/quit':
93
- console.log();
94
- showSessionSummary();
95
- console.log(indent(t.dim('Goodbye! ') + t.primary('✦')));
96
- console.log();
97
- process.exit(0);
124
+ return { action: 'quit' };
98
125
 
99
126
  default:
100
- console.log(indent(t.yellow(`Unknown command: ${cmd}. Type /help for available commands.`)));
101
- console.log();
127
+ store.addMessage({ role: 'system', content: `Unknown command: ${cmd}. Type /help for available commands.` });
102
128
  }
129
+
130
+ return null;
103
131
  }
104
132
 
105
133
  module.exports = { handleSlashCommand };
@@ -0,0 +1,83 @@
1
+ import { TextAttributes } from '@opentui/core';
2
+ import { colors } from '../theme.js';
3
+ import { useLayout } from '../hooks/useLayout.js';
4
+
5
+ export default function AssistantMessage({ content, isStreaming }) {
6
+ const { indent, isNarrow, width } = useLayout();
7
+ const codeIndent = isNarrow ? 1 : 2;
8
+ const separatorWidth = Math.min(width - indent - codeIndent, isNarrow ? 40 : 60);
9
+ if (!content) return null;
10
+
11
+ const lines = content.split('\n');
12
+ const rendered = [];
13
+ let inCodeBlock = false;
14
+ let codeLines = [];
15
+ let codeLang = '';
16
+
17
+ for (let i = 0; i < lines.length; i++) {
18
+ const line = lines[i];
19
+
20
+ if (line.startsWith('```') && !inCodeBlock) {
21
+ inCodeBlock = true;
22
+ codeLang = line.slice(3).trim() || 'code';
23
+ codeLines = [];
24
+ } else if (line.startsWith('```') && inCodeBlock) {
25
+ inCodeBlock = false;
26
+ rendered.push(
27
+ <box key={`code-${i}`} style={{ flexDirection: 'column', paddingLeft: codeIndent, marginTop: 0 }}>
28
+ <text fg={colors.dim} content={`── ${codeLang} ──`} />
29
+ {codeLines.map((cl, j) => (
30
+ <text key={j}>
31
+ <span fg={colors.dim}>{String(j + 1).padStart(isNarrow ? 2 : 3) + ' │ '}</span>
32
+ <span fg={colors.text}>{cl}</span>
33
+ </text>
34
+ ))}
35
+ <text fg={colors.dim} content={'─'.repeat(Math.max(separatorWidth, 10))} />
36
+ </box>
37
+ );
38
+ } else if (inCodeBlock) {
39
+ codeLines.push(line);
40
+ } else {
41
+ const processed = line.replace(/`([^`]+)`/g, '«$1»');
42
+ if (processed.includes('«')) {
43
+ const parts = processed.split(/«|»/);
44
+ rendered.push(
45
+ <text key={`line-${i}`}>
46
+ {parts.map((part, j) =>
47
+ j % 2 === 0
48
+ ? <span key={j} fg={colors.text}>{part}</span>
49
+ : <span key={j} fg={colors.cyan}>{part}</span>
50
+ )}
51
+ </text>
52
+ );
53
+ } else {
54
+ rendered.push(
55
+ <text key={`line-${i}`}>
56
+ <span fg={colors.text}>{line}</span>
57
+ </text>
58
+ );
59
+ }
60
+ }
61
+ }
62
+
63
+ if (inCodeBlock && codeLines.length > 0) {
64
+ rendered.push(
65
+ <box key="code-tail" style={{ flexDirection: 'column', paddingLeft: codeIndent }}>
66
+ <text fg={colors.dim} content={`── ${codeLang} ──`} />
67
+ {codeLines.map((cl, j) => (
68
+ <text key={j}>
69
+ <span fg={colors.dim}>{String(j + 1).padStart(isNarrow ? 2 : 3) + ' │ '}</span>
70
+ <span fg={colors.text}>{cl}</span>
71
+ </text>
72
+ ))}
73
+ </box>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <box style={{ flexDirection: 'column', paddingLeft: indent }}>
79
+ {rendered}
80
+ {isStreaming ? <text fg={colors.accent} content="▊" /> : null}
81
+ </box>
82
+ );
83
+ }
@@ -0,0 +1,84 @@
1
+ import { TextAttributes } from '@opentui/core';
2
+ import { colors } from '../theme.js';
3
+ import { useLayout } from '../hooks/useLayout.js';
4
+ import Welcome from './Welcome.jsx';
5
+ import UserMessage from './UserMessage.jsx';
6
+ import AssistantMessage from './AssistantMessage.jsx';
7
+ import ThinkBlock from './ThinkBlock.jsx';
8
+ import ToolCallItem from './ToolCallItem.jsx';
9
+ import DiffView from './DiffView.jsx';
10
+ import SystemMessage from './SystemMessage.jsx';
11
+ import Spinner from './Spinner.jsx';
12
+ import { toggleMessageExpanded } from '../store.js';
13
+
14
+ function MessageItem({ message }) {
15
+ const { width } = useLayout();
16
+ switch (message.role) {
17
+ case 'user':
18
+ return <UserMessage content={message.content} />;
19
+ case 'assistant':
20
+ return (
21
+ <box style={{ flexDirection: 'column', marginTop: 1 }}>
22
+ <text fg={colors.primary} attributes={TextAttributes.BOLD} style={{ paddingLeft: 1 }} content="Apex" />
23
+ <AssistantMessage content={message.content} />
24
+ </box>
25
+ );
26
+ case 'thinking':
27
+ return (
28
+ <ThinkBlock
29
+ content={message.content}
30
+ expanded={message.expanded}
31
+ onToggle={() => toggleMessageExpanded(message.id)}
32
+ />
33
+ );
34
+ case 'tool':
35
+ return <ToolCallItem message={message} />;
36
+ case 'diff':
37
+ return <DiffView filename={message.filename} content={message.content} />;
38
+ case 'system':
39
+ return <SystemMessage message={message} />;
40
+ case 'divider':
41
+ return <text fg={colors.dim} style={{ paddingLeft: 1 }} content={'─'.repeat(Math.max(width - 2, 10))} />;
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export default function ChatArea({ messages, streamingContent, streamingThinking, isProcessing }) {
48
+ const { indent } = useLayout();
49
+ return (
50
+ <scrollbox
51
+ style={{ flexGrow: 1 }}
52
+ focused
53
+ stickyScroll
54
+ stickyStart="bottom"
55
+ scrollY
56
+ >
57
+ <box style={{ flexDirection: 'column' }}>
58
+ <Welcome />
59
+ {messages.map(msg => (
60
+ <MessageItem key={msg.id} message={msg} />
61
+ ))}
62
+ {streamingThinking ? (
63
+ <box style={{ paddingLeft: 2, marginTop: 0 }}>
64
+ <text fg={colors.dim} attributes={TextAttributes.ITALIC}>
65
+ <span fg={colors.dim}>{'▸ Thinking: '}</span>
66
+ <span fg={colors.dim}>{streamingThinking.slice(-100)}</span>
67
+ </text>
68
+ </box>
69
+ ) : null}
70
+ {streamingContent ? (
71
+ <box style={{ flexDirection: 'column', marginTop: 0 }}>
72
+ <AssistantMessage content={streamingContent} isStreaming />
73
+ </box>
74
+ ) : null}
75
+ {isProcessing && !streamingContent && !streamingThinking ? (
76
+ <box style={{ paddingLeft: indent, marginTop: 1 }}>
77
+ <Spinner label="Reasoning..." />
78
+ </box>
79
+ ) : null}
80
+ <box style={{ height: 1 }} />
81
+ </box>
82
+ </scrollbox>
83
+ );
84
+ }
@@ -0,0 +1,26 @@
1
+ import { TextAttributes } from '@opentui/core';
2
+ import { colors } from '../theme.js';
3
+ import { useLayout } from '../hooks/useLayout.js';
4
+
5
+ const path = require('path');
6
+
7
+ export default function DiffView({ filename, content }) {
8
+ const { indent } = useLayout();
9
+ if (!content) return null;
10
+ const lines = content.split('\n');
11
+
12
+ return (
13
+ <box style={{ flexDirection: 'column', paddingLeft: indent }}>
14
+ <text fg={colors.text} attributes={TextAttributes.BOLD} content={path.basename(filename || '')} />
15
+ {lines.map((line, i) => {
16
+ if (line.startsWith('+')) {
17
+ return <text key={i} fg={colors.green} content={line} />;
18
+ }
19
+ if (line.startsWith('-')) {
20
+ return <text key={i} fg={colors.red} content={line} />;
21
+ }
22
+ return null;
23
+ })}
24
+ </box>
25
+ );
26
+ }
@@ -0,0 +1,8 @@
1
+ import { colors } from '../theme.js';
2
+ import { useLayout } from '../hooks/useLayout.js';
3
+
4
+ export default function Divider() {
5
+ const { width } = useLayout();
6
+ const cols = Math.min(width, 120);
7
+ return <text fg={colors.dim} content={'─'.repeat(cols)} />;
8
+ }
@@ -0,0 +1,44 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { TextAttributes } from '@opentui/core';
3
+ import { colors } from '../theme.js';
4
+ import { PROJECT_ROOT, getMode } from '../config.js';
5
+ import { useLayout } from '../hooks/useLayout.js';
6
+
7
+ const path = require('path');
8
+ const { execSync } = require('child_process');
9
+
10
+ export default function Header() {
11
+ const [branch, setBranch] = useState('');
12
+ const { isNarrow } = useLayout();
13
+ const cwd = path.basename(PROJECT_ROOT);
14
+ const mode = getMode();
15
+ const modeColors = { default: colors.dim, max: colors.accent, lite: colors.muted };
16
+
17
+ useEffect(() => {
18
+ try {
19
+ const b = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', {
20
+ encoding: 'utf-8',
21
+ cwd: PROJECT_ROOT,
22
+ }).trim();
23
+ setBranch(b);
24
+ } catch {}
25
+ }, []);
26
+
27
+ return (
28
+ <box style={{ flexDirection: 'row', paddingLeft: 1, paddingRight: 1 }}>
29
+ <text>
30
+ <span fg={colors.primary} attributes={TextAttributes.BOLD}>⚡ Apex</span>
31
+ <span fg={colors.dim}>{' '}</span>
32
+ <span fg={modeColors[mode] || colors.dim}>[{mode}]</span>
33
+ <span fg={colors.dim}>{' '}</span>
34
+ <span fg={colors.muted}>{isNarrow && cwd.length > 12 ? cwd.slice(0, 12) + '…' : cwd}</span>
35
+ {branch && !isNarrow ? (
36
+ <>
37
+ <span fg={colors.dim}>{' on '}</span>
38
+ <span fg={colors.text}>{branch}</span>
39
+ </>
40
+ ) : null}
41
+ </text>
42
+ </box>
43
+ );
44
+ }
@@ -0,0 +1,81 @@
1
+ import { TextAttributes } from '@opentui/core';
2
+ import { useKeyboard } from '@opentui/react';
3
+ import { colors } from '../theme.js';
4
+ import { useLayout } from '../hooks/useLayout.js';
5
+
6
+ const COMMANDS = [
7
+ { cmd: '/help', desc: 'Show this menu' },
8
+ { cmd: '/mode', desc: 'Show/set mode (default, max, lite)' },
9
+ { cmd: '/compact', desc: 'Compact conversation context' },
10
+ { cmd: '/files', desc: 'Show project file tree' },
11
+ { cmd: '/clear', desc: 'Clear conversation' },
12
+ { cmd: '/cost', desc: 'Show session stats' },
13
+ { cmd: '/undo', desc: 'Undo last edit' },
14
+ { cmd: '/diff', desc: 'Show git diff' },
15
+ { cmd: '/git <cmd>', desc: 'Run a git command' },
16
+ { cmd: '/quit', desc: 'Exit' },
17
+ ];
18
+
19
+ const TOOLS = [
20
+ 'Read', 'Write', 'Edit', 'Patch', 'Bash', 'Grep', 'Glob', 'ListDir', 'UndoEdit', 'Task', 'CodeReview',
21
+ ];
22
+
23
+ const SUBAGENTS = [
24
+ 'FilePickerMax', 'Thinker', 'ThinkerBestOfN*', 'EditorMultiPrompt*', 'CodeReviewMulti*', 'Commander', 'ContextPruner',
25
+ ];
26
+
27
+ export default function HelpModal({ onClose, onCommand }) {
28
+ const { isNarrow } = useLayout();
29
+
30
+ useKeyboard((key) => {
31
+ if (key.name === 'escape' || key.name === 'q') {
32
+ onClose();
33
+ }
34
+ });
35
+
36
+ return (
37
+ <box
38
+ zIndex={100}
39
+ border
40
+ borderColor={colors.primary}
41
+ backgroundColor="#0d0d1a"
42
+ title=" Help "
43
+ titleAlignment="center"
44
+ style={{
45
+ position: 'absolute',
46
+ top: 2,
47
+ left: isNarrow ? 1 : 4,
48
+ bottom: 2,
49
+ right: isNarrow ? 1 : 4,
50
+ padding: 1,
51
+ flexDirection: 'column',
52
+ }}
53
+ >
54
+ <text fg={colors.white} attributes={TextAttributes.BOLD} content="Commands" />
55
+ <box style={{ flexDirection: 'column', marginTop: 0 }}>
56
+ {COMMANDS.map(({ cmd, desc }) => (
57
+ <box
58
+ key={cmd}
59
+ style={{ flexDirection: 'row' }}
60
+ onMouseDown={() => {
61
+ const slashCmd = cmd.split(' ')[0];
62
+ if (onCommand && !cmd.includes('<')) onCommand(slashCmd);
63
+ onClose();
64
+ }}
65
+ >
66
+ <text>
67
+ <span fg={colors.accent}>{cmd.padEnd(isNarrow ? 10 : 14)}</span>
68
+ <span fg={colors.text}>{desc}</span>
69
+ </text>
70
+ </box>
71
+ ))}
72
+ </box>
73
+ <text fg={colors.white} attributes={TextAttributes.BOLD} style={{ marginTop: 1 }} content="Tools" />
74
+ <text fg={colors.dim} content={TOOLS.join(', ')} />
75
+ <text fg={colors.white} attributes={TextAttributes.BOLD} style={{ marginTop: 1 }} content="Sub-Agents" />
76
+ <text fg={colors.dim} content={SUBAGENTS.join(', ')} />
77
+ <text fg={colors.dim} content=" * = MAX mode only" />
78
+ <text fg={colors.dim} style={{ marginTop: 1 }} content="Press ESC or q to close" />
79
+ </box>
80
+ );
81
+ }
@@ -0,0 +1,32 @@
1
+ import { useRef } from 'react';
2
+ import { colors } from '../theme.js';
3
+ import { useLayout } from '../hooks/useLayout.js';
4
+
5
+ export default function InputBar({ disabled, onSubmit }) {
6
+ const inputRef = useRef(null);
7
+ const { isNarrow } = useLayout();
8
+
9
+ const handleSubmit = (value) => {
10
+ const trimmed = value.trim();
11
+ if (!trimmed) return;
12
+ if (inputRef.current) inputRef.current.value = '';
13
+ onSubmit(trimmed);
14
+ };
15
+
16
+ return (
17
+ <box style={{ flexDirection: 'column' }}>
18
+ <text fg={colors.dim} style={{ paddingLeft: isNarrow ? 1 : 2 }} content={isNarrow ? '^C exit · /help' : 'Ctrl+C to exit · /help for commands'} />
19
+ <box style={{ flexDirection: 'row', paddingLeft: 1 }}>
20
+ <text fg={colors.primary} content="❯ " />
21
+ <input
22
+ ref={inputRef}
23
+ focused={!disabled}
24
+ placeholder={disabled ? 'Processing...' : 'Type a message...'}
25
+ onSubmit={handleSubmit}
26
+ fg={colors.text}
27
+ style={{ flexGrow: 1 }}
28
+ />
29
+ </box>
30
+ </box>
31
+ );
32
+ }
@@ -0,0 +1,23 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { colors } from '../theme.js';
3
+
4
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+
6
+ export default function Spinner({ label }) {
7
+ const [frame, setFrame] = useState(0);
8
+ const timerRef = useRef(null);
9
+
10
+ useEffect(() => {
11
+ timerRef.current = setInterval(() => {
12
+ setFrame(f => (f + 1) % FRAMES.length);
13
+ }, 80);
14
+ return () => clearInterval(timerRef.current);
15
+ }, []);
16
+
17
+ return (
18
+ <text>
19
+ <span fg={colors.accent}>{FRAMES[frame]}</span>
20
+ {label ? <span fg={colors.dim}>{' ' + label}</span> : null}
21
+ </text>
22
+ );
23
+ }