@calliopelabs/cli 0.4.6 → 0.6.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 (112) hide show
  1. package/README.md +258 -105
  2. package/dist/branching.d.ts +56 -0
  3. package/dist/branching.d.ts.map +1 -0
  4. package/dist/branching.js +211 -0
  5. package/dist/branching.js.map +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +235 -2
  8. package/dist/cli.js.map +1 -1
  9. package/dist/completion.d.ts +75 -0
  10. package/dist/completion.d.ts.map +1 -0
  11. package/dist/completion.js +234 -0
  12. package/dist/completion.js.map +1 -0
  13. package/dist/config.d.ts +36 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +90 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/diff.d.ts +73 -0
  18. package/dist/diff.d.ts.map +1 -0
  19. package/dist/diff.js +317 -0
  20. package/dist/diff.js.map +1 -0
  21. package/dist/errors.d.ts +41 -0
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/errors.js +199 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/file-watcher.d.ts +91 -0
  26. package/dist/file-watcher.d.ts.map +1 -0
  27. package/dist/file-watcher.js +269 -0
  28. package/dist/file-watcher.js.map +1 -0
  29. package/dist/files.d.ts +49 -0
  30. package/dist/files.d.ts.map +1 -0
  31. package/dist/files.js +191 -0
  32. package/dist/files.js.map +1 -0
  33. package/dist/fuzzy-search.d.ts +75 -0
  34. package/dist/fuzzy-search.d.ts.map +1 -0
  35. package/dist/fuzzy-search.js +240 -0
  36. package/dist/fuzzy-search.js.map +1 -0
  37. package/dist/hooks.d.ts +79 -0
  38. package/dist/hooks.d.ts.map +1 -0
  39. package/dist/hooks.js +271 -0
  40. package/dist/hooks.js.map +1 -0
  41. package/dist/keyboard.d.ts +57 -0
  42. package/dist/keyboard.d.ts.map +1 -0
  43. package/dist/keyboard.js +265 -0
  44. package/dist/keyboard.js.map +1 -0
  45. package/dist/markdown.d.ts +14 -0
  46. package/dist/markdown.d.ts.map +1 -0
  47. package/dist/markdown.js +248 -0
  48. package/dist/markdown.js.map +1 -0
  49. package/dist/mcp.d.ts +90 -0
  50. package/dist/mcp.d.ts.map +1 -0
  51. package/dist/mcp.js +290 -0
  52. package/dist/mcp.js.map +1 -0
  53. package/dist/memory.d.ts +104 -0
  54. package/dist/memory.d.ts.map +1 -0
  55. package/dist/memory.js +394 -0
  56. package/dist/memory.js.map +1 -0
  57. package/dist/model-router.d.ts +67 -0
  58. package/dist/model-router.d.ts.map +1 -0
  59. package/dist/model-router.js +289 -0
  60. package/dist/model-router.js.map +1 -0
  61. package/dist/parallel-tools.d.ts +51 -0
  62. package/dist/parallel-tools.d.ts.map +1 -0
  63. package/dist/parallel-tools.js +278 -0
  64. package/dist/parallel-tools.js.map +1 -0
  65. package/dist/project-config.d.ts +84 -0
  66. package/dist/project-config.d.ts.map +1 -0
  67. package/dist/project-config.js +250 -0
  68. package/dist/project-config.js.map +1 -0
  69. package/dist/providers.d.ts +10 -2
  70. package/dist/providers.d.ts.map +1 -1
  71. package/dist/providers.js +240 -38
  72. package/dist/providers.js.map +1 -1
  73. package/dist/risk.d.ts +31 -0
  74. package/dist/risk.d.ts.map +1 -0
  75. package/dist/risk.js +367 -0
  76. package/dist/risk.js.map +1 -0
  77. package/dist/sandbox.d.ts +49 -0
  78. package/dist/sandbox.d.ts.map +1 -0
  79. package/dist/sandbox.js +347 -0
  80. package/dist/sandbox.js.map +1 -0
  81. package/dist/skills.d.ts +71 -0
  82. package/dist/skills.d.ts.map +1 -0
  83. package/dist/skills.js +383 -0
  84. package/dist/skills.js.map +1 -0
  85. package/dist/storage.d.ts +139 -0
  86. package/dist/storage.d.ts.map +1 -0
  87. package/dist/storage.js +508 -0
  88. package/dist/storage.js.map +1 -0
  89. package/dist/streaming.d.ts +94 -0
  90. package/dist/streaming.d.ts.map +1 -0
  91. package/dist/streaming.js +305 -0
  92. package/dist/streaming.js.map +1 -0
  93. package/dist/summarization.d.ts +76 -0
  94. package/dist/summarization.d.ts.map +1 -0
  95. package/dist/summarization.js +242 -0
  96. package/dist/summarization.js.map +1 -0
  97. package/dist/themes.d.ts +110 -0
  98. package/dist/themes.d.ts.map +1 -0
  99. package/dist/themes.js +329 -0
  100. package/dist/themes.js.map +1 -0
  101. package/dist/tools.d.ts.map +1 -1
  102. package/dist/tools.js +335 -1
  103. package/dist/tools.js.map +1 -1
  104. package/dist/types.d.ts +56 -1
  105. package/dist/types.d.ts.map +1 -1
  106. package/dist/types.js +105 -0
  107. package/dist/types.js.map +1 -1
  108. package/dist/ui-cli.d.ts +9 -2
  109. package/dist/ui-cli.d.ts.map +1 -1
  110. package/dist/ui-cli.js +1315 -220
  111. package/dist/ui-cli.js.map +1 -1
  112. package/package.json +1 -1
package/dist/ui-cli.js CHANGED
@@ -1,19 +1,41 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * Calliope CLI - Ink UI Integration
3
+ * Calliope CLI - Ink UI
4
4
  *
5
- * Connects the ink-based UI to the existing agent/provider logic.
5
+ * Component hierarchy inspired by Claude Code:
6
+ * App
7
+ * └── TerminalChat (main hub)
8
+ * ├── MessageHistory (Static for messages)
9
+ * │ └── MessageItem (formatted messages)
10
+ * ├── ProcessingIndicator (animated spinner)
11
+ * ├── ChatInput (input line)
12
+ * └── StatusBar (footer)
6
13
  */
7
14
  import { useState, useCallback, useRef, useEffect } from 'react';
8
15
  import { render, Box, Text, useInput, useApp, useStdout, Static } from 'ink';
9
16
  import TextInput from 'ink-text-input';
17
+ import * as fs from 'fs';
10
18
  import * as config from './config.js';
11
19
  import { chat, getAvailableProviders, selectProvider } from './providers.js';
12
20
  import { TOOLS, executeTool } from './tools.js';
13
- import { getSystemPrompt, DEFAULT_MODELS } from './types.js';
21
+ import { getSystemPrompt, DEFAULT_MODELS, MODE_CONFIG, RISK_CONFIG, supportsVision, calculateCost } from './types.js';
14
22
  import { getVersion, getLatestVersion, performUpgrade } from './version-check.js';
15
23
  import { getAvailableModels } from './model-detection.js';
