tokenmaw 0.3.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/README.md +150 -0
- package/agents/coordinator.md +13 -0
- package/agents/explorer.md +21 -0
- package/agents/implement.md +23 -0
- package/agents/main.md +25 -0
- package/agents/review.md +21 -0
- package/dist/backend.js +595 -0
- package/dist/cli.js +101 -0
- package/dist/config.js +155 -0
- package/dist/diff.js +45 -0
- package/dist/domain/agent.js +1 -0
- package/dist/fetch.js +110 -0
- package/dist/infra/file-snapshot.js +54 -0
- package/dist/infra/tools.js +1300 -0
- package/dist/markdown.js +274 -0
- package/dist/model-config.js +48 -0
- package/dist/policy.js +80 -0
- package/dist/responses.js +81 -0
- package/dist/runtime/agent-registry.js +139 -0
- package/dist/runtime/agent-runtime.js +993 -0
- package/dist/runtime/agent-store.js +152 -0
- package/dist/runtime/locks.js +46 -0
- package/dist/runtime/session-timeline.js +92 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/registry.js +51 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/clipboard.js +24 -0
- package/dist/ui/commands.js +20 -0
- package/dist/ui/composer-layout.js +31 -0
- package/dist/ui/fullscreen-tui.js +1405 -0
- package/dist/ui/markdown.js +81 -0
- package/dist/ui/syntax.js +17 -0
- package/dist/ui/tui-design.js +94 -0
- package/dist/ui/welcome.js +24 -0
- package/dist/version.js +4 -0
- package/docs/architecture-revision.md +281 -0
- package/package.json +47 -0
- package/skills/debugging.md +18 -0
- package/skills/git-workflow.md +14 -0
- package/skills/node-express.md +27 -0
- package/skills/python-flask.md +22 -0
- package/skills/react-component.md +24 -0
- package/skills/sql-database.md +18 -0
- package/skills/testing.md +12 -0
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import { renderTuiMarkdown, toolDiff } from './markdown.js';
|
|
3
|
+
import { resilientFetch } from '../fetch.js';
|
|
4
|
+
import { layoutComposer } from './composer-layout.js';
|
|
5
|
+
import { renderWelcome } from './welcome.js';
|
|
6
|
+
import { copyText } from './clipboard.js';
|
|
7
|
+
import { commandMatches } from './commands.js';
|
|
8
|
+
import { diffPreview, elapsedLabel, STATUS_PRESENTATION, toolPresentation, tuiLayout, visibleTimelineEntries } from './tui-design.js';
|
|
9
|
+
import { recordTimeline } from '../runtime/session-timeline.js';
|
|
10
|
+
const PROVIDERS = [
|
|
11
|
+
{ id: 'openai', label: 'OpenAI', backend: 'openai', baseUrl: 'https://api.openai.com/v1', needsKey: true },
|
|
12
|
+
{ id: 'openrouter', label: 'OpenRouter', backend: 'openai', baseUrl: 'https://openrouter.ai/api/v1', needsKey: true },
|
|
13
|
+
{ id: 'anthropic', label: 'Anthropic', backend: 'anthropic', baseUrl: 'https://api.anthropic.com', needsKey: true },
|
|
14
|
+
{ id: 'opencode-go', label: 'OpenCode Go', backend: 'openai', baseUrl: 'https://opencode.ai/zen/go/v1', needsKey: true },
|
|
15
|
+
{ id: 'ollama', label: 'Ollama · local', backend: 'ollama', baseUrl: 'http://localhost:11434', needsKey: false },
|
|
16
|
+
{ id: 'custom', label: 'Custom · OpenAI compatible', backend: 'openai', baseUrl: '', needsKey: false },
|
|
17
|
+
];
|
|
18
|
+
const COLOR = {
|
|
19
|
+
// Stick to the ANSI palette so Windows consoles do not quantize custom RGB
|
|
20
|
+
// values into black. In blessed, `gray` is bright-black (color 8), so it can
|
|
21
|
+
// disappear on a black background; `white` is the readable 8-color fallback.
|
|
22
|
+
background: 'black', panel: 'black', elevated: '#20242a', modal: '#171a1f', modalRule: '#2b3139', line: 'gray',
|
|
23
|
+
text: 'light-white', muted: 'white', subtle: 'gray', accent: 'light-cyan', success: 'light-green',
|
|
24
|
+
warning: 'light-yellow', error: 'light-red',
|
|
25
|
+
};
|
|
26
|
+
const TONE_COLOR = { muted: COLOR.muted, accent: COLOR.accent, success: COLOR.success, warning: COLOR.warning, error: COLOR.error };
|
|
27
|
+
function oneLine(value, max = 72) {
|
|
28
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
29
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
30
|
+
}
|
|
31
|
+
function safe(value) {
|
|
32
|
+
return blessed.escape(value);
|
|
33
|
+
}
|
|
34
|
+
function providerName(config) {
|
|
35
|
+
if (config.backend === 'anthropic')
|
|
36
|
+
return 'Anthropic';
|
|
37
|
+
if (config.backend === 'ollama')
|
|
38
|
+
return 'Ollama';
|
|
39
|
+
if (config.baseUrl?.includes('openrouter.ai'))
|
|
40
|
+
return 'OpenRouter';
|
|
41
|
+
if (config.baseUrl?.includes('opencode.ai/zen'))
|
|
42
|
+
return 'OpenCode Go';
|
|
43
|
+
return 'OpenAI compatible';
|
|
44
|
+
}
|
|
45
|
+
async function fetchRemoteModels(baseUrl, apiKey, backend) {
|
|
46
|
+
try {
|
|
47
|
+
const trimmed = baseUrl.replace(/\/+$/, '');
|
|
48
|
+
const url = backend === 'anthropic' ? `${trimmed}/v1/models` : backend === 'ollama' ? `${trimmed}/v1/models` : `${trimmed}/models`;
|
|
49
|
+
const headers = backend === 'anthropic'
|
|
50
|
+
? { 'x-api-key': apiKey ?? '', 'anthropic-version': '2023-06-01' }
|
|
51
|
+
: apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
|
52
|
+
const response = await resilientFetch(url, { headers, retries: 0, timeout: 15000 });
|
|
53
|
+
const body = await response.json();
|
|
54
|
+
return { models: [...new Set((body.data ?? []).map((model) => model.id).filter((id) => Boolean(id)))].sort() };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return { models: [], error: error instanceof Error ? error.message : String(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function runFullscreenTui(runtime, options) {
|
|
61
|
+
let sessionId = `session-${Date.now()}`;
|
|
62
|
+
let session = await runtime.openSession(sessionId);
|
|
63
|
+
const instanceCache = new Map(runtime.listInstances(sessionId).map((instance) => [instance.instanceId, instance]));
|
|
64
|
+
let activeModel = options.modelName;
|
|
65
|
+
let activityVisible = false;
|
|
66
|
+
let composerPinned = true;
|
|
67
|
+
let selectedActivityIndex = 0;
|
|
68
|
+
let closed = false;
|
|
69
|
+
let composerCursor = 0;
|
|
70
|
+
let historyIndex;
|
|
71
|
+
let historyDraft = '';
|
|
72
|
+
let spinnerFrame = 0;
|
|
73
|
+
let spinnerTimer;
|
|
74
|
+
let welcomeTimer;
|
|
75
|
+
let welcomeFrame = 0;
|
|
76
|
+
const composerChars = [];
|
|
77
|
+
const inputHistory = [];
|
|
78
|
+
const pendingTurns = new Set();
|
|
79
|
+
const streams = new Map();
|
|
80
|
+
const activityLog = new Map();
|
|
81
|
+
const thinkingBlocks = new Map();
|
|
82
|
+
const thinkingBlockLines = new Map();
|
|
83
|
+
let latestThinkingTurnId;
|
|
84
|
+
let conversationFollowOutput = true;
|
|
85
|
+
let conversationScrollOffset = 0;
|
|
86
|
+
let restoringConversationScroll = false;
|
|
87
|
+
let conversationDirty = true;
|
|
88
|
+
let activityDirty = true;
|
|
89
|
+
let lastLayoutKey = '';
|
|
90
|
+
let composerRow = 0;
|
|
91
|
+
let composerColumn = 0;
|
|
92
|
+
let notice = '';
|
|
93
|
+
let completionIndex = 0;
|
|
94
|
+
let completionQuery = '';
|
|
95
|
+
let dismissedCompletion = '';
|
|
96
|
+
let nativeSelection = false;
|
|
97
|
+
let selection;
|
|
98
|
+
const hasSelection = () => Boolean(selection && (selection.start.x !== selection.end.x || selection.start.y !== selection.end.y));
|
|
99
|
+
const restoreThinking = () => {
|
|
100
|
+
const restored = new Map();
|
|
101
|
+
for (const message of session.messages) {
|
|
102
|
+
if (message.thinking && message.turnId)
|
|
103
|
+
restored.set(message.turnId, `${restored.get(message.turnId) ?? ''}${message.thinking}`);
|
|
104
|
+
}
|
|
105
|
+
for (const [turnId, thinking] of restored) {
|
|
106
|
+
if (!thinkingBlocks.has(turnId))
|
|
107
|
+
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'completed', thinking });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
restoreThinking();
|
|
111
|
+
const screen = blessed.screen({
|
|
112
|
+
smartCSR: true, fullUnicode: true, title: 'TokenMaw',
|
|
113
|
+
style: { bg: COLOR.background, fg: COLOR.text },
|
|
114
|
+
});
|
|
115
|
+
const screenBuffer = screen;
|
|
116
|
+
const statusbar = blessed.box({
|
|
117
|
+
parent: screen, bottom: 0, left: 0, width: '100%', height: 1, tags: true,
|
|
118
|
+
padding: { left: 1, right: 1 }, style: { bg: COLOR.background, fg: COLOR.muted },
|
|
119
|
+
});
|
|
120
|
+
const conversation = blessed.box({
|
|
121
|
+
parent: screen, top: 0, left: 0, width: '100%', bottom: 3,
|
|
122
|
+
tags: true, scrollable: true, alwaysScroll: true, keys: true, vi: true, mouse: true, autoFocus: false,
|
|
123
|
+
scrollbar: {
|
|
124
|
+
ch: '│',
|
|
125
|
+
track: { bg: COLOR.panel },
|
|
126
|
+
style: { fg: COLOR.muted },
|
|
127
|
+
},
|
|
128
|
+
padding: { left: 2, right: 2 },
|
|
129
|
+
style: { bg: COLOR.background, fg: COLOR.text },
|
|
130
|
+
});
|
|
131
|
+
const activity = blessed.list({
|
|
132
|
+
parent: screen, top: 2, right: 0, width: '28%', bottom: 2,
|
|
133
|
+
tags: true, keys: true, vi: true, mouse: true,
|
|
134
|
+
scrollable: true, padding: { left: 1, right: 1 },
|
|
135
|
+
style: {
|
|
136
|
+
bg: COLOR.background, fg: COLOR.muted,
|
|
137
|
+
selected: { bg: COLOR.elevated, fg: COLOR.accent, bold: true },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const composer = blessed.box({
|
|
141
|
+
parent: screen, bottom: 1, left: 3, width: '100%-4', height: 2,
|
|
142
|
+
input: true, keys: true, mouse: true, padding: { left: 0, right: 1 },
|
|
143
|
+
style: { bg: COLOR.background, fg: COLOR.text },
|
|
144
|
+
});
|
|
145
|
+
const divider = blessed.box({
|
|
146
|
+
parent: screen, bottom: 3, left: 1, width: '100%-2', height: 1,
|
|
147
|
+
style: { fg: COLOR.line, bg: COLOR.background },
|
|
148
|
+
});
|
|
149
|
+
const composerPrompt = blessed.box({
|
|
150
|
+
parent: screen, bottom: 1, left: 1, width: 2, height: 2,
|
|
151
|
+
content: '›', style: { bg: COLOR.background, fg: COLOR.accent },
|
|
152
|
+
});
|
|
153
|
+
const completions = blessed.list({
|
|
154
|
+
parent: screen, left: 2, bottom: 4, width: '100%-4', height: 5,
|
|
155
|
+
hidden: true, tags: true, mouse: true, keys: false, autoFocus: false,
|
|
156
|
+
padding: { left: 1, right: 1 },
|
|
157
|
+
style: { bg: COLOR.panel, fg: COLOR.muted, selected: { bg: COLOR.elevated, fg: COLOR.accent, bold: true } },
|
|
158
|
+
});
|
|
159
|
+
const activityHeader = blessed.box({
|
|
160
|
+
parent: screen, top: 0, right: 0, width: '28%', height: 2, hidden: true, tags: true,
|
|
161
|
+
padding: { left: 1, right: 1 }, style: { bg: COLOR.background, fg: COLOR.text },
|
|
162
|
+
});
|
|
163
|
+
screen.program.setMouse({ vt200Mouse: true, sgrMouse: true, utfMouse: false, cellMotion: true, allMotion: true }, true);
|
|
164
|
+
const placeComposerCursor = () => {
|
|
165
|
+
if (closed || screen.focused !== composer)
|
|
166
|
+
return;
|
|
167
|
+
const lpos = composer.lpos;
|
|
168
|
+
if (!lpos)
|
|
169
|
+
return;
|
|
170
|
+
screen.program.cursorPos(lpos.yi + Number(composer.itop) + composerRow, lpos.xi + Number(composer.ileft) + composerColumn);
|
|
171
|
+
};
|
|
172
|
+
const composerValue = () => composerChars.join('');
|
|
173
|
+
const setComposerValue = (value) => {
|
|
174
|
+
composerChars.splice(0, composerChars.length, ...Array.from(value));
|
|
175
|
+
composerCursor = composerChars.length;
|
|
176
|
+
historyIndex = undefined;
|
|
177
|
+
historyDraft = '';
|
|
178
|
+
};
|
|
179
|
+
const renderComposer = () => {
|
|
180
|
+
const width = Math.max(2, Number(screen.width) - 5);
|
|
181
|
+
const result = layoutComposer(composerValue(), composerCursor, width, (text) => Number(composer.strWidth(text)));
|
|
182
|
+
const height = Math.min(Math.max(2, result.rows.length), Math.max(2, Math.min(6, Number(screen.height) - 7)));
|
|
183
|
+
const start = Math.max(0, result.cursor.row - height + 1);
|
|
184
|
+
composer.height = height;
|
|
185
|
+
composerPrompt.height = height;
|
|
186
|
+
conversation.bottom = height + 2;
|
|
187
|
+
activity.bottom = height + 2;
|
|
188
|
+
divider.bottom = height + 1;
|
|
189
|
+
divider.setContent('─'.repeat(Math.max(0, Number(screen.width) - 2)));
|
|
190
|
+
composerRow = result.cursor.row - start;
|
|
191
|
+
composerColumn = result.cursor.column;
|
|
192
|
+
composer.setContent(result.rows.slice(start, start + height).join('\n'));
|
|
193
|
+
const query = composerValue();
|
|
194
|
+
if (query !== completionQuery) {
|
|
195
|
+
completionIndex = 0;
|
|
196
|
+
completionQuery = query;
|
|
197
|
+
}
|
|
198
|
+
const matches = query === dismissedCompletion ? [] : commandMatches(query);
|
|
199
|
+
if (!matches.length)
|
|
200
|
+
completions.hide();
|
|
201
|
+
else {
|
|
202
|
+
completions.bottom = height + 2;
|
|
203
|
+
completions.height = Math.min(matches.length, 6, Math.max(1, Number(screen.height) - height - 3));
|
|
204
|
+
completionIndex = Math.min(completionIndex, matches.length - 1);
|
|
205
|
+
completions.setItems(matches.map((item) => `{${COLOR.accent}-fg}${safe(item.name.padEnd(12))}{/${COLOR.accent}-fg} {${COLOR.muted}-fg}${safe(item.description)}{/${COLOR.muted}-fg}`));
|
|
206
|
+
completions.select(completionIndex);
|
|
207
|
+
completions.show();
|
|
208
|
+
completions.setFront();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const renderComposerFrame = () => {
|
|
212
|
+
renderComposer();
|
|
213
|
+
screen.program.hideCursor();
|
|
214
|
+
screen.render();
|
|
215
|
+
placeComposerCursor();
|
|
216
|
+
screen.program.showCursor();
|
|
217
|
+
};
|
|
218
|
+
const updateHistory = (direction) => {
|
|
219
|
+
if (inputHistory.length === 0)
|
|
220
|
+
return;
|
|
221
|
+
if (historyIndex === undefined) {
|
|
222
|
+
if (direction > 0)
|
|
223
|
+
return;
|
|
224
|
+
historyDraft = composerValue();
|
|
225
|
+
historyIndex = inputHistory.length - 1;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const next = historyIndex + direction;
|
|
229
|
+
if (next < 0)
|
|
230
|
+
return;
|
|
231
|
+
if (next >= inputHistory.length) {
|
|
232
|
+
historyIndex = undefined;
|
|
233
|
+
setComposerValue(historyDraft);
|
|
234
|
+
renderComposer();
|
|
235
|
+
renderComposerFrame();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
historyIndex = next;
|
|
239
|
+
}
|
|
240
|
+
const value = inputHistory[historyIndex] ?? '';
|
|
241
|
+
composerChars.splice(0, composerChars.length, ...Array.from(value));
|
|
242
|
+
composerCursor = composerChars.length;
|
|
243
|
+
renderComposer();
|
|
244
|
+
renderComposerFrame();
|
|
245
|
+
};
|
|
246
|
+
const handleComposerKey = (ch, key) => {
|
|
247
|
+
if (closed)
|
|
248
|
+
return;
|
|
249
|
+
const matches = completions.hidden ? [] : commandMatches(composerValue());
|
|
250
|
+
if (matches.length && (key.name === 'up' || key.name === 'down')) {
|
|
251
|
+
completionIndex = (completionIndex + (key.name === 'up' ? -1 : 1) + matches.length) % matches.length;
|
|
252
|
+
renderComposerFrame();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (matches.length && key.name === 'escape') {
|
|
256
|
+
dismissedCompletion = composerValue();
|
|
257
|
+
renderComposerFrame();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (matches.length && (key.name === 'tab' || ((!key.meta) && (key.name === 'enter' || key.name === 'return')))) {
|
|
261
|
+
setComposerValue(matches[completionIndex].name + (key.name === 'tab' ? ' ' : ''));
|
|
262
|
+
if (key.name === 'tab')
|
|
263
|
+
renderComposerFrame();
|
|
264
|
+
else
|
|
265
|
+
void submit();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if ((key.name === 'enter' || key.name === 'return') && !key.meta) {
|
|
269
|
+
void submit();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if ((key.meta && (key.name === 'enter' || key.name === 'return')) || (key.ctrl && key.name === 'j')) {
|
|
273
|
+
composerChars.splice(composerCursor++, 0, '\n');
|
|
274
|
+
}
|
|
275
|
+
else if (key.name === 'left')
|
|
276
|
+
composerCursor = Math.max(0, composerCursor - 1);
|
|
277
|
+
else if (key.name === 'right')
|
|
278
|
+
composerCursor = Math.min(composerChars.length, composerCursor + 1);
|
|
279
|
+
else if (key.name === 'home' || (key.ctrl && key.name === 'a'))
|
|
280
|
+
composerCursor = 0;
|
|
281
|
+
else if (key.name === 'end' || (key.ctrl && key.name === 'e'))
|
|
282
|
+
composerCursor = composerChars.length;
|
|
283
|
+
else if (key.name === 'up') {
|
|
284
|
+
updateHistory(-1);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
else if (key.name === 'down') {
|
|
288
|
+
updateHistory(1);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
else if (key.name === 'backspace') {
|
|
292
|
+
if (composerCursor > 0)
|
|
293
|
+
composerChars.splice(composerCursor - 1, 1);
|
|
294
|
+
composerCursor = Math.max(0, composerCursor - 1);
|
|
295
|
+
}
|
|
296
|
+
else if (key.name === 'delete') {
|
|
297
|
+
composerChars.splice(composerCursor, 1);
|
|
298
|
+
}
|
|
299
|
+
else if (key.ctrl && key.name === 'u') {
|
|
300
|
+
composerChars.splice(0, composerChars.length);
|
|
301
|
+
composerCursor = 0;
|
|
302
|
+
}
|
|
303
|
+
else if (ch && !key.ctrl && !key.meta && !/^[\x00-\x1f\x7f]$/.test(ch)) {
|
|
304
|
+
composerChars.splice(composerCursor, 0, ...Array.from(ch));
|
|
305
|
+
composerCursor += Array.from(ch).length;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
historyIndex = undefined;
|
|
311
|
+
historyDraft = '';
|
|
312
|
+
renderComposer();
|
|
313
|
+
renderComposerFrame();
|
|
314
|
+
};
|
|
315
|
+
const startSpinner = () => {
|
|
316
|
+
if (spinnerTimer || pendingTurns.size === 0)
|
|
317
|
+
return;
|
|
318
|
+
spinnerTimer = setInterval(() => {
|
|
319
|
+
if (pendingTurns.size === 0 || closed) {
|
|
320
|
+
stopSpinner();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (nativeSelection || hasSelection())
|
|
324
|
+
return;
|
|
325
|
+
spinnerFrame = (spinnerFrame + 1) % 20;
|
|
326
|
+
conversationDirty = true;
|
|
327
|
+
scheduleRefresh();
|
|
328
|
+
}, 800);
|
|
329
|
+
spinnerTimer.unref?.();
|
|
330
|
+
};
|
|
331
|
+
const stopSpinner = () => {
|
|
332
|
+
if (!spinnerTimer)
|
|
333
|
+
return;
|
|
334
|
+
clearInterval(spinnerTimer);
|
|
335
|
+
spinnerTimer = undefined;
|
|
336
|
+
};
|
|
337
|
+
const focusComposer = () => {
|
|
338
|
+
if (closed)
|
|
339
|
+
return;
|
|
340
|
+
composerPinned = true;
|
|
341
|
+
if (screen.focused !== composer)
|
|
342
|
+
composer.focus();
|
|
343
|
+
renderComposerFrame();
|
|
344
|
+
};
|
|
345
|
+
const choose = (title, items) => new Promise((resolveChoice) => {
|
|
346
|
+
composerPinned = false;
|
|
347
|
+
const renderedItems = items.map((item) => typeof item === 'string'
|
|
348
|
+
? safe(item)
|
|
349
|
+
: `{bold}${safe(item.label)}{/bold}${item.detail ? ` {${COLOR.muted}-fg}${safe(item.detail)}{/${COLOR.muted}-fg}` : ''}`);
|
|
350
|
+
const itemWidths = items.map((item) => typeof item === 'string' ? item.length : Math.max(item.label.length, item.detail?.length ?? 0));
|
|
351
|
+
const width = Math.min(Math.max(28, Number(screen.width) - 4), 82, Math.max(36, ...itemWidths.map((item) => item + 8)));
|
|
352
|
+
const height = Math.min(items.length + 4, 22, Math.max(6, Number(screen.height) - 2));
|
|
353
|
+
const modal = blessed.box({
|
|
354
|
+
parent: screen, top: 'center', left: 'center', width, height,
|
|
355
|
+
tags: true, style: { bg: COLOR.modal, fg: COLOR.text },
|
|
356
|
+
});
|
|
357
|
+
const heading = blessed.box({
|
|
358
|
+
parent: modal, top: 0, left: 1, right: 1, height: 1, tags: true,
|
|
359
|
+
content: `{bold}${safe(title)}{/bold}`, style: { bg: COLOR.modal, fg: COLOR.text },
|
|
360
|
+
});
|
|
361
|
+
const rule = blessed.box({
|
|
362
|
+
parent: modal, top: 1, left: 1, right: 1, height: 1, tags: true,
|
|
363
|
+
content: `{${COLOR.modalRule}-fg}${'─'.repeat(Math.max(0, width - 2))}{/${COLOR.modalRule}-fg}`,
|
|
364
|
+
style: { bg: COLOR.modal, fg: COLOR.modalRule },
|
|
365
|
+
});
|
|
366
|
+
const list = blessed.list({
|
|
367
|
+
parent: modal, top: 2, left: 1, right: 1, bottom: 1, items: renderedItems, tags: true, keys: true, vi: true, mouse: true,
|
|
368
|
+
scrollable: true, style: { bg: COLOR.modal, fg: COLOR.text, selected: { bg: COLOR.modal, fg: COLOR.accent, bold: true } },
|
|
369
|
+
});
|
|
370
|
+
let done = false;
|
|
371
|
+
const finish = (value) => {
|
|
372
|
+
if (done)
|
|
373
|
+
return;
|
|
374
|
+
done = true;
|
|
375
|
+
modal.destroy();
|
|
376
|
+
composerPinned = true;
|
|
377
|
+
focusComposer();
|
|
378
|
+
resolveChoice(value);
|
|
379
|
+
};
|
|
380
|
+
list.on('select', (_item, index) => finish(index));
|
|
381
|
+
list.key(['escape', 'q'], () => finish(-1));
|
|
382
|
+
list.focus();
|
|
383
|
+
void heading;
|
|
384
|
+
void rule;
|
|
385
|
+
screen.render();
|
|
386
|
+
});
|
|
387
|
+
const ask = (label, initial = '', secret = false) => new Promise((resolveAnswer) => {
|
|
388
|
+
composerPinned = false;
|
|
389
|
+
const width = Math.min(76, Math.max(28, Number(screen.width) - 4));
|
|
390
|
+
const modal = blessed.box({
|
|
391
|
+
parent: screen, top: 'center', left: 'center', width, height: 7,
|
|
392
|
+
style: { bg: COLOR.modal, fg: COLOR.text },
|
|
393
|
+
});
|
|
394
|
+
blessed.box({
|
|
395
|
+
parent: modal, top: 0, left: 1, right: 1, height: 1, tags: true,
|
|
396
|
+
content: `{bold}${safe(label)}{/bold}`, style: { bg: COLOR.modal, fg: COLOR.text },
|
|
397
|
+
});
|
|
398
|
+
blessed.box({
|
|
399
|
+
parent: modal, top: 1, left: 1, right: 1, height: 1,
|
|
400
|
+
content: '─'.repeat(Math.max(0, width - 2)), style: { bg: COLOR.modal, fg: COLOR.modalRule },
|
|
401
|
+
});
|
|
402
|
+
const input = blessed.textbox({
|
|
403
|
+
parent: modal, top: 3, left: 1, right: 1, height: 1,
|
|
404
|
+
inputOnFocus: true, keys: true, mouse: true, censor: secret,
|
|
405
|
+
style: { bg: COLOR.modal, fg: COLOR.text, focus: { bg: COLOR.modal, fg: COLOR.text } },
|
|
406
|
+
});
|
|
407
|
+
blessed.box({
|
|
408
|
+
parent: modal, bottom: 0, left: 1, right: 1, height: 1,
|
|
409
|
+
content: 'Enter confirm · Esc cancel', style: { bg: COLOR.modal, fg: COLOR.modalRule },
|
|
410
|
+
});
|
|
411
|
+
input.setValue(initial);
|
|
412
|
+
let done = false;
|
|
413
|
+
const finish = (value) => {
|
|
414
|
+
if (done)
|
|
415
|
+
return;
|
|
416
|
+
done = true;
|
|
417
|
+
modal.destroy();
|
|
418
|
+
composerPinned = true;
|
|
419
|
+
focusComposer();
|
|
420
|
+
resolveAnswer(value.trim());
|
|
421
|
+
};
|
|
422
|
+
input.on('submit', (value) => finish(String(value ?? '')));
|
|
423
|
+
input.on('cancel', () => finish(''));
|
|
424
|
+
input.key('escape', () => finish(''));
|
|
425
|
+
input.focus();
|
|
426
|
+
input.readInput();
|
|
427
|
+
screen.render();
|
|
428
|
+
});
|
|
429
|
+
const instances = () => [...instanceCache.values()];
|
|
430
|
+
const depthPrefix = (instance) => {
|
|
431
|
+
const status = STATUS_PRESENTATION[instance.status];
|
|
432
|
+
const color = TONE_COLOR[status.tone];
|
|
433
|
+
return `${' '.repeat(instance.depth)}{${color}-fg}${status.icon}{/${color}-fg}`;
|
|
434
|
+
};
|
|
435
|
+
const renderStatus = () => {
|
|
436
|
+
const active = instances().filter((item) => item.status === 'running' || item.status === 'waiting' || item.status === 'queued').length;
|
|
437
|
+
const activityText = active ? `${active} active` : 'Ready';
|
|
438
|
+
const width = Math.max(1, Number(screen.width) - 2);
|
|
439
|
+
const left = `maw ${activeModel}`;
|
|
440
|
+
const right = Number(screen.width) >= 78 ? `${activityText} · Ctrl+K commands` : activityText;
|
|
441
|
+
const gap = width - Number(statusbar.strWidth(left)) - Number(statusbar.strWidth(right));
|
|
442
|
+
statusbar.setContent(gap >= 3
|
|
443
|
+
? `{bold}maw{/bold} {${COLOR.muted}-fg}${safe(activeModel)}{/${COLOR.muted}-fg}${' '.repeat(gap)}{${active ? COLOR.accent : COLOR.muted}-fg}${safe(right)}{/${active ? COLOR.accent : COLOR.muted}-fg}`
|
|
444
|
+
: `{bold}maw{/bold}${active ? ` {${COLOR.accent}-fg}${active} active{/${COLOR.accent}-fg}` : ''}`);
|
|
445
|
+
};
|
|
446
|
+
const conversationAtBottom = () => {
|
|
447
|
+
const viewportHeight = Math.max(0, Number(conversation.height) - Number(conversation.iheight));
|
|
448
|
+
const scrollHeight = conversation.getScrollHeight();
|
|
449
|
+
if (scrollHeight <= viewportHeight)
|
|
450
|
+
return true;
|
|
451
|
+
return conversation.childBase >= scrollHeight - viewportHeight - 1;
|
|
452
|
+
};
|
|
453
|
+
// Conversation transcript buffer for the frame in flight. lineCursor tracks
|
|
454
|
+
// the total number of rendered lines (split-aware) pushed so far, giving
|
|
455
|
+
// O(1) anchors for click-to-expand hit-testing instead of rescanning the
|
|
456
|
+
// whole buffer per entry.
|
|
457
|
+
let conversationLines = [];
|
|
458
|
+
let lineCursor = 0;
|
|
459
|
+
const pushConversationLine = (line) => {
|
|
460
|
+
lineCursor += line.split('\n').length;
|
|
461
|
+
conversationLines.push(line);
|
|
462
|
+
};
|
|
463
|
+
const renderConversation = () => {
|
|
464
|
+
if (!conversationDirty)
|
|
465
|
+
return;
|
|
466
|
+
const previousScrollOffset = conversationScrollOffset;
|
|
467
|
+
const shouldFollowOutput = conversationFollowOutput;
|
|
468
|
+
conversationLines = [];
|
|
469
|
+
lineCursor = 0;
|
|
470
|
+
const screenWidth = typeof screen.width === 'number' ? screen.width : 80;
|
|
471
|
+
const metrics = tuiLayout(screenWidth, activityVisible);
|
|
472
|
+
const markdownCols = Math.max(10, Math.min(120, metrics.conversationWidth - metrics.horizontalPadding * 2 - 2));
|
|
473
|
+
thinkingBlockLines.clear();
|
|
474
|
+
const welcomeVisible = !session.messages.length && !streams.size && thinkingBlocks.size === 0;
|
|
475
|
+
if (welcomeVisible) {
|
|
476
|
+
for (const line of renderWelcome(Number(conversation.width) - Number(conversation.iwidth) - 1, Number(conversation.height) - Number(conversation.iheight), Number(screen.height), welcomeFrame))
|
|
477
|
+
pushConversationLine(line);
|
|
478
|
+
if (!welcomeTimer) {
|
|
479
|
+
welcomeTimer = setInterval(() => {
|
|
480
|
+
if (nativeSelection || hasSelection() || screen.focused !== composer)
|
|
481
|
+
return;
|
|
482
|
+
welcomeFrame = (welcomeFrame + 1) % 80;
|
|
483
|
+
scheduleRefresh();
|
|
484
|
+
}, 50);
|
|
485
|
+
welcomeTimer.unref?.();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
else if (welcomeTimer) {
|
|
489
|
+
clearInterval(welcomeTimer);
|
|
490
|
+
welcomeTimer = undefined;
|
|
491
|
+
}
|
|
492
|
+
const renderedBlocks = new Set();
|
|
493
|
+
if (session.timeline) {
|
|
494
|
+
const { entries: visibleTimeline, omitted } = visibleTimelineEntries(session.timeline);
|
|
495
|
+
if (omitted) {
|
|
496
|
+
pushConversationLine(`{${COLOR.subtle}-fg} ${omitted} earlier activity entries omitted from this view{/${COLOR.subtle}-fg}`);
|
|
497
|
+
pushConversationLine('');
|
|
498
|
+
}
|
|
499
|
+
for (const entry of visibleTimeline) {
|
|
500
|
+
if (entry.kind === 'message') {
|
|
501
|
+
pushConversationLine('');
|
|
502
|
+
if (entry.role === 'user') {
|
|
503
|
+
pushConversationLine(`{${COLOR.accent}-fg}{bold}You{/bold}{/${COLOR.accent}-fg}`);
|
|
504
|
+
pushConversationLine(safe(entry.content));
|
|
505
|
+
}
|
|
506
|
+
else if (entry.role === 'system') {
|
|
507
|
+
pushConversationLine(`{${COLOR.warning}-fg}! ${safe(entry.content)}{/${COLOR.warning}-fg}`);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
511
|
+
pushConversationLine(renderTuiMarkdown(entry.content, markdownCols));
|
|
512
|
+
}
|
|
513
|
+
pushConversationLine('');
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const expanded = thinkingBlocks.get(entry.id)?.expanded ?? false;
|
|
517
|
+
const previous = thinkingBlocks.get(entry.id);
|
|
518
|
+
const block = { turnId: entry.id, expanded, content: previous?.content ?? [],
|
|
519
|
+
status: entry.status === 'running' ? 'active' : 'completed',
|
|
520
|
+
thinking: entry.kind === 'thinking' ? entry.content : previous?.thinking,
|
|
521
|
+
startedAt: previous?.startedAt ?? (entry.status === 'running' ? Date.now() : undefined),
|
|
522
|
+
finishedAt: entry.status === 'running' ? undefined : previous?.finishedAt };
|
|
523
|
+
thinkingBlocks.set(entry.id, block);
|
|
524
|
+
if (entry.kind === 'thinking') {
|
|
525
|
+
renderThinkingBlock(block);
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
const headerLine = lineCursor;
|
|
529
|
+
thinkingBlockLines.set(entry.id, { headerLine });
|
|
530
|
+
latestThinkingTurnId = entry.id;
|
|
531
|
+
const agent = entry.instanceId ? instanceCache.get(entry.instanceId)?.agentId : undefined;
|
|
532
|
+
const state = entry.status === 'running'
|
|
533
|
+
? STATUS_PRESENTATION.running
|
|
534
|
+
: entry.status === 'failed'
|
|
535
|
+
? STATUS_PRESENTATION.failed
|
|
536
|
+
: entry.status === 'cancelled'
|
|
537
|
+
? STATUS_PRESENTATION.cancelled
|
|
538
|
+
: STATUS_PRESENTATION.idle;
|
|
539
|
+
const color = TONE_COLOR[state.tone];
|
|
540
|
+
const presentation = toolPresentation(entry.tool ?? '', entry.input);
|
|
541
|
+
const owner = agent && agent !== 'main' ? `${agent} · ` : '';
|
|
542
|
+
const detail = presentation.detail ? ` ${oneLine(presentation.detail, Math.max(18, markdownCols - presentation.label.length - owner.length - 12))}` : '';
|
|
543
|
+
pushConversationLine(`{${color}-fg}${expanded ? '▼' : '▶'} ${state.icon}{/${color}-fg} {${COLOR.muted}-fg}${safe(owner)}${safe(presentation.label)}${safe(detail)}{/${COLOR.muted}-fg}`);
|
|
544
|
+
if (expanded) {
|
|
545
|
+
if (entry.input) {
|
|
546
|
+
pushConversationLine(` {${COLOR.subtle}-fg}Input{/${COLOR.subtle}-fg}`);
|
|
547
|
+
for (const line of safe(entry.input).split('\n'))
|
|
548
|
+
pushConversationLine(` ${line}`);
|
|
549
|
+
}
|
|
550
|
+
pushConversationLine(` {${COLOR.subtle}-fg}${entry.status === 'running' ? 'Output · running' : 'Output'}{/${COLOR.subtle}-fg}`);
|
|
551
|
+
for (const line of renderTuiMarkdown(entry.content || 'Waiting for output…', Math.max(10, markdownCols - 2)).split('\n'))
|
|
552
|
+
pushConversationLine(` ${line}`);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
const patch = toolDiff(entry.tool ?? '', entry.content);
|
|
556
|
+
if (patch)
|
|
557
|
+
pushConversationLine(renderTuiMarkdown(diffPreview(patch), markdownCols));
|
|
558
|
+
if (entry.status === 'failed')
|
|
559
|
+
pushConversationLine(` {${COLOR.error}-fg}${safe(oneLine(entry.content, markdownCols - 2))}{/${COLOR.error}-fg}`);
|
|
560
|
+
}
|
|
561
|
+
pushConversationLine('');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
for (const message of session.timeline ? [] : session.messages) {
|
|
566
|
+
const user = message.role === 'user';
|
|
567
|
+
const content = message.role === 'assistant'
|
|
568
|
+
? renderTuiMarkdown(message.content, markdownCols)
|
|
569
|
+
: safe(message.content);
|
|
570
|
+
pushConversationLine('');
|
|
571
|
+
if (user) {
|
|
572
|
+
pushConversationLine(`{${COLOR.accent}-fg}{bold}You{/bold}{/${COLOR.accent}-fg}`);
|
|
573
|
+
pushConversationLine(content);
|
|
574
|
+
}
|
|
575
|
+
else if (message.role === 'assistant') {
|
|
576
|
+
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
577
|
+
pushConversationLine(content);
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
pushConversationLine(`{${COLOR.warning}-fg}! ${content}{/${COLOR.warning}-fg}`);
|
|
581
|
+
}
|
|
582
|
+
pushConversationLine('');
|
|
583
|
+
if (message.role === 'user' && message.turnId && thinkingBlocks.has(message.turnId)) {
|
|
584
|
+
renderedBlocks.add(message.turnId);
|
|
585
|
+
renderThinkingBlock(thinkingBlocks.get(message.turnId));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (!session.timeline && pendingTurns.size > 0) {
|
|
589
|
+
const turnId = [...pendingTurns][0];
|
|
590
|
+
if (!thinkingBlocks.has(turnId)) {
|
|
591
|
+
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
592
|
+
}
|
|
593
|
+
if (!renderedBlocks.has(turnId)) {
|
|
594
|
+
renderThinkingBlock(thinkingBlocks.get(turnId));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const [turnId, text] of session.timeline ? [] : streams.entries()) {
|
|
598
|
+
if (!text.trim())
|
|
599
|
+
continue;
|
|
600
|
+
pushConversationLine('');
|
|
601
|
+
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
602
|
+
pushConversationLine(renderTuiMarkdown(text, markdownCols));
|
|
603
|
+
pushConversationLine('');
|
|
604
|
+
}
|
|
605
|
+
if (notice) {
|
|
606
|
+
pushConversationLine('');
|
|
607
|
+
const noticeColor = /^Error\b|failed/i.test(notice) ? COLOR.error : COLOR.warning;
|
|
608
|
+
pushConversationLine(`{${noticeColor}-fg}! ${safe(notice)}{/${noticeColor}-fg}`);
|
|
609
|
+
pushConversationLine('');
|
|
610
|
+
}
|
|
611
|
+
restoringConversationScroll = true;
|
|
612
|
+
try {
|
|
613
|
+
conversation.setContent(conversationLines.join('\n'));
|
|
614
|
+
if (welcomeVisible) {
|
|
615
|
+
conversation.resetScroll();
|
|
616
|
+
}
|
|
617
|
+
else if (shouldFollowOutput) {
|
|
618
|
+
conversation.scroll(conversation.getScrollHeight(), true);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
conversation.scroll(Math.max(0, previousScrollOffset) - conversation.childBase, true);
|
|
622
|
+
}
|
|
623
|
+
conversationScrollOffset = conversation.childBase;
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
restoringConversationScroll = false;
|
|
627
|
+
}
|
|
628
|
+
conversationFollowOutput = shouldFollowOutput;
|
|
629
|
+
conversationDirty = false;
|
|
630
|
+
};
|
|
631
|
+
const renderThinkingBlock = (block) => {
|
|
632
|
+
const headerLine = lineCursor;
|
|
633
|
+
const toggle = block.expanded ? '▼' : '▶';
|
|
634
|
+
const icon = block.status === 'active'
|
|
635
|
+
? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][spinnerFrame % 10]
|
|
636
|
+
: toggle;
|
|
637
|
+
const color = block.status === 'active' ? COLOR.accent : COLOR.muted;
|
|
638
|
+
const label = block.thinking
|
|
639
|
+
? (block.status === 'active' ? 'Thinking' : 'Thought')
|
|
640
|
+
: (block.status === 'active' ? 'Working' : 'Activity');
|
|
641
|
+
const duration = elapsedLabel(block.startedAt, block.finishedAt);
|
|
642
|
+
const durationText = duration ? ` ${duration}` : '';
|
|
643
|
+
if (block.status === 'active') {
|
|
644
|
+
const scanLabel = `{${COLOR.accent}-fg}${label}{/${COLOR.accent}-fg}`;
|
|
645
|
+
pushConversationLine(`{${color}-fg}${toggle} ${icon}{/${color}-fg} ${scanLabel}{${COLOR.subtle}-fg}${durationText}{/${COLOR.subtle}-fg}`);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
pushConversationLine(`{${color}-fg}${icon} ${label}${durationText}{/${color}-fg}`);
|
|
649
|
+
}
|
|
650
|
+
thinkingBlockLines.set(block.turnId, { headerLine });
|
|
651
|
+
latestThinkingTurnId = block.turnId;
|
|
652
|
+
if (block.expanded) {
|
|
653
|
+
if (block.thinking) {
|
|
654
|
+
for (const line of safe(block.thinking).split('\n'))
|
|
655
|
+
pushConversationLine(` ${line}`);
|
|
656
|
+
pushConversationLine('');
|
|
657
|
+
}
|
|
658
|
+
const content = block.content.length > 0 ? block.content : block.thinking ? [] : ['Waiting for activity…'];
|
|
659
|
+
for (const c of content) {
|
|
660
|
+
const rendered = c.includes('```diff\n')
|
|
661
|
+
? renderTuiMarkdown(c, Math.max(10, Number(conversation.width) - 8))
|
|
662
|
+
: safe(c);
|
|
663
|
+
for (const line of rendered.split('\n'))
|
|
664
|
+
pushConversationLine(` ${line}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
pushConversationLine('');
|
|
668
|
+
};
|
|
669
|
+
const renderActivity = () => {
|
|
670
|
+
if (!activityDirty)
|
|
671
|
+
return;
|
|
672
|
+
const current = instances();
|
|
673
|
+
const activeCount = current.filter((item) => ['running', 'waiting', 'queued'].includes(item.status)).length;
|
|
674
|
+
activityHeader.setContent(`{bold}Agents{/bold}{${COLOR.muted}-fg}${activeCount ? ` ${activeCount} active` : ''}{/${COLOR.muted}-fg}\n{${COLOR.subtle}-fg}${'─'.repeat(Math.max(0, Number(activity.width) - 2))}{/${COLOR.subtle}-fg}`);
|
|
675
|
+
activity.setItems(current.map((instance) => {
|
|
676
|
+
const state = STATUS_PRESENTATION[instance.status];
|
|
677
|
+
const summary = instance.lastError || activityLog.get(instance.instanceId)?.at(-1) || instance.lastOutput;
|
|
678
|
+
return `${depthPrefix(instance)} {bold}${safe(instance.agentId)}{/bold} {${TONE_COLOR[state.tone]}-fg}${state.label}{/${TONE_COLOR[state.tone]}-fg}\n${' '.repeat(instance.depth + 1)}{${COLOR.muted}-fg}${safe(oneLine(summary, Math.max(16, Number(activity.width) - instance.depth * 2 - 4)) || 'No recent activity')}{/${COLOR.muted}-fg}`;
|
|
679
|
+
}));
|
|
680
|
+
activityDirty = false;
|
|
681
|
+
};
|
|
682
|
+
const layout = () => {
|
|
683
|
+
const metrics = tuiLayout(Number(screen.width), activityVisible);
|
|
684
|
+
const layoutKey = `${metrics.activity}:${metrics.activityWidth}:${metrics.conversationWidth}:${metrics.horizontalPadding}:${Number(screen.height)}`;
|
|
685
|
+
if (layoutKey !== lastLayoutKey) {
|
|
686
|
+
lastLayoutKey = layoutKey;
|
|
687
|
+
conversationDirty = true;
|
|
688
|
+
activityDirty = true;
|
|
689
|
+
}
|
|
690
|
+
const conversationBox = conversation;
|
|
691
|
+
conversationBox.padding.left = metrics.horizontalPadding;
|
|
692
|
+
conversationBox.padding.right = metrics.horizontalPadding;
|
|
693
|
+
if (metrics.activity !== 'hidden') {
|
|
694
|
+
activity.show();
|
|
695
|
+
activityHeader.show();
|
|
696
|
+
activity.width = metrics.activityWidth;
|
|
697
|
+
activityHeader.width = metrics.activityWidth;
|
|
698
|
+
conversation.width = metrics.conversationWidth;
|
|
699
|
+
if (metrics.activity === 'overlay') {
|
|
700
|
+
activity.setFront();
|
|
701
|
+
activityHeader.setFront();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
activity.hide();
|
|
706
|
+
activityHeader.hide();
|
|
707
|
+
conversation.width = '100%';
|
|
708
|
+
}
|
|
709
|
+
composer.width = '100%-4';
|
|
710
|
+
composerPrompt.left = 1;
|
|
711
|
+
};
|
|
712
|
+
// Streaming chunks, spinner ticks, and animation frames can fire many times
|
|
713
|
+
// per macrotask; coalesce them into at most one full repaint per tick.
|
|
714
|
+
let refreshScheduled = false;
|
|
715
|
+
const scheduleRefresh = () => {
|
|
716
|
+
if (refreshScheduled || closed)
|
|
717
|
+
return;
|
|
718
|
+
refreshScheduled = true;
|
|
719
|
+
setImmediate(() => {
|
|
720
|
+
refreshScheduled = false;
|
|
721
|
+
refresh();
|
|
722
|
+
});
|
|
723
|
+
};
|
|
724
|
+
const refresh = () => {
|
|
725
|
+
if (closed)
|
|
726
|
+
return;
|
|
727
|
+
if (hasSelection())
|
|
728
|
+
return;
|
|
729
|
+
layout();
|
|
730
|
+
renderComposer();
|
|
731
|
+
renderStatus();
|
|
732
|
+
renderConversation();
|
|
733
|
+
renderActivity();
|
|
734
|
+
const composerFocused = composerPinned;
|
|
735
|
+
if (composerFocused && screen.focused !== composer)
|
|
736
|
+
composer.focus();
|
|
737
|
+
if (composerFocused)
|
|
738
|
+
screen.program.hideCursor();
|
|
739
|
+
screen.render();
|
|
740
|
+
if (composerFocused) {
|
|
741
|
+
placeComposerCursor();
|
|
742
|
+
screen.program.showCursor();
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
const applyModel = async (alias) => {
|
|
746
|
+
const resolved = options.resolveModel(alias);
|
|
747
|
+
if (!resolved.config.model)
|
|
748
|
+
throw new Error('Selected model is not configured.');
|
|
749
|
+
await options.persistModelSelection?.(alias);
|
|
750
|
+
runtime.setDefaultModel(alias);
|
|
751
|
+
await runtime.setSessionDefaultModel(sessionId, alias);
|
|
752
|
+
activeModel = resolved.name;
|
|
753
|
+
refresh();
|
|
754
|
+
};
|
|
755
|
+
const openModel = async () => {
|
|
756
|
+
const config = options.configManager.getConfig();
|
|
757
|
+
const aliases = [...new Set([...(config.model ? [config.model] : []), ...Object.keys(config.models ?? {}), ...(options.modelAliases ?? [])])];
|
|
758
|
+
if (!aliases.length) {
|
|
759
|
+
await openProvider();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const index = await choose('Model', aliases.map((alias) => {
|
|
763
|
+
const entry = config.models?.[alias];
|
|
764
|
+
return {
|
|
765
|
+
label: `${alias}${alias === activeModel ? ' ✓' : ''}`,
|
|
766
|
+
detail: entry ? `${providerName(entry)} · ${entry.model}` : 'Session model',
|
|
767
|
+
};
|
|
768
|
+
}));
|
|
769
|
+
if (index >= 0)
|
|
770
|
+
await applyModel(aliases[index]);
|
|
771
|
+
};
|
|
772
|
+
const addProvider = async () => {
|
|
773
|
+
const providerIndex = await choose('Add provider', PROVIDERS.map((provider) => provider.label));
|
|
774
|
+
if (providerIndex < 0)
|
|
775
|
+
return;
|
|
776
|
+
const provider = PROVIDERS[providerIndex];
|
|
777
|
+
const baseUrl = await ask('Base URL', provider.baseUrl);
|
|
778
|
+
if (!baseUrl)
|
|
779
|
+
return;
|
|
780
|
+
let apiKey;
|
|
781
|
+
if (provider.needsKey) {
|
|
782
|
+
const envName = provider.id === 'openrouter' ? 'OPENROUTER_API_KEY' : provider.id === 'anthropic' ? 'ANTHROPIC_API_KEY' : provider.id === 'opencode-go' ? 'OPENCODE_API_KEY' : 'OPENAI_API_KEY';
|
|
783
|
+
apiKey = await ask(`API key · blank uses ${envName}`, '', true) || process.env[envName];
|
|
784
|
+
if (!apiKey)
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
else if (provider.id === 'custom') {
|
|
788
|
+
apiKey = await ask('API key · optional', '', true) || undefined;
|
|
789
|
+
}
|
|
790
|
+
const remoteResult = await fetchRemoteModels(baseUrl, apiKey, provider.backend);
|
|
791
|
+
const remoteModels = remoteResult.models;
|
|
792
|
+
if (remoteResult.error)
|
|
793
|
+
notice = 'Could not load models. Enter a model name manually.';
|
|
794
|
+
let model;
|
|
795
|
+
if (remoteModels.length) {
|
|
796
|
+
const index = await choose('Provider model', [...remoteModels, 'Type manually…']);
|
|
797
|
+
if (index < 0)
|
|
798
|
+
return;
|
|
799
|
+
model = index < remoteModels.length ? remoteModels[index] : await ask('Provider model name');
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
model = await ask('Provider model name');
|
|
803
|
+
}
|
|
804
|
+
if (!model)
|
|
805
|
+
return;
|
|
806
|
+
const suggested = provider.id === 'openrouter' ? model.split('/').at(-1) : model.split(':')[0];
|
|
807
|
+
const alias = await ask('Local alias', suggested);
|
|
808
|
+
if (!alias)
|
|
809
|
+
return;
|
|
810
|
+
const contextRaw = await ask('Context window · optional');
|
|
811
|
+
const contextWindow = Number.parseInt(contextRaw, 10);
|
|
812
|
+
const current = options.configManager.getConfig();
|
|
813
|
+
if (current.models?.[alias] && await choose(`Replace ${alias}?`, ['Replace', 'Cancel']) !== 0)
|
|
814
|
+
return;
|
|
815
|
+
const next = {
|
|
816
|
+
...current,
|
|
817
|
+
model: current.model || alias,
|
|
818
|
+
models: {
|
|
819
|
+
...(current.models ?? {}),
|
|
820
|
+
[alias]: {
|
|
821
|
+
model, baseUrl, backend: provider.backend,
|
|
822
|
+
...(apiKey ? { apiKey } : {}),
|
|
823
|
+
...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow } : {}),
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
await options.configManager.saveConfig(next);
|
|
828
|
+
if (!current.model)
|
|
829
|
+
await applyModel(alias);
|
|
830
|
+
};
|
|
831
|
+
const removeProvider = async () => {
|
|
832
|
+
const current = options.configManager.getConfig();
|
|
833
|
+
const entries = Object.entries(current.models ?? {});
|
|
834
|
+
if (!entries.length)
|
|
835
|
+
return;
|
|
836
|
+
const index = await choose('Remove provider', entries.map(([alias, config]) => `${alias} · ${providerName(config)} · ${config.model}`));
|
|
837
|
+
if (index < 0)
|
|
838
|
+
return;
|
|
839
|
+
const alias = entries[index][0];
|
|
840
|
+
if (await choose(`Remove ${alias}?`, ['Remove', 'Cancel']) !== 0)
|
|
841
|
+
return;
|
|
842
|
+
const models = { ...(current.models ?? {}) };
|
|
843
|
+
delete models[alias];
|
|
844
|
+
const model = current.model === alias ? Object.keys(models)[0] : current.model;
|
|
845
|
+
await options.configManager.saveConfig({ ...current, model, models });
|
|
846
|
+
if (activeModel === alias && model)
|
|
847
|
+
await applyModel(model);
|
|
848
|
+
};
|
|
849
|
+
const openProvider = async () => {
|
|
850
|
+
const entries = Object.entries(options.configManager.getConfig().models ?? {});
|
|
851
|
+
const actions = [
|
|
852
|
+
...entries.map(([alias, config]) => ({ label: `${alias} · ${providerName(config)} · ${config.model}${alias === activeModel ? ' ✓' : ''}`, action: 'select', alias })),
|
|
853
|
+
{ label: '+ Add provider', action: 'add', alias: '' },
|
|
854
|
+
...(entries.length ? [{ label: '− Remove provider', action: 'remove', alias: '' }] : []),
|
|
855
|
+
];
|
|
856
|
+
const index = await choose('Provider', actions.map((item) => {
|
|
857
|
+
if (item.action === 'add')
|
|
858
|
+
return { label: 'Add provider', detail: 'Configure a model endpoint' };
|
|
859
|
+
if (item.action === 'remove')
|
|
860
|
+
return { label: 'Remove provider', detail: 'Delete a configured endpoint' };
|
|
861
|
+
const config = options.configManager.getConfig().models?.[item.alias];
|
|
862
|
+
return { label: `${item.alias}${item.alias === activeModel ? ' ✓' : ''}`, detail: config ? `${providerName(config)} · ${config.model}` : undefined };
|
|
863
|
+
}));
|
|
864
|
+
if (index < 0)
|
|
865
|
+
return;
|
|
866
|
+
const selected = actions[index];
|
|
867
|
+
if (selected.action === 'select')
|
|
868
|
+
await applyModel(selected.alias);
|
|
869
|
+
else if (selected.action === 'add')
|
|
870
|
+
await addProvider();
|
|
871
|
+
else
|
|
872
|
+
await removeProvider();
|
|
873
|
+
};
|
|
874
|
+
const showAgents = async () => {
|
|
875
|
+
const specs = runtime.listAgentSpecs();
|
|
876
|
+
const index = await choose('Agent specs', specs.map((spec) => ({
|
|
877
|
+
label: spec.id,
|
|
878
|
+
detail: `${spec.scope} · ${spec.model ?? 'inherit'} · ${oneLine(spec.description, 42)}`,
|
|
879
|
+
})));
|
|
880
|
+
if (index < 0)
|
|
881
|
+
return;
|
|
882
|
+
const spec = specs[index];
|
|
883
|
+
await choose(spec.id, [
|
|
884
|
+
`source ${spec.source}`,
|
|
885
|
+
`model ${spec.model ?? 'inherit'}`,
|
|
886
|
+
`tools ${spec.tools.join(', ') || 'none'}`,
|
|
887
|
+
`agents ${spec.agents.join(', ') || 'none'}`,
|
|
888
|
+
'Close',
|
|
889
|
+
]);
|
|
890
|
+
};
|
|
891
|
+
const switchSession = async (id) => {
|
|
892
|
+
const next = await runtime.openSession(id);
|
|
893
|
+
sessionId = id;
|
|
894
|
+
session = next;
|
|
895
|
+
instanceCache.clear();
|
|
896
|
+
for (const instance of runtime.listInstances(id))
|
|
897
|
+
instanceCache.set(instance.instanceId, instance);
|
|
898
|
+
activityDirty = true;
|
|
899
|
+
activeModel = session.defaultModel ?? options.modelName;
|
|
900
|
+
streams.clear();
|
|
901
|
+
activityLog.clear();
|
|
902
|
+
thinkingBlocks.clear();
|
|
903
|
+
thinkingBlockLines.clear();
|
|
904
|
+
pendingTurns.clear();
|
|
905
|
+
notice = '';
|
|
906
|
+
conversationDirty = true;
|
|
907
|
+
conversationFollowOutput = true;
|
|
908
|
+
conversationScrollOffset = 0;
|
|
909
|
+
restoreThinking();
|
|
910
|
+
for (const instance of instances()) {
|
|
911
|
+
if (instance.instanceId === session.mainInstanceId && instance.activeTurnId)
|
|
912
|
+
pendingTurns.add(instance.activeTurnId);
|
|
913
|
+
}
|
|
914
|
+
startSpinner();
|
|
915
|
+
refresh();
|
|
916
|
+
};
|
|
917
|
+
const openSessions = async () => {
|
|
918
|
+
const sessions = await runtime.listSessions();
|
|
919
|
+
const index = await choose('Sessions', [
|
|
920
|
+
...sessions.map((item) => ({ label: item.sessionId, detail: `${item.messages} messages` })),
|
|
921
|
+
{ label: 'New session', detail: 'Start a blank conversation' },
|
|
922
|
+
]);
|
|
923
|
+
if (index < 0)
|
|
924
|
+
return;
|
|
925
|
+
await switchSession(index === sessions.length ? `session-${Date.now()}` : sessions[index].sessionId);
|
|
926
|
+
};
|
|
927
|
+
const showActivityDetail = async () => {
|
|
928
|
+
const instance = instances()[selectedActivityIndex];
|
|
929
|
+
if (!instance)
|
|
930
|
+
return;
|
|
931
|
+
const log = activityLog.get(instance.instanceId) ?? [];
|
|
932
|
+
await choose(`${instance.agentId} · ${instance.instanceId.slice(0, 8)}`, [
|
|
933
|
+
`status ${instance.status}`,
|
|
934
|
+
`source ${runtime.registry.get(instance.agentId)?.source ?? ''}`,
|
|
935
|
+
...log.slice(-12),
|
|
936
|
+
...(instance.lastOutput ? [`output ${oneLine(instance.lastOutput, 120)}`] : []),
|
|
937
|
+
...(instance.lastError ? [`error ${oneLine(instance.lastError, 120)}`] : []),
|
|
938
|
+
'Close',
|
|
939
|
+
]);
|
|
940
|
+
};
|
|
941
|
+
const command = async (raw) => {
|
|
942
|
+
const [name = '', ...args] = raw.slice(1).trim().split(/\s+/);
|
|
943
|
+
switch (name.toLowerCase()) {
|
|
944
|
+
case 'provider':
|
|
945
|
+
await openProvider();
|
|
946
|
+
break;
|
|
947
|
+
case 'model':
|
|
948
|
+
await openModel();
|
|
949
|
+
break;
|
|
950
|
+
case 'agents':
|
|
951
|
+
await showAgents();
|
|
952
|
+
break;
|
|
953
|
+
case 'sessions':
|
|
954
|
+
await openSessions();
|
|
955
|
+
break;
|
|
956
|
+
case 'new':
|
|
957
|
+
await switchSession(`session-${Date.now()}`);
|
|
958
|
+
break;
|
|
959
|
+
case 'clear':
|
|
960
|
+
await runtime.clearSession(sessionId);
|
|
961
|
+
session.messages = [];
|
|
962
|
+
session.timeline = [];
|
|
963
|
+
instanceCache.clear();
|
|
964
|
+
for (const instance of runtime.listInstances(sessionId))
|
|
965
|
+
instanceCache.set(instance.instanceId, instance);
|
|
966
|
+
activityDirty = true;
|
|
967
|
+
streams.clear();
|
|
968
|
+
thinkingBlocks.clear();
|
|
969
|
+
thinkingBlockLines.clear();
|
|
970
|
+
pendingTurns.clear();
|
|
971
|
+
notice = '';
|
|
972
|
+
conversationDirty = true;
|
|
973
|
+
refresh();
|
|
974
|
+
break;
|
|
975
|
+
case 'cancel': {
|
|
976
|
+
if (!args[0]) {
|
|
977
|
+
await runtime.cancelSession(sessionId);
|
|
978
|
+
notice = 'Stopped. Send a message to continue.';
|
|
979
|
+
refresh();
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
const target = instances().find((item) => item.instanceId === args[0] || item.instanceId.startsWith(args[0] ?? ''));
|
|
983
|
+
const main = runtime.getInstance(session.mainInstanceId);
|
|
984
|
+
if (target && main && target.instanceId !== main.instanceId)
|
|
985
|
+
await runtime.cancelAgent(main.instanceId, target.instanceId);
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
case 'compact': {
|
|
989
|
+
try {
|
|
990
|
+
notice = await runtime.compactInstance(session.mainInstanceId, { focus: args.length ? args.join(' ') : undefined });
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
notice = `Compact failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
994
|
+
}
|
|
995
|
+
refresh();
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
case 'help':
|
|
999
|
+
await commandPalette();
|
|
1000
|
+
break;
|
|
1001
|
+
case 'select':
|
|
1002
|
+
setMouseInteraction(false);
|
|
1003
|
+
break;
|
|
1004
|
+
case 'mouse':
|
|
1005
|
+
setMouseInteraction(nativeSelection);
|
|
1006
|
+
break;
|
|
1007
|
+
case 'exit':
|
|
1008
|
+
case 'quit':
|
|
1009
|
+
close();
|
|
1010
|
+
break;
|
|
1011
|
+
default: await choose('Unknown command', [`/${name} is not available`, 'Close']);
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
const commandPalette = async () => {
|
|
1015
|
+
const actions = [
|
|
1016
|
+
{ label: 'Provider', detail: 'Manage model endpoints' },
|
|
1017
|
+
{ label: 'Model', detail: 'Choose the session model' },
|
|
1018
|
+
{ label: 'Agent specs', detail: 'Inspect effective roles and permissions' },
|
|
1019
|
+
{ label: 'Sessions', detail: 'Open a saved conversation' },
|
|
1020
|
+
{ label: 'New session', detail: 'Start a blank conversation' },
|
|
1021
|
+
{ label: 'Clear conversation', detail: 'Remove messages from this session' },
|
|
1022
|
+
{ label: 'Compact context', detail: 'Archive older model context' },
|
|
1023
|
+
{ label: 'Toggle activity', detail: 'Show or hide the agent tree' },
|
|
1024
|
+
{ label: 'Exit', detail: 'Close TokenMaw' },
|
|
1025
|
+
];
|
|
1026
|
+
const index = await choose('Command palette', actions);
|
|
1027
|
+
if (index === 0)
|
|
1028
|
+
await openProvider();
|
|
1029
|
+
if (index === 1)
|
|
1030
|
+
await openModel();
|
|
1031
|
+
if (index === 2)
|
|
1032
|
+
await showAgents();
|
|
1033
|
+
if (index === 3)
|
|
1034
|
+
await openSessions();
|
|
1035
|
+
if (index === 4)
|
|
1036
|
+
await switchSession(`session-${Date.now()}`);
|
|
1037
|
+
if (index === 5)
|
|
1038
|
+
await command('/clear');
|
|
1039
|
+
if (index === 6)
|
|
1040
|
+
await command('/compact');
|
|
1041
|
+
if (index === 7) {
|
|
1042
|
+
activityVisible = !activityVisible;
|
|
1043
|
+
refresh();
|
|
1044
|
+
}
|
|
1045
|
+
if (index === 8)
|
|
1046
|
+
close();
|
|
1047
|
+
};
|
|
1048
|
+
const submit = async () => {
|
|
1049
|
+
const value = composerValue().trim();
|
|
1050
|
+
if (!value) {
|
|
1051
|
+
focusComposer();
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (!inputHistory.includes(value))
|
|
1055
|
+
inputHistory.push(value);
|
|
1056
|
+
setComposerValue('');
|
|
1057
|
+
focusComposer();
|
|
1058
|
+
try {
|
|
1059
|
+
if (value.startsWith('/'))
|
|
1060
|
+
await command(value);
|
|
1061
|
+
else {
|
|
1062
|
+
notice = '';
|
|
1063
|
+
conversationFollowOutput = true;
|
|
1064
|
+
const turnId = await runtime.submitMessage(sessionId, value);
|
|
1065
|
+
const main = runtime.getInstance(session.mainInstanceId);
|
|
1066
|
+
if (main && ['running', 'queued', 'waiting'].includes(main.status))
|
|
1067
|
+
pendingTurns.add(turnId);
|
|
1068
|
+
if (pendingTurns.has(turnId) && !thinkingBlocks.has(turnId)) {
|
|
1069
|
+
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
1070
|
+
}
|
|
1071
|
+
startSpinner();
|
|
1072
|
+
refresh();
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
catch (error) {
|
|
1076
|
+
await choose('Error', [error instanceof Error ? error.message : String(error), 'Close']);
|
|
1077
|
+
}
|
|
1078
|
+
refresh();
|
|
1079
|
+
focusComposer();
|
|
1080
|
+
};
|
|
1081
|
+
const onEvent = (event) => {
|
|
1082
|
+
const eventSession = 'sessionId' in event ? event.sessionId : 'instance' in event ? event.instance.sessionId : 'instanceId' in event && event.instanceId ? instanceCache.get(event.instanceId)?.sessionId ?? runtime.getInstance(event.instanceId)?.sessionId : event.type === 'session_opened' ? event.session.sessionId : undefined;
|
|
1083
|
+
if (eventSession && eventSession !== sessionId)
|
|
1084
|
+
return;
|
|
1085
|
+
if (event.type === 'instance_created' || event.type === 'instance_updated') {
|
|
1086
|
+
instanceCache.set(event.instance.instanceId, event.instance);
|
|
1087
|
+
activityDirty = true;
|
|
1088
|
+
}
|
|
1089
|
+
if (event.type === 'user_message' && !session.messages.some((message) => message.messageId === event.message.messageId)) {
|
|
1090
|
+
session.messages.push({ ...event.message });
|
|
1091
|
+
}
|
|
1092
|
+
if (event.type === 'assistant_message' && !session.messages.some((message) => message.messageId === event.message.messageId)) {
|
|
1093
|
+
session.messages.push({ ...event.message });
|
|
1094
|
+
}
|
|
1095
|
+
recordTimeline(session, event);
|
|
1096
|
+
if (event.type === 'thinking_delta') {
|
|
1097
|
+
conversationDirty = true;
|
|
1098
|
+
const block = thinkingBlocks.get(event.turnId) ?? [...thinkingBlocks.values()].reverse().find((item) => item.status === 'active') ?? thinkingBlocks.get(latestThinkingTurnId ?? '');
|
|
1099
|
+
if (block)
|
|
1100
|
+
block.thinking = `${block.thinking ?? ''}${event.text}`;
|
|
1101
|
+
}
|
|
1102
|
+
if (event.type === 'assistant_delta') {
|
|
1103
|
+
conversationDirty = true;
|
|
1104
|
+
streams.set(event.turnId, `${streams.get(event.turnId) ?? ''}${event.text}`);
|
|
1105
|
+
}
|
|
1106
|
+
if (event.type === 'assistant_message') {
|
|
1107
|
+
conversationDirty = true;
|
|
1108
|
+
streams.delete(event.message.turnId ?? '');
|
|
1109
|
+
}
|
|
1110
|
+
if (event.type === 'tool_started' || event.type === 'tool_finished') {
|
|
1111
|
+
conversationDirty = true;
|
|
1112
|
+
activityDirty = true;
|
|
1113
|
+
const log = activityLog.get(event.instanceId) ?? [];
|
|
1114
|
+
log.push(`${event.tool} ${oneLine(event.type === 'tool_started' ? event.input : event.output, 120)}`);
|
|
1115
|
+
activityLog.set(event.instanceId, log.slice(-100));
|
|
1116
|
+
}
|
|
1117
|
+
if (event.type === 'tool_started') {
|
|
1118
|
+
const block = thinkingBlocks.get(event.turnId) ?? [...thinkingBlocks.values()].reverse().find(b => b.status === 'active') ?? thinkingBlocks.get(latestThinkingTurnId ?? '');
|
|
1119
|
+
if (block) {
|
|
1120
|
+
const agent = runtime.getInstance(event.instanceId)?.agentId;
|
|
1121
|
+
block.content.push(`→ ${agent && agent !== 'main' ? `${agent} · ` : ''}${event.tool} ${oneLine(event.input, 60)}`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (event.type === 'tool_finished') {
|
|
1125
|
+
const block = thinkingBlocks.get(event.turnId) ?? [...thinkingBlocks.values()].reverse().find(b => b.status === 'active') ?? thinkingBlocks.get(latestThinkingTurnId ?? '');
|
|
1126
|
+
if (block) {
|
|
1127
|
+
block.content.push(`✓ ${event.tool} ${oneLine(event.output, 60)}`);
|
|
1128
|
+
const patch = toolDiff(event.tool, event.output);
|
|
1129
|
+
if (patch)
|
|
1130
|
+
block.content.push(patch);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (event.type === 'context_compacted' && event.sessionId === sessionId) {
|
|
1134
|
+
conversationDirty = true;
|
|
1135
|
+
const label = event.instanceId === session.mainInstanceId
|
|
1136
|
+
? 'Context compacted'
|
|
1137
|
+
: `${runtime.getInstance(event.instanceId)?.agentId ?? 'agent'} context compacted`;
|
|
1138
|
+
notice = `${label} (${event.reason}): archived ${event.archivedMessages} messages, ${event.charsBefore} → ${event.charsAfter} chars.`;
|
|
1139
|
+
}
|
|
1140
|
+
if (event.type === 'runtime_error') {
|
|
1141
|
+
conversationDirty = true;
|
|
1142
|
+
if (event.sessionId === sessionId && event.instanceId === session.mainInstanceId) {
|
|
1143
|
+
notice = `Error: ${event.error}`;
|
|
1144
|
+
pendingTurns.clear();
|
|
1145
|
+
stopSpinner();
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (event.type === 'instance_updated' && event.instance.instanceId === session.mainInstanceId
|
|
1149
|
+
&& event.instance.activeTurnId && ['running', 'waiting'].includes(event.instance.status)) {
|
|
1150
|
+
const turnId = event.instance.activeTurnId;
|
|
1151
|
+
conversationDirty = true;
|
|
1152
|
+
pendingTurns.clear();
|
|
1153
|
+
pendingTurns.add(turnId);
|
|
1154
|
+
for (const [id, block] of thinkingBlocks) {
|
|
1155
|
+
if (id !== turnId) {
|
|
1156
|
+
block.status = 'completed';
|
|
1157
|
+
block.finishedAt = Date.now();
|
|
1158
|
+
streams.delete(id);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (!thinkingBlocks.has(turnId))
|
|
1162
|
+
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
1163
|
+
startSpinner();
|
|
1164
|
+
}
|
|
1165
|
+
if (event.type === 'instance_updated'
|
|
1166
|
+
&& event.instance.sessionId === sessionId
|
|
1167
|
+
&& event.instance.instanceId === session.mainInstanceId
|
|
1168
|
+
&& ['idle', 'failed', 'cancelled'].includes(event.instance.status)) {
|
|
1169
|
+
const block = [...thinkingBlocks.values()].find(b => b.status === 'active');
|
|
1170
|
+
conversationDirty = true;
|
|
1171
|
+
if (block) {
|
|
1172
|
+
block.status = 'completed';
|
|
1173
|
+
block.finishedAt = Date.now();
|
|
1174
|
+
}
|
|
1175
|
+
pendingTurns.clear();
|
|
1176
|
+
streams.clear();
|
|
1177
|
+
stopSpinner();
|
|
1178
|
+
}
|
|
1179
|
+
scheduleRefresh();
|
|
1180
|
+
};
|
|
1181
|
+
const unsubscribe = runtime.subscribe(onEvent);
|
|
1182
|
+
let finish;
|
|
1183
|
+
const done = new Promise((resolveDone) => { finish = resolveDone; });
|
|
1184
|
+
function close() {
|
|
1185
|
+
if (closed)
|
|
1186
|
+
return;
|
|
1187
|
+
closed = true;
|
|
1188
|
+
stopSpinner();
|
|
1189
|
+
if (welcomeTimer)
|
|
1190
|
+
clearInterval(welcomeTimer);
|
|
1191
|
+
unsubscribe();
|
|
1192
|
+
screen.destroy();
|
|
1193
|
+
finish?.();
|
|
1194
|
+
}
|
|
1195
|
+
composer.on('keypress', handleComposerKey);
|
|
1196
|
+
completions.on('select', (_item, index) => {
|
|
1197
|
+
const selected = commandMatches(composerValue())[index];
|
|
1198
|
+
if (selected) {
|
|
1199
|
+
setComposerValue(selected.name + ' ');
|
|
1200
|
+
focusComposer();
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
const runAction = (action) => {
|
|
1204
|
+
void action().catch((error) => { notice = `Error: ${error instanceof Error ? error.message : String(error)}`; refresh(); });
|
|
1205
|
+
};
|
|
1206
|
+
activity.key(['enter', 'space'], () => { runAction(showActivityDetail); });
|
|
1207
|
+
activity.on('select item', (_item, index) => { selectedActivityIndex = index; });
|
|
1208
|
+
const openCommandPalette = () => {
|
|
1209
|
+
if (screen.focused === composer || screen.focused === conversation || screen.focused === activity)
|
|
1210
|
+
runAction(commandPalette);
|
|
1211
|
+
};
|
|
1212
|
+
const toggleActivity = () => {
|
|
1213
|
+
activityVisible = !activityVisible;
|
|
1214
|
+
refresh();
|
|
1215
|
+
focusComposer();
|
|
1216
|
+
};
|
|
1217
|
+
screen.key(['C-k'], openCommandPalette);
|
|
1218
|
+
screen.key(['C-b'], toggleActivity);
|
|
1219
|
+
// Conversation and activity panes are mouse-scrollable. Focus returns to the
|
|
1220
|
+
// composer after generation or when a modal closes.
|
|
1221
|
+
const toggleThinkingBlock = (turnId) => {
|
|
1222
|
+
const block = thinkingBlocks.get(turnId);
|
|
1223
|
+
if (!block)
|
|
1224
|
+
return;
|
|
1225
|
+
conversationFollowOutput = false;
|
|
1226
|
+
conversationScrollOffset = conversation.childBase;
|
|
1227
|
+
block.expanded = !block.expanded;
|
|
1228
|
+
conversationDirty = true;
|
|
1229
|
+
refresh();
|
|
1230
|
+
};
|
|
1231
|
+
const focusConversation = () => {
|
|
1232
|
+
// Browsing is independent of keyboard focus: typing always goes to the draft.
|
|
1233
|
+
focusComposer();
|
|
1234
|
+
};
|
|
1235
|
+
const setMouseInteraction = (enabled) => {
|
|
1236
|
+
selection = undefined;
|
|
1237
|
+
nativeSelection = !enabled;
|
|
1238
|
+
if (nativeSelection)
|
|
1239
|
+
screen.program.disableMouse();
|
|
1240
|
+
else {
|
|
1241
|
+
screen.program.enableMouse();
|
|
1242
|
+
if (process.platform === 'win32' || screen.program.term('windows')) {
|
|
1243
|
+
screen.program.setMouse({ vt200Mouse: true, sgrMouse: true, utfMouse: false, cellMotion: true, allMotion: true }, true);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
refresh();
|
|
1247
|
+
};
|
|
1248
|
+
screen.key(['f2'], () => setMouseInteraction(nativeSelection));
|
|
1249
|
+
composer.on('click', focusComposer);
|
|
1250
|
+
conversation.on('click', (data) => {
|
|
1251
|
+
if (hasSelection())
|
|
1252
|
+
return;
|
|
1253
|
+
focusConversation();
|
|
1254
|
+
if (!data || data.y === undefined)
|
|
1255
|
+
return;
|
|
1256
|
+
const lpos = conversation.lpos;
|
|
1257
|
+
if (!lpos)
|
|
1258
|
+
return;
|
|
1259
|
+
const contentTop = lpos.yi + Number(conversation.itop);
|
|
1260
|
+
const relY = data.y - contentTop;
|
|
1261
|
+
const row = Math.floor(relY) + conversation.childBase;
|
|
1262
|
+
// RenderThinkingBlock records indices in the raw `lines` array, but blessed
|
|
1263
|
+
// re-parses/wraps content into `_clines`. Translate via ftor so the click
|
|
1264
|
+
// still hits the header even when a preceding long line was wrapped.
|
|
1265
|
+
const clines = conversation._clines;
|
|
1266
|
+
const renderedLine = (real) => {
|
|
1267
|
+
const bucket = clines?.ftor?.[real];
|
|
1268
|
+
return bucket && bucket.length > 0 ? Number(bucket[0]) : real;
|
|
1269
|
+
};
|
|
1270
|
+
for (const [turnId, pos] of thinkingBlockLines) {
|
|
1271
|
+
if (row === renderedLine(pos.headerLine)) {
|
|
1272
|
+
toggleThinkingBlock(turnId);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
screen.key(['C-y'], () => {
|
|
1278
|
+
if (latestThinkingTurnId)
|
|
1279
|
+
toggleThinkingBlock(latestThinkingTurnId);
|
|
1280
|
+
});
|
|
1281
|
+
activity.on('click', focusComposer);
|
|
1282
|
+
conversation.on('mousedown', focusConversation);
|
|
1283
|
+
conversation.on('wheelup', () => {
|
|
1284
|
+
selection = undefined;
|
|
1285
|
+
focusConversation();
|
|
1286
|
+
conversationFollowOutput = false;
|
|
1287
|
+
conversationScrollOffset = conversation.childBase;
|
|
1288
|
+
refresh();
|
|
1289
|
+
});
|
|
1290
|
+
conversation.on('wheeldown', () => {
|
|
1291
|
+
selection = undefined;
|
|
1292
|
+
focusConversation();
|
|
1293
|
+
conversationScrollOffset = conversation.childBase;
|
|
1294
|
+
conversationFollowOutput = conversationAtBottom();
|
|
1295
|
+
refresh();
|
|
1296
|
+
});
|
|
1297
|
+
conversation.on('scroll', () => {
|
|
1298
|
+
if (restoringConversationScroll)
|
|
1299
|
+
return;
|
|
1300
|
+
conversationScrollOffset = conversation.childBase;
|
|
1301
|
+
conversationFollowOutput = conversationAtBottom();
|
|
1302
|
+
});
|
|
1303
|
+
screen.key(['pageup', 'pagedown'], (_ch, key) => {
|
|
1304
|
+
selection = undefined;
|
|
1305
|
+
if (!composerPinned && screen.focused !== conversation)
|
|
1306
|
+
return;
|
|
1307
|
+
focusConversation();
|
|
1308
|
+
conversation.scroll((key.name === 'pageup' ? -1 : 1) * Math.max(1, Number(conversation.height) - 2));
|
|
1309
|
+
screen.render();
|
|
1310
|
+
});
|
|
1311
|
+
screen.key(['C-x'], () => { void command('/cancel').catch((error) => { notice = String(error); refresh(); }); });
|
|
1312
|
+
screen.key(['tab', 'escape'], () => {
|
|
1313
|
+
if (screen.focused === conversation || screen.focused === activity)
|
|
1314
|
+
focusComposer();
|
|
1315
|
+
});
|
|
1316
|
+
const orderedSelection = () => {
|
|
1317
|
+
if (!selection || !hasSelection())
|
|
1318
|
+
return;
|
|
1319
|
+
const { start, end } = selection;
|
|
1320
|
+
return start.y < end.y || (start.y === end.y && start.x <= end.x) ? [start, end] : [end, start];
|
|
1321
|
+
};
|
|
1322
|
+
conversation.on('render', () => {
|
|
1323
|
+
const range = orderedSelection();
|
|
1324
|
+
if (!range || !selection)
|
|
1325
|
+
return;
|
|
1326
|
+
const [start, end] = range;
|
|
1327
|
+
for (let y = start.y; y <= end.y; y++) {
|
|
1328
|
+
const row = screenBuffer.lines[y];
|
|
1329
|
+
if (!row)
|
|
1330
|
+
continue;
|
|
1331
|
+
const left = y === start.y ? start.x : selection.left;
|
|
1332
|
+
const right = y === end.y ? end.x : selection.right - 1;
|
|
1333
|
+
for (let x = left; x <= right; x++) {
|
|
1334
|
+
const cell = row[x];
|
|
1335
|
+
if (cell)
|
|
1336
|
+
cell[0] = (cell[0] & ~0x3ffff) | (0 << 9) | 6;
|
|
1337
|
+
}
|
|
1338
|
+
row.dirty = true;
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
// Handle the raw protocol before Blessed: SGR drag reports (button 32) are
|
|
1342
|
+
// misclassified as repeated presses by Blessed 0.1.x on some terminals.
|
|
1343
|
+
screen.program.prependListener('mouse', (data) => {
|
|
1344
|
+
if (nativeSelection)
|
|
1345
|
+
return;
|
|
1346
|
+
const bounds = conversation.lpos;
|
|
1347
|
+
if (!bounds)
|
|
1348
|
+
return;
|
|
1349
|
+
const rawButton = Number(data.raw?.[0]);
|
|
1350
|
+
const motion = data.action === 'mousemove' || (Number.isFinite(rawButton) && (rawButton & 32) !== 0 && (rawButton & 64) === 0);
|
|
1351
|
+
if (motion && selection?.dragging) {
|
|
1352
|
+
data.action = 'mousemove';
|
|
1353
|
+
selection.end = { x: Math.max(selection.left, Math.min(selection.right - 1, data.x)), y: Math.max(selection.top, Math.min(selection.bottom - 1, data.y)) };
|
|
1354
|
+
screen.render();
|
|
1355
|
+
}
|
|
1356
|
+
else if (data.action === 'mousedown' && data.button === 'left'
|
|
1357
|
+
&& data.x >= bounds.xi + Number(conversation.ileft) && data.x < bounds.xl - (Number(conversation.iwidth) - Number(conversation.ileft)) - 1
|
|
1358
|
+
&& data.y >= bounds.yi && data.y < bounds.yl) {
|
|
1359
|
+
selection = {
|
|
1360
|
+
start: { x: data.x, y: data.y }, end: { x: data.x, y: data.y }, dragging: true,
|
|
1361
|
+
rows: screenBuffer.lines.map((row) => row.map((cell) => cell[1])),
|
|
1362
|
+
left: bounds.xi + Number(conversation.ileft), right: bounds.xl - (Number(conversation.iwidth) - Number(conversation.ileft)) - 1,
|
|
1363
|
+
top: bounds.yi, bottom: bounds.yl,
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
else if (data.action === 'mouseup' && selection?.dragging) {
|
|
1367
|
+
selection.dragging = false;
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
screen.key(['C-c'], () => {
|
|
1371
|
+
const range = orderedSelection();
|
|
1372
|
+
if (!range || !selection) {
|
|
1373
|
+
close();
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const [start, end] = range;
|
|
1377
|
+
const lines = [];
|
|
1378
|
+
for (let y = start.y; y <= end.y; y++) {
|
|
1379
|
+
const left = y === start.y ? start.x : selection.left;
|
|
1380
|
+
const right = y === end.y ? end.x + 1 : selection.right;
|
|
1381
|
+
lines.push((selection.rows[y] ?? []).slice(left, right).join('').replace(/[\x00\x03]/g, '').trimEnd());
|
|
1382
|
+
}
|
|
1383
|
+
runAction(async () => {
|
|
1384
|
+
try {
|
|
1385
|
+
await (options.copyToClipboard ?? copyText)(lines.join('\n'));
|
|
1386
|
+
}
|
|
1387
|
+
finally {
|
|
1388
|
+
selection = undefined;
|
|
1389
|
+
}
|
|
1390
|
+
refresh();
|
|
1391
|
+
focusComposer();
|
|
1392
|
+
});
|
|
1393
|
+
});
|
|
1394
|
+
composer.on('keypress', (_ch, key) => {
|
|
1395
|
+
if (hasSelection() && !(key.ctrl && key.name === 'c')) {
|
|
1396
|
+
selection = undefined;
|
|
1397
|
+
refresh();
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
screen.key(['escape'], () => { selection = undefined; refresh(); });
|
|
1401
|
+
screen.on('resize', refresh);
|
|
1402
|
+
refresh();
|
|
1403
|
+
focusComposer();
|
|
1404
|
+
await done;
|
|
1405
|
+
}
|