scalix 0.0.1 → 1.1.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.
- package/.env.example +29 -0
- package/CHANGELOG.md +122 -0
- package/LICENSE +100 -0
- package/README.md +300 -2
- package/bin/scalix.js +19 -0
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent-replay.js +166 -0
- package/dist/agent.js +1130 -0
- package/dist/api-client.js +77 -0
- package/dist/auto-verify.js +136 -0
- package/dist/change-tracker.js +53 -0
- package/dist/cli.js +2454 -0
- package/dist/commands.js +71 -0
- package/dist/config.js +16 -0
- package/dist/constants.js +358 -0
- package/dist/context-builder.js +158 -0
- package/dist/context-manager.js +148 -0
- package/dist/context-retriever.js +257 -0
- package/dist/cost-tracker.js +85 -0
- package/dist/diff.js +140 -0
- package/dist/git-context.js +183 -0
- package/dist/git-undo.js +67 -0
- package/dist/grounding-checker.js +154 -0
- package/dist/headless.js +743 -0
- package/dist/hooks.js +100 -0
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +232 -0
- package/dist/logger.js +45 -0
- package/dist/markdown.js +41 -0
- package/dist/mcp.js +49 -0
- package/dist/model-router.js +68 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +376 -0
- package/dist/project-memory.js +165 -0
- package/dist/safe-math.js +194 -0
- package/dist/sandbox.js +403 -0
- package/dist/scoped-rules.js +82 -0
- package/dist/sessions.js +106 -0
- package/dist/settings.js +260 -0
- package/dist/sub-agent.js +154 -0
- package/dist/task-board.js +105 -0
- package/dist/token-counter.js +47 -0
- package/dist/tool-helper.js +97 -0
- package/dist/tool-selector.js +77 -0
- package/dist/tools-advanced.js +487 -0
- package/dist/tools-browser.js +534 -0
- package/dist/tools-extra.js +390 -0
- package/dist/tools-vision.js +116 -0
- package/dist/tools-voice.js +147 -0
- package/dist/tools-web.js +346 -0
- package/dist/tools.js +380 -0
- package/dist/types.js +1 -0
- package/dist/update-checker.js +111 -0
- package/dist/worktree.js +172 -0
- package/docs/DISTRIBUTION.md +301 -0
- package/package.json +71 -6
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2454 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useCallback, useRef, useMemo, memo } from 'react';
|
|
3
|
+
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import TextInput from 'ink-text-input';
|
|
6
|
+
import Gradient from 'ink-gradient';
|
|
7
|
+
import BigText from 'ink-big-text';
|
|
8
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import SelectInput from 'ink-select-input';
|
|
13
|
+
import { createAgent, messageContentToString } from './agent.js';
|
|
14
|
+
import { defaultTools } from './tools.js';
|
|
15
|
+
import { extraTools } from './tools-extra.js';
|
|
16
|
+
import { webTools } from './tools-web.js';
|
|
17
|
+
import { advancedTools } from './tools-advanced.js';
|
|
18
|
+
import { visionTools } from './tools-vision.js';
|
|
19
|
+
import { voiceTools } from './tools-voice.js';
|
|
20
|
+
import { browserTools, closeBrowser } from './tools-browser.js';
|
|
21
|
+
import { loadMcpServices } from './mcp.js';
|
|
22
|
+
import { renderMarkdown } from './markdown.js';
|
|
23
|
+
import { costTracker } from './cost-tracker.js';
|
|
24
|
+
import { saveSession, listSessions, loadSession, generateSessionId } from './sessions.js';
|
|
25
|
+
import { logger } from './logger.js';
|
|
26
|
+
import { VERSION, PRODUCT_NAME, DEFAULT_MODELS, DEFAULT_BASE_URL, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, TOOL_DEFAULTS, HEADLESS_DEFAULTS, fetchModelSpecs } from './constants.js';
|
|
27
|
+
import { checkForUpdate, getUpdateBanner, performUpdate } from './update-checker.js';
|
|
28
|
+
import { loadConfig, saveConfig, isConfigured, DEFAULT_LIMITS, THINKING_MODES } from './settings.js';
|
|
29
|
+
import { getModeDescription, PERMISSION_MODES, setSandboxRoot } from './sandbox.js';
|
|
30
|
+
import { buildProjectIndex, indexToContext } from './project-index.js';
|
|
31
|
+
import { collectGitInfo, gitInfoToContext } from './git-context.js';
|
|
32
|
+
import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote, detectCorrection } from './project-memory.js';
|
|
33
|
+
import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
|
|
34
|
+
import { createRouterConfig, getRouterStatus } from './model-router.js';
|
|
35
|
+
import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
|
|
36
|
+
import { hooksSummary } from './hooks.js';
|
|
37
|
+
import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
|
|
38
|
+
import { createCheckpoint, undoLastCheckpoint, listCheckpoints } from './git-undo.js';
|
|
39
|
+
import { loadScopedRules, rulesToContext } from './scoped-rules.js';
|
|
40
|
+
import { AgentRegistry } from './agent-registry.js';
|
|
41
|
+
logger.setLevel('silent');
|
|
42
|
+
setSandboxRoot(process.cwd());
|
|
43
|
+
// Suppress noisy React 19 development-mode reconciler warnings that leak to stderr
|
|
44
|
+
const _origConsoleError = console.error;
|
|
45
|
+
console.error = (...args) => {
|
|
46
|
+
if (typeof args[0] === 'string' && args[0].startsWith('Warning:') && args[0].includes('same key'))
|
|
47
|
+
return;
|
|
48
|
+
_origConsoleError(...args);
|
|
49
|
+
};
|
|
50
|
+
// Disable bracketed paste mode — terminals (via zsh) may leave it enabled,
|
|
51
|
+
// which wraps pasted text in escape sequences that corrupt TextInput values.
|
|
52
|
+
process.stdout.write('\x1b[?2004l');
|
|
53
|
+
const mcpTools = loadMcpServices();
|
|
54
|
+
let pluginTools = [];
|
|
55
|
+
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
|
|
56
|
+
// ─── Confirmation bridge (event-driven) ───
|
|
57
|
+
import { EventEmitter } from 'node:events';
|
|
58
|
+
const confirmEmitter = new EventEmitter();
|
|
59
|
+
let pendingConfirm = null;
|
|
60
|
+
const confirmToolCall = async (toolName, args) => new Promise((resolve) => {
|
|
61
|
+
pendingConfirm = { resolve, toolName, args };
|
|
62
|
+
confirmEmitter.emit('pending');
|
|
63
|
+
});
|
|
64
|
+
// ─── Agent (deferred until config is ready) ───
|
|
65
|
+
let baseInstructions = DEFAULT_INSTRUCTIONS;
|
|
66
|
+
let agent = null;
|
|
67
|
+
let sessionId = generateSessionId();
|
|
68
|
+
let projectIndex = null;
|
|
69
|
+
let loadedPlugins = [];
|
|
70
|
+
let projectMemory = null;
|
|
71
|
+
let customCommands = [];
|
|
72
|
+
async function buildEnhancedInstructions() {
|
|
73
|
+
let instructions = baseInstructions;
|
|
74
|
+
const cwd = process.cwd();
|
|
75
|
+
try {
|
|
76
|
+
const gitInfo = await collectGitInfo(cwd);
|
|
77
|
+
if (gitInfo.isRepo)
|
|
78
|
+
instructions += '\n\n' + gitInfoToContext(gitInfo);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
try {
|
|
82
|
+
const memory = await loadMemory(cwd);
|
|
83
|
+
const autoConventions = await autoDetectConventions(cwd);
|
|
84
|
+
for (const c of autoConventions) {
|
|
85
|
+
if (!memory.conventions.some((mc) => mc.key === c.key)) {
|
|
86
|
+
memory.conventions.push(c);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
projectMemory = memory;
|
|
90
|
+
if (memory.conventions.length > 0 || memory.patterns.length > 0 || memory.notes.length > 0) {
|
|
91
|
+
instructions += '\n\n' + memoryToContext(memory);
|
|
92
|
+
await saveMemory(cwd, memory);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
try {
|
|
97
|
+
projectIndex = await buildProjectIndex(cwd);
|
|
98
|
+
instructions += '\n\n' + indexToContext(projectIndex);
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
try {
|
|
102
|
+
const rules = await loadScopedRules(cwd);
|
|
103
|
+
const rulesCtx = rulesToContext(rules);
|
|
104
|
+
if (rulesCtx)
|
|
105
|
+
instructions += '\n\n' + rulesCtx;
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
return instructions;
|
|
109
|
+
}
|
|
110
|
+
function initAgent(config) {
|
|
111
|
+
const envModels = process.env[ENV_MODEL]
|
|
112
|
+
? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
|
|
113
|
+
: null;
|
|
114
|
+
const models = envModels || config.models;
|
|
115
|
+
if (models.length === 0) {
|
|
116
|
+
throw new Error('No models configured. Please add at least one model in setup.');
|
|
117
|
+
}
|
|
118
|
+
const limits = config.limits || DEFAULT_LIMITS;
|
|
119
|
+
agent = createAgent({
|
|
120
|
+
apiKey: config.apiKey,
|
|
121
|
+
baseUrl: config.baseUrl,
|
|
122
|
+
model: models.length === 1 ? models[0] : models,
|
|
123
|
+
instructions: baseInstructions,
|
|
124
|
+
tools: allTools,
|
|
125
|
+
confirmToolCall,
|
|
126
|
+
permissionMode: config.permissionMode || 'default',
|
|
127
|
+
maxSteps: limits.maxSteps,
|
|
128
|
+
maxMessages: limits.maxMessages,
|
|
129
|
+
contextManager: {
|
|
130
|
+
maxContextTokens: limits.maxContextTokens,
|
|
131
|
+
compressionThreshold: limits.compressionThreshold,
|
|
132
|
+
maxToolResultTokens: limits.maxToolResultTokens,
|
|
133
|
+
},
|
|
134
|
+
instructionLoader: buildEnhancedInstructions,
|
|
135
|
+
autoVerify: true,
|
|
136
|
+
thinkingMode: config.thinkingMode || 'off',
|
|
137
|
+
});
|
|
138
|
+
const currentAgent = agent;
|
|
139
|
+
loadedPlugins = [];
|
|
140
|
+
discoverPlugins(process.cwd()).then((plugins) => {
|
|
141
|
+
loadedPlugins = plugins;
|
|
142
|
+
const pTools = getPluginTools(plugins);
|
|
143
|
+
pluginTools = pTools;
|
|
144
|
+
for (const pt of pTools)
|
|
145
|
+
currentAgent.addTool(pt);
|
|
146
|
+
}).catch((err) => {
|
|
147
|
+
logger.warn('Plugin discovery failed', { error: err.message });
|
|
148
|
+
});
|
|
149
|
+
buildEnhancedInstructions().then((enhanced) => {
|
|
150
|
+
currentAgent.setInstructions(enhanced);
|
|
151
|
+
}).catch((err) => {
|
|
152
|
+
logger.warn('Failed to build enhanced instructions', { error: err.message });
|
|
153
|
+
});
|
|
154
|
+
loadCustomCommands(process.cwd()).then((cmds) => {
|
|
155
|
+
customCommands = cmds;
|
|
156
|
+
}).catch((err) => { logger.warn('Failed to load custom commands', { error: err.message }); });
|
|
157
|
+
return agent;
|
|
158
|
+
}
|
|
159
|
+
// ─── Helpers ───
|
|
160
|
+
function shortModel(m) {
|
|
161
|
+
return m.split('/').pop().replace(/:free$/, '').replace(/-instruct$/, '');
|
|
162
|
+
}
|
|
163
|
+
function useTermSize() {
|
|
164
|
+
const { stdout } = useStdout();
|
|
165
|
+
const [size, setSize] = useState({ cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24 });
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
const fn = () => {
|
|
168
|
+
const newCols = stdout?.columns ?? 80;
|
|
169
|
+
const newRows = stdout?.rows ?? 24;
|
|
170
|
+
setSize((prev) => {
|
|
171
|
+
if (prev.cols === newCols && prev.rows === newRows)
|
|
172
|
+
return prev;
|
|
173
|
+
return { cols: newCols, rows: newRows };
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
stdout?.on('resize', fn);
|
|
177
|
+
return () => { stdout?.off('resize', fn); };
|
|
178
|
+
}, [stdout]);
|
|
179
|
+
return size;
|
|
180
|
+
}
|
|
181
|
+
function elapsed(start) {
|
|
182
|
+
const s = Math.floor((Date.now() - start) / 1000);
|
|
183
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60}s`;
|
|
184
|
+
}
|
|
185
|
+
function truncate(s, max) {
|
|
186
|
+
if (s.length <= max)
|
|
187
|
+
return s;
|
|
188
|
+
const cut = s.slice(0, max - 1);
|
|
189
|
+
const lastBreak = Math.max(cut.lastIndexOf(','), cut.lastIndexOf(' '), cut.lastIndexOf('\n'));
|
|
190
|
+
return (lastBreak > max * 0.5 ? cut.slice(0, lastBreak) : cut) + '\u2026';
|
|
191
|
+
}
|
|
192
|
+
function formatTokens(n) {
|
|
193
|
+
if (n >= 1000000)
|
|
194
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
195
|
+
if (n >= 1000)
|
|
196
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
197
|
+
return String(n);
|
|
198
|
+
}
|
|
199
|
+
// ─── Components ───
|
|
200
|
+
const Banner = memo(function Banner() {
|
|
201
|
+
return (_jsx(Box, { flexDirection: "column", alignItems: "center", marginBottom: 0, children: _jsx(Gradient, { name: "teen", children: _jsx(BigText, { text: PRODUCT_NAME.toLowerCase(), font: "tiny" }) }) }));
|
|
202
|
+
});
|
|
203
|
+
// Strip bracketed paste markers and control chars that leak through ink's parser
|
|
204
|
+
function sanitizeInput(v) {
|
|
205
|
+
return v.replace(/\[20[01]~/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
206
|
+
}
|
|
207
|
+
function LongTextInput({ value, onChange, onSubmit, placeholder, maxVisible = 80 }) {
|
|
208
|
+
const valueRef = useRef(value);
|
|
209
|
+
useEffect(() => { valueRef.current = value; }, [value]);
|
|
210
|
+
useInput((input, key) => {
|
|
211
|
+
if (key.upArrow || key.downArrow || key.tab || (key.ctrl && input === 'c'))
|
|
212
|
+
return;
|
|
213
|
+
if (key.return) {
|
|
214
|
+
onSubmit(valueRef.current);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (key.backspace || key.delete) {
|
|
218
|
+
if (valueRef.current.length > 0) {
|
|
219
|
+
const next = valueRef.current.slice(0, -1);
|
|
220
|
+
valueRef.current = next;
|
|
221
|
+
onChange(next);
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (key.leftArrow || key.rightArrow)
|
|
226
|
+
return;
|
|
227
|
+
const cleaned = sanitizeInput(input);
|
|
228
|
+
if (cleaned.length > 0) {
|
|
229
|
+
const next = valueRef.current + cleaned;
|
|
230
|
+
valueRef.current = next;
|
|
231
|
+
onChange(next);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
if (value.length === 0) {
|
|
235
|
+
return _jsx(Text, { dimColor: true, children: placeholder || '' });
|
|
236
|
+
}
|
|
237
|
+
const display = value.length <= maxVisible
|
|
238
|
+
? value
|
|
239
|
+
: '\u2026' + value.slice(-(maxVisible - 1));
|
|
240
|
+
return _jsxs(Text, { children: [display, _jsx(Text, { color: "magentaBright", children: '\u258E' })] });
|
|
241
|
+
}
|
|
242
|
+
function formatContextSize(tokens) {
|
|
243
|
+
if (tokens >= 1_000_000)
|
|
244
|
+
return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;
|
|
245
|
+
if (tokens >= 1_000)
|
|
246
|
+
return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
|
|
247
|
+
return String(tokens);
|
|
248
|
+
}
|
|
249
|
+
function SetupScreen({ onComplete, existingConfig }) {
|
|
250
|
+
const STEPS = ['api-key', 'base-url', 'models', 'context-window'];
|
|
251
|
+
const [step, setStep] = useState('api-key');
|
|
252
|
+
const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
|
|
253
|
+
const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
|
|
254
|
+
const [models, setModels] = useState(existingConfig?.models?.length ? [...existingConfig.models] : [...DEFAULT_MODELS]);
|
|
255
|
+
const [modelsMode, setModelsMode] = useState('custom');
|
|
256
|
+
const [modelInput, setModelInput] = useState('');
|
|
257
|
+
const [contextInput, setContextInput] = useState(String(existingConfig?.limits?.maxContextTokens ?? DEFAULT_LIMITS.maxContextTokens));
|
|
258
|
+
const [apiKeyError, setApiKeyError] = useState(false);
|
|
259
|
+
const [fetchedModels, setFetchedModels] = useState([]);
|
|
260
|
+
const [fetchingModels, setFetchingModels] = useState(false);
|
|
261
|
+
const { exit } = useApp();
|
|
262
|
+
const totalSteps = STEPS.length;
|
|
263
|
+
const stepNumber = STEPS.indexOf(step) + 1;
|
|
264
|
+
const goBack = useCallback(() => {
|
|
265
|
+
const idx = STEPS.indexOf(step);
|
|
266
|
+
if (idx > 0) {
|
|
267
|
+
if (step === 'models')
|
|
268
|
+
setModelsMode('custom');
|
|
269
|
+
setStep(STEPS[idx - 1]);
|
|
270
|
+
}
|
|
271
|
+
}, [step]);
|
|
272
|
+
useInput((_ch, key) => {
|
|
273
|
+
if (key.escape)
|
|
274
|
+
exit();
|
|
275
|
+
if (key.backspace || key.delete) {
|
|
276
|
+
if (step === 'api-key' && apiKey === '')
|
|
277
|
+
return;
|
|
278
|
+
if (step === 'base-url' && baseUrl === '') {
|
|
279
|
+
goBack();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (step === 'context-window' && contextInput === '') {
|
|
283
|
+
goBack();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, {}), _jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u2501'.repeat(4), " First-time Setup ", '\u2501'.repeat(4)] }) }), _jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: STEPS.map((_, i) => i < stepNumber ? '\u25CF' : '\u25CB').join('') }), _jsxs(Text, { dimColor: true, children: ["Step ", stepNumber, " of ", totalSteps] })] }), step === 'api-key' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "API Key" }), _jsx(Text, { dimColor: true, children: "Paste your API key and press Enter." }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(LongTextInput, { value: apiKey, onChange: (v) => { setApiKey(v); setApiKeyError(false); }, onSubmit: (v) => {
|
|
289
|
+
const cleaned = v.trim();
|
|
290
|
+
if (cleaned) {
|
|
291
|
+
setApiKey(cleaned);
|
|
292
|
+
setStep('base-url');
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
setApiKeyError(true);
|
|
296
|
+
}
|
|
297
|
+
}, placeholder: "sk-or-v1-..." })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Base URL" }), _jsx(Text, { dimColor: true, children: "Scalix API endpoint (or any compatible URL)" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => setBaseUrl(sanitizeInput(v)), onSubmit: (v) => {
|
|
298
|
+
const url = sanitizeInput(v).trim() || baseUrl;
|
|
299
|
+
setBaseUrl(url);
|
|
300
|
+
setFetchingModels(true);
|
|
301
|
+
fetchModelSpecs(url, apiKey).then((result) => {
|
|
302
|
+
if (result.length > 0) {
|
|
303
|
+
setFetchedModels(result.map((m) => ({ id: m.id, name: m.name })));
|
|
304
|
+
}
|
|
305
|
+
}).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); }).finally(() => {
|
|
306
|
+
setFetchingModels(false);
|
|
307
|
+
setStep('models');
|
|
308
|
+
});
|
|
309
|
+
}, placeholder: DEFAULT_BASE_URL })] }), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#20a0a0", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchedModels.length > 0 && modelsMode === 'custom' && models.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
310
|
+
...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
|
|
311
|
+
label: ` ${fm.name || fm.id}`,
|
|
312
|
+
value: fm.id,
|
|
313
|
+
})),
|
|
314
|
+
{ label: '+ Enter custom model ID...', value: '__custom__' },
|
|
315
|
+
...(models.length > 0 ? [{ label: '\u2192 Continue', value: '__done__' }] : []),
|
|
316
|
+
], onSelect: (item) => {
|
|
317
|
+
if (item.value === '__custom__') {
|
|
318
|
+
setModelsMode('custom');
|
|
319
|
+
setFetchedModels([]);
|
|
320
|
+
}
|
|
321
|
+
else if (item.value === '__done__') {
|
|
322
|
+
if (models.length > 0)
|
|
323
|
+
setStep('context-window');
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
if (!models.includes(item.value)) {
|
|
327
|
+
setModels((prev) => [...prev, item.value]);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} }) }), models.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Selected:" }), models.map((m) => (_jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", m] }, m)))] }))] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Type a model ID and press Enter to add. Add at least one." }), models.length > 0 && modelsMode === 'list' ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Added models (Enter to remove, \u2191/\u2193 to navigate):" }), _jsx(SelectInput, { items: [
|
|
331
|
+
...models.map((m) => ({
|
|
332
|
+
label: `\u25CF ${m}`,
|
|
333
|
+
value: m,
|
|
334
|
+
})),
|
|
335
|
+
{ label: '+ Add another model...', value: '__add__' },
|
|
336
|
+
{ label: '\u2192 Continue', value: '__done__' },
|
|
337
|
+
], onSelect: (item) => {
|
|
338
|
+
if (item.value === '__add__') {
|
|
339
|
+
setModelsMode('custom');
|
|
340
|
+
}
|
|
341
|
+
else if (item.value === '__done__') {
|
|
342
|
+
if (models.length > 0)
|
|
343
|
+
setStep('context-window');
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
setModels((prev) => {
|
|
347
|
+
const next = prev.filter((m) => m !== item.value);
|
|
348
|
+
if (next.length === 0)
|
|
349
|
+
setModelsMode('custom');
|
|
350
|
+
return next;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
} })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [models.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: models.map((m) => (_jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", m] }, m))) })), _jsxs(Box, { children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: modelInput, onChange: (v) => setModelInput(sanitizeInput(v)), onSubmit: (v) => {
|
|
354
|
+
const trimmed = sanitizeInput(v).trim();
|
|
355
|
+
if (trimmed) {
|
|
356
|
+
if (!models.includes(trimmed)) {
|
|
357
|
+
setModels((prev) => [...prev, trimmed]);
|
|
358
|
+
}
|
|
359
|
+
setModelInput('');
|
|
360
|
+
}
|
|
361
|
+
else if (models.length > 0) {
|
|
362
|
+
setModelsMode('list');
|
|
363
|
+
}
|
|
364
|
+
}, placeholder: "model-id (from your API provider)" })] }), _jsx(Box, { paddingLeft: 2, marginTop: 0, children: models.length === 0 ? (_jsx(Text, { color: "yellow", dimColor: true, children: "At least one model is required." })) : (_jsx(Text, { dimColor: true, children: "Enter another model or press Enter (empty) to review & continue" })) })] }))] }))] })), step === 'context-window' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Context Window Size" }), _jsx(Text, { dimColor: true, children: "Enter the max token count your model supports." }), _jsx(Text, { dimColor: true, children: "Check your model provider's docs for the exact limit." }), _jsx(Text, { dimColor: true, children: "This controls when the agent auto-compresses conversation context." }), (() => {
|
|
365
|
+
const parsed = parseInt(contextInput, 10);
|
|
366
|
+
const valid = parsed > 0;
|
|
367
|
+
return valid ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "#30c090", children: ['\u25CF', " ", formatContextSize(parsed), " tokens (", parsed.toLocaleString(), ")"] }) })) : null;
|
|
368
|
+
})(), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: contextInput, onChange: setContextInput, onSubmit: (v) => {
|
|
369
|
+
const parsed = parseInt(v.trim(), 10);
|
|
370
|
+
const tokens = parsed > 0 ? parsed : DEFAULT_LIMITS.maxContextTokens;
|
|
371
|
+
const limits = { ...DEFAULT_LIMITS, maxContextTokens: tokens };
|
|
372
|
+
const newConfig = {
|
|
373
|
+
apiKey,
|
|
374
|
+
baseUrl,
|
|
375
|
+
models,
|
|
376
|
+
customModels: existingConfig?.customModels || {},
|
|
377
|
+
limits,
|
|
378
|
+
permissionMode: existingConfig?.permissionMode || 'default',
|
|
379
|
+
thinkingMode: existingConfig?.thinkingMode || 'off',
|
|
380
|
+
security: existingConfig?.security || {},
|
|
381
|
+
injectionDetection: existingConfig?.injectionDetection || { enabled: true, sensitivity: 'medium' },
|
|
382
|
+
};
|
|
383
|
+
saveConfig(newConfig).then(() => {
|
|
384
|
+
onComplete(newConfig);
|
|
385
|
+
}).catch(() => {
|
|
386
|
+
onComplete(newConfig);
|
|
387
|
+
});
|
|
388
|
+
}, placeholder: String(DEFAULT_LIMITS.maxContextTokens) })] })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [stepNumber > 1 ? 'Backspace (empty) to go back \u2022 ' : '', "ESC to quit"] }) })] })] }));
|
|
389
|
+
}
|
|
390
|
+
const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart, tokens, mode }) {
|
|
391
|
+
const { cols } = useTermSize();
|
|
392
|
+
const [elapsedStr, setElapsedStr] = useState('');
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (!thinkStart) {
|
|
395
|
+
setElapsedStr('');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
setElapsedStr(elapsed(thinkStart));
|
|
399
|
+
const timer = setInterval(() => setElapsedStr(elapsed(thinkStart)), 1000);
|
|
400
|
+
return () => clearInterval(timer);
|
|
401
|
+
}, [thinkStart]);
|
|
402
|
+
const modeColor = mode === 'yolo' ? 'red' : mode === 'auto' ? 'yellow' : mode === 'plan' ? 'blue' : 'green';
|
|
403
|
+
const statusIcon = status === 'thinking' ? '\u25D4' : status === 'error' ? '\u2718' : '\u25CF';
|
|
404
|
+
const statusColor = status === 'thinking' ? 'yellow' : status === 'error' ? 'red' : 'green';
|
|
405
|
+
const activeAgents = AgentRegistry.getInstance().listActive().length;
|
|
406
|
+
const leftParts = [];
|
|
407
|
+
leftParts.push(shortModel(model));
|
|
408
|
+
if (tokens > 0)
|
|
409
|
+
leftParts.push(`${formatTokens(tokens)} tok`);
|
|
410
|
+
leftParts.push(`${toolCount} tools`);
|
|
411
|
+
if (activeAgents > 0)
|
|
412
|
+
leftParts.push(`${activeAgents} agent${activeAgents > 1 ? 's' : ''}`);
|
|
413
|
+
const leftText = leftParts.join(' \u00B7 ');
|
|
414
|
+
const rightLen = mode.length + VERSION.length + 6 + (elapsedStr ? elapsedStr.length + 3 : 0);
|
|
415
|
+
const gap = Math.max(cols - leftText.length - rightLen - 8, 2);
|
|
416
|
+
return (_jsxs(Box, { paddingX: 2, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: statusColor, children: statusIcon }), _jsx(Text, { dimColor: true, children: leftText })] }), _jsx(Text, { children: ' '.repeat(gap) }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: modeColor, bold: true, children: mode }), elapsedStr ? _jsxs(Text, { dimColor: true, children: ['\u00B7', " ", elapsedStr] }) : null, _jsxs(Text, { dimColor: true, children: ['\u00B7', " v", VERSION] })] })] }));
|
|
417
|
+
});
|
|
418
|
+
const WelcomeScreen = memo(function WelcomeScreen() {
|
|
419
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, flexWrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "Tools:" }), ['shell', 'read', 'write', 'edit', 'search', 'find', 'git', 'web', 'tree'].map((t) => (_jsx(Text, { dimColor: true, bold: true, children: t }, t)))] })] }));
|
|
420
|
+
});
|
|
421
|
+
const UserMessage = memo(function UserMessage({ text }) {
|
|
422
|
+
return (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, children: [_jsx(Text, { color: "magentaBright", bold: true, children: '\u276F' }), _jsx(Text, { wrap: "wrap", bold: true, children: text })] }));
|
|
423
|
+
});
|
|
424
|
+
const AssistantMessage = memo(function AssistantMessage({ text }) {
|
|
425
|
+
const rendered = renderMarkdown(text);
|
|
426
|
+
return (_jsx(Box, { paddingLeft: 3, paddingRight: 2, flexDirection: "column", marginTop: 1, children: _jsx(Text, { wrap: "wrap", children: rendered }) }));
|
|
427
|
+
});
|
|
428
|
+
const ErrorMessage = memo(function ErrorMessage({ text, hint }) {
|
|
429
|
+
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, flexDirection: "column", marginTop: 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "red", children: '\u2718' }), _jsx(Text, { wrap: "wrap", color: "red", children: text })] }), hint && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { wrap: "wrap", color: "yellow", dimColor: true, children: hint }) }))] }));
|
|
430
|
+
});
|
|
431
|
+
const ToolCallMessage = memo(function ToolCallMessage({ name, args }) {
|
|
432
|
+
let summary = '';
|
|
433
|
+
try {
|
|
434
|
+
const parsed = JSON.parse(args);
|
|
435
|
+
if (parsed.command)
|
|
436
|
+
summary = parsed.command;
|
|
437
|
+
else if (parsed.filePath || parsed.file_path)
|
|
438
|
+
summary = parsed.filePath || parsed.file_path;
|
|
439
|
+
else if (parsed.pattern)
|
|
440
|
+
summary = parsed.pattern;
|
|
441
|
+
else if (parsed.query)
|
|
442
|
+
summary = parsed.query;
|
|
443
|
+
else
|
|
444
|
+
summary = truncate(args, 80);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
summary = truncate(args, 80);
|
|
448
|
+
}
|
|
449
|
+
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '\u25CB' }), _jsx(Text, { color: "cyan", bold: true, children: name }), _jsx(Text, { dimColor: true, children: summary })] }));
|
|
450
|
+
});
|
|
451
|
+
const ToolResultMessage = memo(function ToolResultMessage({ name, result }) {
|
|
452
|
+
const { cols } = useTermSize();
|
|
453
|
+
const maxLen = Math.max(cols - name.length - 10, 40);
|
|
454
|
+
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "green", children: '\u25CF' }), _jsxs(Text, { dimColor: true, children: [name, ":"] }), _jsx(Text, { dimColor: true, children: truncate(result, maxLen) })] }));
|
|
455
|
+
});
|
|
456
|
+
const ToolBlockedMessage = memo(function ToolBlockedMessage({ text }) {
|
|
457
|
+
return (_jsxs(Box, { paddingLeft: 3, paddingRight: 2, gap: 1, children: [_jsx(Text, { color: "red", children: '\u25CF' }), _jsx(Text, { color: "red", children: text })] }));
|
|
458
|
+
});
|
|
459
|
+
const DIFF_MAX_LINES = 60;
|
|
460
|
+
const DiffMessage = memo(function DiffMessage({ text, meta }) {
|
|
461
|
+
const allLines = text.split('\n');
|
|
462
|
+
const truncated = allLines.length > DIFF_MAX_LINES;
|
|
463
|
+
const lines = truncated ? [...allLines.slice(0, DIFF_MAX_LINES - 1), ` ... ${allLines.length - DIFF_MAX_LINES + 1} more lines`] : allLines;
|
|
464
|
+
return (_jsxs(Box, { paddingX: 2, paddingLeft: 3, flexDirection: "column", marginTop: 0, marginBottom: 0, children: [_jsxs(Text, { color: "cyan", dimColor: true, children: ['\u250C', " ", meta || 'Changes'] }), _jsx(Box, { flexDirection: "column", paddingLeft: 1, children: lines.map((line, i) => {
|
|
465
|
+
if (line.startsWith('+ ') || line.startsWith('+\t')) {
|
|
466
|
+
return _jsxs(Text, { color: "green", children: ['\u2502', " ", line] }, i);
|
|
467
|
+
}
|
|
468
|
+
if (line.startsWith('- ') || line.startsWith('-\t')) {
|
|
469
|
+
return _jsxs(Text, { color: "red", children: ['\u2502', " ", line] }, i);
|
|
470
|
+
}
|
|
471
|
+
if (line.startsWith('@@ ')) {
|
|
472
|
+
return _jsxs(Text, { color: "cyan", dimColor: true, children: ['\u2502', " ", line] }, i);
|
|
473
|
+
}
|
|
474
|
+
if (line.startsWith('---') || line.startsWith('+++')) {
|
|
475
|
+
return _jsxs(Text, { bold: true, children: ['\u2502', " ", line] }, i);
|
|
476
|
+
}
|
|
477
|
+
if (line.includes('additions') || line.includes('deletions')) {
|
|
478
|
+
return _jsxs(Text, { color: "cyan", dimColor: true, children: ['\u2514', " ", line] }, i);
|
|
479
|
+
}
|
|
480
|
+
return _jsxs(Text, { dimColor: true, children: ['\u2502', " ", line] }, i);
|
|
481
|
+
}) })] }));
|
|
482
|
+
});
|
|
483
|
+
const SystemMessage = memo(function SystemMessage({ text }) {
|
|
484
|
+
return (_jsxs(Box, { paddingX: 2, paddingLeft: 3, marginY: 0, gap: 1, children: [_jsx(Text, { dimColor: true, children: '\u2500' }), _jsx(Text, { dimColor: true, wrap: "wrap", children: text })] }));
|
|
485
|
+
});
|
|
486
|
+
const RenderItem = memo(function RenderItem({ item }) {
|
|
487
|
+
switch (item.kind) {
|
|
488
|
+
case 'user': return _jsx(UserMessage, { text: item.text });
|
|
489
|
+
case 'assistant': return _jsx(AssistantMessage, { text: item.text });
|
|
490
|
+
case 'error': return _jsx(ErrorMessage, { text: item.text, hint: item.hint });
|
|
491
|
+
case 'tool-call': return _jsx(ToolCallMessage, { name: item.meta ?? '', args: item.text });
|
|
492
|
+
case 'tool-result': return _jsx(ToolResultMessage, { name: item.meta ?? '', result: item.text });
|
|
493
|
+
case 'tool-blocked': return _jsx(ToolBlockedMessage, { text: item.text });
|
|
494
|
+
case 'diff': return _jsx(DiffMessage, { text: item.text, meta: item.meta });
|
|
495
|
+
case 'system': return _jsx(SystemMessage, { text: item.text });
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
const StreamingArea = memo(function StreamingArea({ text, reasoning, model, maxLines }) {
|
|
499
|
+
const { rows } = useTermSize();
|
|
500
|
+
const limit = maxLines ?? Math.floor(rows * 0.5);
|
|
501
|
+
const displayText = useMemo(() => {
|
|
502
|
+
if (!text)
|
|
503
|
+
return '';
|
|
504
|
+
const lines = text.split('\n');
|
|
505
|
+
if (lines.length <= limit)
|
|
506
|
+
return text;
|
|
507
|
+
return '…\n' + lines.slice(-limit).join('\n');
|
|
508
|
+
}, [text, limit]);
|
|
509
|
+
const displayReasoning = useMemo(() => {
|
|
510
|
+
if (!reasoning)
|
|
511
|
+
return '';
|
|
512
|
+
const lines = reasoning.split('\n');
|
|
513
|
+
if (lines.length <= 3)
|
|
514
|
+
return reasoning;
|
|
515
|
+
return lines.slice(-3).join('\n');
|
|
516
|
+
}, [reasoning]);
|
|
517
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 3, paddingRight: 2, marginTop: 1, children: [displayReasoning.length > 0 && (_jsx(Box, { paddingLeft: 0, marginBottom: 0, children: _jsxs(Text, { color: "magenta", dimColor: true, italic: true, wrap: "wrap", children: ['\u2502', " ", displayReasoning] }) })), displayText.length > 0 ? (_jsxs(Text, { wrap: "wrap", children: [renderMarkdown(displayText), _jsx(Text, { color: "magentaBright", children: '\u258E' })] })) : displayReasoning.length === 0 ? (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { dimColor: true, children: ["Thinking", model ? ` (${model})` : '', "..."] })] })) : null] }));
|
|
518
|
+
});
|
|
519
|
+
function ConfirmPrompt({ toolName, args, onRespond, onScrollUp, onScrollDown }) {
|
|
520
|
+
const { cols } = useTermSize();
|
|
521
|
+
useInput((ch, key) => {
|
|
522
|
+
if (ch.toLowerCase() === 'y')
|
|
523
|
+
onRespond(true);
|
|
524
|
+
else if (ch.toLowerCase() === 'n' || key.escape)
|
|
525
|
+
onRespond(false);
|
|
526
|
+
else if (key.pageUp || (key.upArrow && key.shift))
|
|
527
|
+
onScrollUp?.();
|
|
528
|
+
else if (key.pageDown || (key.downArrow && key.shift))
|
|
529
|
+
onScrollDown?.();
|
|
530
|
+
});
|
|
531
|
+
let parsed = {};
|
|
532
|
+
try {
|
|
533
|
+
parsed = typeof args === 'string' ? JSON.parse(args) : args ?? {};
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
parsed = {};
|
|
537
|
+
}
|
|
538
|
+
const isCommand = toolName === 'run_command';
|
|
539
|
+
const isWrite = toolName === 'write_file' || toolName === 'edit_file' || toolName === 'notebook_edit';
|
|
540
|
+
const isGit = toolName === 'git_commit';
|
|
541
|
+
const highlight = parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message ?? null;
|
|
542
|
+
const highlightLabel = isCommand ? 'Command' : isGit ? 'Message' : isWrite ? 'File' : 'Args';
|
|
543
|
+
const highlightStr = highlight ? String(highlight) : null;
|
|
544
|
+
const extraFields = Object.entries(parsed).filter(([k]) => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content'].includes(k));
|
|
545
|
+
const boxWidth = Math.min(cols - 2, 80);
|
|
546
|
+
const border = '\u2500'.repeat(boxWidth - 2);
|
|
547
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: "yellow", children: ['\u250C', border, '\u2510'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { color: "yellow", bold: true, children: ['\u26A0', " Permission Required"] }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - 24)) }), '\u2502'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { dimColor: true, children: "Tool:" }), " ", _jsx(Text, { color: "cyan", bold: true, children: toolName }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - 9 - toolName.length)) }), '\u2502'] }), highlightStr && (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [highlightLabel, ":"] }), " ", _jsx(Text, { color: "white", bold: true, children: truncate(highlightStr, boxWidth - highlightLabel.length - 6) }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - highlightLabel.length - 4 - Math.min(highlightStr.length, boxWidth - highlightLabel.length - 6))) }), '\u2502'] })), extraFields.slice(0, 3).map(([k, v]) => {
|
|
548
|
+
const val = truncate(String(v), boxWidth - k.length - 6);
|
|
549
|
+
return (_jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsxs(Text, { dimColor: true, children: [k, ":"] }), " ", _jsx(Text, { children: val }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - k.length - 4 - val.length)) }), '\u2502'] }, k));
|
|
550
|
+
}), _jsxs(Text, { color: "yellow", children: ['\u251C', border, '\u2524'] }), _jsxs(Text, { color: "yellow", children: ['\u2502', " ", _jsx(Text, { color: "green", bold: true, children: "[Y]" }), _jsx(Text, { children: " Allow " }), _jsx(Text, { color: "red", bold: true, children: "[N]" }), _jsx(Text, { children: " Deny " }), _jsx(Text, { dimColor: true, children: "ESC to deny" }), _jsx(Text, { children: ' '.repeat(Math.max(0, boxWidth - 33)) }), '\u2502'] }), _jsxs(Text, { color: "yellow", children: ['\u2514', border, '\u2518'] })] }));
|
|
551
|
+
}
|
|
552
|
+
function ModelPicker({ currentModels, onSelect, onCancel }) {
|
|
553
|
+
const [customInput, setCustomInput] = useState('');
|
|
554
|
+
const [showCustom, setShowCustom] = useState(false);
|
|
555
|
+
useInput((_ch, key) => { if (key.escape) {
|
|
556
|
+
if (showCustom)
|
|
557
|
+
setShowCustom(false);
|
|
558
|
+
else
|
|
559
|
+
onCancel();
|
|
560
|
+
} });
|
|
561
|
+
if (showCustom) {
|
|
562
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, children: [_jsx(Text, { bold: true, children: "Enter Model ID" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "magentaBright", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: customInput, onChange: setCustomInput, onSubmit: (v) => {
|
|
563
|
+
if (v.trim())
|
|
564
|
+
onSelect(v.trim(), shortModel(v.trim()));
|
|
565
|
+
else
|
|
566
|
+
setShowCustom(false);
|
|
567
|
+
}, placeholder: "provider/model-name" })] }), _jsx(Text, { dimColor: true, children: "ESC to go back" })] }));
|
|
568
|
+
}
|
|
569
|
+
const items = [
|
|
570
|
+
...currentModels.map((m) => ({
|
|
571
|
+
label: `\u25CF ${shortModel(m)}`,
|
|
572
|
+
value: m,
|
|
573
|
+
})),
|
|
574
|
+
{ label: '+ Add new model...', value: '__custom__' },
|
|
575
|
+
{ label: ' Cancel', value: '__cancel__' },
|
|
576
|
+
];
|
|
577
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, children: [_jsx(Text, { bold: true, children: "Switch Model" }), _jsxs(Text, { dimColor: true, children: ["Current: ", currentModels.map(shortModel).join(', ')] }), _jsx(SelectInput, { items: items, onSelect: (item) => {
|
|
578
|
+
if (item.value === '__cancel__')
|
|
579
|
+
onCancel();
|
|
580
|
+
else if (item.value === '__custom__')
|
|
581
|
+
setShowCustom(true);
|
|
582
|
+
else
|
|
583
|
+
onSelect(item.value, item.label);
|
|
584
|
+
} }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
|
|
585
|
+
}
|
|
586
|
+
function SessionPicker({ sessions, onSelect, onCancel }) {
|
|
587
|
+
useInput((_ch, key) => { if (key.escape)
|
|
588
|
+
onCancel(); });
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
if (sessions.length === 0) {
|
|
591
|
+
const timer = setTimeout(onCancel, 1500);
|
|
592
|
+
return () => clearTimeout(timer);
|
|
593
|
+
}
|
|
594
|
+
}, [sessions.length, onCancel]);
|
|
595
|
+
if (sessions.length === 0) {
|
|
596
|
+
return (_jsx(Box, { paddingX: 3, marginY: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "No saved sessions found. Press ESC or wait..." }) }));
|
|
597
|
+
}
|
|
598
|
+
const items = [
|
|
599
|
+
...sessions.slice(0, 10).map((s) => ({
|
|
600
|
+
label: `${s.name} (${s.messageCount} msgs, ${shortModel(s.model)}, ${new Date(s.updatedAt).toLocaleDateString()})`,
|
|
601
|
+
value: s.id,
|
|
602
|
+
})),
|
|
603
|
+
{ label: 'Cancel', value: '__cancel__' },
|
|
604
|
+
];
|
|
605
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, children: [_jsx(Text, { bold: true, children: "Resume Session" }), _jsx(SelectInput, { items: items, onSelect: (item) => {
|
|
606
|
+
if (item.value === '__cancel__')
|
|
607
|
+
return onCancel();
|
|
608
|
+
const session = sessions.find((s) => s.id === item.value);
|
|
609
|
+
if (session)
|
|
610
|
+
onSelect(session);
|
|
611
|
+
else
|
|
612
|
+
onCancel();
|
|
613
|
+
} }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
|
|
614
|
+
}
|
|
615
|
+
function ModePicker({ currentMode, onSelect, onCancel }) {
|
|
616
|
+
useInput((_ch, key) => { if (key.escape)
|
|
617
|
+
onCancel(); });
|
|
618
|
+
const items = [
|
|
619
|
+
...PERMISSION_MODES.map((m) => ({
|
|
620
|
+
label: `${m === currentMode ? '\u25CF' : '\u25CB'} ${m.padEnd(8)} \u2014 ${getModeDescription(m)}`,
|
|
621
|
+
value: m,
|
|
622
|
+
})),
|
|
623
|
+
{ label: ' Cancel', value: '__cancel__' },
|
|
624
|
+
];
|
|
625
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, children: [_jsx(Text, { bold: true, children: "Permission Mode" }), _jsxs(Text, { dimColor: true, children: ["Current: ", currentMode] }), _jsx(SelectInput, { items: items, initialIndex: PERMISSION_MODES.indexOf(currentMode), onSelect: (item) => {
|
|
626
|
+
if (item.value === '__cancel__')
|
|
627
|
+
onCancel();
|
|
628
|
+
else
|
|
629
|
+
onSelect(item.value);
|
|
630
|
+
} }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
|
|
631
|
+
}
|
|
632
|
+
function SettingsEditor({ onSave, onClose }) {
|
|
633
|
+
const [selectedKey, setSelectedKey] = useState(null);
|
|
634
|
+
const [editValue, setEditValue] = useState('');
|
|
635
|
+
const [currentConfig, setCurrentConfig] = useState(null);
|
|
636
|
+
const reload = useCallback(() => { loadConfig().then(setCurrentConfig).catch((err) => { logger.warn('Failed to load config', { error: err.message }); }); }, []);
|
|
637
|
+
useEffect(() => { reload(); }, [reload]);
|
|
638
|
+
useInput((_ch, key) => {
|
|
639
|
+
if (key.escape) {
|
|
640
|
+
if (selectedKey)
|
|
641
|
+
setSelectedKey(null);
|
|
642
|
+
else
|
|
643
|
+
onClose();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
if (!currentConfig) {
|
|
647
|
+
return (_jsx(Box, { paddingX: 2, children: _jsxs(Text, { dimColor: true, children: ['\u25CF', " Loading settings..."] }) }));
|
|
648
|
+
}
|
|
649
|
+
const limits = currentConfig.limits || DEFAULT_LIMITS;
|
|
650
|
+
if (selectedKey) {
|
|
651
|
+
const currentVal = String(limits[selectedKey]);
|
|
652
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, children: [_jsxs(Text, { bold: true, children: ["Edit: ", selectedKey] }), _jsxs(Text, { dimColor: true, children: ["Current: ", currentVal] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "magentaBright", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: editValue, onChange: setEditValue, onSubmit: (v) => {
|
|
653
|
+
const trimmed = v.trim();
|
|
654
|
+
if (!trimmed) {
|
|
655
|
+
setSelectedKey(null);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const num = Number(trimmed);
|
|
659
|
+
if (isNaN(num) || num < 0) {
|
|
660
|
+
setEditValue('');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (selectedKey.includes('Ratio') || selectedKey.includes('Threshold') || selectedKey === 'slidingWindowTarget') {
|
|
664
|
+
if (num < 0 || num > 1) {
|
|
665
|
+
setEditValue('');
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
onSave(selectedKey, trimmed);
|
|
670
|
+
setSelectedKey(null);
|
|
671
|
+
reload();
|
|
672
|
+
}, placeholder: currentVal })] }), _jsx(Text, { dimColor: true, children: "Enter to save \u2022 ESC to go back" })] }));
|
|
673
|
+
}
|
|
674
|
+
const editableKeys = [
|
|
675
|
+
// Agent
|
|
676
|
+
{ key: 'maxSteps', label: 'Max Steps (tool-call rounds)', value: String(limits.maxSteps) },
|
|
677
|
+
{ key: 'maxMessages', label: 'Max Messages (history size)', value: String(limits.maxMessages) },
|
|
678
|
+
{ key: 'maxRetries', label: 'Max Retries', value: String(limits.maxRetries) },
|
|
679
|
+
{ key: 'toolTimeoutMs', label: 'Tool Timeout (ms)', value: limits.toolTimeoutMs.toLocaleString() },
|
|
680
|
+
{ key: 'circuitBreakerThreshold', label: 'Circuit Breaker Threshold', value: String(limits.circuitBreakerThreshold) },
|
|
681
|
+
{ key: 'circuitBreakerResetMs', label: 'Circuit Breaker Reset (ms)', value: limits.circuitBreakerResetMs.toLocaleString() },
|
|
682
|
+
{ key: 'messageKeepRatio', label: 'Message Keep Ratio (0-1)', value: String(limits.messageKeepRatio) },
|
|
683
|
+
// Context
|
|
684
|
+
{ key: 'maxContextTokens', label: 'Max Context Tokens', value: limits.maxContextTokens.toLocaleString() },
|
|
685
|
+
{ key: 'maxToolResultTokens', label: 'Max Tool Result Tokens', value: String(limits.maxToolResultTokens) },
|
|
686
|
+
{ key: 'compressionThreshold', label: 'Compression Threshold (0-1)', value: String(limits.compressionThreshold) },
|
|
687
|
+
{ key: 'charsPerToken', label: 'Chars Per Token', value: String(limits.charsPerToken) },
|
|
688
|
+
{ key: 'minRecentMessages', label: 'Min Recent Messages', value: String(limits.minRecentMessages) },
|
|
689
|
+
{ key: 'slidingWindowTarget', label: 'Sliding Window Target (0-1)', value: String(limits.slidingWindowTarget) },
|
|
690
|
+
// File & Shell
|
|
691
|
+
{ key: 'readFileMaxSize', label: 'Read File Max Size (bytes)', value: limits.readFileMaxSize.toLocaleString() },
|
|
692
|
+
{ key: 'maxEditStringLength', label: 'Max Edit String Length', value: limits.maxEditStringLength.toLocaleString() },
|
|
693
|
+
{ key: 'commandTimeoutMs', label: 'Command Timeout (ms)', value: limits.commandTimeoutMs.toLocaleString() },
|
|
694
|
+
{ key: 'searchTimeoutMs', label: 'Search Timeout (ms)', value: limits.searchTimeoutMs.toLocaleString() },
|
|
695
|
+
{ key: 'fileRefTruncation', label: 'File Ref Truncation', value: limits.fileRefTruncation.toLocaleString() },
|
|
696
|
+
{ key: 'treeMaxEntries', label: 'Tree Max Entries', value: String(limits.treeMaxEntries) },
|
|
697
|
+
{ key: 'shellDetectionTimeoutMs', label: 'Shell Detection Timeout (ms)', value: limits.shellDetectionTimeoutMs.toLocaleString() },
|
|
698
|
+
// Background processes
|
|
699
|
+
{ key: 'bgBufferMax', label: 'BG Buffer Max (bytes)', value: limits.bgBufferMax.toLocaleString() },
|
|
700
|
+
{ key: 'bgCleanupMs', label: 'BG Cleanup (ms)', value: limits.bgCleanupMs.toLocaleString() },
|
|
701
|
+
// Git
|
|
702
|
+
{ key: 'gitTimeoutMs', label: 'Git Timeout (ms)', value: limits.gitTimeoutMs.toLocaleString() },
|
|
703
|
+
{ key: 'gitDiffMaxBuffer', label: 'Git Diff Max Buffer (bytes)', value: limits.gitDiffMaxBuffer.toLocaleString() },
|
|
704
|
+
// Web
|
|
705
|
+
{ key: 'webFetchTimeoutMs', label: 'Web Fetch Timeout (ms)', value: limits.webFetchTimeoutMs.toLocaleString() },
|
|
706
|
+
{ key: 'htmlMaxSize', label: 'HTML Max Size (bytes)', value: limits.htmlMaxSize.toLocaleString() },
|
|
707
|
+
{ key: 'defaultSearchResults', label: 'Default Search Results', value: String(limits.defaultSearchResults) },
|
|
708
|
+
{ key: 'defaultMaxFetchLength', label: 'Default Max Fetch Length', value: limits.defaultMaxFetchLength.toLocaleString() },
|
|
709
|
+
{ key: 'maxWebFetchLength', label: 'Max Web Fetch Length', value: limits.maxWebFetchLength.toLocaleString() },
|
|
710
|
+
// Sub-agents
|
|
711
|
+
{ key: 'subAgentTimeoutMs', label: 'Sub-Agent Timeout (ms)', value: limits.subAgentTimeoutMs.toLocaleString() },
|
|
712
|
+
{ key: 'subAgentMaxSteps', label: 'Sub-Agent Max Steps', value: String(limits.subAgentMaxSteps) },
|
|
713
|
+
{ key: 'subAgentMaxContextTokens', label: 'Sub-Agent Max Context Tokens', value: limits.subAgentMaxContextTokens.toLocaleString() },
|
|
714
|
+
{ key: 'subAgentCompressionThreshold', label: 'Sub-Agent Compression Threshold', value: String(limits.subAgentCompressionThreshold) },
|
|
715
|
+
{ key: 'agentToolTimeoutMs', label: 'Agent Tool Timeout (ms)', value: limits.agentToolTimeoutMs.toLocaleString() },
|
|
716
|
+
{ key: 'exploreMaxSteps', label: 'Explore Max Steps', value: String(limits.exploreMaxSteps) },
|
|
717
|
+
// Notebooks
|
|
718
|
+
{ key: 'maxNotebookSource', label: 'Max Notebook Source', value: limits.maxNotebookSource.toLocaleString() },
|
|
719
|
+
];
|
|
720
|
+
const items = [
|
|
721
|
+
...editableKeys.map((e) => ({
|
|
722
|
+
label: `${e.label}: ${e.value}`,
|
|
723
|
+
value: e.key,
|
|
724
|
+
})),
|
|
725
|
+
{ label: ' Done', value: '__done__' },
|
|
726
|
+
];
|
|
727
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, children: [_jsx(Text, { bold: true, children: "Settings" }), _jsxs(Text, { dimColor: true, children: ['\u2191\u2193', " navigate ", '\u00B7', " Enter to edit ", '\u00B7', " ESC to close"] }), _jsx(SelectInput, { items: items, limit: 15, onSelect: (item) => {
|
|
728
|
+
if (item.value === '__done__')
|
|
729
|
+
onClose();
|
|
730
|
+
else {
|
|
731
|
+
setSelectedKey(item.value);
|
|
732
|
+
setEditValue(String(limits[item.value]));
|
|
733
|
+
}
|
|
734
|
+
} })] }));
|
|
735
|
+
}
|
|
736
|
+
const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, onClear, onScrollUp, onScrollDown, onHeightChange }) {
|
|
737
|
+
const { exit } = useApp();
|
|
738
|
+
const { cols } = useTermSize();
|
|
739
|
+
const line = useMemo(() => '\u2500'.repeat(Math.max(cols - 6, 20)), [cols]);
|
|
740
|
+
const [value, setValue] = useState('');
|
|
741
|
+
const [stash, setStash] = useState('');
|
|
742
|
+
const [ctrlCCount, setCtrlCCount] = useState(0);
|
|
743
|
+
const ctrlCTimer = useRef(null);
|
|
744
|
+
const [history, setHistory] = useState([]);
|
|
745
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
746
|
+
const savedInput = useRef('');
|
|
747
|
+
const valueRef = useRef('');
|
|
748
|
+
const onChange = useCallback((v) => {
|
|
749
|
+
setValue(v);
|
|
750
|
+
valueRef.current = v;
|
|
751
|
+
setHistoryIndex(-1);
|
|
752
|
+
}, []);
|
|
753
|
+
const handleSubmit = useCallback((v) => {
|
|
754
|
+
const trimmed = v.trim();
|
|
755
|
+
if (!trimmed)
|
|
756
|
+
return;
|
|
757
|
+
setHistory((prev) => {
|
|
758
|
+
const filtered = prev.filter((h) => h !== trimmed);
|
|
759
|
+
return [...filtered, trimmed].slice(-100);
|
|
760
|
+
});
|
|
761
|
+
setHistoryIndex(-1);
|
|
762
|
+
setValue('');
|
|
763
|
+
valueRef.current = '';
|
|
764
|
+
onSubmit(trimmed);
|
|
765
|
+
}, [onSubmit]);
|
|
766
|
+
useInput((ch, key) => {
|
|
767
|
+
const val = valueRef.current;
|
|
768
|
+
// ── Ctrl+C: interrupt → clear → exit (escalating) ──
|
|
769
|
+
if (ch === 'c' && key.ctrl) {
|
|
770
|
+
if (disabled) {
|
|
771
|
+
onAbort();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (val.length > 0) {
|
|
775
|
+
onChange('');
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
setCtrlCCount((prev) => {
|
|
779
|
+
if (prev >= 1) {
|
|
780
|
+
exit();
|
|
781
|
+
return 0;
|
|
782
|
+
}
|
|
783
|
+
if (ctrlCTimer.current)
|
|
784
|
+
clearTimeout(ctrlCTimer.current);
|
|
785
|
+
ctrlCTimer.current = setTimeout(() => setCtrlCCount(0), 3000);
|
|
786
|
+
return prev + 1;
|
|
787
|
+
});
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
// ── Ctrl+D: exit when input is empty ──
|
|
791
|
+
if (ch === 'd' && key.ctrl) {
|
|
792
|
+
if (!disabled && val.length === 0)
|
|
793
|
+
exit();
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
// ── Ctrl+L: clear screen ──
|
|
797
|
+
if (ch === 'l' && key.ctrl) {
|
|
798
|
+
onClear();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// ── Ctrl+U: clear entire input ──
|
|
802
|
+
if (ch === 'u' && key.ctrl && !disabled) {
|
|
803
|
+
onChange('');
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
// ── Ctrl+W: delete last word ──
|
|
807
|
+
if (ch === 'w' && key.ctrl && !disabled) {
|
|
808
|
+
const trimmed = val.replace(/\s*\S+\s*$/, '');
|
|
809
|
+
onChange(trimmed === val ? '' : trimmed);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
// ── Ctrl+K: delete from cursor to end (clears all since cursor is at end) ──
|
|
813
|
+
if (ch === 'k' && key.ctrl && !disabled) {
|
|
814
|
+
onChange('');
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
// ── Ctrl+S: stash / restore prompt ──
|
|
818
|
+
if (ch === 's' && key.ctrl && !disabled) {
|
|
819
|
+
if (val.length > 0) {
|
|
820
|
+
setStash(val);
|
|
821
|
+
onChange('');
|
|
822
|
+
}
|
|
823
|
+
else if (stash.length > 0) {
|
|
824
|
+
onChange(stash);
|
|
825
|
+
setStash('');
|
|
826
|
+
}
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
// ── Tab: autocomplete slash commands ──
|
|
830
|
+
if (key.tab && !disabled && val.startsWith('/') && !val.includes(' ')) {
|
|
831
|
+
const cmdNames = commands.map((c) => c.cmd);
|
|
832
|
+
const matches = cmdNames.filter((c) => c.startsWith(val.toLowerCase()));
|
|
833
|
+
if (matches.length === 1)
|
|
834
|
+
onChange(matches[0]);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// ── Escape: close command picker or exit ──
|
|
838
|
+
if (key.escape) {
|
|
839
|
+
if (val.startsWith('/') && !val.includes(' ')) {
|
|
840
|
+
onChange('');
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
exit();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
// ── When command picker is open, handle input manually since TextInput is unfocused ──
|
|
847
|
+
const pickerOpen = !disabled && val.startsWith('/') && !val.includes(' ');
|
|
848
|
+
if (pickerOpen) {
|
|
849
|
+
if (key.upArrow || key.downArrow || key.return)
|
|
850
|
+
return;
|
|
851
|
+
if (key.backspace || key.delete) {
|
|
852
|
+
onChange(val.slice(0, -1));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (!key.ctrl && !key.meta && !key.tab && ch.length > 0) {
|
|
856
|
+
onChange(val + sanitizeInput(ch));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
// ── Scroll ──
|
|
862
|
+
if (key.pageUp || (key.upArrow && key.shift)) {
|
|
863
|
+
onScrollUp();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (key.pageDown || (key.downArrow && key.shift)) {
|
|
867
|
+
onScrollDown();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
// ── History navigation ──
|
|
871
|
+
if (!disabled) {
|
|
872
|
+
if (key.upArrow && history.length > 0) {
|
|
873
|
+
setHistoryIndex((prev) => {
|
|
874
|
+
if (prev === -1)
|
|
875
|
+
savedInput.current = val;
|
|
876
|
+
const next = Math.min(prev + 1, history.length - 1);
|
|
877
|
+
const restored = history[history.length - 1 - next] || '';
|
|
878
|
+
setValue(restored);
|
|
879
|
+
valueRef.current = restored;
|
|
880
|
+
return next;
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
if (key.downArrow && historyIndex >= 0) {
|
|
884
|
+
setHistoryIndex((prev) => {
|
|
885
|
+
const next = Math.max(prev - 1, -1);
|
|
886
|
+
const restored = next === -1 ? savedInput.current : (history[history.length - 1 - next] || '');
|
|
887
|
+
setValue(restored);
|
|
888
|
+
valueRef.current = restored;
|
|
889
|
+
return next;
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
const commands = useMemo(() => [
|
|
895
|
+
{ cmd: '/help', desc: 'Show all commands' },
|
|
896
|
+
{ cmd: '/clear', desc: 'Reset conversation' },
|
|
897
|
+
{ cmd: '/models', desc: 'List active models' },
|
|
898
|
+
{ cmd: '/model', desc: 'Switch model' },
|
|
899
|
+
{ cmd: '/mode', desc: 'Permission mode (plan/default/auto/yolo)' },
|
|
900
|
+
{ cmd: '/thinking', desc: 'Thinking mode (off/low/medium/high)' },
|
|
901
|
+
{ cmd: '/plan', desc: 'Enter plan mode (read-only)' },
|
|
902
|
+
{ cmd: '/compact', desc: 'Compress conversation context' },
|
|
903
|
+
{ cmd: '/context', desc: 'Show context usage & token count' },
|
|
904
|
+
{ cmd: '/cost', desc: 'Show token usage & cost' },
|
|
905
|
+
{ cmd: '/diff', desc: 'Show uncommitted changes' },
|
|
906
|
+
{ cmd: '/copy', desc: 'Copy last response to clipboard' },
|
|
907
|
+
{ cmd: '/save', desc: 'Save current session' },
|
|
908
|
+
{ cmd: '/load', desc: 'Resume a saved session' },
|
|
909
|
+
{ cmd: '/export', desc: 'Export conversation as text' },
|
|
910
|
+
{ cmd: '/index', desc: 'Scan & index project files' },
|
|
911
|
+
{ cmd: '/init', desc: 'Initialize project config (SCALIX.md)' },
|
|
912
|
+
{ cmd: '/git', desc: 'Show git context' },
|
|
913
|
+
{ cmd: '/memory', desc: 'Show project memory' },
|
|
914
|
+
{ cmd: '/remember', desc: 'Save a note about this project' },
|
|
915
|
+
{ cmd: '/replay', desc: 'Show session timeline' },
|
|
916
|
+
{ cmd: '/plugins', desc: 'Show loaded plugins' },
|
|
917
|
+
{ cmd: '/router', desc: 'Show model routing status' },
|
|
918
|
+
{ cmd: '/status', desc: 'Version, model & connection info' },
|
|
919
|
+
{ cmd: '/doctor', desc: 'Diagnose installation' },
|
|
920
|
+
{ cmd: '/mcp', desc: 'Add MCP server' },
|
|
921
|
+
{ cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
|
|
922
|
+
{ cmd: '/settings', desc: 'View & configure limits' },
|
|
923
|
+
{ cmd: '/shortcuts', desc: 'Keyboard shortcuts' },
|
|
924
|
+
{ cmd: '/hooks', desc: 'Show lifecycle hooks' },
|
|
925
|
+
{ cmd: '/commands', desc: 'Show custom commands' },
|
|
926
|
+
{ cmd: '/delegate', desc: 'Run task with sub-agent' },
|
|
927
|
+
{ cmd: '/agents', desc: 'Show active agents & usage' },
|
|
928
|
+
{ cmd: '/checkpoint', desc: 'Create git checkpoint' },
|
|
929
|
+
{ cmd: '/undo', desc: 'Undo last checkpoint' },
|
|
930
|
+
{ cmd: '/checkpoints', desc: 'List recent checkpoints' },
|
|
931
|
+
{ cmd: '/commit', desc: 'Auto-generate commit message & commit' },
|
|
932
|
+
{ cmd: '/review', desc: 'AI code review of current changes' },
|
|
933
|
+
{ cmd: '/watch', desc: 'Run command, auto-fix on failure' },
|
|
934
|
+
{ cmd: '/create', desc: 'Scaffold a new project' },
|
|
935
|
+
{ cmd: '/exit', desc: 'Save & exit' },
|
|
936
|
+
...customCommands.map((c) => ({ cmd: `/${c.name}`, desc: c.description })),
|
|
937
|
+
], [customCommands]);
|
|
938
|
+
const prefix = value.split(' ')[0].toLowerCase();
|
|
939
|
+
const showCommandPicker = !disabled && value.startsWith('/') && !value.includes(' ');
|
|
940
|
+
const filteredCommands = showCommandPicker
|
|
941
|
+
? commands.filter((c) => c.cmd.startsWith(prefix))
|
|
942
|
+
: [];
|
|
943
|
+
const pickerActive = showCommandPicker && filteredCommands.length > 0;
|
|
944
|
+
useEffect(() => {
|
|
945
|
+
if (!onHeightChange)
|
|
946
|
+
return;
|
|
947
|
+
let h = 3; // separator + input row + padding
|
|
948
|
+
if (pickerActive)
|
|
949
|
+
h += Math.min(filteredCommands.length, 10) + 2; // items + header + footer
|
|
950
|
+
if (stash.length > 0 && !disabled)
|
|
951
|
+
h += 1;
|
|
952
|
+
onHeightChange(h);
|
|
953
|
+
}, [pickerActive, filteredCommands.length, stash.length, disabled, onHeightChange]);
|
|
954
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { paddingX: 2, children: _jsx(Text, { dimColor: true, children: line }) }), pickerActive && (_jsxs(Box, { flexDirection: "column", paddingX: 3, marginX: 0, paddingY: 0, children: [_jsxs(Box, { gap: 1, marginBottom: 0, children: [_jsx(Text, { dimColor: true, bold: true, children: "Commands" }), _jsxs(Text, { dimColor: true, children: ["(", filteredCommands.length, ")"] })] }), _jsx(SelectInput, { items: filteredCommands.map((c) => ({
|
|
955
|
+
label: `${c.cmd.padEnd(16)} ${c.desc}`,
|
|
956
|
+
value: c.cmd,
|
|
957
|
+
})), isFocused: true, onSelect: (item) => {
|
|
958
|
+
const argCmds = ['/remember', '/delegate', '/save', '/mode', '/thinking', '/mcp', '/watch', '/create', '/export'];
|
|
959
|
+
if (argCmds.includes(item.value)) {
|
|
960
|
+
onChange(item.value + ' ');
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
onChange('');
|
|
964
|
+
handleSubmit(item.value);
|
|
965
|
+
}
|
|
966
|
+
}, limit: 10 }), _jsxs(Text, { dimColor: true, italic: true, children: ['↑↓', " navigate ", '•', " Enter select ", '•', " keep typing to filter"] })] })), stash.length > 0 && !disabled && (_jsxs(Box, { paddingX: 3, children: [_jsxs(Text, { color: "magenta", dimColor: true, children: ['⚑', " Stashed: ", truncate(stash, 40), " "] }), _jsx(Text, { dimColor: true, children: "(Ctrl+S to restore)" })] })), _jsx(Box, { paddingX: 2, children: _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { flexShrink: 0, width: 2, children: disabled ? (_jsx(Text, { color: "magentaBright", children: _jsx(Spinner, { type: "dots" }) })) : (_jsx(Text, { color: "magentaBright", bold: true, children: '❯' })) }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { value: value, onChange: onChange, onSubmit: handleSubmit, placeholder: disabled ? 'Queue a message... (sent after current task)' : 'Type a message...', showCursor: true, focus: !pickerActive }) }), disabled && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, children: " ^C interrupt" }) }))] }) })] }));
|
|
967
|
+
});
|
|
968
|
+
// ─── App ───
|
|
969
|
+
function estimateLines(item, cols) {
|
|
970
|
+
// Account for paddingLeft=3 + paddingRight=2 used by most message components
|
|
971
|
+
const textWidth = Math.max(cols - 7, 20);
|
|
972
|
+
const lines = item.text.split('\n');
|
|
973
|
+
// Count wrapped lines per actual line
|
|
974
|
+
let totalLines = 0;
|
|
975
|
+
for (const line of lines) {
|
|
976
|
+
totalLines += Math.max(1, Math.ceil((line.length || 1) / textWidth));
|
|
977
|
+
}
|
|
978
|
+
switch (item.kind) {
|
|
979
|
+
case 'user': return totalLines + 2; // marginTop=1 + prompt icon row
|
|
980
|
+
case 'assistant': {
|
|
981
|
+
// Markdown rendering can add ~20% extra lines for headings, code blocks, spacing
|
|
982
|
+
const markdownExpansion = Math.ceil(totalLines * 0.2);
|
|
983
|
+
return totalLines + markdownExpansion + 2; // marginTop=1 + padding
|
|
984
|
+
}
|
|
985
|
+
case 'error': return totalLines + 2;
|
|
986
|
+
case 'tool-call': return 2; // single row + marginTop=1
|
|
987
|
+
case 'tool-result': return 1;
|
|
988
|
+
case 'tool-blocked': return 1;
|
|
989
|
+
case 'diff': return lines.length + 2; // header + each diff line + footer
|
|
990
|
+
case 'system': return 1;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function useScrollableItems(items, availableHeight, cols, isLoading, streamLineCount = 0) {
|
|
994
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
995
|
+
const prevItemCount = useRef(items.length);
|
|
996
|
+
const autoScroll = useRef(true);
|
|
997
|
+
useEffect(() => {
|
|
998
|
+
if (items.length > prevItemCount.current && autoScroll.current) {
|
|
999
|
+
setScrollOffset(0);
|
|
1000
|
+
}
|
|
1001
|
+
prevItemCount.current = items.length;
|
|
1002
|
+
}, [items.length]);
|
|
1003
|
+
useEffect(() => {
|
|
1004
|
+
if (isLoading && autoScroll.current)
|
|
1005
|
+
setScrollOffset(0);
|
|
1006
|
+
}, [isLoading]);
|
|
1007
|
+
const scrollUp = useCallback((amount = 3) => {
|
|
1008
|
+
setScrollOffset((prev) => {
|
|
1009
|
+
const maxOffset = Math.max(items.length - 1, 0);
|
|
1010
|
+
const next = Math.min(prev + amount, maxOffset);
|
|
1011
|
+
autoScroll.current = false;
|
|
1012
|
+
return next;
|
|
1013
|
+
});
|
|
1014
|
+
}, [items.length]);
|
|
1015
|
+
const scrollDown = useCallback((amount = 3) => {
|
|
1016
|
+
setScrollOffset((prev) => {
|
|
1017
|
+
const next = Math.max(prev - amount, 0);
|
|
1018
|
+
if (next === 0)
|
|
1019
|
+
autoScroll.current = true;
|
|
1020
|
+
return next;
|
|
1021
|
+
});
|
|
1022
|
+
}, []);
|
|
1023
|
+
const scrollToBottom = useCallback(() => {
|
|
1024
|
+
setScrollOffset(0);
|
|
1025
|
+
autoScroll.current = true;
|
|
1026
|
+
}, []);
|
|
1027
|
+
const reservedForStream = isLoading ? Math.max(4, Math.min(streamLineCount + 3, Math.floor(availableHeight * 0.5))) : 0;
|
|
1028
|
+
const effectiveHeight = Math.max(availableHeight - reservedForStream, 4);
|
|
1029
|
+
const visibleItems = [];
|
|
1030
|
+
let usedLines = 0;
|
|
1031
|
+
const endIdx = items.length - scrollOffset;
|
|
1032
|
+
for (let i = endIdx - 1; i >= 0; i--) {
|
|
1033
|
+
const lines = estimateLines(items[i], cols);
|
|
1034
|
+
if (usedLines + lines > effectiveHeight && visibleItems.length > 0)
|
|
1035
|
+
break;
|
|
1036
|
+
visibleItems.unshift(items[i]);
|
|
1037
|
+
usedLines += lines;
|
|
1038
|
+
}
|
|
1039
|
+
const isAtBottom = scrollOffset === 0;
|
|
1040
|
+
const isAtTop = visibleItems.length > 0 && visibleItems[0].id === items[0]?.id;
|
|
1041
|
+
return { visibleItems, scrollUp, scrollDown, scrollToBottom, isAtBottom, isAtTop, scrollOffset };
|
|
1042
|
+
}
|
|
1043
|
+
function App({ config }) {
|
|
1044
|
+
const { rows, cols } = useTermSize();
|
|
1045
|
+
const [items, setItems] = useState([]);
|
|
1046
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
1047
|
+
const [status, setStatus] = useState('ready');
|
|
1048
|
+
const [models, setModels] = useState(agent?.getModels() ?? []);
|
|
1049
|
+
const [streamText, setStreamText] = useState('');
|
|
1050
|
+
const [reasoningText, setReasoningText] = useState('');
|
|
1051
|
+
const [selectingModel, setSelectingModel] = useState(false);
|
|
1052
|
+
const [confirmInfo, setConfirmInfo] = useState(null);
|
|
1053
|
+
const [thinkStart, setThinkStart] = useState(null);
|
|
1054
|
+
const [showBanner, setShowBanner] = useState(true);
|
|
1055
|
+
const [totalTokens, setTotalTokens] = useState(0);
|
|
1056
|
+
const [sessionList, setSessionList] = useState(null);
|
|
1057
|
+
const [showSetup, setShowSetup] = useState(false);
|
|
1058
|
+
const [currentMode, setCurrentMode] = useState(agent?.getPermissionMode() ?? 'default');
|
|
1059
|
+
const [selectingMode, setSelectingMode] = useState(false);
|
|
1060
|
+
const [editingSettings, setEditingSettings] = useState(false);
|
|
1061
|
+
const [agentVersion, setAgentVersion] = useState(0);
|
|
1062
|
+
const counter = useRef(0);
|
|
1063
|
+
const showBannerRef = useRef(true);
|
|
1064
|
+
const modelsRef = useRef(models);
|
|
1065
|
+
useEffect(() => { modelsRef.current = models; }, [models]);
|
|
1066
|
+
const nextId = () => ++counter.current;
|
|
1067
|
+
const MAX_DISPLAY_ITEMS = 2000;
|
|
1068
|
+
const add = useCallback((kind, text, meta, hint) => {
|
|
1069
|
+
setItems((prev) => {
|
|
1070
|
+
const next = [...prev, { id: nextId(), kind, text, meta, hint }];
|
|
1071
|
+
return next.length > MAX_DISPLAY_ITEMS ? next.slice(next.length - MAX_DISPLAY_ITEMS) : next;
|
|
1072
|
+
});
|
|
1073
|
+
if (showBannerRef.current && kind !== 'system') {
|
|
1074
|
+
showBannerRef.current = false;
|
|
1075
|
+
setShowBanner(false);
|
|
1076
|
+
}
|
|
1077
|
+
}, []);
|
|
1078
|
+
// Register agent event handlers ONCE
|
|
1079
|
+
useEffect(() => {
|
|
1080
|
+
const onThinkingStart = () => {
|
|
1081
|
+
setIsLoading(true);
|
|
1082
|
+
setStreamText('');
|
|
1083
|
+
setReasoningText('');
|
|
1084
|
+
setStatus('thinking');
|
|
1085
|
+
setThinkStart(Date.now());
|
|
1086
|
+
};
|
|
1087
|
+
const onThinkingEnd = () => {
|
|
1088
|
+
setIsLoading(false);
|
|
1089
|
+
setStatus('ready');
|
|
1090
|
+
setThinkStart(null);
|
|
1091
|
+
};
|
|
1092
|
+
let streamBuffer = '';
|
|
1093
|
+
let streamFlushTimer = null;
|
|
1094
|
+
let streamStallTimer = null;
|
|
1095
|
+
const STREAM_THROTTLE_MS = 50;
|
|
1096
|
+
const STREAM_STALL_MS = 15_000;
|
|
1097
|
+
const flushStream = () => {
|
|
1098
|
+
streamFlushTimer = null;
|
|
1099
|
+
setStreamText(streamBuffer);
|
|
1100
|
+
};
|
|
1101
|
+
const resetStallTimer = () => {
|
|
1102
|
+
if (streamStallTimer)
|
|
1103
|
+
clearTimeout(streamStallTimer);
|
|
1104
|
+
streamStallTimer = setTimeout(() => {
|
|
1105
|
+
if (isLoadingRef.current) {
|
|
1106
|
+
add('system', 'Stream appears stalled. Press Ctrl+C to abort, or wait for a response.');
|
|
1107
|
+
}
|
|
1108
|
+
}, STREAM_STALL_MS);
|
|
1109
|
+
};
|
|
1110
|
+
const onDelta = (_d, acc) => {
|
|
1111
|
+
streamBuffer = acc;
|
|
1112
|
+
resetStallTimer();
|
|
1113
|
+
if (!streamFlushTimer) {
|
|
1114
|
+
streamFlushTimer = setTimeout(flushStream, STREAM_THROTTLE_MS);
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
const onStreamEnd = (full) => {
|
|
1118
|
+
if (streamFlushTimer) {
|
|
1119
|
+
clearTimeout(streamFlushTimer);
|
|
1120
|
+
streamFlushTimer = null;
|
|
1121
|
+
}
|
|
1122
|
+
if (streamStallTimer) {
|
|
1123
|
+
clearTimeout(streamStallTimer);
|
|
1124
|
+
streamStallTimer = null;
|
|
1125
|
+
}
|
|
1126
|
+
streamBuffer = '';
|
|
1127
|
+
setStreamText('');
|
|
1128
|
+
setReasoningText('');
|
|
1129
|
+
if (full.trim()) {
|
|
1130
|
+
add('assistant', full);
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
let reasoningBuffer = '';
|
|
1134
|
+
let reasoningFlushTimer = null;
|
|
1135
|
+
const onReasoning = (t) => {
|
|
1136
|
+
reasoningBuffer = t;
|
|
1137
|
+
if (!reasoningFlushTimer) {
|
|
1138
|
+
reasoningFlushTimer = setTimeout(() => {
|
|
1139
|
+
reasoningFlushTimer = null;
|
|
1140
|
+
setReasoningText(reasoningBuffer);
|
|
1141
|
+
}, STREAM_THROTTLE_MS);
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
const onToolCall = (name, args) => {
|
|
1145
|
+
add('tool-call', typeof args === 'string' ? args : JSON.stringify(args), name);
|
|
1146
|
+
};
|
|
1147
|
+
const onToolResult = (name, result) => {
|
|
1148
|
+
// Extract diff from write_file / edit_file results and show separately
|
|
1149
|
+
let parsed = null;
|
|
1150
|
+
if (typeof result === 'object' && result !== null) {
|
|
1151
|
+
parsed = result;
|
|
1152
|
+
}
|
|
1153
|
+
else if (typeof result === 'string') {
|
|
1154
|
+
try {
|
|
1155
|
+
parsed = JSON.parse(result);
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
parsed = null;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (parsed?.diff && typeof parsed.diff === 'string') {
|
|
1162
|
+
// Strip ANSI codes from diff for clean display
|
|
1163
|
+
const cleanDiff = parsed.diff.replace(/\x1b\[[0-9;]*m/g, '');
|
|
1164
|
+
const fileName = parsed.message?.match(/to (.+)$|in (.+)$/)?.[1] || parsed.message?.match(/in (.+)$/)?.[1] || name;
|
|
1165
|
+
add('diff', cleanDiff, fileName);
|
|
1166
|
+
// Show the result summary without the diff
|
|
1167
|
+
const { diff: _d, ...rest } = parsed;
|
|
1168
|
+
const summary = rest.message || (rest.success ? 'Done' : 'Failed');
|
|
1169
|
+
add('tool-result', summary, name);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
let text = typeof result === 'string' ? result : JSON.stringify(result);
|
|
1173
|
+
const MAX_DISPLAY = 2000;
|
|
1174
|
+
if (text.length > MAX_DISPLAY) {
|
|
1175
|
+
const truncated = text.length - MAX_DISPLAY;
|
|
1176
|
+
text = text.slice(0, MAX_DISPLAY) + `\n... (${truncated.toLocaleString()} chars hidden)`;
|
|
1177
|
+
}
|
|
1178
|
+
add('tool-result', text, name);
|
|
1179
|
+
};
|
|
1180
|
+
const onToolBlocked = (name, reason) => {
|
|
1181
|
+
add('tool-blocked', `${name} denied: ${reason}`);
|
|
1182
|
+
};
|
|
1183
|
+
const onUsage = (data) => {
|
|
1184
|
+
costTracker.record(data.model || modelsRef.current[0], data);
|
|
1185
|
+
setTotalTokens(costTracker.totalTokens);
|
|
1186
|
+
};
|
|
1187
|
+
const onError = (err) => {
|
|
1188
|
+
setIsLoading(false);
|
|
1189
|
+
setStreamText('');
|
|
1190
|
+
setReasoningText('');
|
|
1191
|
+
setStatus('error');
|
|
1192
|
+
setThinkStart(null);
|
|
1193
|
+
const meta = err.metadata;
|
|
1194
|
+
const reason = meta?.error?.message || err.message;
|
|
1195
|
+
const model = meta?.model ? shortModel(meta.model) : '';
|
|
1196
|
+
const msg = model ? `${reason} (${model})` : reason;
|
|
1197
|
+
let hint;
|
|
1198
|
+
const lower = reason?.toLowerCase() || '';
|
|
1199
|
+
if (lower.includes('rate limit') || lower.includes('429')) {
|
|
1200
|
+
hint = 'Rate limited. Try /model to switch models, or wait a moment.';
|
|
1201
|
+
}
|
|
1202
|
+
else if (lower.includes('auth') || lower.includes('401') || lower.includes('api key')) {
|
|
1203
|
+
hint = 'Authentication failed. Run /setup to reconfigure your API key.';
|
|
1204
|
+
}
|
|
1205
|
+
else if (lower.includes('422') || lower.includes('unprocessable') || lower.includes('no body')) {
|
|
1206
|
+
hint = 'Request rejected by the API. Check your model name is valid (/model), or run /setup to reconfigure.';
|
|
1207
|
+
}
|
|
1208
|
+
else if (lower.includes('timeout') || lower.includes('timed out')) {
|
|
1209
|
+
hint = 'Request timed out. Try /compact to reduce context size, or try again.';
|
|
1210
|
+
}
|
|
1211
|
+
else if (lower.includes('context') || lower.includes('token')) {
|
|
1212
|
+
hint = 'Context too large. Use /compact to compress the conversation.';
|
|
1213
|
+
}
|
|
1214
|
+
add('error', msg, undefined, hint);
|
|
1215
|
+
};
|
|
1216
|
+
const onContextCompress = (info) => {
|
|
1217
|
+
add('system', `Context compressed: ${formatTokens(info.saved)} tokens saved (${formatTokens(info.before)} \u2192 ${formatTokens(info.after)})`);
|
|
1218
|
+
};
|
|
1219
|
+
const onContextUsage = (info) => {
|
|
1220
|
+
if (info.percentage > 80) {
|
|
1221
|
+
add('system', `Context usage: ${info.percentage}% (${formatTokens(info.tokens)}/${formatTokens(info.maxTokens)} tokens)`);
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
agent.on('thinking:start', onThinkingStart);
|
|
1225
|
+
agent.on('thinking:end', onThinkingEnd);
|
|
1226
|
+
agent.on('stream:delta', onDelta);
|
|
1227
|
+
agent.on('stream:end', onStreamEnd);
|
|
1228
|
+
agent.on('reasoning:update', onReasoning);
|
|
1229
|
+
agent.on('tool:call', onToolCall);
|
|
1230
|
+
agent.on('tool:result', onToolResult);
|
|
1231
|
+
agent.on('tool:blocked', onToolBlocked);
|
|
1232
|
+
agent.on('usage', onUsage);
|
|
1233
|
+
agent.on('error', onError);
|
|
1234
|
+
const onVerifyPass = (r) => { add('system', `\u2705 ${r.checker} passed (${r.durationMs}ms)`); };
|
|
1235
|
+
const onVerifyFail = (r) => { add('error', `\u274C ${r.checker} failed: ${r.output.split('\n').slice(0, 5).join('\n')}`); };
|
|
1236
|
+
const onFileModified = (f) => { add('system', `\u270F\uFE0F Modified: ${f}`); };
|
|
1237
|
+
agent.on('verify:pass', onVerifyPass);
|
|
1238
|
+
agent.on('verify:fail', onVerifyFail);
|
|
1239
|
+
agent.on('file:modified', onFileModified);
|
|
1240
|
+
agent.on('context:compress', onContextCompress);
|
|
1241
|
+
agent.on('context:usage', onContextUsage);
|
|
1242
|
+
const currentAgent = agent;
|
|
1243
|
+
return () => {
|
|
1244
|
+
if (streamFlushTimer)
|
|
1245
|
+
clearTimeout(streamFlushTimer);
|
|
1246
|
+
if (streamStallTimer)
|
|
1247
|
+
clearTimeout(streamStallTimer);
|
|
1248
|
+
if (reasoningFlushTimer)
|
|
1249
|
+
clearTimeout(reasoningFlushTimer);
|
|
1250
|
+
currentAgent.off('thinking:start', onThinkingStart);
|
|
1251
|
+
currentAgent.off('thinking:end', onThinkingEnd);
|
|
1252
|
+
currentAgent.off('stream:delta', onDelta);
|
|
1253
|
+
currentAgent.off('stream:end', onStreamEnd);
|
|
1254
|
+
currentAgent.off('reasoning:update', onReasoning);
|
|
1255
|
+
currentAgent.off('tool:call', onToolCall);
|
|
1256
|
+
currentAgent.off('tool:result', onToolResult);
|
|
1257
|
+
currentAgent.off('tool:blocked', onToolBlocked);
|
|
1258
|
+
currentAgent.off('usage', onUsage);
|
|
1259
|
+
currentAgent.off('error', onError);
|
|
1260
|
+
currentAgent.off('verify:pass', onVerifyPass);
|
|
1261
|
+
currentAgent.off('verify:fail', onVerifyFail);
|
|
1262
|
+
currentAgent.off('file:modified', onFileModified);
|
|
1263
|
+
currentAgent.off('context:compress', onContextCompress);
|
|
1264
|
+
currentAgent.off('context:usage', onContextUsage);
|
|
1265
|
+
};
|
|
1266
|
+
}, [add, agentVersion]);
|
|
1267
|
+
// Confirmation bridge (event-driven)
|
|
1268
|
+
useEffect(() => {
|
|
1269
|
+
const onPending = () => {
|
|
1270
|
+
if (pendingConfirm && !confirmInfo) {
|
|
1271
|
+
setConfirmInfo({ toolName: pendingConfirm.toolName, args: pendingConfirm.args });
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
confirmEmitter.on('pending', onPending);
|
|
1275
|
+
return () => { confirmEmitter.off('pending', onPending); };
|
|
1276
|
+
}, [confirmInfo]);
|
|
1277
|
+
useEffect(() => {
|
|
1278
|
+
const banner = getUpdateBanner();
|
|
1279
|
+
if (banner)
|
|
1280
|
+
add('system', banner);
|
|
1281
|
+
}, [add]);
|
|
1282
|
+
const handleConfirm = useCallback((ok) => {
|
|
1283
|
+
if (pendingConfirm) {
|
|
1284
|
+
pendingConfirm.resolve(ok);
|
|
1285
|
+
pendingConfirm = null;
|
|
1286
|
+
}
|
|
1287
|
+
setConfirmInfo(null);
|
|
1288
|
+
}, []);
|
|
1289
|
+
const handleCommand = useCallback((text) => {
|
|
1290
|
+
const cmd = text.split(' ')[0].toLowerCase();
|
|
1291
|
+
if (cmd === '/help') {
|
|
1292
|
+
const helpLines = [
|
|
1293
|
+
'Session & Navigation:',
|
|
1294
|
+
' /help \u2014 show commands',
|
|
1295
|
+
' /shortcuts \u2014 keyboard shortcuts',
|
|
1296
|
+
' /clear \u2014 reset conversation',
|
|
1297
|
+
' /save [name] \u2014 save current session',
|
|
1298
|
+
' /load \u2014 resume a saved session',
|
|
1299
|
+
' /export [f] \u2014 export conversation as text',
|
|
1300
|
+
' /exit \u2014 save & exit',
|
|
1301
|
+
'',
|
|
1302
|
+
'Model & Mode:',
|
|
1303
|
+
' /models \u2014 list active models',
|
|
1304
|
+
' /model \u2014 switch model',
|
|
1305
|
+
' /mode [m] \u2014 permission mode (plan/default/auto/yolo)',
|
|
1306
|
+
' /thinking [m] \u2014 thinking mode (off/low/medium/high)',
|
|
1307
|
+
' /plan \u2014 enter plan mode (read-only)',
|
|
1308
|
+
'',
|
|
1309
|
+
'Context & Cost:',
|
|
1310
|
+
' /compact \u2014 compress conversation context',
|
|
1311
|
+
' /context \u2014 show context usage & token count',
|
|
1312
|
+
' /cost \u2014 show token usage & cost',
|
|
1313
|
+
'',
|
|
1314
|
+
'Code & Git:',
|
|
1315
|
+
' /diff \u2014 show uncommitted changes',
|
|
1316
|
+
' /commit \u2014 auto-generate commit message & commit',
|
|
1317
|
+
' /review \u2014 AI code review of current changes',
|
|
1318
|
+
' /checkpoint \u2014 create git checkpoint',
|
|
1319
|
+
' /undo \u2014 undo last checkpoint',
|
|
1320
|
+
' /checkpoints \u2014 list recent checkpoints',
|
|
1321
|
+
' /git \u2014 show git context',
|
|
1322
|
+
'',
|
|
1323
|
+
'Project:',
|
|
1324
|
+
' /init \u2014 initialize project config (SCALIX.md)',
|
|
1325
|
+
' /index \u2014 scan & index project files',
|
|
1326
|
+
' /memory \u2014 show project memory',
|
|
1327
|
+
' /remember <n> \u2014 save a note about this project',
|
|
1328
|
+
' /watch <cmd> \u2014 run command, auto-fix on failure',
|
|
1329
|
+
' /create <d> \u2014 scaffold a new project',
|
|
1330
|
+
' /delegate <t> \u2014 run task with sub-agent',
|
|
1331
|
+
' /agents \u2014 show active agents & usage',
|
|
1332
|
+
'',
|
|
1333
|
+
'Tools & Config:',
|
|
1334
|
+
' /copy \u2014 copy last response to clipboard',
|
|
1335
|
+
' /status \u2014 show version, model & connection info',
|
|
1336
|
+
' /update \u2014 check for and install updates',
|
|
1337
|
+
' /doctor \u2014 diagnose installation',
|
|
1338
|
+
' /settings \u2014 view & configure limits',
|
|
1339
|
+
' /setup \u2014 reconfigure API key, URL & models',
|
|
1340
|
+
' /mcp <l> <u> \u2014 add MCP server',
|
|
1341
|
+
' /plugins \u2014 show loaded plugins',
|
|
1342
|
+
' /router \u2014 show model routing status',
|
|
1343
|
+
' /replay \u2014 show session timeline',
|
|
1344
|
+
' /hooks \u2014 show lifecycle hooks',
|
|
1345
|
+
' /commands \u2014 show custom commands',
|
|
1346
|
+
'',
|
|
1347
|
+
'Input modes:',
|
|
1348
|
+
' !<cmd> \u2014 run shell command (e.g. !ls -la)',
|
|
1349
|
+
' text\\ \u2014 multiline input (end with \\ to continue)',
|
|
1350
|
+
];
|
|
1351
|
+
if (customCommands.length > 0) {
|
|
1352
|
+
helpLines.push('', 'Custom commands:');
|
|
1353
|
+
for (const c of customCommands)
|
|
1354
|
+
helpLines.push(` /${c.name.padEnd(12)} \u2014 ${c.description}`);
|
|
1355
|
+
}
|
|
1356
|
+
helpLines.forEach((line) => add('system', line));
|
|
1357
|
+
return true;
|
|
1358
|
+
}
|
|
1359
|
+
if (cmd === '/clear') {
|
|
1360
|
+
agent.abort();
|
|
1361
|
+
if (pendingConfirm) {
|
|
1362
|
+
pendingConfirm.resolve(false);
|
|
1363
|
+
pendingConfirm = null;
|
|
1364
|
+
}
|
|
1365
|
+
const msgs = agent.getMessages();
|
|
1366
|
+
if (msgs.length > 0) {
|
|
1367
|
+
saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).then((meta) => {
|
|
1368
|
+
add('system', `Auto-saved: ${meta.name}`);
|
|
1369
|
+
}).catch((err) => { logger.warn('Failed to auto-save session', { error: err.message }); });
|
|
1370
|
+
}
|
|
1371
|
+
agent.clearHistory();
|
|
1372
|
+
setItems([]);
|
|
1373
|
+
setIsLoading(false);
|
|
1374
|
+
setStatus('ready');
|
|
1375
|
+
setStreamText('');
|
|
1376
|
+
setReasoningText('');
|
|
1377
|
+
setThinkStart(null);
|
|
1378
|
+
setConfirmInfo(null);
|
|
1379
|
+
showBannerRef.current = true;
|
|
1380
|
+
setShowBanner(true);
|
|
1381
|
+
costTracker.reset();
|
|
1382
|
+
setTotalTokens(0);
|
|
1383
|
+
sessionId = generateSessionId();
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
if (cmd === '/models') {
|
|
1387
|
+
add('system', `Active models: ${agent.getModels().map(shortModel).join(', ')}`);
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
1390
|
+
if (cmd === '/model') {
|
|
1391
|
+
setSelectingModel(true);
|
|
1392
|
+
return true;
|
|
1393
|
+
}
|
|
1394
|
+
if (cmd === '/compact') {
|
|
1395
|
+
const before = agent.getContextUsage();
|
|
1396
|
+
add('system', `Compacting... (${before.tokens.toLocaleString()} tokens, ${before.percentage}% used)`);
|
|
1397
|
+
setIsLoading(true);
|
|
1398
|
+
setStatus('thinking');
|
|
1399
|
+
setThinkStart(Date.now());
|
|
1400
|
+
agent.compact().then((result) => {
|
|
1401
|
+
const after = agent.getContextUsage();
|
|
1402
|
+
add('system', result);
|
|
1403
|
+
add('system', `Context: ${before.tokens.toLocaleString()} → ${after.tokens.toLocaleString()} tokens (saved ${(before.tokens - after.tokens).toLocaleString()})`);
|
|
1404
|
+
}).catch((err) => {
|
|
1405
|
+
add('error', `Compact failed: ${err.message}`);
|
|
1406
|
+
}).finally(() => {
|
|
1407
|
+
setIsLoading(false);
|
|
1408
|
+
setStatus('ready');
|
|
1409
|
+
setThinkStart(null);
|
|
1410
|
+
});
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
if (cmd === '/cost') {
|
|
1414
|
+
add('system', costTracker.summary());
|
|
1415
|
+
return true;
|
|
1416
|
+
}
|
|
1417
|
+
if (cmd === '/save') {
|
|
1418
|
+
const msgs = agent.getMessages();
|
|
1419
|
+
if (msgs.length === 0) {
|
|
1420
|
+
add('system', 'Nothing to save — conversation is empty.');
|
|
1421
|
+
return true;
|
|
1422
|
+
}
|
|
1423
|
+
const explicit = text.slice(5).trim();
|
|
1424
|
+
const rawContent = msgs.find((m) => m.role === 'user')?.content;
|
|
1425
|
+
const firstUserMsg = messageContentToString(rawContent);
|
|
1426
|
+
const autoName = firstUserMsg.slice(0, 50).replace(/\n/g, ' ').trim();
|
|
1427
|
+
const name = explicit || autoName || undefined;
|
|
1428
|
+
saveSession(sessionId, msgs, models[0] || 'unknown', name, costTracker.getState()).then((meta) => {
|
|
1429
|
+
add('system', `Session saved: ${meta.name} (${meta.messageCount} messages)`);
|
|
1430
|
+
}).catch((err) => {
|
|
1431
|
+
add('error', `Failed to save session: ${err.message}`);
|
|
1432
|
+
});
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
if (cmd === '/load') {
|
|
1436
|
+
listSessions().then((sessions) => {
|
|
1437
|
+
if (sessions.length === 0) {
|
|
1438
|
+
add('system', 'No saved sessions found.');
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
setSessionList(sessions);
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
return true;
|
|
1445
|
+
}
|
|
1446
|
+
if (cmd === '/shortcuts') {
|
|
1447
|
+
const shortcuts = [
|
|
1448
|
+
'Ctrl+C \u2014 interrupt agent / clear input / double-press to quit',
|
|
1449
|
+
'Ctrl+D \u2014 exit (when input is empty)',
|
|
1450
|
+
'Ctrl+L \u2014 clear screen & conversation',
|
|
1451
|
+
'Ctrl+U \u2014 clear entire input line',
|
|
1452
|
+
'Ctrl+W \u2014 delete last word',
|
|
1453
|
+
'Ctrl+K \u2014 delete to end of line',
|
|
1454
|
+
'Ctrl+S \u2014 stash / restore current prompt',
|
|
1455
|
+
'Tab \u2014 autocomplete slash commands',
|
|
1456
|
+
'\u2191/\u2193 \u2014 navigate input history',
|
|
1457
|
+
'Shift+\u2191 / PageUp \u2014 scroll chat up',
|
|
1458
|
+
'Shift+\u2193 / PageDown \u2014 scroll chat down',
|
|
1459
|
+
'Y/N \u2014 confirm or deny tool execution',
|
|
1460
|
+
'Esc \u2014 quit',
|
|
1461
|
+
'',
|
|
1462
|
+
'Copy \u2014 Cmd+C (Mac) / Ctrl+Shift+C (Linux)',
|
|
1463
|
+
'Paste \u2014 Cmd+V (Mac) / Ctrl+Shift+V (Linux)',
|
|
1464
|
+
'',
|
|
1465
|
+
'!<cmd> \u2014 run shell command directly',
|
|
1466
|
+
'text\\ \u2014 continue on next line (multiline mode, 50-line max)',
|
|
1467
|
+
];
|
|
1468
|
+
shortcuts.forEach((s) => add('system', s));
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
if (cmd === '/settings') {
|
|
1472
|
+
setEditingSettings(true);
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
if (cmd === '/setup') {
|
|
1476
|
+
setShowSetup(true);
|
|
1477
|
+
return true;
|
|
1478
|
+
}
|
|
1479
|
+
if (cmd === '/mode') {
|
|
1480
|
+
const arg = text.split(' ')[1]?.toLowerCase();
|
|
1481
|
+
if (arg && PERMISSION_MODES.includes(arg)) {
|
|
1482
|
+
agent.setPermissionMode(arg);
|
|
1483
|
+
setCurrentMode(arg);
|
|
1484
|
+
loadConfig().then(cfg => {
|
|
1485
|
+
cfg.permissionMode = arg;
|
|
1486
|
+
saveConfig(cfg);
|
|
1487
|
+
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
1488
|
+
add('system', `Mode: ${arg.toUpperCase()} \u2014 ${getModeDescription(arg)}`);
|
|
1489
|
+
}
|
|
1490
|
+
else {
|
|
1491
|
+
setSelectingMode(true);
|
|
1492
|
+
}
|
|
1493
|
+
return true;
|
|
1494
|
+
}
|
|
1495
|
+
if (cmd === '/thinking') {
|
|
1496
|
+
const arg = text.split(' ')[1]?.toLowerCase();
|
|
1497
|
+
if (arg && THINKING_MODES.includes(arg)) {
|
|
1498
|
+
agent.setThinking(arg);
|
|
1499
|
+
loadConfig().then(cfg => {
|
|
1500
|
+
cfg.thinkingMode = arg;
|
|
1501
|
+
saveConfig(cfg);
|
|
1502
|
+
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
1503
|
+
add('system', `Thinking: ${arg.toUpperCase()}${arg === 'off' ? '' : ' \u2014 model will reason before responding'}`);
|
|
1504
|
+
}
|
|
1505
|
+
else {
|
|
1506
|
+
add('system', `Thinking mode: ${agent.getThinking().toUpperCase()}\nUsage: /thinking <off|low|medium|high>`);
|
|
1507
|
+
}
|
|
1508
|
+
return true;
|
|
1509
|
+
}
|
|
1510
|
+
if (cmd === '/mcp') {
|
|
1511
|
+
const p = text.split(' ').filter(Boolean);
|
|
1512
|
+
if (p.length < 3) {
|
|
1513
|
+
add('system', 'Usage: /mcp <label> <url>');
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
1516
|
+
{
|
|
1517
|
+
try {
|
|
1518
|
+
new URL(p[2]);
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
add('error', `Invalid URL: ${p[2]}`);
|
|
1522
|
+
return true;
|
|
1523
|
+
}
|
|
1524
|
+
agent.addTool({ type: 'mcp', serverLabel: p[1], serverUrl: p[2] });
|
|
1525
|
+
add('system', `MCP registered: ${p[1]} \u2192 ${p[2]}`);
|
|
1526
|
+
}
|
|
1527
|
+
return true;
|
|
1528
|
+
}
|
|
1529
|
+
if (cmd === '/index') {
|
|
1530
|
+
setIsLoading(true);
|
|
1531
|
+
setStatus('thinking');
|
|
1532
|
+
setThinkStart(Date.now());
|
|
1533
|
+
buildProjectIndex(process.cwd()).then((idx) => {
|
|
1534
|
+
projectIndex = idx;
|
|
1535
|
+
add('system', idx.summary);
|
|
1536
|
+
add('system', `Tech stack: ${idx.techStack.join(', ') || 'Unknown'}`);
|
|
1537
|
+
add('system', `Top symbols: ${idx.symbols.slice(0, 10).map((s) => s.name).join(', ')}`);
|
|
1538
|
+
}).catch((err) => {
|
|
1539
|
+
add('error', `Index failed: ${err.message}`);
|
|
1540
|
+
}).finally(() => {
|
|
1541
|
+
setIsLoading(false);
|
|
1542
|
+
setStatus('ready');
|
|
1543
|
+
setThinkStart(null);
|
|
1544
|
+
});
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
if (cmd === '/git') {
|
|
1548
|
+
collectGitInfo(process.cwd()).then((gitInfo) => {
|
|
1549
|
+
if (!gitInfo.isRepo) {
|
|
1550
|
+
add('system', 'Not a git repository.');
|
|
1551
|
+
}
|
|
1552
|
+
else {
|
|
1553
|
+
const lines = gitInfoToContext(gitInfo).split('\n');
|
|
1554
|
+
lines.forEach((l) => add('system', l));
|
|
1555
|
+
}
|
|
1556
|
+
}).catch(() => { add('system', 'Failed to collect git info.'); });
|
|
1557
|
+
return true;
|
|
1558
|
+
}
|
|
1559
|
+
if (cmd === '/plugins') {
|
|
1560
|
+
add('system', pluginsSummary(loadedPlugins));
|
|
1561
|
+
return true;
|
|
1562
|
+
}
|
|
1563
|
+
if (cmd === '/replay') {
|
|
1564
|
+
const replay = agent.getReplay();
|
|
1565
|
+
const timeline = replay.getTimeline();
|
|
1566
|
+
const lines = timeline.split('\n');
|
|
1567
|
+
lines.forEach((l) => add('system', l));
|
|
1568
|
+
return true;
|
|
1569
|
+
}
|
|
1570
|
+
if (cmd === '/remember') {
|
|
1571
|
+
const note = text.slice(10).trim();
|
|
1572
|
+
if (!note) {
|
|
1573
|
+
add('system', 'Usage: /remember <note about this project>');
|
|
1574
|
+
}
|
|
1575
|
+
else {
|
|
1576
|
+
addNote(process.cwd(), note).then(() => {
|
|
1577
|
+
add('system', `Remembered: ${note}`);
|
|
1578
|
+
}).catch(() => {
|
|
1579
|
+
add('system', 'Failed to save note.');
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
return true;
|
|
1583
|
+
}
|
|
1584
|
+
if (cmd === '/memory') {
|
|
1585
|
+
loadMemory(process.cwd()).then((mem) => {
|
|
1586
|
+
if (mem.conventions.length === 0 && mem.patterns.length === 0 && mem.notes.length === 0) {
|
|
1587
|
+
add('system', 'No project memory yet. Use /remember to add notes.');
|
|
1588
|
+
}
|
|
1589
|
+
else {
|
|
1590
|
+
const lines = memoryToContext(mem).split('\n');
|
|
1591
|
+
lines.forEach((l) => add('system', l));
|
|
1592
|
+
}
|
|
1593
|
+
}).catch(() => {
|
|
1594
|
+
add('error', 'Failed to load project memory.');
|
|
1595
|
+
});
|
|
1596
|
+
return true;
|
|
1597
|
+
}
|
|
1598
|
+
if (cmd === '/router') {
|
|
1599
|
+
const routerConfig = createRouterConfig(models);
|
|
1600
|
+
const lines = getRouterStatus(routerConfig).split('\n');
|
|
1601
|
+
lines.forEach((l) => add('system', l));
|
|
1602
|
+
return true;
|
|
1603
|
+
}
|
|
1604
|
+
if (cmd === '/hooks') {
|
|
1605
|
+
const hooks = agent.getHooks();
|
|
1606
|
+
hooksSummary(hooks).split('\n').forEach((l) => add('system', l));
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1609
|
+
if (cmd === '/commands') {
|
|
1610
|
+
commandsSummary(customCommands).split('\n').forEach((l) => add('system', l));
|
|
1611
|
+
return true;
|
|
1612
|
+
}
|
|
1613
|
+
if (cmd === '/delegate') {
|
|
1614
|
+
const prompt = text.slice(10).trim();
|
|
1615
|
+
if (!prompt) {
|
|
1616
|
+
add('system', 'Usage: /delegate <task description>');
|
|
1617
|
+
return true;
|
|
1618
|
+
}
|
|
1619
|
+
setIsLoading(true);
|
|
1620
|
+
setStatus('thinking');
|
|
1621
|
+
setThinkStart(Date.now());
|
|
1622
|
+
const cheapModel = pickCheapestModel(agent.getModels());
|
|
1623
|
+
add('system', `Delegating to sub-agent (${shortModel(cheapModel)})...`);
|
|
1624
|
+
spawnSubAgent({ apiKey: agent.getApiKey(), baseUrl: agent.getBaseUrl(), models: agent.getModels() }, prompt, { model: cheapModel, tools: 'read-only', maxSteps: 10 }).then((result) => {
|
|
1625
|
+
add('assistant', result.response);
|
|
1626
|
+
add('system', `Sub-agent: ${result.steps} steps, ~${formatTokens(result.tokenEstimate)} tokens (${shortModel(result.model)})`);
|
|
1627
|
+
}).catch((err) => {
|
|
1628
|
+
add('error', `Sub-agent failed: ${err.message}`);
|
|
1629
|
+
}).finally(() => {
|
|
1630
|
+
setIsLoading(false);
|
|
1631
|
+
setStatus('ready');
|
|
1632
|
+
setThinkStart(null);
|
|
1633
|
+
});
|
|
1634
|
+
return true;
|
|
1635
|
+
}
|
|
1636
|
+
if (cmd === '/agents') {
|
|
1637
|
+
const registry = AgentRegistry.getInstance();
|
|
1638
|
+
const summary = registry.getUsageSummary();
|
|
1639
|
+
const tree = registry.getAgentTree();
|
|
1640
|
+
const lines = [
|
|
1641
|
+
`Active: ${summary.activeCount} | Completed: ${summary.completedCount} | Failed: ${summary.failedCount}`,
|
|
1642
|
+
`Total tokens: ${summary.totalTokens.toLocaleString()}`,
|
|
1643
|
+
];
|
|
1644
|
+
if (tree && tree !== '(no agents)') {
|
|
1645
|
+
lines.push('', 'Agent tree:', tree);
|
|
1646
|
+
}
|
|
1647
|
+
else {
|
|
1648
|
+
lines.push('', 'No agents have been spawned yet.');
|
|
1649
|
+
}
|
|
1650
|
+
lines.forEach(l => add('system', l));
|
|
1651
|
+
return true;
|
|
1652
|
+
}
|
|
1653
|
+
if (cmd === '/checkpoint') {
|
|
1654
|
+
const label = text.slice(12).trim() || undefined;
|
|
1655
|
+
createCheckpoint(process.cwd(), label).then((hash) => {
|
|
1656
|
+
if (hash)
|
|
1657
|
+
add('system', `Checkpoint created: ${hash}${label ? ` (${label})` : ''}`);
|
|
1658
|
+
else
|
|
1659
|
+
add('system', 'No changes to checkpoint (or not a git repo).');
|
|
1660
|
+
}).catch((err) => add('error', `Checkpoint failed: ${err.message}`));
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
if (cmd === '/undo') {
|
|
1664
|
+
undoLastCheckpoint(process.cwd()).then((r) => {
|
|
1665
|
+
add(r.success ? 'system' : 'error', r.message);
|
|
1666
|
+
}).catch((err) => add('error', `Undo failed: ${err.message}`));
|
|
1667
|
+
return true;
|
|
1668
|
+
}
|
|
1669
|
+
if (cmd === '/checkpoints') {
|
|
1670
|
+
listCheckpoints(process.cwd()).then((list) => {
|
|
1671
|
+
if (list.length === 0)
|
|
1672
|
+
add('system', 'No checkpoints found.');
|
|
1673
|
+
else {
|
|
1674
|
+
add('system', 'Recent checkpoints:');
|
|
1675
|
+
list.forEach((l) => add('system', ` ${l}`));
|
|
1676
|
+
}
|
|
1677
|
+
}).catch(() => add('error', 'Failed to list checkpoints.'));
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
if (cmd === '/commit') {
|
|
1681
|
+
setIsLoading(true);
|
|
1682
|
+
setStatus('thinking');
|
|
1683
|
+
setThinkStart(Date.now());
|
|
1684
|
+
add('system', 'Generating commit...');
|
|
1685
|
+
(async () => {
|
|
1686
|
+
try {
|
|
1687
|
+
// Check for staged changes
|
|
1688
|
+
let diff = '';
|
|
1689
|
+
try {
|
|
1690
|
+
diff = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1691
|
+
}
|
|
1692
|
+
catch { }
|
|
1693
|
+
if (!diff) {
|
|
1694
|
+
// Nothing staged — stage tracked files only (safer than -A)
|
|
1695
|
+
try {
|
|
1696
|
+
execSync('git add -u', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() });
|
|
1697
|
+
diff = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1698
|
+
}
|
|
1699
|
+
catch (e) {
|
|
1700
|
+
add('error', `Failed to stage files: ${e.message}`);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
if (!diff) {
|
|
1705
|
+
add('system', 'No changes to commit.');
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
// Send diff to agent to generate commit message
|
|
1709
|
+
const truncatedDiff = diff.length > 8000 ? diff.slice(0, 8000) + '\n... (diff truncated)' : diff;
|
|
1710
|
+
const commitMsg = await agent.send(`Generate a concise git commit message for these changes. Just output the commit message, nothing else. No quotes, no backticks, no explanation.\n\n${truncatedDiff}`);
|
|
1711
|
+
const cleanMsg = commitMsg.trim().replace(/^["'`]+|["'`]+$/g, '');
|
|
1712
|
+
if (!cleanMsg) {
|
|
1713
|
+
add('error', 'Agent returned empty commit message.');
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
add('system', `Commit message: ${cleanMsg}`);
|
|
1717
|
+
try {
|
|
1718
|
+
const result = execFileSync('git', ['commit', '-m', cleanMsg], { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1719
|
+
add('system', result);
|
|
1720
|
+
}
|
|
1721
|
+
catch (e) {
|
|
1722
|
+
add('error', `Commit failed: ${e.stderr?.trim() || e.message}`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
catch (e) {
|
|
1726
|
+
add('error', e.message || 'Commit failed');
|
|
1727
|
+
}
|
|
1728
|
+
finally {
|
|
1729
|
+
setIsLoading(false);
|
|
1730
|
+
setStatus('ready');
|
|
1731
|
+
setThinkStart(null);
|
|
1732
|
+
}
|
|
1733
|
+
})();
|
|
1734
|
+
return true;
|
|
1735
|
+
}
|
|
1736
|
+
if (cmd === '/review') {
|
|
1737
|
+
setIsLoading(true);
|
|
1738
|
+
setStatus('thinking');
|
|
1739
|
+
setThinkStart(Date.now());
|
|
1740
|
+
add('system', 'Running code review...');
|
|
1741
|
+
(async () => {
|
|
1742
|
+
try {
|
|
1743
|
+
let diff = '';
|
|
1744
|
+
let baseBranch = 'main';
|
|
1745
|
+
try {
|
|
1746
|
+
baseBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim().replace('refs/remotes/origin/', '');
|
|
1747
|
+
}
|
|
1748
|
+
catch {
|
|
1749
|
+
try {
|
|
1750
|
+
const branches = execSync('git branch --list main master', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1751
|
+
if (branches.includes('master'))
|
|
1752
|
+
baseBranch = 'master';
|
|
1753
|
+
}
|
|
1754
|
+
catch { }
|
|
1755
|
+
}
|
|
1756
|
+
try {
|
|
1757
|
+
diff = execSync(`git diff ${baseBranch}...HEAD`, { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1758
|
+
}
|
|
1759
|
+
catch { }
|
|
1760
|
+
if (!diff) {
|
|
1761
|
+
try {
|
|
1762
|
+
diff = execSync('git diff', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1763
|
+
}
|
|
1764
|
+
catch { }
|
|
1765
|
+
}
|
|
1766
|
+
if (!diff) {
|
|
1767
|
+
add('system', 'No changes to review.');
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const truncatedDiff = diff.length > 15000 ? diff.slice(0, 15000) + '\n... (diff truncated)' : diff;
|
|
1771
|
+
await agent.send(`Review this code diff. Point out bugs, security issues, performance problems, and suggest improvements. Be specific with file names and line numbers.\n\n${truncatedDiff}`);
|
|
1772
|
+
}
|
|
1773
|
+
catch (e) {
|
|
1774
|
+
add('error', e.message || 'Review failed');
|
|
1775
|
+
}
|
|
1776
|
+
finally {
|
|
1777
|
+
setIsLoading(false);
|
|
1778
|
+
setStatus('ready');
|
|
1779
|
+
setThinkStart(null);
|
|
1780
|
+
}
|
|
1781
|
+
})();
|
|
1782
|
+
return true;
|
|
1783
|
+
}
|
|
1784
|
+
if (cmd === '/watch') {
|
|
1785
|
+
const watchCmd = text.slice(7).trim();
|
|
1786
|
+
if (!watchCmd) {
|
|
1787
|
+
add('system', 'Usage: /watch <command> — runs the command, and if it fails, sends the error to the agent for auto-fix');
|
|
1788
|
+
return true;
|
|
1789
|
+
}
|
|
1790
|
+
setIsLoading(true);
|
|
1791
|
+
setStatus('thinking');
|
|
1792
|
+
setThinkStart(Date.now());
|
|
1793
|
+
add('system', `Watch: running "${watchCmd}"...`);
|
|
1794
|
+
(async () => {
|
|
1795
|
+
const MAX_RETRIES = 5;
|
|
1796
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
1797
|
+
try {
|
|
1798
|
+
const output = execSync(watchCmd, { encoding: 'utf-8', timeout: TOOL_DEFAULTS.maxCommandTimeoutMs, cwd: process.cwd() }).trim();
|
|
1799
|
+
if (output)
|
|
1800
|
+
add('system', output.length > 2000 ? output.slice(0, 2000) + '\n... (truncated)' : output);
|
|
1801
|
+
add('system', `Watch: command succeeded on attempt ${attempt}.`);
|
|
1802
|
+
break;
|
|
1803
|
+
}
|
|
1804
|
+
catch (err) {
|
|
1805
|
+
const errorOutput = (err.stderr?.trim() || '') + '\n' + (err.stdout?.trim() || '');
|
|
1806
|
+
add('error', `Attempt ${attempt}/${MAX_RETRIES} failed:\n${errorOutput.trim().slice(0, 1000)}`);
|
|
1807
|
+
if (attempt >= MAX_RETRIES) {
|
|
1808
|
+
add('system', 'Watch: max retries reached. Giving up.');
|
|
1809
|
+
break;
|
|
1810
|
+
}
|
|
1811
|
+
add('system', `Watch: sending error to agent for auto-fix (attempt ${attempt})...`);
|
|
1812
|
+
try {
|
|
1813
|
+
await agent.send(`The command "${watchCmd}" failed with this error. Please read the relevant files, analyze the error, and fix the issue:\n\n${errorOutput.trim().slice(0, 4000)}`);
|
|
1814
|
+
}
|
|
1815
|
+
catch (agentErr) {
|
|
1816
|
+
add('error', `Agent fix attempt failed: ${agentErr.message}`);
|
|
1817
|
+
break;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
setIsLoading(false);
|
|
1822
|
+
setStatus('ready');
|
|
1823
|
+
setThinkStart(null);
|
|
1824
|
+
})();
|
|
1825
|
+
return true;
|
|
1826
|
+
}
|
|
1827
|
+
if (cmd === '/create') {
|
|
1828
|
+
const description = text.slice(8).trim();
|
|
1829
|
+
if (!description) {
|
|
1830
|
+
add('system', 'Usage: /create <project description>');
|
|
1831
|
+
return true;
|
|
1832
|
+
}
|
|
1833
|
+
setIsLoading(true);
|
|
1834
|
+
setStatus('thinking');
|
|
1835
|
+
setThinkStart(Date.now());
|
|
1836
|
+
agent.send(`Create a new project based on this description: ${description}. Set up the project structure, install dependencies, and create initial files. Use best practices for the tech stack.`).then((result) => {
|
|
1837
|
+
if (!result?.trim())
|
|
1838
|
+
add('system', 'Agent returned no response.');
|
|
1839
|
+
}).catch((err) => {
|
|
1840
|
+
add('error', err.message || 'Create failed');
|
|
1841
|
+
}).finally(() => {
|
|
1842
|
+
setIsLoading(false);
|
|
1843
|
+
setStatus('ready');
|
|
1844
|
+
setThinkStart(null);
|
|
1845
|
+
});
|
|
1846
|
+
return true;
|
|
1847
|
+
}
|
|
1848
|
+
if (cmd === '/diff') {
|
|
1849
|
+
try {
|
|
1850
|
+
let diff = '';
|
|
1851
|
+
try {
|
|
1852
|
+
diff = execSync('git diff', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1853
|
+
}
|
|
1854
|
+
catch { }
|
|
1855
|
+
let staged = '';
|
|
1856
|
+
try {
|
|
1857
|
+
staged = execSync('git diff --staged', { encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, cwd: process.cwd() }).trim();
|
|
1858
|
+
}
|
|
1859
|
+
catch { }
|
|
1860
|
+
if (staged)
|
|
1861
|
+
diff = (diff ? diff + '\n' : '') + '--- Staged changes ---\n' + staged;
|
|
1862
|
+
if (!diff) {
|
|
1863
|
+
add('system', 'No uncommitted changes.');
|
|
1864
|
+
}
|
|
1865
|
+
else {
|
|
1866
|
+
const truncated = diff.length > 5000 ? diff.slice(0, 5000) + '\n... (truncated, full diff is ' + diff.length + ' chars)' : diff;
|
|
1867
|
+
truncated.split('\n').forEach((l) => add('system', l));
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
catch (e) {
|
|
1871
|
+
add('error', `Failed to get diff: ${e.message}`);
|
|
1872
|
+
}
|
|
1873
|
+
return true;
|
|
1874
|
+
}
|
|
1875
|
+
if (cmd === '/copy') {
|
|
1876
|
+
const msgs = agent.getMessages();
|
|
1877
|
+
const assistantMsgs = msgs.filter((m) => m.role === 'assistant' && m.content);
|
|
1878
|
+
if (assistantMsgs.length === 0) {
|
|
1879
|
+
add('system', 'No assistant responses to copy.');
|
|
1880
|
+
}
|
|
1881
|
+
else {
|
|
1882
|
+
const rawResponse = assistantMsgs[assistantMsgs.length - 1].content || '';
|
|
1883
|
+
const lastResponse = messageContentToString(rawResponse);
|
|
1884
|
+
try {
|
|
1885
|
+
if (process.platform === 'darwin') {
|
|
1886
|
+
execSync('pbcopy', { input: lastResponse, timeout: 3000 });
|
|
1887
|
+
}
|
|
1888
|
+
else if (process.platform === 'linux') {
|
|
1889
|
+
const isWSL = existsSync('/proc/version') && readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
|
|
1890
|
+
if (isWSL) {
|
|
1891
|
+
execSync('clip.exe', { input: lastResponse, timeout: 3000 });
|
|
1892
|
+
}
|
|
1893
|
+
else {
|
|
1894
|
+
try {
|
|
1895
|
+
execSync('xclip -selection clipboard', { input: lastResponse, timeout: 3000 });
|
|
1896
|
+
}
|
|
1897
|
+
catch {
|
|
1898
|
+
execSync('xsel --clipboard --input', { input: lastResponse, timeout: 3000 });
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
else {
|
|
1903
|
+
execSync('clip', { input: lastResponse, timeout: 3000 });
|
|
1904
|
+
}
|
|
1905
|
+
add('system', `Copied last response to clipboard (${lastResponse.length} chars).`);
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
add('error', 'Failed to copy to clipboard. Clipboard tool not available.');
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return true;
|
|
1912
|
+
}
|
|
1913
|
+
if (cmd === '/status') {
|
|
1914
|
+
const mode = agent.getPermissionMode();
|
|
1915
|
+
const modelsStr = agent.getModels().map(shortModel).join(', ');
|
|
1916
|
+
const baseUrl = agent.getBaseUrl() || 'default';
|
|
1917
|
+
const ctx = agent.getContextUsage();
|
|
1918
|
+
const msgCount = agent.getMessages().length;
|
|
1919
|
+
add('system', `${PRODUCT_NAME} v${VERSION}`);
|
|
1920
|
+
add('system', `Model: ${modelsStr}`);
|
|
1921
|
+
add('system', `API: ${baseUrl}`);
|
|
1922
|
+
add('system', `Mode: ${mode}`);
|
|
1923
|
+
add('system', `Context: ${ctx.percentage}% (${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)})`);
|
|
1924
|
+
add('system', `Messages: ${msgCount}`);
|
|
1925
|
+
add('system', `Session: ${sessionId}`);
|
|
1926
|
+
add('system', `CWD: ${process.cwd()}`);
|
|
1927
|
+
const updateBanner = getUpdateBanner();
|
|
1928
|
+
if (updateBanner)
|
|
1929
|
+
add('system', updateBanner);
|
|
1930
|
+
return true;
|
|
1931
|
+
}
|
|
1932
|
+
if (cmd === '/update') {
|
|
1933
|
+
add('system', 'Checking for updates...');
|
|
1934
|
+
checkForUpdate().then((info) => {
|
|
1935
|
+
if (info && info.updateAvailable) {
|
|
1936
|
+
add('system', `New version available: ${info.current} → ${info.latest}`);
|
|
1937
|
+
add('system', 'Installing update...');
|
|
1938
|
+
performUpdate().then(({ success, output }) => {
|
|
1939
|
+
if (success) {
|
|
1940
|
+
add('system', `Updated successfully. Restart Scalix to use v${info.latest}.`);
|
|
1941
|
+
}
|
|
1942
|
+
else {
|
|
1943
|
+
add('system', `Update failed: ${output}\nTry manually: npm install -g scalix@latest`);
|
|
1944
|
+
}
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
else {
|
|
1948
|
+
add('system', `Scalix v${VERSION} is up to date.`);
|
|
1949
|
+
}
|
|
1950
|
+
}).catch(() => {
|
|
1951
|
+
add('system', 'Failed to check for updates. Try: npm install -g scalix@latest');
|
|
1952
|
+
});
|
|
1953
|
+
return true;
|
|
1954
|
+
}
|
|
1955
|
+
if (cmd === '/context') {
|
|
1956
|
+
const ctx = agent.getContextUsage();
|
|
1957
|
+
const pct = Math.min(ctx.percentage, 100);
|
|
1958
|
+
const filled = Math.round(pct / 5);
|
|
1959
|
+
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(20 - filled);
|
|
1960
|
+
add('system', `Context usage: [${bar}] ${ctx.percentage}%`);
|
|
1961
|
+
add('system', `Tokens: ${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)}`);
|
|
1962
|
+
add('system', `Messages: ${agent.getMessages().length}`);
|
|
1963
|
+
if (pct > 80)
|
|
1964
|
+
add('system', 'Tip: Use /compact to free up context space.');
|
|
1965
|
+
else if (pct > 50)
|
|
1966
|
+
add('system', 'Context is healthy. /compact available if needed.');
|
|
1967
|
+
return true;
|
|
1968
|
+
}
|
|
1969
|
+
if (cmd === '/init') {
|
|
1970
|
+
const configPath = path.join(process.cwd(), 'SCALIX.md');
|
|
1971
|
+
(async () => {
|
|
1972
|
+
try {
|
|
1973
|
+
const exists = await fs.access(configPath).then(() => true).catch(() => false);
|
|
1974
|
+
if (exists) {
|
|
1975
|
+
add('system', 'SCALIX.md already exists in this directory.');
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
setIsLoading(true);
|
|
1979
|
+
setStatus('thinking');
|
|
1980
|
+
setThinkStart(Date.now());
|
|
1981
|
+
const response = await agent.send('Analyze this project directory and generate a SCALIX.md file. Include: project description, tech stack, build/test commands, coding conventions, and any important architectural decisions. Format as markdown. Just output the file content, nothing else.');
|
|
1982
|
+
if (response?.trim()) {
|
|
1983
|
+
await fs.writeFile(configPath, response.trim() + '\n', 'utf-8');
|
|
1984
|
+
add('system', `Created SCALIX.md (${response.length} chars). This will be loaded as project context.`);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
catch (e) {
|
|
1988
|
+
add('error', `Init failed: ${e.message}`);
|
|
1989
|
+
}
|
|
1990
|
+
finally {
|
|
1991
|
+
setIsLoading(false);
|
|
1992
|
+
setStatus('ready');
|
|
1993
|
+
setThinkStart(null);
|
|
1994
|
+
}
|
|
1995
|
+
})();
|
|
1996
|
+
return true;
|
|
1997
|
+
}
|
|
1998
|
+
if (cmd === '/exit' || cmd === '/quit') {
|
|
1999
|
+
const msgs = agent.getMessages();
|
|
2000
|
+
if (msgs.length > 0) {
|
|
2001
|
+
saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).catch((err) => { logger.warn('Failed to save session on exit', { error: err.message }); }).finally(() => process.exit(0));
|
|
2002
|
+
}
|
|
2003
|
+
else {
|
|
2004
|
+
process.exit(0);
|
|
2005
|
+
}
|
|
2006
|
+
return true;
|
|
2007
|
+
}
|
|
2008
|
+
if (cmd === '/plan') {
|
|
2009
|
+
agent.setPermissionMode('plan');
|
|
2010
|
+
setCurrentMode('plan');
|
|
2011
|
+
loadConfig().then(cfg => {
|
|
2012
|
+
cfg.permissionMode = 'plan';
|
|
2013
|
+
saveConfig(cfg);
|
|
2014
|
+
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
2015
|
+
add('system', `Mode: PLAN — ${getModeDescription('plan')}`);
|
|
2016
|
+
return true;
|
|
2017
|
+
}
|
|
2018
|
+
if (cmd === '/export') {
|
|
2019
|
+
const msgs = agent.getMessages();
|
|
2020
|
+
if (msgs.length === 0) {
|
|
2021
|
+
add('system', 'No conversation to export.');
|
|
2022
|
+
return true;
|
|
2023
|
+
}
|
|
2024
|
+
const filename = text.split(' ')[1]?.trim() || `scalix-export-${new Date().toISOString().slice(0, 10)}.txt`;
|
|
2025
|
+
const lines = [];
|
|
2026
|
+
for (const m of msgs) {
|
|
2027
|
+
if (m.role === 'system')
|
|
2028
|
+
continue;
|
|
2029
|
+
const prefix = m.role === 'user' ? 'You' : m.role === 'assistant' ? 'Assistant' : m.role === 'tool' ? `Tool(${m.name || 'unknown'})` : m.role;
|
|
2030
|
+
if (m.content)
|
|
2031
|
+
lines.push(`[${prefix}]\n${messageContentToString(m.content)}\n`);
|
|
2032
|
+
if (m.tool_calls) {
|
|
2033
|
+
for (const tc of m.tool_calls) {
|
|
2034
|
+
lines.push(`[Tool Call: ${tc.function.name}]\n${tc.function.arguments}\n`);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
const exportPath = path.join(process.cwd(), filename);
|
|
2039
|
+
fs.writeFile(exportPath, lines.join('\n'), 'utf-8').then(() => {
|
|
2040
|
+
add('system', `Exported ${msgs.length} messages to ${filename}`);
|
|
2041
|
+
}).catch((e) => {
|
|
2042
|
+
add('error', `Export failed: ${e.message}`);
|
|
2043
|
+
});
|
|
2044
|
+
return true;
|
|
2045
|
+
}
|
|
2046
|
+
if (cmd === '/doctor') {
|
|
2047
|
+
add('system', `${PRODUCT_NAME} v${VERSION} — Diagnostics`);
|
|
2048
|
+
add('system', `Platform: ${process.platform} (${process.arch})`);
|
|
2049
|
+
add('system', `Node: ${process.version}`);
|
|
2050
|
+
add('system', `CWD: ${process.cwd()}`);
|
|
2051
|
+
// Check git
|
|
2052
|
+
try {
|
|
2053
|
+
const gitVersion = execSync('git --version', { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
2054
|
+
add('system', `Git: ${gitVersion}`);
|
|
2055
|
+
}
|
|
2056
|
+
catch {
|
|
2057
|
+
add('error', 'Git: not found');
|
|
2058
|
+
}
|
|
2059
|
+
// Check API connectivity
|
|
2060
|
+
add('system', `API Base: ${agent.getBaseUrl() || DEFAULT_BASE_URL}`);
|
|
2061
|
+
add('system', `API Key: ${agent.getApiKey() ? '****' + agent.getApiKey().slice(-4) : 'NOT SET'}`);
|
|
2062
|
+
add('system', `Models: ${agent.getModels().map(shortModel).join(', ')}`);
|
|
2063
|
+
add('system', `Mode: ${agent.getPermissionMode()}`);
|
|
2064
|
+
// Check config
|
|
2065
|
+
loadConfig().then(cfg => {
|
|
2066
|
+
add('system', `Config: ${cfg.apiKey ? 'configured' : 'not configured'}`);
|
|
2067
|
+
}).catch(() => {
|
|
2068
|
+
add('error', 'Config: failed to load');
|
|
2069
|
+
});
|
|
2070
|
+
return true;
|
|
2071
|
+
}
|
|
2072
|
+
const customCmd = customCommands.find((c) => cmd === `/${c.name}`);
|
|
2073
|
+
if (customCmd) {
|
|
2074
|
+
const args = text.slice(cmd.length).trim();
|
|
2075
|
+
const expanded = expandCommand(customCmd, args);
|
|
2076
|
+
add('system', `Running custom command: /${customCmd.name}`);
|
|
2077
|
+
setIsLoading(true);
|
|
2078
|
+
setStatus('thinking');
|
|
2079
|
+
setThinkStart(Date.now());
|
|
2080
|
+
agent.send(expanded).then((result) => {
|
|
2081
|
+
if (!result?.trim())
|
|
2082
|
+
add('system', 'Agent returned no response.');
|
|
2083
|
+
}).catch((err) => {
|
|
2084
|
+
add('error', err.message || 'Unknown error');
|
|
2085
|
+
}).finally(() => {
|
|
2086
|
+
setIsLoading(false);
|
|
2087
|
+
setStatus('ready');
|
|
2088
|
+
setThinkStart(null);
|
|
2089
|
+
});
|
|
2090
|
+
return true;
|
|
2091
|
+
}
|
|
2092
|
+
return false;
|
|
2093
|
+
}, [add, models]);
|
|
2094
|
+
// Use a ref for isLoading to avoid stale closures in handleSubmit
|
|
2095
|
+
const isLoadingRef = useRef(false);
|
|
2096
|
+
useEffect(() => { isLoadingRef.current = isLoading; }, [isLoading]);
|
|
2097
|
+
const multilineRef = useRef([]);
|
|
2098
|
+
const messageQueueRef = useRef([]);
|
|
2099
|
+
const handleSubmit = useCallback(async (text) => {
|
|
2100
|
+
const trimmed = text.trim();
|
|
2101
|
+
if (!trimmed)
|
|
2102
|
+
return;
|
|
2103
|
+
// Allow /mode even while the agent is working
|
|
2104
|
+
if (isLoadingRef.current && trimmed.startsWith('/mode')) {
|
|
2105
|
+
handleCommand(trimmed);
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
// Queue messages while agent is working
|
|
2109
|
+
if (isLoadingRef.current) {
|
|
2110
|
+
messageQueueRef.current.push(trimmed);
|
|
2111
|
+
add('system', `Queued (${messageQueueRef.current.length}): ${truncate(trimmed, 60)}`);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (trimmed.endsWith('\\')) {
|
|
2115
|
+
if (multilineRef.current.length >= 50) {
|
|
2116
|
+
add('system', 'Multiline limit reached (50 lines). Sending now.');
|
|
2117
|
+
}
|
|
2118
|
+
else {
|
|
2119
|
+
multilineRef.current.push(trimmed.slice(0, -1));
|
|
2120
|
+
add('system', '... (continue typing, submit without \\ to send)');
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
let finalText;
|
|
2125
|
+
if (multilineRef.current.length > 0) {
|
|
2126
|
+
multilineRef.current.push(trimmed);
|
|
2127
|
+
finalText = multilineRef.current.join('\n');
|
|
2128
|
+
multilineRef.current = [];
|
|
2129
|
+
}
|
|
2130
|
+
else {
|
|
2131
|
+
finalText = trimmed;
|
|
2132
|
+
}
|
|
2133
|
+
if (finalText.startsWith('/') && handleCommand(finalText))
|
|
2134
|
+
return;
|
|
2135
|
+
if (finalText.startsWith('!')) {
|
|
2136
|
+
const shellCmd = finalText.slice(1).trim();
|
|
2137
|
+
if (!shellCmd)
|
|
2138
|
+
return;
|
|
2139
|
+
add('system', `$ ${shellCmd}`);
|
|
2140
|
+
try {
|
|
2141
|
+
let output = execSync(shellCmd, { encoding: 'utf-8', timeout: HEADLESS_DEFAULTS.commandTimeoutMs, cwd: process.cwd() }).trim();
|
|
2142
|
+
if (output) {
|
|
2143
|
+
if (output.length > 2000) {
|
|
2144
|
+
output = output.slice(0, 2000) + `\n... (${(output.length - 2000).toLocaleString()} chars truncated)`;
|
|
2145
|
+
}
|
|
2146
|
+
add('system', output);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
catch (err) {
|
|
2150
|
+
add('error', err.stderr?.trim() || err.message);
|
|
2151
|
+
}
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
const correction = detectCorrection(finalText);
|
|
2155
|
+
if (correction) {
|
|
2156
|
+
addNote(process.cwd(), `[correction] ${correction}`).catch((err) => { logger.warn('Failed to save correction note', { error: err.message }); });
|
|
2157
|
+
}
|
|
2158
|
+
add('user', finalText);
|
|
2159
|
+
setIsLoading(true);
|
|
2160
|
+
setStatus('thinking');
|
|
2161
|
+
setThinkStart(Date.now());
|
|
2162
|
+
try {
|
|
2163
|
+
const result = await agent.send(finalText);
|
|
2164
|
+
if (!result || !result.trim()) {
|
|
2165
|
+
add('system', 'Agent returned no response.');
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
catch (e) {
|
|
2169
|
+
add('error', e.message || 'Unknown error');
|
|
2170
|
+
}
|
|
2171
|
+
finally {
|
|
2172
|
+
// Process queued messages sequentially
|
|
2173
|
+
while (messageQueueRef.current.length > 0) {
|
|
2174
|
+
const next = messageQueueRef.current.shift();
|
|
2175
|
+
const remaining = messageQueueRef.current.length;
|
|
2176
|
+
if (remaining > 0)
|
|
2177
|
+
add('system', `${remaining} more queued`);
|
|
2178
|
+
add('user', next);
|
|
2179
|
+
setStatus('thinking');
|
|
2180
|
+
setThinkStart(Date.now());
|
|
2181
|
+
try {
|
|
2182
|
+
const qResult = await agent.send(next);
|
|
2183
|
+
if (!qResult || !qResult.trim())
|
|
2184
|
+
add('system', 'Agent returned no response.');
|
|
2185
|
+
}
|
|
2186
|
+
catch (e) {
|
|
2187
|
+
add('error', e.message || 'Unknown error');
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
setIsLoading(false);
|
|
2191
|
+
setStatus('ready');
|
|
2192
|
+
setThinkStart(null);
|
|
2193
|
+
}
|
|
2194
|
+
}, [add, handleCommand]);
|
|
2195
|
+
const handleSessionSelect = useCallback((session) => {
|
|
2196
|
+
setSessionList(null);
|
|
2197
|
+
setIsLoading(true);
|
|
2198
|
+
setStatus('thinking');
|
|
2199
|
+
setThinkStart(Date.now());
|
|
2200
|
+
loadSession(session.id).then((data) => {
|
|
2201
|
+
agent.loadMessages(data.messages);
|
|
2202
|
+
sessionId = session.id;
|
|
2203
|
+
setModels([session.model]);
|
|
2204
|
+
agent.setModel(session.model);
|
|
2205
|
+
if (data.costState) {
|
|
2206
|
+
costTracker.restore(data.costState);
|
|
2207
|
+
setTotalTokens(data.costState.totalTokens);
|
|
2208
|
+
}
|
|
2209
|
+
setItems([]);
|
|
2210
|
+
showBannerRef.current = false;
|
|
2211
|
+
setShowBanner(false);
|
|
2212
|
+
for (const msg of data.messages) {
|
|
2213
|
+
const text = messageContentToString(msg.content);
|
|
2214
|
+
if (msg.role === 'user' && text)
|
|
2215
|
+
add('user', text);
|
|
2216
|
+
else if (msg.role === 'assistant' && text)
|
|
2217
|
+
add('assistant', text);
|
|
2218
|
+
}
|
|
2219
|
+
add('system', `Resumed: ${session.name} (${session.messageCount} messages)`);
|
|
2220
|
+
}).catch(() => {
|
|
2221
|
+
add('error', 'Failed to load session.');
|
|
2222
|
+
}).finally(() => {
|
|
2223
|
+
setIsLoading(false);
|
|
2224
|
+
setStatus('ready');
|
|
2225
|
+
setThinkStart(null);
|
|
2226
|
+
});
|
|
2227
|
+
}, [add]);
|
|
2228
|
+
const handleAbort = useCallback(() => {
|
|
2229
|
+
agent.abort();
|
|
2230
|
+
if (pendingConfirm) {
|
|
2231
|
+
pendingConfirm.resolve(false);
|
|
2232
|
+
pendingConfirm = null;
|
|
2233
|
+
}
|
|
2234
|
+
setConfirmInfo(null);
|
|
2235
|
+
setIsLoading(false);
|
|
2236
|
+
setStatus('ready');
|
|
2237
|
+
setStreamText('');
|
|
2238
|
+
setReasoningText('');
|
|
2239
|
+
setThinkStart(null);
|
|
2240
|
+
add('system', 'Interrupted.');
|
|
2241
|
+
}, [add]);
|
|
2242
|
+
const handleClearScreen = useCallback(() => {
|
|
2243
|
+
const msgs = agent.getMessages();
|
|
2244
|
+
if (msgs.length > 0) {
|
|
2245
|
+
saveSession(sessionId, msgs, models[0] || 'unknown', undefined, costTracker.getState()).catch((err) => { logger.warn('Failed to save session on clear', { error: err.message }); });
|
|
2246
|
+
}
|
|
2247
|
+
agent.clearHistory();
|
|
2248
|
+
setItems([]);
|
|
2249
|
+
showBannerRef.current = true;
|
|
2250
|
+
setShowBanner(true);
|
|
2251
|
+
costTracker.reset();
|
|
2252
|
+
setTotalTokens(0);
|
|
2253
|
+
sessionId = generateSessionId();
|
|
2254
|
+
}, [models]);
|
|
2255
|
+
const handleSetupDone = useCallback((newConfig) => {
|
|
2256
|
+
setShowSetup(false);
|
|
2257
|
+
try {
|
|
2258
|
+
initAgent(newConfig);
|
|
2259
|
+
setModels(agent.getModels());
|
|
2260
|
+
setAgentVersion(v => v + 1);
|
|
2261
|
+
add('system', 'Configuration updated. Agent reinitialized.');
|
|
2262
|
+
}
|
|
2263
|
+
catch (err) {
|
|
2264
|
+
add('error', `Failed to reinitialize: ${err.message}`);
|
|
2265
|
+
}
|
|
2266
|
+
}, [add]);
|
|
2267
|
+
const [inputSectionHeight, setInputSectionHeight] = useState(3);
|
|
2268
|
+
const handleInputHeightChange = useCallback((h) => setInputSectionHeight(h), []);
|
|
2269
|
+
const bannerHeight = showBanner ? 5 : 0;
|
|
2270
|
+
const statusBarHeight = 1;
|
|
2271
|
+
const bottomHeight = useMemo(() => {
|
|
2272
|
+
if (confirmInfo) {
|
|
2273
|
+
let parsed = {};
|
|
2274
|
+
try {
|
|
2275
|
+
parsed = typeof confirmInfo.args === 'string' ? JSON.parse(confirmInfo.args) : confirmInfo.args ?? {};
|
|
2276
|
+
}
|
|
2277
|
+
catch {
|
|
2278
|
+
parsed = {};
|
|
2279
|
+
}
|
|
2280
|
+
const extraCount = Math.min(Object.keys(parsed).filter(k => !['command', 'filePath', 'file_path', 'notebook_path', 'message', 'content'].includes(k)).length, 3);
|
|
2281
|
+
const hasHighlight = !!(parsed.command ?? parsed.filePath ?? parsed.file_path ?? parsed.notebook_path ?? parsed.message);
|
|
2282
|
+
return 5 + (hasHighlight ? 1 : 0) + extraCount;
|
|
2283
|
+
}
|
|
2284
|
+
if (selectingModel)
|
|
2285
|
+
return 6 + Math.min(models.length + 2, 12);
|
|
2286
|
+
if (selectingMode)
|
|
2287
|
+
return 5 + PERMISSION_MODES.length + 1;
|
|
2288
|
+
if (editingSettings)
|
|
2289
|
+
return Math.min(rows - 8, 42);
|
|
2290
|
+
if (sessionList)
|
|
2291
|
+
return 5 + Math.min((sessionList?.length ?? 0) + 1, 12);
|
|
2292
|
+
return inputSectionHeight;
|
|
2293
|
+
}, [confirmInfo, selectingModel, selectingMode, editingSettings, sessionList, models.length, rows, inputSectionHeight]);
|
|
2294
|
+
const chromeHeight = bannerHeight + statusBarHeight + bottomHeight;
|
|
2295
|
+
const chatHeight = Math.max(rows - chromeHeight, 6);
|
|
2296
|
+
const streamLineCount = useMemo(() => {
|
|
2297
|
+
if (!isLoading)
|
|
2298
|
+
return 0;
|
|
2299
|
+
const textLines = streamText ? (streamText.match(/\n/g) || []).length + 1 : 0;
|
|
2300
|
+
const reasonLines = reasoningText ? (reasoningText.match(/\n/g) || []).length + 1 : 0;
|
|
2301
|
+
return textLines + reasonLines;
|
|
2302
|
+
}, [isLoading, streamText, reasoningText]);
|
|
2303
|
+
const { visibleItems, scrollUp, scrollDown, scrollToBottom, isAtBottom, } = useScrollableItems(items, chatHeight, cols, isLoading, streamLineCount);
|
|
2304
|
+
const scrollToBottomRef = useRef(scrollToBottom);
|
|
2305
|
+
scrollToBottomRef.current = scrollToBottom;
|
|
2306
|
+
const handleSubmitRef = useRef(handleSubmit);
|
|
2307
|
+
handleSubmitRef.current = handleSubmit;
|
|
2308
|
+
const handleInputSubmit = useCallback((v) => {
|
|
2309
|
+
scrollToBottomRef.current();
|
|
2310
|
+
handleSubmitRef.current(v);
|
|
2311
|
+
}, []);
|
|
2312
|
+
if (showSetup) {
|
|
2313
|
+
return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
|
|
2314
|
+
}
|
|
2315
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 6, overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, {}), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || '') }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, onSelect: (v) => {
|
|
2316
|
+
agent.setModel(v);
|
|
2317
|
+
setModels([v]);
|
|
2318
|
+
setSelectingModel(false);
|
|
2319
|
+
loadConfig().then(cfg => {
|
|
2320
|
+
cfg.models = [v];
|
|
2321
|
+
saveConfig(cfg);
|
|
2322
|
+
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
2323
|
+
add('system', `Model switched to ${shortModel(v)}`);
|
|
2324
|
+
}, onCancel: () => setSelectingModel(false) })) : selectingMode ? (_jsx(ModePicker, { currentMode: currentMode, onSelect: (mode) => {
|
|
2325
|
+
agent.setPermissionMode(mode);
|
|
2326
|
+
setCurrentMode(mode);
|
|
2327
|
+
setSelectingMode(false);
|
|
2328
|
+
loadConfig().then(cfg => {
|
|
2329
|
+
cfg.permissionMode = mode;
|
|
2330
|
+
saveConfig(cfg);
|
|
2331
|
+
}).catch((err) => { logger.warn('Failed to save config', { error: err.message }); });
|
|
2332
|
+
add('system', `Mode: ${mode.toUpperCase()} \u2014 ${getModeDescription(mode)}`);
|
|
2333
|
+
}, onCancel: () => setSelectingMode(false) })) : editingSettings ? (_jsx(SettingsEditor, { onSave: (key, value) => {
|
|
2334
|
+
loadConfig().then((c) => {
|
|
2335
|
+
const limits = { ...(c.limits || DEFAULT_LIMITS) };
|
|
2336
|
+
const floatKeys = new Set([
|
|
2337
|
+
'compressionThreshold', 'messageKeepRatio', 'subAgentCompressionThreshold',
|
|
2338
|
+
'slidingWindowTarget', 'charsPerToken',
|
|
2339
|
+
]);
|
|
2340
|
+
const parsed = floatKeys.has(key) ? parseFloat(value) : parseInt(value, 10);
|
|
2341
|
+
if (!isNaN(parsed) && parsed >= 0) {
|
|
2342
|
+
limits[key] = parsed;
|
|
2343
|
+
const newConfig = { ...c, limits };
|
|
2344
|
+
saveConfig(newConfig).then(() => {
|
|
2345
|
+
agent?.updateLimit(key, parsed);
|
|
2346
|
+
add('system', `Setting updated: ${key} = ${parsed}`);
|
|
2347
|
+
}).catch(() => {
|
|
2348
|
+
add('error', `Failed to save setting: ${key}`);
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
else {
|
|
2352
|
+
add('error', `Invalid value for ${key}: ${value}`);
|
|
2353
|
+
}
|
|
2354
|
+
}).catch(() => {
|
|
2355
|
+
add('error', 'Failed to load config.');
|
|
2356
|
+
});
|
|
2357
|
+
}, onClose: () => setEditingSettings(false) })) : sessionList ? (_jsx(SessionPicker, { sessions: sessionList, onSelect: handleSessionSelect, onCancel: () => setSessionList(null) })) : (_jsx(InputSection, { onSubmit: handleInputSubmit, disabled: isLoading, onAbort: handleAbort, onClear: handleClearScreen, onScrollUp: scrollUp, onScrollDown: scrollDown, onHeightChange: handleInputHeightChange })) })] }));
|
|
2358
|
+
}
|
|
2359
|
+
// ─── Root (config loading + setup gate) ───
|
|
2360
|
+
function Root() {
|
|
2361
|
+
const [config, setConfig] = useState(null);
|
|
2362
|
+
const [ready, setReady] = useState(false);
|
|
2363
|
+
const [initError, setInitError] = useState(null);
|
|
2364
|
+
useEffect(() => {
|
|
2365
|
+
loadConfig().then((c) => {
|
|
2366
|
+
const envKey = process.env[ENV_API_KEY];
|
|
2367
|
+
if (envKey)
|
|
2368
|
+
c.apiKey = envKey;
|
|
2369
|
+
setConfig(c);
|
|
2370
|
+
if (isConfigured(c)) {
|
|
2371
|
+
// Fetch model specs from API in background (uses user-defined/built-in as fallback)
|
|
2372
|
+
fetchModelSpecs(c.baseUrl, c.apiKey).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); });
|
|
2373
|
+
if (!process.env.NO_UPDATE_NOTIFIER && !c.disableUpdateCheck) {
|
|
2374
|
+
checkForUpdate().catch(() => { });
|
|
2375
|
+
}
|
|
2376
|
+
try {
|
|
2377
|
+
initAgent(c);
|
|
2378
|
+
setReady(true);
|
|
2379
|
+
}
|
|
2380
|
+
catch (err) {
|
|
2381
|
+
setInitError(err.message);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
}).catch(() => {
|
|
2385
|
+
setConfig({ apiKey: '', baseUrl: DEFAULT_BASE_URL, models: [...DEFAULT_MODELS], customModels: {}, limits: { ...DEFAULT_LIMITS }, permissionMode: 'default', thinkingMode: 'off', security: {}, injectionDetection: { enabled: true, sensitivity: 'medium' } });
|
|
2386
|
+
});
|
|
2387
|
+
}, []);
|
|
2388
|
+
const handleSetupComplete = useCallback((c) => {
|
|
2389
|
+
setConfig(c);
|
|
2390
|
+
setInitError(null);
|
|
2391
|
+
try {
|
|
2392
|
+
initAgent(c);
|
|
2393
|
+
setReady(true);
|
|
2394
|
+
}
|
|
2395
|
+
catch (err) {
|
|
2396
|
+
setInitError(err.message);
|
|
2397
|
+
}
|
|
2398
|
+
}, []);
|
|
2399
|
+
if (config === null) {
|
|
2400
|
+
return (_jsx(Box, { paddingX: 3, paddingY: 1, children: _jsxs(Text, { dimColor: true, children: ['\u25CF', " Loading configuration..."] }) }));
|
|
2401
|
+
}
|
|
2402
|
+
if (initError) {
|
|
2403
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 3, paddingY: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["Initialization failed: ", initError] }), _jsx(Text, { dimColor: true, children: "Running setup to reconfigure..." }), _jsx(SetupScreen, { onComplete: (c) => { setInitError(null); handleSetupComplete(c); } })] }));
|
|
2404
|
+
}
|
|
2405
|
+
if (!ready) {
|
|
2406
|
+
return _jsx(SetupScreen, { onComplete: handleSetupComplete });
|
|
2407
|
+
}
|
|
2408
|
+
return _jsx(App, { config: config });
|
|
2409
|
+
}
|
|
2410
|
+
// ─── Flicker-free rendering for Windows terminals ───
|
|
2411
|
+
// Buffer all stdout writes and flush at a capped frame rate (~60fps).
|
|
2412
|
+
// This ensures the terminal receives complete frames instead of
|
|
2413
|
+
// partial line-by-line rewrites that cause visible blinking.
|
|
2414
|
+
process.stdout.write('\x1b[?25l'); // hide cursor
|
|
2415
|
+
const _origWrite = process.stdout.write.bind(process.stdout);
|
|
2416
|
+
let _buffer = '';
|
|
2417
|
+
let _flushTimer = null;
|
|
2418
|
+
const FRAME_MS = 16; // ~60fps
|
|
2419
|
+
process.stdout.write = function (chunk, encodingOrCb, cb) {
|
|
2420
|
+
if (typeof chunk === 'string') {
|
|
2421
|
+
_buffer += chunk;
|
|
2422
|
+
if (!_flushTimer) {
|
|
2423
|
+
_flushTimer = setTimeout(() => {
|
|
2424
|
+
_flushTimer = null;
|
|
2425
|
+
const data = _buffer;
|
|
2426
|
+
_buffer = '';
|
|
2427
|
+
if (data)
|
|
2428
|
+
_origWrite(data);
|
|
2429
|
+
}, FRAME_MS);
|
|
2430
|
+
}
|
|
2431
|
+
// Handle callback argument
|
|
2432
|
+
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb;
|
|
2433
|
+
if (typeof callback === 'function')
|
|
2434
|
+
process.nextTick(callback);
|
|
2435
|
+
return true;
|
|
2436
|
+
}
|
|
2437
|
+
return _origWrite(chunk, encodingOrCb, cb);
|
|
2438
|
+
};
|
|
2439
|
+
function _restoreTerminal() {
|
|
2440
|
+
if (_flushTimer) {
|
|
2441
|
+
clearTimeout(_flushTimer);
|
|
2442
|
+
_flushTimer = null;
|
|
2443
|
+
}
|
|
2444
|
+
process.stdout.write = _origWrite;
|
|
2445
|
+
if (_buffer) {
|
|
2446
|
+
_origWrite(_buffer);
|
|
2447
|
+
_buffer = '';
|
|
2448
|
+
}
|
|
2449
|
+
_origWrite('\x1b[?25h'); // show cursor
|
|
2450
|
+
}
|
|
2451
|
+
process.on('exit', _restoreTerminal);
|
|
2452
|
+
process.on('SIGINT', () => { _restoreTerminal(); closeBrowser().finally(() => process.exit(0)); });
|
|
2453
|
+
process.on('SIGTERM', () => { _restoreTerminal(); closeBrowser().finally(() => process.exit(0)); });
|
|
2454
|
+
render(_jsx(Root, {}), { exitOnCtrlC: false, patchConsole: false });
|