16
- // ASCII Banner
24
+ import { assessToolRisk } from './risk.js';
25
+ import { formatError } from './errors.js';
26
+ import * as storage from './storage.js';
27
+ import { parseFileReferences, processFilesForMessage, formatFileInfo } from './files.js';
28
+ import { renderMarkdown } from './markdown.js';
29
+ import * as mcp from './mcp.js';
30
+ import * as skills from './skills.js';
31
+ import * as memory from './memory.js';
32
+ import * as hooks from './hooks.js';
33
+ import * as modelRouter from './model-router.js';
34
+ import * as summarization from './summarization.js';
35
+ import { requiresConfirmation } from './risk.js';
36
+ // ============================================================================
37
+ // Constants
38
+ // ============================================================================
17
39
  const BANNER_LINES = [
18
40
  ' ██████╗ █████╗ ██╗ ██╗ ██╗ ██████╗ ██████╗ ███████╗',
19
41
  '██╔════╝██╔══██╗██║ ██║ ██║██╔═══██╗██╔══██╗██╔════╝',
@@ -22,185 +44,367 @@ const BANNER_LINES = [
22
44
  '╚██████╗██║ ██║███████╗███████╗██║╚██████╔╝██║ ███████╗',
23
45
  ' ╚═════╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚══════╝',
24
46
  ];
25
- // Format helpers
26
- const formatTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n);
27
- const formatCost = (c) => c < 0.01 ? '<$0.01' : `$${c.toFixed(2)}`;
28
- const formatTime = (d) => {
29
- const s = Math.floor((Date.now() - d.getTime()) / 1000);
30
- if (s < 60)
31
- return `${s}s`;
32
- const m = Math.floor(s / 60);
33
- return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`;
34
- };
35
- // Spinner frames
36
47
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
37
- // Animated Spinner component
38
- function Spinner({ label }) {
39
- const [frame, setFrame] = useState(0);
40
- useEffect(() => {
41
- const timer = setInterval(() => {
42
- setFrame(f => (f + 1) % SPINNER_FRAMES.length);
43
- }, 80);
44
- return () => clearInterval(timer);
45
- }, []);
46
- return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { dimColor: true, children: [" ", label] })] }));
47
- }
48
- // Tool icons
49
48
  const TOOL_ICONS = {
50
49
  shell: '⚡',
51
50
  read_file: '📄',
52
51
  write_file: '✍️',
53
52
  list_files: '📁',
54
53
  think: '💭',
54
+ execute_code: '▶️',
55
+ web_search: '🔍',
56
+ git: '🔀',
57
+ mermaid: '📊',
55
58
  };
56
- // Separator
57
- function Sep() {
59
+ // ============================================================================
60
+ // Utility Components
61
+ // ============================================================================
62
+ function Separator() {
58
63
  const { stdout } = useStdout();
59
- return _jsx(Text, { dimColor: true, children: '─'.repeat(stdout?.columns || 80) });
64
+ const width = stdout?.columns || 80;
65
+ return _jsx(Text, { dimColor: true, children: '─'.repeat(width) });
60
66
  }
61
- // Message component with nice formatting
62
- function Message({ msg }) {
63
- if (msg.type === 'user') {
64
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "\u203A" }), " ", msg.content] })] }));
65
- }
66
- if (msg.type === 'assistant') {
67
- const lines = msg.content.split('\n');
68
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsx(Text, { children: _jsx(Text, { color: "cyan", children: "\u2727 Calliope:" }) }), _jsx(Text, { children: " " }), lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "blue", children: "\u2502" }), " ", line] }, i))), _jsx(Text, { children: " " })] }));
69
- }
70
- if (msg.type === 'tool') {
71
- // Parse tool message format: "⚡ tool_name: preview" or just result lines
72
- const isToolCall = msg.content.startsWith('⚡');
73
- if (isToolCall) {
74
- const match = msg.content.match(/^⚡ (\w+): (.*)$/);
75
- if (match) {
76
- const [, toolName, preview] = match;
77
- const icon = TOOL_ICONS[toolName] || '⚙️';
78
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u256D\u2500" }), " ", icon, " ", _jsx(Text, { color: "yellow", children: toolName })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2502" }), " ", _jsx(Text, { dimColor: true, children: preview })] })] }));
67
+ function ThinkingDisplay({ state }) {
68
+ const [frame, setFrame] = useState(0);
69
+ useEffect(() => {
70
+ const timer = setInterval(() => {
71
+ setFrame(f => (f + 1) % SPINNER_FRAMES.length);
72
+ }, 80);
73
+ return () => clearInterval(timer);
74
+ }, []);
75
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { children: [" ", state.status] }), state.iteration && state.maxIterations && (_jsxs(Text, { dimColor: true, children: [" (", state.iteration, "/", state.maxIterations, ")"] }))] }), state.detail && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u21B3 ", state.detail] }) })), state.thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsx(Text, { color: "magenta", children: "\uD83D\uDCAD Thinking:" }), state.thinking.split('\n').slice(0, 5).map((line, i) => (_jsxs(Text, { dimColor: true, children: [" ", line.substring(0, 80)] }, i))), state.thinking.split('\n').length > 5 && (_jsx(Text, { dimColor: true, children: " ..." }))] }))] }));
76
+ }
77
+ // Legacy simple indicator for non-agent operations
78
+ function ProcessingIndicator({ label }) {
79
+ const [frame, setFrame] = useState(0);
80
+ useEffect(() => {
81
+ const timer = setInterval(() => {
82
+ setFrame(f => (f + 1) % SPINNER_FRAMES.length);
83
+ }, 80);
84
+ return () => clearInterval(timer);
85
+ }, []);
86
+ return (_jsxs(Box, { marginY: 1, children: [_jsx(Text, { color: "cyan", children: SPINNER_FRAMES[frame] }), _jsxs(Text, { dimColor: true, children: [" ", label] })] }));
87
+ }
88
+ // ============================================================================
89
+ // Message Components
90
+ // ============================================================================
91
+ function MessageItem({ msg }) {
92
+ switch (msg.type) {
93
+ case 'user':
94
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "\u203A" }), " ", msg.content] }) }));
95
+ case 'assistant': {
96
+ // Render markdown with syntax highlighting
97
+ const rendered = renderMarkdown(msg.content);
98
+ const lines = rendered.split('\n');
99
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsx(Text, { color: "cyan", children: "\u2727 Calliope:" }), _jsx(Text, { children: " " }), lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "blue", children: "\u2502" }), " ", line] }, i)))] }));
100
+ }
101
+ case 'tool': {
102
+ const isToolCall = msg.content.startsWith('⚡');
103
+ if (isToolCall) {
104
+ const match = msg.content.match(/^⚡ (\w+): (.*)$/);
105
+ if (match) {
106
+ const [, toolName, preview] = match;
107
+ const icon = TOOL_ICONS[toolName] || '⚙️';
108
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u256D\u2500" }), " ", icon, " ", _jsx(Text, { color: "yellow", children: toolName })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2502" }), " ", _jsx(Text, { dimColor: true, children: preview })] })] }));
109
+ }
110
+ }
111
+ // Check for diff output from write_file
112
+ const isDiff = msg.content.startsWith('DIFF:');
113
+ if (isDiff) {
114
+ const lines = msg.content.split('\n');
115
+ const header = lines[0];
116
+ const isNewFile = header.includes('NEW_FILE:');
117
+ const filePath = isNewFile
118
+ ? header.replace('DIFF:NEW_FILE:', '')
119
+ : header.replace('DIFF:', '');
120
+ const diffLines = lines.slice(1, 12);
121
+ const hasMore = lines.length > 12;
122
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u251C\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: [" ", isNewFile ? '(new file)' : '(modified)'] })] }), diffLines.map((line, i) => {
123
+ let color;
124
+ if (line.startsWith('+ '))
125
+ color = 'green';
126
+ else if (line.startsWith('- '))
127
+ color = 'red';
128
+ else if (line.startsWith('@@'))
129
+ color = 'cyan';
130
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { color: color, children: [" ", line.substring(0, 80)] })] }, i));
131
+ }), hasMore && _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2502" }), " ", _jsx(Text, { dimColor: true, children: "..." })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2570\u2500" }), " ", _jsx(Text, { color: "green", children: "\u2713" }), " ", _jsx(Text, { dimColor: true, children: filePath })] })] }));
79
132
  }
133
+ // Regular tool result
134
+ const lines = msg.content.split('\n').slice(0, 5);
135
+ const hasMore = msg.content.split('\n').length > 5;
136
+ const hasError = msg.content.toLowerCase().includes('error');
137
+ return (_jsxs(Box, { flexDirection: "column", children: [lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2502" }), " ", _jsx(Text, { dimColor: true, children: line.substring(0, 100) })] }, i))), hasMore && _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2502" }), " ", _jsx(Text, { dimColor: true, children: "..." })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2570\u2500" }), " ", hasError ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" })] })] }));
80
138
  }
81
- // Tool result
82
- const lines = msg.content.split('\n').slice(0, 5);
83
- const hasMore = msg.content.split('\n').length > 5;
84
- const hasError = msg.content.toLowerCase().includes('error');
85
- return (_jsxs(Box, { flexDirection: "column", children: [lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2502" }), " ", _jsx(Text, { dimColor: true, children: line.substring(0, 100) })] }, i))), hasMore && _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2502" }), " ", _jsx(Text, { dimColor: true, children: "..." })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2570\u2500" }), " ", hasError ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" })] })] }));
139
+ case 'system':
140
+ return _jsx(Text, { color: "yellow", children: msg.content });
141
+ case 'error':
142
+ return _jsxs(Text, { color: "red", children: ["\u2717 ", msg.content] });
143
+ default:
144
+ return _jsx(Text, { children: msg.content });
86
145
  }
87
- if (msg.type === 'system') {
88
- return _jsx(Text, { color: "yellow", children: msg.content });
89
- }
90
- // error
91
- return _jsxs(Text, { color: "red", children: ["\u2717 ", msg.content] });
92
146
  }
93
- function ModelSelector({ models, selectedIndex, onSelect, onCancel }) {
94
- const [index, setIndex] = useState(selectedIndex);
147
+ function MessageHistory({ messages }) {
148
+ return (_jsx(Static, { items: messages, children: (msg) => (_jsx(Box, { children: _jsx(MessageItem, { msg: msg }) }, msg.id)) }));
149
+ }
150
+ // ============================================================================
151
+ // Modal Components
152
+ // ============================================================================
153
+ function ModelSelector({ models, onSelect, onCancel }) {
154
+ const [index, setIndex] = useState(0);
95
155
  const pageSize = 10;
96
156
  const start = Math.max(0, Math.min(index - Math.floor(pageSize / 2), models.length - pageSize));
97
157
  const visible = models.slice(start, start + pageSize);
98
158
  useInput((input, key) => {
99
- if (key.upArrow) {
159
+ if (key.upArrow)
100
160
  setIndex(i => Math.max(0, i - 1));
101
- }
102
- else if (key.downArrow) {
161
+ else if (key.downArrow)
103
162
  setIndex(i => Math.min(models.length - 1, i + 1));
104
- }
105
- else if (key.return) {
163
+ else if (key.return)
106
164
  onSelect(models[index].id);
107
- }
108
- else if (key.escape || input === 'q') {
165
+ else if (key.escape || input === 'q')
109
166
  onCancel();
110
- }
111
167
  });
112
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Select model (\u2191/\u2193 navigate, Enter select, Esc cancel):" }), visible.map((model, i) => {
168
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { color: "yellow", children: "Select model (\u2191/\u2193 navigate, Enter select, Esc cancel):" }), visible.map((model, i) => {
113
169
  const globalIndex = start + i;
114
170
  const isSelected = globalIndex === index;
115
171
  const name = model.name || model.id;
116
172
  const displayName = name.length > 50 ? name.slice(0, 47) + '...' : name;
117
- return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: [isSelected ? '❯ ' : ' ', displayName] }) }, model.id));
173
+ return (_jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: [isSelected ? '❯ ' : ' ', displayName] }, model.id));
118
174
  }), models.length > pageSize && (_jsxs(Text, { dimColor: true, children: [" (", index + 1, "/", models.length, ")"] }))] }));
119
175
  }
120
176
  function UpgradePrompt({ currentVersion, latestVersion, onConfirm, onCancel }) {
121
177
  useInput((input, key) => {
122
- if (input === 'y' || input === 'Y') {
178
+ if (input === 'y' || input === 'Y')
123
179
  onConfirm();
124
- }
125
- else if (input === 'n' || input === 'N' || key.escape) {
180
+ else if (input === 'n' || input === 'N' || key.escape)
126
181
  onCancel();
127
- }
128
182
  });
129
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Update available: v", currentVersion, " \u2192 ", _jsxs(Text, { color: "green", children: ["v", latestVersion] })] }), _jsxs(Text, { children: ["Upgrade now? ", _jsx(Text, { color: "cyan", children: "(y/N)" })] })] }));
183
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { color: "yellow", children: ["Update available: v", currentVersion, " \u2192 ", _jsxs(Text, { color: "green", children: ["v", latestVersion] })] }), _jsxs(Text, { children: ["Upgrade now? ", _jsx(Text, { color: "cyan", children: "(y/N)" })] })] }));
184
+ }
185
+ function ToolConfirmation({ toolCall, riskLevel, reason, onConfirm, onDeny }) {
186
+ useInput((input, key) => {
187
+ if (input === 'y' || input === 'Y')
188
+ onConfirm();
189
+ else if (input === 'n' || input === 'N' || key.escape)
190
+ onDeny();
191
+ });
192
+ const args = toolCall.arguments;
193
+ const preview = String(args.command || args.path || args.operation || '...');
194
+ const riskColor = riskLevel === 'critical' ? 'red' : 'yellow';
195
+ const riskIcon = riskLevel === 'critical' ? '⚠️' : '⚡';
196
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, borderStyle: "round", borderColor: riskColor, paddingX: 1, children: [_jsxs(Text, { color: riskColor, bold: true, children: [riskIcon, " ", riskLevel.toUpperCase(), " RISK OPERATION"] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Tool: ", _jsx(Text, { color: "cyan", children: toolCall.name })] }), _jsxs(Text, { children: ["Command: ", _jsx(Text, { dimColor: true, children: preview.substring(0, 60) })] }), _jsxs(Text, { children: ["Reason: ", _jsx(Text, { dimColor: true, children: reason })] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Execute this operation? ", _jsx(Text, { color: "cyan", children: "(y/N)" })] })] }));
197
+ }
198
+ // ============================================================================
199
+ // Input Components
200
+ // ============================================================================
201
+ function ChatInput({ value, onChange, onSubmit, disabled, mode }) {
202
+ if (disabled)
203
+ return null;
204
+ const modeConfig = MODE_CONFIG[mode];
205
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Separator, {}), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "calliope " }), _jsx(Text, { children: modeConfig.icon }), _jsx(Text, { dimColor: true, children: "> " }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit })] })] }));
206
+ }
207
+ // Context window limits by model (approximate)
208
+ const CONTEXT_LIMITS = {
209
+ 'claude-sonnet-4': 200000,
210
+ 'claude-opus-4': 200000,
211
+ 'claude-3': 200000,
212
+ 'gpt-4o': 128000,
213
+ 'gpt-4-turbo': 128000,
214
+ 'gpt-4': 8192,
215
+ 'gemini-2': 1000000,
216
+ 'gemini-1.5-pro': 1000000,
217
+ 'gemini-1.5-flash': 1000000,
218
+ 'llama-3.3': 128000,
219
+ 'llama-3.1': 128000,
220
+ 'mistral-large': 128000,
221
+ 'default': 32000,
222
+ };
223
+ function getContextLimit(model) {
224
+ for (const [key, limit] of Object.entries(CONTEXT_LIMITS)) {
225
+ if (model.toLowerCase().includes(key.toLowerCase())) {
226
+ return limit;
227
+ }
228
+ }
229
+ return CONTEXT_LIMITS.default;
130
230
  }
131
- // Main App
132
- function App({ skipPermissions = false }) {
231
+ function StatusBar({ provider, model, stats, mode, contextTokens, }) {
232
+ const formatTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n);
233
+ const formatCost = (c) => c < 0.01 ? '<$0.01' : `$${c.toFixed(2)}`;
234
+ const displayModel = model.length > 25 ? model.slice(0, 22) + '...' : model;
235
+ const modeConfig = MODE_CONFIG[mode];
236
+ // Context usage indicator
237
+ const contextLimit = getContextLimit(model);
238
+ const contextPct = Math.min(100, Math.round((contextTokens / contextLimit) * 100));
239
+ const contextColor = contextPct > 80 ? 'red' : contextPct > 50 ? 'yellow' : 'green';
240
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Separator, {}), _jsxs(Text, { dimColor: true, children: [modeConfig.icon, " ", modeConfig.label, ' │ ', provider, ":", displayModel, ' │ ', _jsxs(Text, { color: contextColor, children: [formatTokens(contextTokens), "/", formatTokens(contextLimit)] }), ' │ ', formatTokens(stats.inputTokens + stats.outputTokens), " used", ' │ ', formatCost(stats.cost), ' │ ', _jsx(Text, { dimColor: true, children: "Esc: exit" })] })] }));
241
+ }
242
+ // ============================================================================
243
+ // Main Chat Component
244
+ // ============================================================================
245
+ function TerminalChat() {
133
246
  const { exit } = useApp();
134
247
  const { stdout } = useStdout();
135
- // State
248
+ const width = stdout?.columns || 80;
249
+ // Core state
136
250
  const [input, setInput] = useState('');
137
251
  const [messages, setMessages] = useState([]);
138
252
  const [isProcessing, setIsProcessing] = useState(false);
253
+ const [thinkingState, setThinkingState] = useState(null);
254
+ const [streamingResponse, setStreamingResponse] = useState('');
255
+ // Config state
139
256
  const [provider, setProvider] = useState(config.get('defaultProvider'));
140
257
  const [model, setModel] = useState(config.get('defaultModel'));
141
258
  const [persona, setPersona] = useState(config.get('persona'));
142
- // Model selection state
143
- const [modelSelectMode, setModelSelectMode] = useState(false);
259
+ const [mode, setMode] = useState('hybrid'); // Default to hybrid mode
260
+ const [confirmMode, setConfirmMode] = useState(true); // Require confirmation for risky ops
261
+ // Modal state
262
+ const [modalMode, setModalMode] = useState('none');
263
+ const [pendingToolCall, setPendingToolCall] = useState(null);
144
264
  const [availableModels, setAvailableModels] = useState([]);
145
- const [modelLoading, setModelLoading] = useState(false);
146
- // Upgrade state
147
- const [upgradeMode, setUpgradeMode] = useState(false);
148
265
  const [latestVersion, setLatestVersion] = useState(null);
149
- const [upgrading, setUpgrading] = useState(false);
266
+ // Stats
150
267
  const [stats, setStats] = useState({
151
- provider: selectProvider(config.get('defaultProvider')),
152
- model: config.get('defaultModel') || DEFAULT_MODELS[selectProvider(config.get('defaultProvider'))],
153
268
  inputTokens: 0,
154
269
  outputTokens: 0,
155
270
  cost: 0,
156
- messages: 0,
157
- startTime: new Date(),
271
+ messageCount: 0,
158
272
  });
159
- // Conversation history for LLM
273
+ const [contextTokens, setContextTokens] = useState(0);
274
+ // LLM conversation history
160
275
  const llmMessages = useRef([
161
276
  { role: 'system', content: getSystemPrompt(persona) }
162
277
  ]);
163
- // Add UI message
278
+ // Estimate context tokens (rough: ~4 chars per token)
279
+ const estimateContextTokens = useCallback(() => {
280
+ let chars = 0;
281
+ for (const msg of llmMessages.current) {
282
+ if (typeof msg.content === 'string') {
283
+ chars += msg.content.length;
284
+ }
285
+ else if (Array.isArray(msg.content)) {
286
+ for (const block of msg.content) {
287
+ if (block.type === 'text') {
288
+ chars += block.text.length;
289
+ }
290
+ else if (block.type === 'image') {
291
+ chars += 1000; // Images count as ~250 tokens
292
+ }
293
+ }
294
+ }
295
+ }
296
+ return Math.round(chars / 4);
297
+ }, []);
298
+ // Session state
299
+ const sessionRef = useRef(null);
300
+ const [autoRoute, setAutoRoute] = useState(false); // Auto model routing
301
+ const [memoryLoaded, setMemoryLoaded] = useState(false);
302
+ // Initialize session and load memory on mount
303
+ useEffect(() => {
304
+ const session = storage.getOrCreateSession(process.cwd());
305
+ sessionRef.current = session;
306
+ // Load memory context into system prompt
307
+ if (!memoryLoaded) {
308
+ const cwd = process.cwd();
309
+ const memoryContext = memory.buildMemoryContext(cwd);
310
+ if (memoryContext.trim()) {
311
+ // Append memory context to system prompt
312
+ const currentSystem = llmMessages.current[0];
313
+ if (currentSystem && currentSystem.role === 'system') {
314
+ const systemContent = typeof currentSystem.content === 'string'
315
+ ? currentSystem.content
316
+ : '';
317
+ llmMessages.current[0] = {
318
+ role: 'system',
319
+ content: systemContent + '\n\n--- Project Context ---\n' + memoryContext,
320
+ };
321
+ }
322
+ }
323
+ setMemoryLoaded(true);
324
+ // Execute session start hooks
325
+ hooks.executeHooks('session-start', {}).catch(() => { });
326
+ }
327
+ }, [memoryLoaded]);
328
+ // Derived values
329
+ const actualProvider = selectProvider(provider);
330
+ const actualModel = model || DEFAULT_MODELS[actualProvider];
331
+ const isModalActive = modalMode !== 'none';
332
+ // Add message helper
164
333
  const addMessage = useCallback((type, content) => {
165
- setMessages(prev => [...prev, { id: `${Date.now()}-${Math.random()}`, type, content }]);
334
+ setMessages(prev => [...prev, {
335
+ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
336
+ type,
337
+ content
338
+ }]);
166
339
  }, []);
167
- // Handle commands
340
+ // Handle slash commands
168
341
  const handleCommand = useCallback(async (cmd) => {
169
342
  const parts = cmd.split(/\s+/);
170
343
  const command = parts[0].toLowerCase();
171
344
  switch (command) {
172
345
  case '/help':
173
346
  case '/h':
174
- addMessage('system', `Commands: /help /provider /model /models /persona /clear /status /config /upgrade /exit\nLoop mode: use --legacy flag`);
175
- return true;
347
+ addMessage('system', `Commands:
348
+ /mode [plan|hybrid|work] - Switch modes (Shift+Tab to cycle)
349
+ /provider [name] - Switch AI provider
350
+ /model [name] - Switch model
351
+ /route [on|off|test] - Auto model routing by complexity
352
+ /persona [name] - Switch personality
353
+ /todo [add|done|list] - Manage TODOs
354
+ /plans [list|view] - View plan history
355
+ /session [list|info] - Session management
356
+ /history [search] - Chat history
357
+ /context [load|summary] - Context management
358
+ /summarize [context|compact] - Summarize/compact context
359
+ /clear - Clear conversation
360
+ /copy - Copy last response to clipboard
361
+ /export [file.md] - Export conversation to markdown
362
+ /edit - Edit and resend last message
363
+ /undo - Remove last exchange
364
+ /confirm [on|off] - Toggle risky op confirmation
365
+ /profile [name|save|del] - Switch/save/delete profiles
366
+ /mcp [add|remove|tools] - Manage MCP servers
367
+ /skills [add|remove] - Manage agent skills
368
+ /memory [init|add|show] - Project memory (CALLIOPE.md)
369
+ /project [init|show|run] - Project config (.calliope)
370
+ /find <pattern> - Fuzzy file search
371
+ /branch [new|switch] - Conversation branches
372
+ /theme [name|list] - Color themes
373
+ /hooks [list|add] - Pre/post tool hooks
374
+ /search <query> - Search conversation
375
+ /status - Show status
376
+ /config - Show config
377
+ /upgrade - Check for updates
378
+ /exit - Exit
379
+
380
+ File references: @filename, ./path, /absolute/path
381
+ Modes: 📋 Plan | 🔄 Hybrid | 🔧 Work
382
+ Auto-route: ${autoRoute ? 'ON' : 'OFF'}`);
383
+ break;
176
384
  case '/provider':
177
385
  case '/p':
178
386
  if (parts[1]) {
179
387
  const p = parts[1].toLowerCase();
180
388
  setProvider(p);
181
- setStats(s => ({ ...s, provider: selectProvider(p) }));
182
389
  addMessage('system', `Provider: ${selectProvider(p)}`);
183
390
  }
184
391
  else {
185
- addMessage('system', `Provider: ${selectProvider(provider)} | Available: ${getAvailableProviders().join(', ')}`);
392
+ addMessage('system', `Provider: ${actualProvider} | Available: ${getAvailableProviders().join(', ')}`);
186
393
  }
187
- return true;
394
+ break;
188
395
  case '/model':
189
396
  case '/m':
190
397
  if (parts[1]) {
191
398
  setModel(parts[1]);
192
- setStats(s => ({ ...s, model: parts[1] }));
193
399
  addMessage('system', `Model: ${parts[1]}`);
194
400
  }
195
401
  else {
196
- // Trigger inline model selection
197
- setModelLoading(true);
198
- addMessage('system', `Discovering models for ${selectProvider(provider)}...`);
402
+ addMessage('system', `Discovering models for ${actualProvider}...`);
199
403
  try {
200
- const models = await getAvailableModels(selectProvider(provider));
404
+ const models = await getAvailableModels(actualProvider);
201
405
  if (models.length > 0) {
202
406
  setAvailableModels(models);
203
- setModelSelectMode(true);
407
+ setModalMode('model');
204
408
  }
205
409
  else {
206
410
  addMessage('error', 'No models found');
@@ -209,20 +413,15 @@ function App({ skipPermissions = false }) {
209
413
  catch (e) {
210
414
  addMessage('error', `Failed to fetch models: ${e instanceof Error ? e.message : String(e)}`);
211
415
  }
212
- finally {
213
- setModelLoading(false);
214
- }
215
416
  }
216
- return true;
417
+ break;
217
418
  case '/models':
218
- // Trigger inline model selection
219
- setModelLoading(true);
220
- addMessage('system', `Discovering models for ${selectProvider(provider)}...`);
419
+ addMessage('system', `Discovering models for ${actualProvider}...`);
221
420
  try {
222
- const models = await getAvailableModels(selectProvider(provider));
421
+ const models = await getAvailableModels(actualProvider);
223
422
  if (models.length > 0) {
224
423
  setAvailableModels(models);
225
- setModelSelectMode(true);
424
+ setModalMode('model');
226
425
  }
227
426
  else {
228
427
  addMessage('error', 'No models found');
@@ -231,10 +430,18 @@ function App({ skipPermissions = false }) {
231
430
  catch (e) {
232
431
  addMessage('error', `Failed to fetch models: ${e instanceof Error ? e.message : String(e)}`);
233
432
  }
234
- finally {
235
- setModelLoading(false);
433
+ break;
434
+ case '/mode':
435
+ if (parts[1] && ['plan', 'hybrid', 'work'].includes(parts[1])) {
436
+ const m = parts[1];
437
+ setMode(m);
438
+ addMessage('system', `Mode: ${MODE_CONFIG[m].icon} ${MODE_CONFIG[m].label} - ${MODE_CONFIG[m].description}`);
439
+ }
440
+ else {
441
+ const currentConfig = MODE_CONFIG[mode];
442
+ addMessage('system', `Mode: ${currentConfig.icon} ${currentConfig.label}\nOptions: plan (📋), hybrid (🔄), work (🔧)\nUse Shift+Tab to cycle`);
236
443
  }
237
- return true;
444
+ break;
238
445
  case '/persona':
239
446
  if (parts[1] && ['calliope', 'professional', 'minimal'].includes(parts[1])) {
240
447
  const p = parts[1];
@@ -245,29 +452,634 @@ function App({ skipPermissions = false }) {
245
452
  else {
246
453
  addMessage('system', `Persona: ${persona} | Options: calliope, professional, minimal`);
247
454
  }
248
- return true;
455
+ break;
249
456
  case '/clear':
250
457
  case '/c':
251
458
  setMessages([]);
252
459
  llmMessages.current = [{ role: 'system', content: getSystemPrompt(persona) }];
253
- setStats(s => ({ ...s, messages: 0, inputTokens: 0, outputTokens: 0, cost: 0 }));
254
- return true;
460
+ setStats({ inputTokens: 0, outputTokens: 0, cost: 0, messageCount: 0 });
461
+ break;
462
+ case '/copy': {
463
+ // Copy last assistant response to clipboard
464
+ const lastAssistant = [...messages].reverse().find(m => m.type === 'assistant');
465
+ if (lastAssistant) {
466
+ try {
467
+ const { execSync } = await import('child_process');
468
+ // Try different clipboard commands based on platform
469
+ const content = lastAssistant.content;
470
+ if (process.platform === 'darwin') {
471
+ execSync('pbcopy', { input: content });
472
+ }
473
+ else if (process.platform === 'win32') {
474
+ execSync('clip', { input: content });
475
+ }
476
+ else {
477
+ // Linux - try xclip, xsel, or wl-copy
478
+ try {
479
+ execSync('xclip -selection clipboard', { input: content });
480
+ }
481
+ catch {
482
+ try {
483
+ execSync('xsel --clipboard --input', { input: content });
484
+ }
485
+ catch {
486
+ execSync('wl-copy', { input: content });
487
+ }
488
+ }
489
+ }
490
+ addMessage('system', '✓ Copied to clipboard');
491
+ }
492
+ catch (e) {
493
+ addMessage('error', `Clipboard not available: ${e instanceof Error ? e.message : String(e)}`);
494
+ }
495
+ }
496
+ else {
497
+ addMessage('system', 'No assistant message to copy');
498
+ }
499
+ break;
500
+ }
501
+ case '/export': {
502
+ // Export conversation to markdown
503
+ const filename = parts[1] || `calliope-export-${Date.now()}.md`;
504
+ const fs = await import('fs');
505
+ const path = await import('path');
506
+ let markdown = `# Calliope Conversation Export\n\n`;
507
+ markdown += `**Date:** ${new Date().toLocaleString()}\n`;
508
+ markdown += `**Provider:** ${actualProvider}\n`;
509
+ markdown += `**Model:** ${actualModel}\n\n---\n\n`;
510
+ for (const msg of messages) {
511
+ if (msg.type === 'user') {
512
+ markdown += `## 👤 User\n\n${msg.content}\n\n`;
513
+ }
514
+ else if (msg.type === 'assistant') {
515
+ markdown += `## 🤖 Assistant\n\n${msg.content}\n\n`;
516
+ }
517
+ else if (msg.type === 'tool') {
518
+ markdown += `> 🔧 Tool: ${msg.content}\n\n`;
519
+ }
520
+ else if (msg.type === 'system') {
521
+ markdown += `> ℹ️ ${msg.content}\n\n`;
522
+ }
523
+ else if (msg.type === 'error') {
524
+ markdown += `> ⚠️ Error: ${msg.content}\n\n`;
525
+ }
526
+ }
527
+ const filepath = path.resolve(process.cwd(), filename);
528
+ fs.writeFileSync(filepath, markdown);
529
+ addMessage('system', `✓ Exported to ${filename}`);
530
+ break;
531
+ }
532
+ case '/edit': {
533
+ // Edit last user message
534
+ const lastUserIdx = [...messages].reverse().findIndex(m => m.type === 'user');
535
+ if (lastUserIdx >= 0) {
536
+ const lastUser = messages[messages.length - 1 - lastUserIdx];
537
+ setInput(lastUser.content);
538
+ addMessage('system', 'Edit the message above and press Enter to resend');
539
+ }
540
+ else {
541
+ addMessage('system', 'No user message to edit');
542
+ }
543
+ break;
544
+ }
545
+ case '/undo': {
546
+ // Remove last exchange (user message + assistant response)
547
+ let removed = 0;
548
+ const newMessages = [...messages];
549
+ // Remove from the end until we've removed a user message
550
+ while (newMessages.length > 0 && removed < 10) {
551
+ const last = newMessages.pop();
552
+ removed++;
553
+ if (last?.type === 'user')
554
+ break;
555
+ }
556
+ // Also remove from LLM context
557
+ while (llmMessages.current.length > 1) {
558
+ const last = llmMessages.current[llmMessages.current.length - 1];
559
+ if (last.role === 'user') {
560
+ llmMessages.current.pop();
561
+ break;
562
+ }
563
+ llmMessages.current.pop();
564
+ }
565
+ setMessages(newMessages);
566
+ addMessage('system', `✓ Removed last ${removed} message(s)`);
567
+ break;
568
+ }
255
569
  case '/status':
256
570
  case '/s':
257
- addMessage('system', `${selectProvider(provider)}:${model || DEFAULT_MODELS[selectProvider(provider)]} | ${stats.messages} msgs | ${formatTokens(stats.inputTokens + stats.outputTokens)} tokens | ${formatCost(stats.cost)}`);
258
- return true;
571
+ addMessage('system', `${actualProvider}:${actualModel} | ${stats.messageCount} msgs | ${stats.inputTokens + stats.outputTokens} tokens`);
572
+ break;
259
573
  case '/config':
260
574
  addMessage('system', `Config: ${config.getConfigPath()}\nProviders: ${config.getConfiguredProviders().join(', ') || 'none'}`);
261
- return true;
575
+ break;
262
576
  case '/setup':
263
- addMessage('system', 'Setup requires legacy CLI mode. Run: calliope --legacy then /setup');
264
- return true;
265
577
  case '/loop':
266
- addMessage('system', 'Loop mode requires legacy CLI. Run: calliope --legacy');
267
- return true;
268
- case '/cancel-loop':
269
- addMessage('system', 'No active loop');
270
- return true;
578
+ addMessage('system', 'This feature requires legacy CLI. Run: calliope --legacy');
579
+ break;
580
+ case '/confirm':
581
+ if (parts[1] === 'on') {
582
+ setConfirmMode(true);
583
+ addMessage('system', '✓ Confirmation mode ON - will ask before risky operations');
584
+ }
585
+ else if (parts[1] === 'off') {
586
+ setConfirmMode(false);
587
+ addMessage('system', '⚠️ Confirmation mode OFF - risky operations will auto-execute');
588
+ }
589
+ else {
590
+ addMessage('system', `Confirm mode: ${confirmMode ? 'ON' : 'OFF'}\nUsage: /confirm [on|off]`);
591
+ }
592
+ break;
593
+ case '/profile': {
594
+ const subCmd = parts[1];
595
+ if (subCmd === 'list' || !subCmd) {
596
+ const profiles = config.listProfiles();
597
+ const active = config.getActiveProfile();
598
+ const list = profiles.map(p => {
599
+ const marker = p.name === active ? '→ ' : ' ';
600
+ const tag = p.builtin ? '(built-in)' : '(custom)';
601
+ return `${marker}${p.name}: ${p.profile.provider}/${p.profile.model || 'default'} ${tag}`;
602
+ }).join('\n');
603
+ addMessage('system', `Profiles:\n${list}\n\nUsage: /profile <name> | /profile save <name>`);
604
+ }
605
+ else if (subCmd === 'save' && parts[2]) {
606
+ const name = parts[2];
607
+ config.saveProfile(name, {
608
+ provider: provider,
609
+ model: model,
610
+ persona: persona,
611
+ confirmMode: confirmMode,
612
+ });
613
+ addMessage('system', `✓ Saved profile: ${name}`);
614
+ }
615
+ else if (subCmd === 'delete' && parts[2]) {
616
+ const name = parts[2];
617
+ if (config.deleteProfile(name)) {
618
+ addMessage('system', `✓ Deleted profile: ${name}`);
619
+ }
620
+ else {
621
+ addMessage('error', `Cannot delete profile: ${name} (built-in or not found)`);
622
+ }
623
+ }
624
+ else {
625
+ // Load profile
626
+ const profile = config.getProfile(subCmd);
627
+ if (profile) {
628
+ setProvider(profile.provider);
629
+ if (profile.model)
630
+ setModel(profile.model);
631
+ setPersona(profile.persona);
632
+ if (profile.confirmMode !== undefined)
633
+ setConfirmMode(profile.confirmMode);
634
+ config.setActiveProfile(subCmd);
635
+ addMessage('system', `✓ Loaded profile: ${subCmd} (${profile.provider}/${profile.model || 'default'})`);
636
+ }
637
+ else {
638
+ addMessage('error', `Profile not found: ${subCmd}\nBuilt-in: fast, smart, cheap, local`);
639
+ }
640
+ }
641
+ break;
642
+ }
643
+ case '/mcp': {
644
+ const subCmd = parts[1];
645
+ if (subCmd === 'list' || !subCmd) {
646
+ const servers = mcp.listServers();
647
+ if (servers.length === 0) {
648
+ addMessage('system', 'No MCP servers registered.\n\nUsage:\n /mcp add <url> - Register MCP server\n /mcp remove <id> - Remove server');
649
+ }
650
+ else {
651
+ const list = servers.map(s => {
652
+ const status = s.status === 'connected' ? '🟢' : s.status === 'error' ? '🔴' : '⚪';
653
+ return `${status} ${s.name} (${s.tools.length} tools)\n ${s.url}`;
654
+ }).join('\n\n');
655
+ addMessage('system', `MCP Servers:\n\n${list}`);
656
+ }
657
+ }
658
+ else if (subCmd === 'add' && parts[2]) {
659
+ const url = parts[2];
660
+ addMessage('system', `Registering MCP server: ${url}...`);
661
+ try {
662
+ const server = await mcp.registerServer(url);
663
+ addMessage('system', `✓ Registered: ${server.name} (${server.tools.length} tools)`);
664
+ }
665
+ catch (e) {
666
+ addMessage('error', `Failed to register: ${e instanceof Error ? e.message : String(e)}`);
667
+ }
668
+ }
669
+ else if ((subCmd === 'remove' || subCmd === 'rm') && parts[2]) {
670
+ if (mcp.unregisterServer(parts[2])) {
671
+ addMessage('system', '✓ Server removed');
672
+ }
673
+ else {
674
+ addMessage('error', 'Server not found');
675
+ }
676
+ }
677
+ else if (subCmd === 'refresh') {
678
+ const servers = mcp.listServers();
679
+ let connected = 0;
680
+ for (const s of servers) {
681
+ const updated = await mcp.refreshServer(s.id);
682
+ if (updated?.status === 'connected')
683
+ connected++;
684
+ }
685
+ addMessage('system', `Refreshed ${servers.length} servers (${connected} connected)`);
686
+ }
687
+ else if (subCmd === 'tools') {
688
+ const tools = mcp.getMCPTools();
689
+ if (tools.length === 0) {
690
+ addMessage('system', 'No MCP tools available. Add servers with /mcp add <url>');
691
+ }
692
+ else {
693
+ const list = tools.map(t => `• ${t.name}\n ${t.description}`).join('\n\n');
694
+ addMessage('system', `MCP Tools:\n\n${list}`);
695
+ }
696
+ }
697
+ else {
698
+ addMessage('system', 'Usage: /mcp [list|add <url>|remove <id>|refresh|tools]');
699
+ }
700
+ break;
701
+ }
702
+ case '/skills': {
703
+ const subCmd = parts[1];
704
+ if (subCmd === 'list' || !subCmd) {
705
+ const allSkills = skills.getSkills();
706
+ if (allSkills.length === 0) {
707
+ addMessage('system', 'No skills installed.\n\nUsage:\n /skills add <name> - Install from agentskills.io\n /skills add <github-url> - Install from GitHub\n /skills add <path> - Install from local directory');
708
+ }
709
+ else {
710
+ const list = allSkills.map(s => {
711
+ const src = s.source === 'github' ? '(GitHub)' : s.source === 'registry' ? '(agentskills.io)' : '(local)';
712
+ return `• ${s.metadata.name} ${src}\n ${s.metadata.description.substring(0, 80)}...`;
713
+ }).join('\n\n');
714
+ addMessage('system', `Installed Skills:\n\n${list}`);
715
+ }
716
+ }
717
+ else if (subCmd === 'add' && parts[2]) {
718
+ const source = parts[2];
719
+ addMessage('system', `Installing skill: ${source}...`);
720
+ try {
721
+ let skill;
722
+ if (source.startsWith('http')) {
723
+ skill = await skills.installFromGithub(source);
724
+ }
725
+ else if (fs.existsSync(source)) {
726
+ skill = skills.installLocalSkill(source);
727
+ }
728
+ else {
729
+ skill = await skills.installFromRegistry(source);
730
+ }
731
+ if (skill) {
732
+ addMessage('system', `✓ Installed: ${skill.metadata.name}`);
733
+ }
734
+ else {
735
+ addMessage('error', 'Failed to install skill');
736
+ }
737
+ }
738
+ catch (e) {
739
+ addMessage('error', `Failed: ${e instanceof Error ? e.message : String(e)}`);
740
+ }
741
+ }
742
+ else if ((subCmd === 'remove' || subCmd === 'rm') && parts[2]) {
743
+ if (skills.uninstallSkill(parts[2])) {
744
+ addMessage('system', '✓ Skill removed');
745
+ }
746
+ else {
747
+ addMessage('error', 'Skill not found');
748
+ }
749
+ }
750
+ else if (subCmd === 'info' && parts[2]) {
751
+ const skill = skills.getSkill(parts[2]);
752
+ if (skill) {
753
+ let info = `# ${skill.metadata.name}\n\n`;
754
+ info += `${skill.metadata.description}\n\n`;
755
+ if (skill.metadata.compatibility)
756
+ info += `Compatibility: ${skill.metadata.compatibility}\n`;
757
+ if (skill.metadata.license)
758
+ info += `License: ${skill.metadata.license}\n`;
759
+ if (skill.sourceUrl)
760
+ info += `Source: ${skill.sourceUrl}\n`;
761
+ addMessage('system', info);
762
+ }
763
+ else {
764
+ addMessage('error', 'Skill not found');
765
+ }
766
+ }
767
+ else {
768
+ addMessage('system', 'Usage: /skills [list|add <source>|remove <name>|info <name>]');
769
+ }
770
+ break;
771
+ }
772
+ case '/memory': {
773
+ const memory = await import('./memory.js');
774
+ const subCmd = parts[1];
775
+ const cwd = process.cwd();
776
+ if (subCmd === 'init') {
777
+ const memPath = memory.initProjectMemory(cwd);
778
+ addMessage('system', `Created: ${memPath}\nEdit the file to add context and preferences.`);
779
+ }
780
+ else if (subCmd === 'show' || !subCmd) {
781
+ const memPath = memory.findProjectMemory(cwd);
782
+ if (!memPath) {
783
+ addMessage('system', 'No CALLIOPE.md found.\nRun /memory init to create one.');
784
+ }
785
+ else {
786
+ const mem = memory.loadMemory(memPath);
787
+ let info = `Memory: ${memPath}\n\n`;
788
+ if (mem.context.length)
789
+ info += `**Context:**\n${mem.context.map(c => ` - ${c}`).join('\n')}\n\n`;
790
+ if (mem.preferences.length)
791
+ info += `**Preferences:**\n${mem.preferences.map(p => ` - ${p}`).join('\n')}\n\n`;
792
+ if (mem.history.length)
793
+ info += `**History:**\n${mem.history.slice(-5).map(h => ` - ${h}`).join('\n')}\n`;
794
+ addMessage('system', info);
795
+ }
796
+ }
797
+ else if (subCmd === 'add' && parts[2]) {
798
+ const type = parts[2];
799
+ const content = parts.slice(3).join(' ');
800
+ if (!content) {
801
+ addMessage('error', 'Usage: /memory add <type> <content>');
802
+ }
803
+ else {
804
+ let memPath = memory.findProjectMemory(cwd);
805
+ if (!memPath) {
806
+ memPath = memory.initProjectMemory(cwd);
807
+ }
808
+ memory.addMemoryEntry(memPath, {
809
+ type,
810
+ content,
811
+ timestamp: new Date().toISOString().split('T')[0],
812
+ });
813
+ addMessage('system', `Added ${type}: ${content}`);
814
+ }
815
+ }
816
+ else if (subCmd === 'remove' && parts[2]) {
817
+ const type = parts[2];
818
+ const content = parts.slice(3).join(' ');
819
+ const memPath = memory.findProjectMemory(cwd);
820
+ if (memPath && memory.removeMemoryEntry(memPath, type, content)) {
821
+ addMessage('system', `Removed matching ${type}`);
822
+ }
823
+ else {
824
+ addMessage('error', 'Entry not found');
825
+ }
826
+ }
827
+ else if (subCmd === 'global') {
828
+ const globalMem = memory.getGlobalMemory();
829
+ let info = 'Global Memory:\n\n';
830
+ if (globalMem.preferences.length)
831
+ info += `**Preferences:**\n${globalMem.preferences.map(p => ` - ${p}`).join('\n')}\n`;
832
+ if (globalMem.notes.length)
833
+ info += `**Notes:**\n${globalMem.notes.map(n => ` - ${n}`).join('\n')}\n`;
834
+ addMessage('system', info || 'No global memories yet.');
835
+ }
836
+ else {
837
+ addMessage('system', 'Usage: /memory [init|show|add <type> <text>|remove <type> <text>|global]');
838
+ }
839
+ break;
840
+ }
841
+ case '/find': {
842
+ const fuzzy = await import('./fuzzy-search.js');
843
+ const query = parts.slice(1).join(' ');
844
+ if (!query) {
845
+ addMessage('system', 'Usage: /find <pattern>\nFuzzy search for files');
846
+ }
847
+ else {
848
+ const results = fuzzy.searchWithHighlight(process.cwd(), query, { maxResults: 20 });
849
+ if (results.length === 0) {
850
+ addMessage('system', 'No files found');
851
+ }
852
+ else {
853
+ const list = results.map((r, i) => `${i + 1}. ${r.highlighted}`).join('\n');
854
+ addMessage('system', `Found ${results.length} files:\n\n${list}`);
855
+ }
856
+ }
857
+ break;
858
+ }
859
+ case '/branch': {
860
+ const branching = await import('./branching.js');
861
+ const subCmd = parts[1];
862
+ const sessionId = `session_${Date.now()}`; // Would use actual session ID
863
+ if (subCmd === 'list' || !subCmd) {
864
+ const tree = branching.getBranchTree(sessionId);
865
+ addMessage('system', `Branches:\n${tree}`);
866
+ }
867
+ else if (subCmd === 'new' && parts[2]) {
868
+ const branch = branching.createBranch(sessionId, parts[2], llmMessages.current, parts.slice(3).join(' '));
869
+ addMessage('system', `Created branch: ${branch.name}`);
870
+ }
871
+ else if (subCmd === 'switch' && parts[2]) {
872
+ const msgs = branching.switchBranch(sessionId, parts[2], llmMessages.current);
873
+ if (msgs) {
874
+ llmMessages.current = msgs;
875
+ addMessage('system', `Switched to branch: ${parts[2]}`);
876
+ }
877
+ else {
878
+ addMessage('error', 'Branch not found');
879
+ }
880
+ }
881
+ else if (subCmd === 'delete' && parts[2]) {
882
+ if (branching.deleteBranch(sessionId, parts[2])) {
883
+ addMessage('system', 'Branch deleted');
884
+ }
885
+ else {
886
+ addMessage('error', 'Cannot delete branch');
887
+ }
888
+ }
889
+ else {
890
+ addMessage('system', 'Usage: /branch [list|new <name>|switch <name>|delete <name>]');
891
+ }
892
+ break;
893
+ }
894
+ case '/theme': {
895
+ const themes = await import('./themes.js');
896
+ const subCmd = parts[1];
897
+ if (subCmd === 'list' || !subCmd) {
898
+ const list = themes.listThemes();
899
+ const current = themes.getCurrentThemeName();
900
+ const formatted = list.map(t => {
901
+ const marker = t.name === current ? ' *' : '';
902
+ const custom = t.custom ? ' (custom)' : '';
903
+ return ` ${t.name}${marker}${custom} - ${t.description || 'No description'}`;
904
+ }).join('\n');
905
+ addMessage('system', `Available themes:\n${formatted}`);
906
+ }
907
+ else if (themes.setCurrentTheme(subCmd)) {
908
+ themes.clearThemeCache();
909
+ addMessage('system', `Theme set to: ${subCmd}`);
910
+ }
911
+ else {
912
+ addMessage('error', `Theme not found: ${subCmd}`);
913
+ }
914
+ break;
915
+ }
916
+ case '/hooks': {
917
+ const hooks = await import('./hooks.js');
918
+ const subCmd = parts[1];
919
+ if (subCmd === 'list' || !subCmd) {
920
+ addMessage('system', hooks.listHooksFormatted());
921
+ }
922
+ else if (subCmd === 'add' && parts[2]) {
923
+ const event = parts[2];
924
+ const command = parts.slice(3).join(' ');
925
+ if (!command) {
926
+ addMessage('system', 'Usage: /hooks add <event> <command>');
927
+ }
928
+ else {
929
+ hooks.addHook({ event, name: `Hook for ${event}`, command, enabled: true, async: false });
930
+ addMessage('system', 'Hook added');
931
+ }
932
+ }
933
+ else if (subCmd === 'init') {
934
+ hooks.initDefaultHooks();
935
+ addMessage('system', 'Default hooks initialized');
936
+ }
937
+ else {
938
+ addMessage('system', 'Usage: /hooks [list|add <event> <command>|init]');
939
+ }
940
+ break;
941
+ }
942
+ case '/search': {
943
+ const query = parts.slice(1).join(' ');
944
+ if (!query) {
945
+ addMessage('system', 'Usage: /search <query>\nSearch conversation history');
946
+ }
947
+ else {
948
+ const lower = query.toLowerCase();
949
+ const matches = messages.filter(m => m.content.toLowerCase().includes(lower));
950
+ if (matches.length === 0) {
951
+ addMessage('system', 'No matches found');
952
+ }
953
+ else {
954
+ const results = matches.slice(-10).map(m => {
955
+ const preview = m.content.slice(0, 100).replace(/\n/g, ' ');
956
+ return `[${m.type}] ${preview}...`;
957
+ }).join('\n\n');
958
+ addMessage('system', `Found ${matches.length} matches:\n\n${results}`);
959
+ }
960
+ }
961
+ break;
962
+ }
963
+ case '/project': {
964
+ const projectConfig = await import('./project-config.js');
965
+ const subCmd = parts[1];
966
+ const cwd = process.cwd();
967
+ if (subCmd === 'init') {
968
+ const configPath = projectConfig.createProjectConfig(cwd);
969
+ addMessage('system', `Created project config: ${configPath}\nEdit the file to customize settings.`);
970
+ }
971
+ else if (subCmd === 'show' || !subCmd) {
972
+ const configPath = projectConfig.findProjectConfig(cwd);
973
+ if (!configPath) {
974
+ addMessage('system', 'No project config found.\nRun /project init to create one.');
975
+ }
976
+ else {
977
+ const cfg = projectConfig.loadProjectConfig(configPath);
978
+ if (cfg) {
979
+ let info = `Config: ${configPath}\n\n`;
980
+ if (cfg.project)
981
+ info += `Project: ${cfg.project}\n`;
982
+ if (cfg.provider)
983
+ info += `Provider: ${cfg.provider}\n`;
984
+ if (cfg.model)
985
+ info += `Model: ${cfg.model}\n`;
986
+ if (cfg.tech?.length)
987
+ info += `Tech: ${cfg.tech.join(', ')}\n`;
988
+ if (cfg.conventions?.length)
989
+ info += `\nConventions:\n${cfg.conventions.map(c => ` - ${c}`).join('\n')}\n`;
990
+ if (cfg.commands)
991
+ info += `\nCommands: ${Object.keys(cfg.commands).join(', ')}\n`;
992
+ addMessage('system', info);
993
+ }
994
+ else {
995
+ addMessage('error', 'Failed to parse config');
996
+ }
997
+ }
998
+ }
999
+ else if (subCmd === 'run' && parts[2]) {
1000
+ const configPath = projectConfig.findProjectConfig(cwd);
1001
+ const cfg = configPath ? projectConfig.loadProjectConfig(configPath) : null;
1002
+ const cmdName = parts[2];
1003
+ if (cfg?.commands?.[cmdName]) {
1004
+ addMessage('system', `Running: ${cfg.commands[cmdName]}`);
1005
+ // Queue the command to run
1006
+ const { spawn } = await import('child_process');
1007
+ const proc = spawn('sh', ['-c', cfg.commands[cmdName]], { cwd, stdio: 'pipe' });
1008
+ let output = '';
1009
+ proc.stdout?.on('data', (d) => output += d.toString());
1010
+ proc.stderr?.on('data', (d) => output += d.toString());
1011
+ proc.on('close', (code) => {
1012
+ addMessage('system', `Exit ${code}\n${output}`);
1013
+ });
1014
+ }
1015
+ else {
1016
+ addMessage('error', `Command not found: ${cmdName}`);
1017
+ }
1018
+ }
1019
+ else {
1020
+ addMessage('system', 'Usage: /project [init|show|run <cmd>]');
1021
+ }
1022
+ break;
1023
+ }
1024
+ case '/route':
1025
+ case '/autoroute': {
1026
+ if (parts[1] === 'on') {
1027
+ setAutoRoute(true);
1028
+ addMessage('system', '✓ Auto-routing ON - model selected based on task complexity');
1029
+ }
1030
+ else if (parts[1] === 'off') {
1031
+ setAutoRoute(false);
1032
+ addMessage('system', '✓ Auto-routing OFF - using fixed model');
1033
+ }
1034
+ else if (parts[1] === 'test' && parts[2]) {
1035
+ const testMsg = parts.slice(2).join(' ');
1036
+ const decision = modelRouter.routeRequest(testMsg, actualProvider);
1037
+ addMessage('system', `Route test: ${decision.tier} tier (${decision.complexity})\nModel: ${decision.model.model}\nReason: ${decision.reason}\nConfidence: ${Math.round(decision.confidence * 100)}%`);
1038
+ }
1039
+ else {
1040
+ const tiers = modelRouter.getAllTiers(actualProvider);
1041
+ addMessage('system', `Auto-route: ${autoRoute ? 'ON' : 'OFF'}\n\nModel tiers for ${actualProvider}:\n fast: ${tiers.fast.model}\n balanced: ${tiers.balanced.model}\n smart: ${tiers.smart.model}\n\nUsage: /route [on|off|test <message>]`);
1042
+ }
1043
+ break;
1044
+ }
1045
+ case '/summarize': {
1046
+ const subCmd = parts[1];
1047
+ if (subCmd === 'context' || !subCmd) {
1048
+ const msgCount = llmMessages.current.length;
1049
+ if (msgCount < 5) {
1050
+ addMessage('system', 'Not enough messages to summarize.');
1051
+ }
1052
+ else {
1053
+ const summary = summarization.extractKeyInfo(llmMessages.current);
1054
+ let info = 'Context Summary:\n\n';
1055
+ if (summary.topics.length)
1056
+ info += `**Topics:** ${summary.topics.join(', ')}\n`;
1057
+ if (summary.decisions.length)
1058
+ info += `**Decisions:**\n${summary.decisions.map(d => ` - ${d}`).join('\n')}\n`;
1059
+ if (summary.actions.length)
1060
+ info += `**Actions:**\n${summary.actions.map(a => ` - ${a}`).join('\n')}\n`;
1061
+ if (summary.codeChanges.length)
1062
+ info += `**Code Changes:**\n${summary.codeChanges.slice(0, 5).map(c => ` - ${c}`).join('\n')}\n`;
1063
+ addMessage('system', info || 'No key information extracted.');
1064
+ }
1065
+ }
1066
+ else if (subCmd === 'compact') {
1067
+ // Summarize and compact the conversation
1068
+ const result = summarization.summarizeConversation(llmMessages.current, { maxTokens: 50000 });
1069
+ if (result.summarizedCount > 0) {
1070
+ llmMessages.current = result.messages;
1071
+ setContextTokens(estimateContextTokens());
1072
+ addMessage('system', `✓ Compacted ${result.summarizedCount} messages (${result.originalTokens} → ${result.reducedTokens} tokens)`);
1073
+ }
1074
+ else {
1075
+ addMessage('system', 'Context already within limits, no compaction needed.');
1076
+ }
1077
+ }
1078
+ else {
1079
+ addMessage('system', 'Usage: /summarize [context|compact]');
1080
+ }
1081
+ break;
1082
+ }
271
1083
  case '/upgrade':
272
1084
  addMessage('system', 'Checking for updates...');
273
1085
  try {
@@ -275,69 +1087,322 @@ function App({ skipPermissions = false }) {
275
1087
  const latest = await getLatestVersion();
276
1088
  if (!latest) {
277
1089
  addMessage('error', 'Could not check for updates');
278
- return true;
279
- }
280
- const currentParts = current.split('.').map(Number);
281
- const latestParts = latest.split('.').map(Number);
282
- let hasUpdate = false;
283
- for (let i = 0; i < 3; i++) {
284
- if ((latestParts[i] || 0) > (currentParts[i] || 0)) {
285
- hasUpdate = true;
286
- break;
287
- }
288
- if ((latestParts[i] || 0) < (currentParts[i] || 0))
289
- break;
1090
+ break;
290
1091
  }
291
- if (!hasUpdate) {
292
- addMessage('system', `You're on the latest version (v${current})`);
1092
+ const [cMaj, cMin, cPat] = current.split('.').map(Number);
1093
+ const [lMaj, lMin, lPat] = latest.split('.').map(Number);
1094
+ const hasUpdate = lMaj > cMaj || (lMaj === cMaj && lMin > cMin) || (lMaj === cMaj && lMin === cMin && lPat > cPat);
1095
+ if (hasUpdate) {
1096
+ setLatestVersion(latest);
1097
+ setModalMode('upgrade');
293
1098
  }
294
1099
  else {
295
- setLatestVersion(latest);
296
- setUpgradeMode(true);
1100
+ addMessage('system', `You're on the latest version (v${current})`);
297
1101
  }
298
1102
  }
299
1103
  catch (e) {
300
1104
  addMessage('error', `Failed to check for updates: ${e instanceof Error ? e.message : String(e)}`);
301
1105
  }
302
- return true;
1106
+ break;
1107
+ case '/session':
1108
+ if (parts[1] === 'list') {
1109
+ const sessions = storage.listSessions(5);
1110
+ if (sessions.length === 0) {
1111
+ addMessage('system', 'No previous sessions found.');
1112
+ }
1113
+ else {
1114
+ const list = sessions.map(s => `${s.projectName} (${new Date(s.lastAccessedAt).toLocaleDateString()}) - ${s.messageCount} msgs`).join('\n');
1115
+ addMessage('system', `Recent sessions:\n${list}`);
1116
+ }
1117
+ }
1118
+ else if (parts[1] === 'info') {
1119
+ const session = sessionRef.current;
1120
+ if (session) {
1121
+ addMessage('system', `Session: ${session.projectName}\nCreated: ${new Date(session.createdAt).toLocaleString()}\nMessages: ${session.messageCount}`);
1122
+ }
1123
+ else {
1124
+ addMessage('system', 'No active session.');
1125
+ }
1126
+ }
1127
+ else {
1128
+ addMessage('system', 'Usage: /session [list|info]');
1129
+ }
1130
+ break;
1131
+ case '/todo': {
1132
+ const subCommand = parts[1];
1133
+ if (subCommand === 'add' && parts.length > 2) {
1134
+ const content = parts.slice(2).join(' ');
1135
+ const isGlobal = content.includes('--global');
1136
+ const isHigh = content.includes('--priority') && content.includes('high');
1137
+ const cleanContent = content.replace(/--global|--priority\s*\w+/g, '').trim();
1138
+ const todo = storage.addTodo(cleanContent, {
1139
+ global: isGlobal,
1140
+ priority: isHigh ? 'high' : 'normal',
1141
+ });
1142
+ addMessage('system', `✓ TODO added (#${todo.id.slice(-4)}${isGlobal ? ', global' : ''})`);
1143
+ }
1144
+ else if (subCommand === 'done' && parts[2]) {
1145
+ const id = parts[2];
1146
+ const todos = [...storage.getSessionTodos(), ...storage.getGlobalTodos()];
1147
+ const todo = todos.find(t => t.id.endsWith(id) || t.id === id);
1148
+ if (todo) {
1149
+ storage.updateTodo(todo.id, { status: 'completed' });
1150
+ addMessage('system', `✓ TODO #${id} marked done`);
1151
+ }
1152
+ else {
1153
+ addMessage('error', `TODO #${id} not found`);
1154
+ }
1155
+ }
1156
+ else if (subCommand === 'list' || !subCommand) {
1157
+ const sessionTodos = storage.getSessionTodos();
1158
+ const globalTodos = storage.getGlobalTodos();
1159
+ const pending = [...sessionTodos, ...globalTodos].filter(t => t.status !== 'completed');
1160
+ const completed = [...sessionTodos, ...globalTodos].filter(t => t.status === 'completed').slice(-3);
1161
+ if (pending.length === 0 && completed.length === 0) {
1162
+ addMessage('system', 'No TODOs. Use /todo add <task> to create one.');
1163
+ }
1164
+ else {
1165
+ let output = '📋 TODOs:\n';
1166
+ if (pending.length > 0) {
1167
+ output += pending.map(t => ` ${t.priority === 'high' ? '!' : '□'} #${t.id.slice(-4)} ${t.content}`).join('\n');
1168
+ }
1169
+ if (completed.length > 0) {
1170
+ output += '\n\nCompleted:\n' + completed.map(t => ` ✓ #${t.id.slice(-4)} ${t.content}`).join('\n');
1171
+ }
1172
+ addMessage('system', output);
1173
+ }
1174
+ }
1175
+ else {
1176
+ addMessage('system', 'Usage: /todo [add <task>|done <id>|list]');
1177
+ }
1178
+ break;
1179
+ }
1180
+ case '/plans': {
1181
+ const subCommand = parts[1];
1182
+ if (subCommand === 'list' || !subCommand) {
1183
+ const plans = storage.getPlans();
1184
+ if (plans.length === 0) {
1185
+ addMessage('system', 'No plans yet. Plans are created in hybrid mode.');
1186
+ }
1187
+ else {
1188
+ const list = plans.slice(0, 5).map(p => `${p.status === 'completed' ? '✓' : '○'} ${p.id.slice(-4)}: ${p.title}`).join('\n');
1189
+ addMessage('system', `📋 Plans:\n${list}`);
1190
+ }
1191
+ }
1192
+ else if (subCommand === 'view' && parts[2]) {
1193
+ const plans = storage.getPlans();
1194
+ const plan = plans.find(p => p.id.endsWith(parts[2]) || p.id === parts[2]);
1195
+ if (plan) {
1196
+ const phases = plan.phases.map(ph => ` ${ph.status === 'completed' ? '✓' : '○'} ${ph.name} (${ph.risk} risk)`).join('\n');
1197
+ addMessage('system', `Plan: ${plan.title}\nStatus: ${plan.status}\n\nPhases:\n${phases}`);
1198
+ }
1199
+ else {
1200
+ addMessage('error', `Plan #${parts[2]} not found`);
1201
+ }
1202
+ }
1203
+ else {
1204
+ addMessage('system', 'Usage: /plans [list|view <id>]');
1205
+ }
1206
+ break;
1207
+ }
1208
+ case '/history': {
1209
+ const subCommand = parts[1];
1210
+ if (subCommand === 'search' && parts[2]) {
1211
+ const query = parts.slice(2).join(' ');
1212
+ const results = storage.searchChatHistory(query);
1213
+ if (results.length === 0) {
1214
+ addMessage('system', `No matches for "${query}"`);
1215
+ }
1216
+ else {
1217
+ const list = results.slice(-5).map(m => `${new Date(m.timestamp).toLocaleTimeString()}: ${m.content.substring(0, 60)}...`).join('\n');
1218
+ addMessage('system', `🔍 Found ${results.length} matches:\n${list}`);
1219
+ }
1220
+ }
1221
+ else if (subCommand === 'clear') {
1222
+ addMessage('system', 'History is preserved per session. Start a new session for fresh history.');
1223
+ }
1224
+ else {
1225
+ const history = storage.getChatHistory(5);
1226
+ if (history.length === 0) {
1227
+ addMessage('system', 'No chat history yet.');
1228
+ }
1229
+ else {
1230
+ const list = history.map(m => `${m.role}: ${m.content.substring(0, 50)}...`).join('\n');
1231
+ addMessage('system', `Recent history:\n${list}\n\nUse /history search <query> to search.`);
1232
+ }
1233
+ }
1234
+ break;
1235
+ }
1236
+ case '/context': {
1237
+ const subCommand = parts[1];
1238
+ if (subCommand === 'load') {
1239
+ const limit = parseInt(parts[2]) || 20;
1240
+ const history = storage.getChatHistory(limit);
1241
+ if (history.length > 0) {
1242
+ // Load history into LLM context
1243
+ for (const msg of history) {
1244
+ if (msg.role === 'user' || msg.role === 'assistant') {
1245
+ llmMessages.current.push({
1246
+ role: msg.role,
1247
+ content: msg.content,
1248
+ });
1249
+ }
1250
+ }
1251
+ addMessage('system', `✓ Loaded ${history.length} messages into context`);
1252
+ }
1253
+ else {
1254
+ addMessage('system', 'No history to load.');
1255
+ }
1256
+ }
1257
+ else if (subCommand === 'summary') {
1258
+ const msgCount = llmMessages.current.length;
1259
+ const estTokens = llmMessages.current.reduce((sum, m) => sum + (m.content?.length || 0) / 4, 0);
1260
+ addMessage('system', `Context: ${msgCount} messages (~${Math.round(estTokens)} tokens)`);
1261
+ }
1262
+ else {
1263
+ addMessage('system', 'Usage: /context [load [n]|summary]');
1264
+ }
1265
+ break;
1266
+ }
303
1267
  case '/exit':
304
1268
  case '/quit':
305
1269
  case '/q':
306
1270
  exit();
307
- return true;
1271
+ break;
308
1272
  default:
309
- addMessage('error', `Unknown command: ${command}`);
310
- return true;
1273
+ addMessage('error', `Unknown command: ${command}. Type /help for help.`);
1274
+ }
1275
+ }, [actualProvider, actualModel, persona, stats, addMessage, exit]);
1276
+ // Run agent with user prompt
1277
+ const runAgent = useCallback(async (content) => {
1278
+ llmMessages.current.push({ role: 'user', content });
1279
+ setStats(s => ({ ...s, messageCount: s.messageCount + 1 }));
1280
+ setStreamingResponse('');
1281
+ // Auto-route to appropriate model based on task complexity
1282
+ let effectiveModel = model;
1283
+ if (autoRoute && typeof content === 'string') {
1284
+ const routeDecision = modelRouter.routeRequest(content, provider, {
1285
+ messageCount: stats.messageCount,
1286
+ hasCode: content.includes('```') || /\.(ts|js|py|go|rs|java)/.test(content),
1287
+ });
1288
+ effectiveModel = routeDecision.model.model;
1289
+ if (effectiveModel !== model) {
1290
+ addMessage('system', `[Auto-route: ${routeDecision.tier} tier - ${routeDecision.reason}]`);
1291
+ }
311
1292
  }
312
- }, [provider, model, persona, stats, addMessage, exit]);
313
- // Run agent
314
- const runAgent = useCallback(async (prompt) => {
315
- llmMessages.current.push({ role: 'user', content: prompt });
316
- setStats(s => ({ ...s, messages: s.messages + 1 }));
317
1293
  const maxIterations = config.get('maxIterations');
318
- let iteration = 0;
319
- while (iteration < maxIterations) {
320
- iteration++;
1294
+ for (let i = 0; i < maxIterations; i++) {
321
1295
  try {
322
- const response = await chat(provider, llmMessages.current, TOOLS, model);
323
- // Update token stats
1296
+ // Update thinking state for LLM call
1297
+ setThinkingState({
1298
+ status: i === 0 ? 'Analyzing request...' : 'Processing response...',
1299
+ detail: `Iteration ${i + 1}/${maxIterations}`,
1300
+ iteration: i + 1,
1301
+ maxIterations,
1302
+ });
1303
+ // Streaming callback for final response
1304
+ const onToken = (token) => {
1305
+ setThinkingState(null); // Clear thinking when streaming starts
1306
+ setStreamingResponse(prev => prev + token);
1307
+ };
1308
+ // Retry callback for error recovery
1309
+ const onRetry = (attempt, error, delayMs) => {
1310
+ setThinkingState({
1311
+ status: `Retrying... (attempt ${attempt + 1})`,
1312
+ detail: `${error.message.substring(0, 40)}... Waiting ${Math.round(delayMs / 1000)}s`,
1313
+ iteration: i + 1,
1314
+ maxIterations,
1315
+ });
1316
+ };
1317
+ const response = await chat(provider, llmMessages.current, TOOLS, effectiveModel, onToken, onRetry);
1318
+ // Update token stats and cost
324
1319
  if (response.usage) {
1320
+ const usageCost = calculateCost(model || DEFAULT_MODELS[provider], response.usage.inputTokens, response.usage.outputTokens);
325
1321
  setStats(s => ({
326
1322
  ...s,
327
1323
  inputTokens: s.inputTokens + response.usage.inputTokens,
328
1324
  outputTokens: s.outputTokens + response.usage.outputTokens,
1325
+ cost: s.cost + usageCost,
329
1326
  }));
330
1327
  }
331
1328
  // Handle tool calls
332
- if (response.toolCalls && response.toolCalls.length > 0) {
1329
+ if (response.toolCalls?.length) {
333
1330
  llmMessages.current.push({
334
1331
  role: 'assistant',
335
1332
  content: response.content,
336
1333
  toolCalls: response.toolCalls,
337
1334
  });
338
1335
  for (const toolCall of response.toolCalls) {
339
- addMessage('tool', `⚡ ${toolCall.name}: ${toolCall.arguments.command || toolCall.arguments.path || '...'}`);
1336
+ const args = toolCall.arguments;
1337
+ const toolPreview = String(args.command || args.path || '...');
1338
+ // Assess risk
1339
+ const risk = assessToolRisk(toolCall);
1340
+ const riskConfig = RISK_CONFIG[risk.level];
1341
+ const riskDisplay = risk.level !== 'none' ? ` [${riskConfig.bar}]` : '';
1342
+ // Special handling for think tool
1343
+ if (toolCall.name === 'think') {
1344
+ const thought = String(args.thought || '');
1345
+ setThinkingState({
1346
+ status: 'Reasoning...',
1347
+ detail: thought.substring(0, 60) + (thought.length > 60 ? '...' : ''),
1348
+ thinking: thought,
1349
+ iteration: i + 1,
1350
+ maxIterations,
1351
+ });
1352
+ }
1353
+ else {
1354
+ setThinkingState({
1355
+ status: `Executing ${toolCall.name}...`,
1356
+ detail: toolPreview.substring(0, 60),
1357
+ thinking: undefined,
1358
+ iteration: i + 1,
1359
+ maxIterations,
1360
+ });
1361
+ }
1362
+ // In plan mode, don't execute tools (except think)
1363
+ if (mode === 'plan' && toolCall.name !== 'think') {
1364
+ addMessage('tool', `📋 ${toolCall.name}: ${toolPreview}${riskDisplay} (plan mode - not executed)`);
1365
+ llmMessages.current.push({
1366
+ role: 'tool',
1367
+ content: '[Plan mode: Tool not executed. Describe what this would do.]',
1368
+ toolCallId: toolCall.id,
1369
+ });
1370
+ continue;
1371
+ }
1372
+ // Check if confirmation is required for risky operations
1373
+ if (confirmMode && requiresConfirmation(risk, false) && toolCall.name !== 'think') {
1374
+ // Show warning and skip execution
1375
+ const riskIcon = risk.level === 'critical' ? '🛑' : '⚠️';
1376
+ addMessage('tool', `${riskIcon} ${toolCall.name}: ${toolPreview}${riskDisplay}\n → Requires confirmation (use /confirm off to disable)`);
1377
+ llmMessages.current.push({
1378
+ role: 'tool',
1379
+ content: `[Operation blocked - ${risk.level} risk: ${risk.reason}. User confirmation required.]`,
1380
+ toolCallId: toolCall.id,
1381
+ });
1382
+ continue;
1383
+ }
1384
+ addMessage('tool', `⚡ ${toolCall.name}: ${toolPreview}${riskDisplay}`);
1385
+ // Execute pre-tool hooks
1386
+ const preHookResult = await hooks.checkHooksAllow('pre-tool', {
1387
+ tool: toolCall.name,
1388
+ toolArgs: args,
1389
+ });
1390
+ if (!preHookResult.allowed) {
1391
+ addMessage('tool', `🛑 Blocked by hook: ${preHookResult.reason}`);
1392
+ llmMessages.current.push({
1393
+ role: 'tool',
1394
+ content: `[Blocked by hook: ${preHookResult.reason}]`,
1395
+ toolCallId: toolCall.id,
1396
+ });
1397
+ continue;
1398
+ }
340
1399
  const result = await executeTool(toolCall, process.cwd());
1400
+ // Execute post-tool hooks
1401
+ hooks.executeHooks('post-tool', {
1402
+ tool: toolCall.name,
1403
+ toolArgs: args,
1404
+ toolResult: result.result,
1405
+ }).catch(() => { });
341
1406
  const preview = result.result.split('\n').slice(0, 3).join('\n');
342
1407
  addMessage('tool', preview + (result.result.split('\n').length > 3 ? '\n...' : ''));
343
1408
  llmMessages.current.push({
@@ -348,19 +1413,31 @@ function App({ skipPermissions = false }) {
348
1413
  }
349
1414
  continue;
350
1415
  }
351
- // Final response
1416
+ // Final response - move streaming content to message history
1417
+ setThinkingState(null);
352
1418
  llmMessages.current.push({ role: 'assistant', content: response.content });
353
1419
  addMessage('assistant', response.content);
1420
+ setStreamingResponse('');
1421
+ setContextTokens(estimateContextTokens());
1422
+ // Auto-continue if response was truncated due to length
1423
+ if (response.finishReason === 'length') {
1424
+ addMessage('system', '(auto-continuing...)');
1425
+ llmMessages.current.push({ role: 'user', content: 'Please continue where you left off.' });
1426
+ continue; // Loop again to get continuation
1427
+ }
354
1428
  break;
355
1429
  }
356
1430
  catch (error) {
357
- const msg = error instanceof Error ? error.message : String(error);
358
- addMessage('error', `Error: ${msg}`);
1431
+ setThinkingState(null);
1432
+ setStreamingResponse('');
1433
+ addMessage('error', formatError(error));
359
1434
  break;
360
1435
  }
361
1436
  }
362
- }, [provider, model, addMessage]);
363
- // Handle submit
1437
+ // Update context tokens after agent run
1438
+ setContextTokens(estimateContextTokens());
1439
+ }, [provider, model, addMessage, mode, estimateContextTokens]);
1440
+ // Handle input submission
364
1441
  const handleSubmit = useCallback(async (value) => {
365
1442
  const trimmed = value.trim();
366
1443
  if (!trimmed || isProcessing)
@@ -370,54 +1447,59 @@ function App({ skipPermissions = false }) {
370
1447
  await handleCommand(trimmed);
371
1448
  return;
372
1449
  }
373
- addMessage('user', trimmed);
1450
+ // Parse file references from input
1451
+ const { text: cleanText, files } = parseFileReferences(trimmed, process.cwd());
1452
+ // Show user message (with file info if any)
1453
+ if (files.length > 0) {
1454
+ const fileInfo = formatFileInfo(files);
1455
+ addMessage('user', `${cleanText}\n📎 ${fileInfo}`);
1456
+ }
1457
+ else {
1458
+ addMessage('user', trimmed);
1459
+ }
374
1460
  setIsProcessing(true);
375
1461
  try {
376
- await runAgent(trimmed);
1462
+ // Build message content (with file/image support)
1463
+ let messageContent;
1464
+ if (files.length > 0) {
1465
+ const visionSupported = supportsVision(provider, model);
1466
+ const { content, warnings } = processFilesForMessage(cleanText || trimmed, files, visionSupported);
1467
+ // Show any warnings about files
1468
+ for (const warning of warnings) {
1469
+ addMessage('system', warning);
1470
+ }
1471
+ messageContent = content;
1472
+ }
1473
+ else {
1474
+ messageContent = trimmed;
1475
+ }
1476
+ await runAgent(messageContent);
377
1477
  }
378
1478
  finally {
379
1479
  setIsProcessing(false);
1480
+ setThinkingState(null);
1481
+ setStreamingResponse('');
380
1482
  }
381
- }, [isProcessing, handleCommand, runAgent, addMessage]);
382
- // Track terminal width for resize
383
- const [width, setWidth] = useState(stdout?.columns || 80);
384
- useEffect(() => {
385
- const handleResize = () => {
386
- setWidth(stdout?.columns || 80);
387
- };
388
- process.stdout.on('resize', handleResize);
389
- return () => {
390
- process.stdout.off('resize', handleResize);
391
- };
392
- }, [stdout]);
393
- // Escape to exit (but not when in model select mode)
394
- useInput((_, key) => {
395
- if (key.escape && !modelSelectMode)
396
- exit();
397
- }, { isActive: !modelSelectMode });
398
- // Model selection handlers
1483
+ }, [isProcessing, handleCommand, runAgent, addMessage, provider, model]);
1484
+ // Modal handlers
399
1485
  const handleModelSelect = useCallback((selectedModel) => {
400
1486
  setModel(selectedModel);
401
- setStats(s => ({ ...s, model: selectedModel }));
402
1487
  addMessage('system', `Model: ${selectedModel}`);
403
- setModelSelectMode(false);
1488
+ setModalMode('none');
404
1489
  setAvailableModels([]);
405
1490
  }, [addMessage]);
406
- const handleModelCancel = useCallback(() => {
407
- setModelSelectMode(false);
1491
+ const handleModalCancel = useCallback(() => {
1492
+ setModalMode('none');
408
1493
  setAvailableModels([]);
409
- addMessage('system', 'Model selection cancelled');
410
- }, [addMessage]);
411
- // Upgrade handlers
1494
+ setLatestVersion(null);
1495
+ }, []);
412
1496
  const handleUpgradeConfirm = useCallback(async () => {
413
- setUpgradeMode(false);
414
- setUpgrading(true);
1497
+ setModalMode('none');
415
1498
  addMessage('system', 'Upgrading...');
416
1499
  try {
417
1500
  const success = await performUpgrade();
418
1501
  if (success) {
419
1502
  addMessage('system', 'Upgrade complete! Restarting...');
420
- // Restart the CLI
421
1503
  const { spawn } = await import('child_process');
422
1504
  const child = spawn(process.argv[0], process.argv.slice(1), {
423
1505
  stdio: 'inherit',
@@ -433,49 +1515,62 @@ function App({ skipPermissions = false }) {
433
1515
  catch (e) {
434
1516
  addMessage('error', `Upgrade failed: ${e instanceof Error ? e.message : String(e)}`);
435
1517
  }
436
- finally {
437
- setUpgrading(false);
438
- setLatestVersion(null);
439
- }
440
- }, [addMessage]);
441
- const handleUpgradeCancel = useCallback(() => {
442
- setUpgradeMode(false);
443
1518
  setLatestVersion(null);
444
- addMessage('system', 'Upgrade cancelled');
445
1519
  }, [addMessage]);
446
- const actualModel = model || DEFAULT_MODELS[selectProvider(provider)];
447
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Static, { items: messages, children: (msg) => (_jsx(Box, { marginBottom: msg.type === 'assistant' ? 1 : 0, children: _jsx(Message, { msg: msg }) }, msg.id)) }), isProcessing && _jsx(Spinner, { label: "Thinking..." }), modelLoading && _jsx(Spinner, { label: "Loading models..." }), modelSelectMode && availableModels.length > 0 && (_jsx(ModelSelector, { models: availableModels, selectedIndex: 0, onSelect: handleModelSelect, onCancel: handleModelCancel })), upgradeMode && latestVersion && (_jsx(UpgradePrompt, { currentVersion: getVersion(), latestVersion: latestVersion, onConfirm: handleUpgradeConfirm, onCancel: handleUpgradeCancel })), upgrading && _jsx(Text, { color: "yellow", children: "\u280B Upgrading..." }), !modelSelectMode && !upgradeMode && !upgrading && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Sep, {}), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "calliope" }), _jsx(Text, { dimColor: true, children: "> " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit })] })] })), _jsxs(Box, { flexDirection: "column", children: [_jsx(Sep, {}), _jsxs(Text, { dimColor: true, children: [selectProvider(provider), ":", actualModel.length > 25 ? actualModel.slice(0, 22) + '...' : actualModel, ' │ ', formatTokens(stats.inputTokens + stats.outputTokens), " tokens", ' │ ', formatCost(stats.cost)] })] })] }));
1520
+ // Cycle through modes
1521
+ const cycleMode = useCallback(() => {
1522
+ setMode(current => {
1523
+ const modes = ['plan', 'hybrid', 'work'];
1524
+ const idx = modes.indexOf(current);
1525
+ const next = modes[(idx + 1) % modes.length];
1526
+ return next;
1527
+ });
1528
+ }, []);
1529
+ // Keyboard shortcuts (Escape to exit, Shift+Tab to cycle mode)
1530
+ useInput((input, key) => {
1531
+ if (key.escape && !isModalActive)
1532
+ exit();
1533
+ // Shift+Tab to cycle mode (key.shift && key.tab)
1534
+ if (key.shift && key.tab && !isProcessing) {
1535
+ cycleMode();
1536
+ }
1537
+ }, { isActive: !isModalActive });
1538
+ // Render
1539
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(MessageHistory, { messages: messages }), streamingResponse && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsx(Text, { color: "cyan", children: "\u2727 Calliope:" }), _jsx(Text, { children: " " }), streamingResponse.split('\n').map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "blue", children: "\u2502" }), " ", line] }, i))), _jsx(Text, { color: "blue", children: "\u2502" }), _jsx(Text, { color: "cyan", children: "\u258C" })] })), isProcessing && thinkingState && !streamingResponse && _jsx(ThinkingDisplay, { state: thinkingState }), isProcessing && !thinkingState && !streamingResponse && _jsx(ProcessingIndicator, { label: "Processing..." }), modalMode === 'model' && availableModels.length > 0 && (_jsx(ModelSelector, { models: availableModels, onSelect: handleModelSelect, onCancel: handleModalCancel })), modalMode === 'upgrade' && latestVersion && (_jsx(UpgradePrompt, { currentVersion: getVersion(), latestVersion: latestVersion, onConfirm: handleUpgradeConfirm, onCancel: handleModalCancel })), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: handleSubmit, disabled: isModalActive || isProcessing, mode: mode }), _jsx(StatusBar, { provider: actualProvider, model: actualModel, mode: mode, stats: stats, contextTokens: contextTokens })] }));
1540
+ }
1541
+ // ============================================================================
1542
+ // App Wrapper & Entry Point
1543
+ // ============================================================================
1544
+ function App() {
1545
+ return _jsx(TerminalChat, {});
448
1546
  }
449
- // ANSI colors for banner
450
- const c = {
1547
+ // ANSI colors for pre-Ink banner
1548
+ const ansi = {
451
1549
  reset: '\x1b[0m',
452
1550
  cyan: '\x1b[36m',
453
1551
  brightCyan: '\x1b[96m',
454
1552
  dim: '\x1b[2m',
455
1553
  };
456
- // Print banner before Ink starts
457
1554
  function printBanner(provider, model) {
458
1555
  console.log();
459
- console.log(`${c.brightCyan}${BANNER_LINES[0]}${c.reset}`);
460
- console.log(`${c.brightCyan}${BANNER_LINES[1]}${c.reset}`);
461
- console.log(`${c.cyan}${BANNER_LINES[2]}${c.reset}`);
462
- console.log(`${c.cyan}${BANNER_LINES[3]}${c.reset}`);
463
- console.log(`${c.brightCyan}${BANNER_LINES[4]}${c.reset}`);
464
- console.log(`${c.cyan}${BANNER_LINES[5]}${c.reset}`);
1556
+ console.log(`${ansi.brightCyan}${BANNER_LINES[0]}${ansi.reset}`);
1557
+ console.log(`${ansi.brightCyan}${BANNER_LINES[1]}${ansi.reset}`);
1558
+ console.log(`${ansi.cyan}${BANNER_LINES[2]}${ansi.reset}`);
1559
+ console.log(`${ansi.cyan}${BANNER_LINES[3]}${ansi.reset}`);
1560
+ console.log(`${ansi.brightCyan}${BANNER_LINES[4]}${ansi.reset}`);
1561
+ console.log(`${ansi.cyan}${BANNER_LINES[5]}${ansi.reset}`);
465
1562
  console.log();
466
- console.log(`${c.dim} The Muse of Digital Eloquence${c.reset}`);
1563
+ console.log(`${ansi.dim} The Muse of Digital Eloquence${ansi.reset}`);
467
1564
  console.log();
468
- console.log(` ${c.dim}v${getVersion()} | ${provider}:${model}${c.reset}`);
469
- console.log(` ${c.dim}/help for commands | ESC to exit${c.reset}`);
1565
+ console.log(` ${ansi.dim}v${getVersion()} | ${provider}:${model}${ansi.reset}`);
1566
+ console.log(` ${ansi.dim}/help for commands | ESC to exit${ansi.reset}`);
470
1567
  console.log();
471
1568
  }
472
- // Export start function
473
1569
  export async function startInkCLI(options = {}) {
474
- // Print banner before Ink takes over
475
1570
  const provider = selectProvider(config.get('defaultProvider'));
476
1571
  const model = config.get('defaultModel') || DEFAULT_MODELS[provider];
477
1572
  printBanner(provider, model);
478
- const { waitUntilExit } = render(_jsx(App, { skipPermissions: options.skipPermissions }));
1573
+ const { waitUntilExit } = render(_jsx(App, {}));
479
1574
  await waitUntilExit();
480
1575
  }
481
1576
  //# sourceMappingURL=ui-cli.js.map