tokenmaw 0.3.0 → 0.4.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 +23 -2
- package/agents/coordinator.md +2 -3
- package/agents/main.md +2 -2
- package/dist/backend.js +31 -1
- package/dist/cli.js +30 -0
- package/dist/infra/tools.js +274 -28
- package/dist/markdown.js +83 -48
- package/dist/responses.js +7 -1
- package/dist/runtime/agent-registry.js +35 -5
- package/dist/runtime/agent-runtime.js +352 -21
- package/dist/runtime/agent-store.js +23 -0
- package/dist/runtime/file-lock.js +256 -0
- package/dist/runtime/locks.js +58 -38
- package/dist/runtime/session-timeline.js +32 -3
- package/dist/runtime/workspace-instances.js +109 -0
- package/dist/runtime/worktree.js +321 -0
- package/dist/ui/bracketed-paste.js +231 -0
- package/dist/ui/commands.js +12 -0
- package/dist/ui/fullscreen-tui.js +1195 -120
- package/dist/ui/markdown.js +19 -9
- package/dist/ui/scrollbar.js +370 -0
- package/dist/ui/syntax.js +3 -5
- package/dist/ui/theme.js +198 -0
- package/dist/ui/tui-design.js +78 -0
- package/dist/ui/welcome.js +144 -11
- package/docs/architecture-revision.md +1 -1
- package/package.json +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve as resolvePath, sep } from 'node:path';
|
|
1
2
|
import blessed from 'blessed';
|
|
2
3
|
import { renderTuiMarkdown, toolDiff } from './markdown.js';
|
|
3
4
|
import { resilientFetch } from '../fetch.js';
|
|
@@ -5,8 +6,15 @@ import { layoutComposer } from './composer-layout.js';
|
|
|
5
6
|
import { renderWelcome } from './welcome.js';
|
|
6
7
|
import { copyText } from './clipboard.js';
|
|
7
8
|
import { commandMatches } from './commands.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { runShellCommand } from '../infra/tools.js';
|
|
10
|
+
import { diffPreview, elapsedLabel, isWaitingForFirstToken, spinnerGlyph, STATUS_PRESENTATION, toolPresentation, tuiLayout, visibleTimelineEntries, waitingIndicatorFrame } from './tui-design.js';
|
|
11
|
+
import { attachPillScrollbar, pillScrollbarColors } from './scrollbar.js';
|
|
12
|
+
import { recordTimeline, recordShellRun } from '../runtime/session-timeline.js';
|
|
13
|
+
import { otherWorkspaceInstances } from '../runtime/workspace-instances.js';
|
|
14
|
+
import { activeTuiTheme, resolveTheme, setActiveTheme, themeNames } from './theme.js';
|
|
15
|
+
import { resetTuiMarkdownCache } from './markdown.js';
|
|
16
|
+
import { BRACKETED_PASTE_DISABLE, BRACKETED_PASTE_ENABLE, enableBracketedPaste } from './bracketed-paste.js';
|
|
17
|
+
import { WorktreeManager } from '../runtime/worktree.js';
|
|
10
18
|
const PROVIDERS = [
|
|
11
19
|
{ id: 'openai', label: 'OpenAI', backend: 'openai', baseUrl: 'https://api.openai.com/v1', needsKey: true },
|
|
12
20
|
{ id: 'openrouter', label: 'OpenRouter', backend: 'openai', baseUrl: 'https://openrouter.ai/api/v1', needsKey: true },
|
|
@@ -15,15 +23,8 @@ const PROVIDERS = [
|
|
|
15
23
|
{ id: 'ollama', label: 'Ollama · local', backend: 'ollama', baseUrl: 'http://localhost:11434', needsKey: false },
|
|
16
24
|
{ id: 'custom', label: 'Custom · OpenAI compatible', backend: 'openai', baseUrl: '', needsKey: false },
|
|
17
25
|
];
|
|
18
|
-
const COLOR =
|
|
19
|
-
|
|
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 };
|
|
26
|
+
const COLOR = () => activeTuiTheme().ui;
|
|
27
|
+
const TONE_COLOR = (tone) => COLOR()[tone];
|
|
27
28
|
function oneLine(value, max = 72) {
|
|
28
29
|
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
29
30
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
@@ -73,6 +74,16 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
73
74
|
let spinnerTimer;
|
|
74
75
|
let welcomeTimer;
|
|
75
76
|
let welcomeFrame = 0;
|
|
77
|
+
let welcomeStartedAt = 0;
|
|
78
|
+
// Terminal focus lifecycle (DECSET 1004): while the window is unfocused the
|
|
79
|
+
// periodic repaints pause — otherwise 20fps of screen updates flood the PTY
|
|
80
|
+
// and the terminal replays the backlog the moment the window regains focus.
|
|
81
|
+
let windowFocused = true;
|
|
82
|
+
let streamTimer;
|
|
83
|
+
let shellAbort;
|
|
84
|
+
let shellAnimationFrame = 0;
|
|
85
|
+
let waitingFrame = 0;
|
|
86
|
+
let lastPaintedStreamText = '';
|
|
76
87
|
const composerChars = [];
|
|
77
88
|
const inputHistory = [];
|
|
78
89
|
const pendingTurns = new Set();
|
|
@@ -80,7 +91,24 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
80
91
|
const activityLog = new Map();
|
|
81
92
|
const thinkingBlocks = new Map();
|
|
82
93
|
const thinkingBlockLines = new Map();
|
|
94
|
+
// Sticky collapse header: when an expanded block's own header has scrolled
|
|
95
|
+
// above the viewport while the block body is still on screen, the header is
|
|
96
|
+
// redrawn pinned to the first conversation row so it can always be clicked
|
|
97
|
+
// to collapse. Sticky rows do not exist in the logical content; they are
|
|
98
|
+
// inserted at the viewport top after scrolling is applied.
|
|
99
|
+
// Streaming events can batch: a thinking segment may be rendered only after
|
|
100
|
+
// it already finished, so the start time is tracked per turn, not per block.
|
|
101
|
+
const thinkingStartedAt = new Map();
|
|
102
|
+
const markThinkingStart = (turnId) => {
|
|
103
|
+
if (!thinkingStartedAt.has(turnId))
|
|
104
|
+
thinkingStartedAt.set(turnId, Date.now());
|
|
105
|
+
};
|
|
83
106
|
let latestThinkingTurnId;
|
|
107
|
+
let stickyHeader;
|
|
108
|
+
let lastStickyKey;
|
|
109
|
+
// Blessed bubbles a click from the sticky overlay up to the conversation
|
|
110
|
+
// box; the flag consumes the bubbled copy so the block is toggled once.
|
|
111
|
+
let stickyClickHandled = false;
|
|
84
112
|
let conversationFollowOutput = true;
|
|
85
113
|
let conversationScrollOffset = 0;
|
|
86
114
|
let restoringConversationScroll = false;
|
|
@@ -94,8 +122,22 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
94
122
|
let completionQuery = '';
|
|
95
123
|
let dismissedCompletion = '';
|
|
96
124
|
let nativeSelection = false;
|
|
125
|
+
// /btw side conversation state. Inside a side session, `sideParentSessionId`
|
|
126
|
+
// points at the conversation /back and Ctrl+C return to. /fork works from
|
|
127
|
+
// anywhere but never changes the mode.
|
|
128
|
+
let sideParentSessionId;
|
|
129
|
+
const isBtw = () => sideParentSessionId !== undefined;
|
|
97
130
|
let selection;
|
|
98
131
|
const hasSelection = () => Boolean(selection && (selection.start.x !== selection.end.x || selection.start.y !== selection.end.y));
|
|
132
|
+
// When the sticky row's identity or text changes, a full redraw avoids
|
|
133
|
+
// blessed CSR diff artifacts around the shifted top row.
|
|
134
|
+
const invalidateStickyIfChanged = (next) => {
|
|
135
|
+
const key = next ? `${next.turnId} :: ${next.line}` : '';
|
|
136
|
+
if (key !== lastStickyKey) {
|
|
137
|
+
lastStickyKey = key || undefined;
|
|
138
|
+
requestFullRedraw();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
99
141
|
const restoreThinking = () => {
|
|
100
142
|
const restored = new Map();
|
|
101
143
|
for (const message of session.messages) {
|
|
@@ -108,58 +150,125 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
108
150
|
}
|
|
109
151
|
};
|
|
110
152
|
restoreThinking();
|
|
153
|
+
setActiveTheme(options.configManager.getConfig().theme);
|
|
154
|
+
// Bracketed paste: the terminal wraps pasted text in `\x1b[200~ ... \x1b[201~`.
|
|
155
|
+
// The filter turns each wrapped chunk into one paste event, so line breaks
|
|
156
|
+
// inside a paste insert literally instead of being read as Enter (which
|
|
157
|
+
// used to submit the half-pasted draft).
|
|
158
|
+
const pasteInput = enableBracketedPaste(process.stdin);
|
|
111
159
|
const screen = blessed.screen({
|
|
160
|
+
input: pasteInput,
|
|
112
161
|
smartCSR: true, fullUnicode: true, title: 'TokenMaw',
|
|
113
|
-
style: { bg: COLOR.background, fg: COLOR.text },
|
|
162
|
+
style: { bg: COLOR().background, fg: COLOR().text },
|
|
114
163
|
});
|
|
164
|
+
screen.program.write(BRACKETED_PASTE_ENABLE);
|
|
165
|
+
// Blessed defers alt-buffer entry to terminfo's smcup, which is empty on
|
|
166
|
+
// several TERM entries — the app then paints into the scrollback and every
|
|
167
|
+
// animated repaint shoves the native scrollbar around. Force ?1049 so the
|
|
168
|
+
// TUI owns the alternate screen (restored on exit) regardless of terminfo.
|
|
169
|
+
screen.program.decset('1049');
|
|
170
|
+
screen.program.decset('1004');
|
|
171
|
+
let fullRedrawPending = true;
|
|
172
|
+
const requestFullRedraw = () => { fullRedrawPending = true; };
|
|
173
|
+
const renderScreen = () => {
|
|
174
|
+
if (fullRedrawPending) {
|
|
175
|
+
// Blessed's smart CSR occasionally leaves the tail of a wide/long line
|
|
176
|
+
// behind when an element shrinks or disappears. Reallocating only for
|
|
177
|
+
// structural transitions clears both its current and previous buffers.
|
|
178
|
+
screen.realloc();
|
|
179
|
+
fullRedrawPending = false;
|
|
180
|
+
}
|
|
181
|
+
screen.render();
|
|
182
|
+
};
|
|
115
183
|
const screenBuffer = screen;
|
|
116
184
|
const statusbar = blessed.box({
|
|
117
185
|
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 },
|
|
186
|
+
padding: { left: 1, right: 1 }, style: { bg: COLOR().background, fg: COLOR().muted },
|
|
119
187
|
});
|
|
120
188
|
const conversation = blessed.box({
|
|
121
189
|
parent: screen, top: 0, left: 0, width: '100%', bottom: 3,
|
|
122
190
|
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
191
|
padding: { left: 2, right: 2 },
|
|
129
|
-
style: { bg: COLOR.background, fg: COLOR.text },
|
|
192
|
+
style: { bg: COLOR().background, fg: COLOR().text },
|
|
193
|
+
});
|
|
194
|
+
// Full-width surface keeps the composer visually continuous at both edges;
|
|
195
|
+
// the editable text box is inset on top of this backdrop.
|
|
196
|
+
const composerBackdrop = blessed.box({
|
|
197
|
+
parent: screen, bottom: 1, left: 0, width: '100%', height: 2,
|
|
198
|
+
style: { bg: COLOR().composer },
|
|
130
199
|
});
|
|
131
200
|
const activity = blessed.list({
|
|
132
|
-
parent: screen, top:
|
|
201
|
+
parent: screen, top: 3, right: 0, width: '28%', bottom: 2,
|
|
133
202
|
tags: true, keys: true, vi: true, mouse: true,
|
|
134
203
|
scrollable: true, padding: { left: 1, right: 1 },
|
|
135
204
|
style: {
|
|
136
|
-
bg: COLOR.
|
|
137
|
-
selected: { bg: COLOR.elevated, fg: COLOR.accent, bold: true },
|
|
205
|
+
bg: COLOR().activity, fg: COLOR().muted,
|
|
206
|
+
selected: { bg: COLOR().elevated, fg: COLOR().accent, bold: true },
|
|
138
207
|
},
|
|
139
208
|
});
|
|
140
209
|
const composer = blessed.box({
|
|
141
210
|
parent: screen, bottom: 1, left: 3, width: '100%-4', height: 2,
|
|
142
211
|
input: true, keys: true, mouse: true, padding: { left: 0, right: 1 },
|
|
143
|
-
|
|
212
|
+
tags: true,
|
|
213
|
+
style: { bg: COLOR().composer, fg: COLOR().text },
|
|
144
214
|
});
|
|
145
215
|
const divider = blessed.box({
|
|
146
|
-
|
|
147
|
-
|
|
216
|
+
// This row is part of the composer surface. Keeping it full width makes
|
|
217
|
+
// the input area read as one continuous band instead of a boxed field
|
|
218
|
+
// separated by a decorative rule.
|
|
219
|
+
parent: screen, bottom: 3, left: 0, width: '100%', height: 1,
|
|
220
|
+
style: { fg: COLOR().composer, bg: COLOR().composer },
|
|
148
221
|
});
|
|
149
222
|
const composerPrompt = blessed.box({
|
|
150
223
|
parent: screen, bottom: 1, left: 1, width: 2, height: 2,
|
|
151
|
-
content: '›', style: { bg: COLOR.
|
|
224
|
+
content: '›', style: { bg: COLOR().composer, fg: COLOR().accent },
|
|
152
225
|
});
|
|
153
226
|
const completions = blessed.list({
|
|
154
227
|
parent: screen, left: 2, bottom: 4, width: '100%-4', height: 5,
|
|
155
228
|
hidden: true, tags: true, mouse: true, keys: false, autoFocus: false,
|
|
156
229
|
padding: { left: 1, right: 1 },
|
|
157
|
-
style: { bg: COLOR.panel, fg: COLOR.muted, selected: { bg: COLOR.elevated, fg: COLOR.accent, bold: true } },
|
|
230
|
+
style: { bg: COLOR().panel, fg: COLOR().muted, selected: { bg: COLOR().elevated, fg: COLOR().accent, bold: true } },
|
|
158
231
|
});
|
|
159
232
|
const activityHeader = blessed.box({
|
|
160
|
-
parent: screen, top: 0, right: 0, width: '28%', height:
|
|
161
|
-
padding: { left: 1, right: 1 }, style: { bg: COLOR.
|
|
233
|
+
parent: screen, top: 0, right: 0, width: '28%', height: 3, hidden: true, tags: true,
|
|
234
|
+
padding: { left: 1, right: 1 }, style: { bg: COLOR().activity, fg: COLOR().text },
|
|
162
235
|
});
|
|
236
|
+
const activityDetailScrollbar = {};
|
|
237
|
+
let activityDetail;
|
|
238
|
+
// Pill scrollbars are screen-level overlay elements; they resolve theme
|
|
239
|
+
// colors on every sync, so a theme switch needs no extra patching.
|
|
240
|
+
const pillColors = () => pillScrollbarColors(COLOR());
|
|
241
|
+
const conversationScrollbar = attachPillScrollbar(conversation, pillColors);
|
|
242
|
+
const activityScrollbar = attachPillScrollbar(activity, pillColors);
|
|
243
|
+
// Persistent widgets capture style objects at creation time; a theme switch
|
|
244
|
+
// must patch them in place so the repaint picks up the new palette.
|
|
245
|
+
const applyWidgetTheme = () => {
|
|
246
|
+
const c = COLOR();
|
|
247
|
+
statusbar.style.bg = c.background;
|
|
248
|
+
statusbar.style.fg = c.muted;
|
|
249
|
+
conversation.style.bg = c.background;
|
|
250
|
+
conversation.style.fg = c.text;
|
|
251
|
+
activity.style.bg = c.activity;
|
|
252
|
+
activity.style.fg = c.muted;
|
|
253
|
+
// blessed's List copies style.item from the constructor palette and reads
|
|
254
|
+
// it per unselected row on every render, so it must be re-created here.
|
|
255
|
+
activity.style.item = { bg: c.activity, fg: c.muted };
|
|
256
|
+
activity.style.selected = { bg: c.elevated, fg: c.accent, bold: true };
|
|
257
|
+
composer.style.bg = c.composer;
|
|
258
|
+
composer.style.fg = c.text;
|
|
259
|
+
composerBackdrop.style.bg = c.composer;
|
|
260
|
+
divider.style.fg = c.composer;
|
|
261
|
+
divider.style.bg = c.composer;
|
|
262
|
+
composerPrompt.style.bg = c.composer;
|
|
263
|
+
composerPrompt.style.fg = c.accent;
|
|
264
|
+
completions.style.bg = c.panel;
|
|
265
|
+
completions.style.fg = c.muted;
|
|
266
|
+
completions.style.item = { bg: c.panel, fg: c.muted };
|
|
267
|
+
completions.style.selected = { bg: c.elevated, fg: c.accent, bold: true };
|
|
268
|
+
activityHeader.style.bg = c.activity;
|
|
269
|
+
activityHeader.style.fg = c.text;
|
|
270
|
+
};
|
|
271
|
+
applyWidgetTheme();
|
|
163
272
|
screen.program.setMouse({ vt200Mouse: true, sgrMouse: true, utfMouse: false, cellMotion: true, allMotion: true }, true);
|
|
164
273
|
const placeComposerCursor = () => {
|
|
165
274
|
if (closed || screen.focused !== composer)
|
|
@@ -181,37 +290,51 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
181
290
|
const result = layoutComposer(composerValue(), composerCursor, width, (text) => Number(composer.strWidth(text)));
|
|
182
291
|
const height = Math.min(Math.max(2, result.rows.length), Math.max(2, Math.min(6, Number(screen.height) - 7)));
|
|
183
292
|
const start = Math.max(0, result.cursor.row - height + 1);
|
|
293
|
+
// A draft starting with `!` is shell mode: the prompt becomes `$` and the
|
|
294
|
+
// command text wears the shell color so the submit target is unambiguous.
|
|
295
|
+
const shellMode = composerValue().startsWith('!');
|
|
296
|
+
composerPrompt.setContent(shellMode ? '$' : '›');
|
|
297
|
+
composerPrompt.style.fg = shellMode ? COLOR().warning : COLOR().accent;
|
|
184
298
|
composer.height = height;
|
|
185
299
|
composerPrompt.height = height;
|
|
300
|
+
composerBackdrop.height = height;
|
|
186
301
|
conversation.bottom = height + 2;
|
|
187
302
|
activity.bottom = height + 2;
|
|
188
303
|
divider.bottom = height + 1;
|
|
189
|
-
divider.setContent('
|
|
304
|
+
divider.setContent(' '.repeat(Math.max(0, Number(screen.width))));
|
|
190
305
|
composerRow = result.cursor.row - start;
|
|
191
306
|
composerColumn = result.cursor.column;
|
|
192
|
-
|
|
307
|
+
// Rows are laid out from plain text; escape them for the tag parser so
|
|
308
|
+
// commands containing literal braces render as typed.
|
|
309
|
+
const visibleRows = result.rows.slice(start, start + height).map((row) => safe(row));
|
|
310
|
+
composer.setContent(shellMode
|
|
311
|
+
? visibleRows.map((row) => `{${COLOR().warning}-fg}${row}{/${COLOR().warning}-fg}`).join('\n')
|
|
312
|
+
: visibleRows.join('\n'));
|
|
193
313
|
const query = composerValue();
|
|
194
314
|
if (query !== completionQuery) {
|
|
195
315
|
completionIndex = 0;
|
|
196
316
|
completionQuery = query;
|
|
197
317
|
}
|
|
198
318
|
const matches = query === dismissedCompletion ? [] : commandMatches(query);
|
|
319
|
+
const completionsWereHidden = completions.hidden;
|
|
199
320
|
if (!matches.length)
|
|
200
321
|
completions.hide();
|
|
201
322
|
else {
|
|
202
323
|
completions.bottom = height + 2;
|
|
203
324
|
completions.height = Math.min(matches.length, 6, Math.max(1, Number(screen.height) - height - 3));
|
|
204
325
|
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}`));
|
|
326
|
+
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
327
|
completions.select(completionIndex);
|
|
207
328
|
completions.show();
|
|
208
329
|
completions.setFront();
|
|
209
330
|
}
|
|
331
|
+
if (completionsWereHidden !== completions.hidden)
|
|
332
|
+
requestFullRedraw();
|
|
210
333
|
};
|
|
211
334
|
const renderComposerFrame = () => {
|
|
212
335
|
renderComposer();
|
|
213
336
|
screen.program.hideCursor();
|
|
214
|
-
|
|
337
|
+
renderScreen();
|
|
215
338
|
placeComposerCursor();
|
|
216
339
|
screen.program.showCursor();
|
|
217
340
|
};
|
|
@@ -243,9 +366,26 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
243
366
|
renderComposer();
|
|
244
367
|
renderComposerFrame();
|
|
245
368
|
};
|
|
369
|
+
const insertPaste = (text) => {
|
|
370
|
+
const chars = Array.from(text);
|
|
371
|
+
composerChars.splice(composerCursor, 0, ...chars);
|
|
372
|
+
composerCursor += chars.length;
|
|
373
|
+
};
|
|
246
374
|
const handleComposerKey = (ch, key) => {
|
|
247
375
|
if (closed)
|
|
248
376
|
return;
|
|
377
|
+
if (pasteInput.pasteActive) {
|
|
378
|
+
// Paste content is literal: line breaks included, never a submit.
|
|
379
|
+
// CR is normalized to LF so browser-style CRLF pastes stay clean.
|
|
380
|
+
if (ch === '\r' || ch === '\n')
|
|
381
|
+
insertPaste('\n');
|
|
382
|
+
else if (ch && !key.ctrl && !key.meta && !/^[\x00-\x1f\x7f]$/.test(ch))
|
|
383
|
+
insertPaste(ch);
|
|
384
|
+
// Long pastes span many reads: keep repainting so the composer does
|
|
385
|
+
// not appear frozen while the paste streams in.
|
|
386
|
+
scheduleRefresh();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
249
389
|
const matches = completions.hidden ? [] : commandMatches(composerValue());
|
|
250
390
|
if (matches.length && (key.name === 'up' || key.name === 'down')) {
|
|
251
391
|
completionIndex = (completionIndex + (key.name === 'up' ? -1 : 1) + matches.length) % matches.length;
|
|
@@ -268,7 +408,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
268
408
|
if ((key.name === 'enter' || key.name === 'return') && !key.meta) {
|
|
269
409
|
void submit();
|
|
270
410
|
return;
|
|
271
|
-
}
|
|
411
|
+
} // guarded above: never fires inside a paste
|
|
272
412
|
if ((key.meta && (key.name === 'enter' || key.name === 'return')) || (key.ctrl && key.name === 'j')) {
|
|
273
413
|
composerChars.splice(composerCursor++, 0, '\n');
|
|
274
414
|
}
|
|
@@ -312,6 +452,9 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
312
452
|
renderComposer();
|
|
313
453
|
renderComposerFrame();
|
|
314
454
|
};
|
|
455
|
+
// The thinking header spins on a fixed 60ms cadence; spinnerGlyphFrame maps
|
|
456
|
+
// each tick onto an eased burst-and-pause rhythm (fast, then slow) so the
|
|
457
|
+
// animation feels alive instead of metronome-slow.
|
|
315
458
|
const startSpinner = () => {
|
|
316
459
|
if (spinnerTimer || pendingTurns.size === 0)
|
|
317
460
|
return;
|
|
@@ -320,12 +463,12 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
320
463
|
stopSpinner();
|
|
321
464
|
return;
|
|
322
465
|
}
|
|
323
|
-
if (nativeSelection || hasSelection())
|
|
466
|
+
if (nativeSelection || hasSelection() || !windowFocused)
|
|
324
467
|
return;
|
|
325
|
-
spinnerFrame
|
|
468
|
+
spinnerFrame += 1;
|
|
326
469
|
conversationDirty = true;
|
|
327
470
|
scheduleRefresh();
|
|
328
|
-
},
|
|
471
|
+
}, 60);
|
|
329
472
|
spinnerTimer.unref?.();
|
|
330
473
|
};
|
|
331
474
|
const stopSpinner = () => {
|
|
@@ -334,6 +477,47 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
334
477
|
clearInterval(spinnerTimer);
|
|
335
478
|
spinnerTimer = undefined;
|
|
336
479
|
};
|
|
480
|
+
// While a turn is live, deltas alone cannot be trusted to defeat blessed's
|
|
481
|
+
// row-diff suppression or the viewport-clamped trailing line, so a slow
|
|
482
|
+
// repaint cadence forces the growing transcript onto the screen. The same
|
|
483
|
+
// tick animates the waiting indicator shown before the first token arrives.
|
|
484
|
+
const stopStreamTimer = () => {
|
|
485
|
+
if (!streamTimer)
|
|
486
|
+
return;
|
|
487
|
+
clearInterval(streamTimer);
|
|
488
|
+
streamTimer = undefined;
|
|
489
|
+
};
|
|
490
|
+
const startStreamTimer = () => {
|
|
491
|
+
if (streamTimer || closed)
|
|
492
|
+
return;
|
|
493
|
+
streamTimer = setInterval(() => {
|
|
494
|
+
if (closed || pendingTurns.size === 0) {
|
|
495
|
+
stopStreamTimer();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// Native text selection owns the screen; never dirty or repaint under it.
|
|
499
|
+
if (nativeSelection || hasSelection() || !windowFocused)
|
|
500
|
+
return;
|
|
501
|
+
const runningEntry = [...(session.timeline ?? [])].find((entry) => entry.status === 'running' && entry.kind !== 'tool' && entry.kind !== 'shell');
|
|
502
|
+
if (streams.size > 0 || runningEntry) {
|
|
503
|
+
const liveText = [...streams.values()].join('')
|
|
504
|
+
+ [...(session.timeline ?? [])].filter((entry) => entry.status === 'running' && entry.kind === 'message').map((entry) => entry.content).join('');
|
|
505
|
+
if (liveText !== lastPaintedStreamText) {
|
|
506
|
+
// Only a real content change earns a structural repaint; plain diffs
|
|
507
|
+
// stay cheap and flicker-free.
|
|
508
|
+
fullRedrawPending = true;
|
|
509
|
+
lastPaintedStreamText = liveText;
|
|
510
|
+
conversationDirty = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
waitingFrame = (waitingFrame + 1) % 24;
|
|
515
|
+
conversationDirty = true;
|
|
516
|
+
}
|
|
517
|
+
scheduleRefresh();
|
|
518
|
+
}, 90);
|
|
519
|
+
streamTimer.unref?.();
|
|
520
|
+
};
|
|
337
521
|
const focusComposer = () => {
|
|
338
522
|
if (closed)
|
|
339
523
|
return;
|
|
@@ -342,72 +526,208 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
342
526
|
composer.focus();
|
|
343
527
|
renderComposerFrame();
|
|
344
528
|
};
|
|
345
|
-
|
|
529
|
+
/** A small clickable ✕ pinned to a modal's top-right corner. Modals already
|
|
530
|
+
* close on Escape; this gives mouse users the same affordance. */
|
|
531
|
+
const attachCloseButton = (modal, onClose) => {
|
|
532
|
+
const button = blessed.box({
|
|
533
|
+
parent: modal, top: 0, right: 0, width: 3, height: 1, tags: true, mouse: true,
|
|
534
|
+
content: ' {bold}✕{/bold} ',
|
|
535
|
+
style: { bg: COLOR().modal, fg: COLOR().muted, hover: { bg: COLOR().modal, fg: COLOR().error } },
|
|
536
|
+
});
|
|
537
|
+
button.on('click', onClose);
|
|
538
|
+
return button;
|
|
539
|
+
};
|
|
540
|
+
const choose = (title, items, options = {}) => new Promise((resolveChoice) => {
|
|
346
541
|
composerPinned = false;
|
|
347
|
-
const
|
|
542
|
+
const searchable = options.searchable === true;
|
|
543
|
+
const renderItem = (item) => typeof item === 'string'
|
|
348
544
|
? safe(item)
|
|
349
|
-
: `{bold}${safe(item.label)}{/bold}${item.detail ? ` {${COLOR.muted}-fg}${safe(item.detail)}{/${COLOR.muted}-fg}` : ''}
|
|
545
|
+
: `{bold}${safe(item.label)}{/bold}${item.detail ? ` {${COLOR().muted}-fg}${safe(item.detail)}{/${COLOR().muted}-fg}` : ''}`;
|
|
350
546
|
const itemWidths = items.map((item) => typeof item === 'string' ? item.length : Math.max(item.label.length, item.detail?.length ?? 0));
|
|
351
547
|
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));
|
|
548
|
+
const height = Math.min(items.length + (searchable ? 5 : 4), 22, Math.max(searchable ? 7 : 6, Number(screen.height) - 2));
|
|
353
549
|
const modal = blessed.box({
|
|
354
550
|
parent: screen, top: 'center', left: 'center', width, height,
|
|
355
|
-
tags: true, style: { bg: COLOR.modal, fg: COLOR.text },
|
|
551
|
+
tags: true, style: { bg: COLOR().modal, fg: COLOR().text },
|
|
356
552
|
});
|
|
357
553
|
const heading = blessed.box({
|
|
358
554
|
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 },
|
|
555
|
+
content: `{bold}${safe(title)}{/bold}`, style: { bg: COLOR().modal, fg: COLOR().text },
|
|
360
556
|
});
|
|
557
|
+
const filterRow = searchable
|
|
558
|
+
? blessed.box({
|
|
559
|
+
parent: modal, top: 1, left: 1, right: 1, height: 1, tags: true,
|
|
560
|
+
style: { bg: COLOR().modal, fg: COLOR().subtle },
|
|
561
|
+
})
|
|
562
|
+
: undefined;
|
|
361
563
|
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 },
|
|
564
|
+
parent: modal, top: searchable ? 2 : 1, left: 1, right: 1, height: 1, tags: true,
|
|
565
|
+
content: `{${COLOR().modalRule}-fg}${'─'.repeat(Math.max(0, width - 2))}{/${COLOR().modalRule}-fg}`,
|
|
566
|
+
style: { bg: COLOR().modal, fg: COLOR().modalRule },
|
|
365
567
|
});
|
|
366
568
|
const list = blessed.list({
|
|
367
|
-
parent: modal, top: 2, left: 1, right: 1, bottom: 1,
|
|
368
|
-
|
|
569
|
+
parent: modal, top: searchable ? 3 : 2, left: 1, right: 1, bottom: 1,
|
|
570
|
+
items: items.map(renderItem), tags: true, keys: true, vi: !searchable, mouse: true,
|
|
571
|
+
scrollable: true, style: { bg: COLOR().modal, fg: COLOR().text, selected: { bg: COLOR().modal, fg: COLOR().accent, bold: true } },
|
|
369
572
|
});
|
|
573
|
+
// Type-to-filter state: `currentMap` maps displayed rows back to the
|
|
574
|
+
// original items order so selection and highlight stay stable under
|
|
575
|
+
// filtering.
|
|
576
|
+
let filterText = '';
|
|
577
|
+
let currentMap = items.map((_, index) => index);
|
|
578
|
+
const renderFilterRow = () => {
|
|
579
|
+
if (!filterRow)
|
|
580
|
+
return;
|
|
581
|
+
const c = COLOR();
|
|
582
|
+
filterRow.setContent(filterText
|
|
583
|
+
? `{${c.accent}-fg}/ ${safe(filterText)}{/${c.accent}-fg}{${c.subtle}-fg}▌{/${c.subtle}-fg}`
|
|
584
|
+
: `{${c.subtle}-fg}type to filter…{/${c.subtle}-fg}`);
|
|
585
|
+
};
|
|
586
|
+
const applyFilter = () => {
|
|
587
|
+
const query = filterText.trim().toLowerCase();
|
|
588
|
+
currentMap = query
|
|
589
|
+
? items.map((_, index) => index).filter((index) => {
|
|
590
|
+
const item = items[index];
|
|
591
|
+
const haystack = typeof item === 'string' ? item : `${item.label} ${item.detail ?? ''}`;
|
|
592
|
+
return haystack.toLowerCase().includes(query);
|
|
593
|
+
})
|
|
594
|
+
: items.map((_, index) => index);
|
|
595
|
+
if (currentMap.length) {
|
|
596
|
+
list.setItems(currentMap.map((index) => renderItem(items[index])));
|
|
597
|
+
list.select(0);
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
list.setItems([`{${COLOR().subtle}-fg} no matches{/${COLOR().subtle}-fg}`]);
|
|
601
|
+
}
|
|
602
|
+
renderFilterRow();
|
|
603
|
+
screen.render();
|
|
604
|
+
};
|
|
605
|
+
if (searchable && filterRow) {
|
|
606
|
+
list.on('keypress', (ch, key) => {
|
|
607
|
+
if (done || key?.ctrl || key?.meta)
|
|
608
|
+
return;
|
|
609
|
+
if (key?.name === 'backspace') {
|
|
610
|
+
if (!filterText)
|
|
611
|
+
return;
|
|
612
|
+
filterText = filterText.slice(0, -1);
|
|
613
|
+
applyFilter();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const printable = typeof ch === 'string' && ch.length === 1 && ch >= ' ' && ch !== '\x7f';
|
|
617
|
+
if (!printable)
|
|
618
|
+
return;
|
|
619
|
+
filterText += ch;
|
|
620
|
+
applyFilter();
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
const closeButton = attachCloseButton(modal, () => finish(-1));
|
|
624
|
+
// The modal captures style objects at creation time; while a live preview
|
|
625
|
+
// swaps the active palette, re-patch it so it does not keep the palette
|
|
626
|
+
// it was opened with.
|
|
627
|
+
const restyleModal = () => {
|
|
628
|
+
const c = COLOR();
|
|
629
|
+
modal.style.bg = c.modal;
|
|
630
|
+
modal.style.fg = c.text;
|
|
631
|
+
heading.style.bg = c.modal;
|
|
632
|
+
heading.style.fg = c.text;
|
|
633
|
+
rule.style.bg = c.modal;
|
|
634
|
+
rule.style.fg = c.modalRule;
|
|
635
|
+
rule.setContent(`{${c.modalRule}-fg}${'─'.repeat(Math.max(0, width - 2))}{/${c.modalRule}-fg}`);
|
|
636
|
+
if (filterRow) {
|
|
637
|
+
filterRow.style.bg = c.modal;
|
|
638
|
+
renderFilterRow();
|
|
639
|
+
}
|
|
640
|
+
list.style.bg = c.modal;
|
|
641
|
+
list.style.fg = c.text;
|
|
642
|
+
// Row elements resolve their palette from list.style.item on render;
|
|
643
|
+
// re-create it or the picker keeps the palette it opened with.
|
|
644
|
+
list.style.item = { bg: c.modal, fg: c.text };
|
|
645
|
+
list.style.selected = { bg: c.modal, fg: c.accent, bold: true };
|
|
646
|
+
closeButton.style.bg = c.modal;
|
|
647
|
+
closeButton.style.fg = c.muted;
|
|
648
|
+
closeButton.style.hover = { bg: c.modal, fg: c.error };
|
|
649
|
+
// Respect the active filter instead of resetting to the full list.
|
|
650
|
+
if (currentMap.length)
|
|
651
|
+
list.setItems(currentMap.map((index) => renderItem(items[index])));
|
|
652
|
+
else
|
|
653
|
+
list.setItems([`{${c.subtle}-fg} no matches{/${c.subtle}-fg}`]);
|
|
654
|
+
};
|
|
370
655
|
let done = false;
|
|
371
656
|
const finish = (value) => {
|
|
372
657
|
if (done)
|
|
373
658
|
return;
|
|
374
659
|
done = true;
|
|
375
660
|
modal.destroy();
|
|
661
|
+
requestFullRedraw();
|
|
376
662
|
composerPinned = true;
|
|
377
663
|
focusComposer();
|
|
378
664
|
resolveChoice(value);
|
|
379
665
|
};
|
|
380
|
-
list.on('select', (_item, index) =>
|
|
381
|
-
|
|
666
|
+
list.on('select', (_item, index) => {
|
|
667
|
+
const mapped = typeof index === 'number' ? currentMap[index] : undefined;
|
|
668
|
+
if (typeof mapped === 'number')
|
|
669
|
+
finish(mapped);
|
|
670
|
+
});
|
|
671
|
+
// setItems() re-emits 'select item' while restoring the selection, so the
|
|
672
|
+
// preview handler must be re-entrancy guarded or it recurses forever.
|
|
673
|
+
let restyling = false;
|
|
674
|
+
list.on('select item', (_item, index) => {
|
|
675
|
+
if (done || restyling || !options.onHighlight || typeof index !== 'number' || index < 0)
|
|
676
|
+
return;
|
|
677
|
+
const mapped = currentMap[index];
|
|
678
|
+
if (typeof mapped !== 'number')
|
|
679
|
+
return;
|
|
680
|
+
restyling = true;
|
|
681
|
+
try {
|
|
682
|
+
options.onHighlight(mapped);
|
|
683
|
+
restyleModal();
|
|
684
|
+
renderScreen();
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
restyling = false;
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
if (searchable) {
|
|
691
|
+
// `q` types into the filter here, so Escape (and ✕) are the only
|
|
692
|
+
// dismissal shortcuts.
|
|
693
|
+
list.key(['escape'], () => finish(-1));
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
list.key(['escape', 'q'], () => finish(-1));
|
|
697
|
+
}
|
|
382
698
|
list.focus();
|
|
699
|
+
if (typeof options.initial === 'number' && options.initial > 0)
|
|
700
|
+
list.select(options.initial);
|
|
701
|
+
renderFilterRow();
|
|
383
702
|
void heading;
|
|
384
703
|
void rule;
|
|
385
|
-
|
|
704
|
+
renderScreen();
|
|
386
705
|
});
|
|
387
706
|
const ask = (label, initial = '', secret = false) => new Promise((resolveAnswer) => {
|
|
388
707
|
composerPinned = false;
|
|
389
708
|
const width = Math.min(76, Math.max(28, Number(screen.width) - 4));
|
|
390
709
|
const modal = blessed.box({
|
|
391
710
|
parent: screen, top: 'center', left: 'center', width, height: 7,
|
|
392
|
-
style: { bg: COLOR.modal, fg: COLOR.text },
|
|
711
|
+
style: { bg: COLOR().modal, fg: COLOR().text },
|
|
393
712
|
});
|
|
394
713
|
blessed.box({
|
|
395
714
|
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 },
|
|
715
|
+
content: `{bold}${safe(label)}{/bold}`, style: { bg: COLOR().modal, fg: COLOR().text },
|
|
397
716
|
});
|
|
398
717
|
blessed.box({
|
|
399
718
|
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 },
|
|
719
|
+
content: '─'.repeat(Math.max(0, width - 2)), style: { bg: COLOR().modal, fg: COLOR().modalRule },
|
|
401
720
|
});
|
|
402
721
|
const input = blessed.textbox({
|
|
403
722
|
parent: modal, top: 3, left: 1, right: 1, height: 1,
|
|
404
723
|
inputOnFocus: true, keys: true, mouse: true, censor: secret,
|
|
405
|
-
style: { bg: COLOR.modal, fg: COLOR.text, focus: { bg: COLOR.modal, fg: COLOR.text } },
|
|
724
|
+
style: { bg: COLOR().modal, fg: COLOR().text, focus: { bg: COLOR().modal, fg: COLOR().text } },
|
|
406
725
|
});
|
|
407
726
|
blessed.box({
|
|
408
727
|
parent: modal, bottom: 0, left: 1, right: 1, height: 1,
|
|
409
|
-
content: 'Enter confirm · Esc cancel', style: { bg: COLOR.modal, fg: COLOR.modalRule },
|
|
728
|
+
content: 'Enter confirm · Esc cancel', style: { bg: COLOR().modal, fg: COLOR().modalRule },
|
|
410
729
|
});
|
|
730
|
+
attachCloseButton(modal, () => finish(''));
|
|
411
731
|
input.setValue(initial);
|
|
412
732
|
let done = false;
|
|
413
733
|
const finish = (value) => {
|
|
@@ -415,6 +735,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
415
735
|
return;
|
|
416
736
|
done = true;
|
|
417
737
|
modal.destroy();
|
|
738
|
+
requestFullRedraw();
|
|
418
739
|
composerPinned = true;
|
|
419
740
|
focusComposer();
|
|
420
741
|
resolveAnswer(value.trim());
|
|
@@ -424,24 +745,82 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
424
745
|
input.key('escape', () => finish(''));
|
|
425
746
|
input.focus();
|
|
426
747
|
input.readInput();
|
|
427
|
-
|
|
748
|
+
renderScreen();
|
|
428
749
|
});
|
|
429
750
|
const instances = () => [...instanceCache.values()];
|
|
751
|
+
// Cross-process awareness: periodically look for other live maw instances
|
|
752
|
+
// in the same workspace so the status bar can warn before edits collide.
|
|
753
|
+
let otherInstances = [];
|
|
754
|
+
const instancePoll = setInterval(() => {
|
|
755
|
+
void otherWorkspaceInstances(runtime.workspace()).then((found) => {
|
|
756
|
+
const changed = found.length !== otherInstances.length
|
|
757
|
+
|| found.some((item, index) => item.pid !== otherInstances[index]?.pid);
|
|
758
|
+
otherInstances = found;
|
|
759
|
+
if (changed) {
|
|
760
|
+
renderStatus();
|
|
761
|
+
screen.render();
|
|
762
|
+
}
|
|
763
|
+
}).catch(() => undefined);
|
|
764
|
+
}, 15_000);
|
|
765
|
+
instancePoll.unref?.();
|
|
766
|
+
void otherWorkspaceInstances(runtime.workspace()).then((found) => {
|
|
767
|
+
otherInstances = found;
|
|
768
|
+
renderStatus();
|
|
769
|
+
}).catch(() => undefined);
|
|
430
770
|
const depthPrefix = (instance) => {
|
|
431
771
|
const status = STATUS_PRESENTATION[instance.status];
|
|
432
|
-
const color = TONE_COLOR
|
|
772
|
+
const color = TONE_COLOR(status.tone);
|
|
433
773
|
return `${' '.repeat(instance.depth)}{${color}-fg}${status.icon}{/${color}-fg}`;
|
|
434
774
|
};
|
|
435
775
|
const renderStatus = () => {
|
|
436
776
|
const active = instances().filter((item) => item.status === 'running' || item.status === 'waiting' || item.status === 'queued').length;
|
|
437
777
|
const activityText = active ? `${active} active` : 'Ready';
|
|
778
|
+
const usage = instances().reduce((total, item) => {
|
|
779
|
+
total.input += item.usage?.inputTokens ?? 0;
|
|
780
|
+
total.output += item.usage?.outputTokens ?? 0;
|
|
781
|
+
total.cached += item.usage?.cachedInputTokens ?? 0;
|
|
782
|
+
if (item.usage?.firstTokenMs !== undefined)
|
|
783
|
+
total.firstTokenMs = total.firstTokenMs === undefined ? item.usage.firstTokenMs : Math.min(total.firstTokenMs, item.usage.firstTokenMs);
|
|
784
|
+
return total;
|
|
785
|
+
}, { input: 0, output: 0, cached: 0, firstTokenMs: undefined });
|
|
786
|
+
const usageText = usage.input || usage.output ? ` · ${usage.input + usage.output} tok${usage.cached ? ` (${usage.cached} cached)` : ''}${usage.firstTokenMs !== undefined ? ` · first ${usage.firstTokenMs}ms` : ''}` : '';
|
|
438
787
|
const width = Math.max(1, Number(screen.width) - 2);
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
const
|
|
788
|
+
const home = process.env.HOME ? resolvePath(process.env.HOME) : undefined;
|
|
789
|
+
const cwd = runtime.workspace();
|
|
790
|
+
const cwdText = home && cwd.startsWith(home + sep) ? `~${cwd.slice(home.length)}` : cwd;
|
|
791
|
+
const left = `maw ${activeModel} ${cwdText}`;
|
|
792
|
+
// Surface the mode-specific Ctrl+C semantics so the double-press quit is
|
|
793
|
+
// never a surprise, and [side] marks a /btw conversation.
|
|
794
|
+
const ctrlHint = isBtw() ? 'Ctrl+C back' : 'Ctrl+C x2 quit';
|
|
795
|
+
// Cross-process state: a read-only badge when another process owns this
|
|
796
|
+
// session, and a warning when other maw instances are live in the
|
|
797
|
+
// same workspace (file conflicts are detected, not hidden).
|
|
798
|
+
const access = runtime.sessionAccess(sessionId);
|
|
799
|
+
const accessBadge = access.writable
|
|
800
|
+
? ''
|
|
801
|
+
: ` {${COLOR().error}-fg}[read-only${access.holderPid ? ` pid ${access.holderPid}` : ''}]{/${COLOR().error}-fg}`;
|
|
802
|
+
const instanceBadge = otherInstances.length
|
|
803
|
+
? ` {${COLOR().warning}-fg}⚠ ${otherInstances.length} other maw${otherInstances.length === 1 ? '' : 's'}{/${COLOR().warning}-fg}`
|
|
804
|
+
: '';
|
|
805
|
+
// A standing goal takes priority over the quit hint; the hint yields so
|
|
806
|
+
// the goal never gets truncated below its floor.
|
|
807
|
+
const right = session.goal
|
|
808
|
+
? `${activityText}${usageText}`
|
|
809
|
+
: Number(screen.width) >= 78
|
|
810
|
+
? `${activityText}${usageText} · Ctrl+K commands · ${ctrlHint}`
|
|
811
|
+
: `${activityText}${usageText} · ${ctrlHint}`;
|
|
812
|
+
const goal = session.goal;
|
|
813
|
+
// The standing goal rides the right cluster so the layout stays a single
|
|
814
|
+
// left/right split; a separate child widget would fight the statusbar's
|
|
815
|
+
// setContent-based repaint. Its budget is whatever the left and right
|
|
816
|
+
// clusters leave over, floored so it never collapses to nothing.
|
|
817
|
+
const goalBudget = Math.max(8, width - Number(statusbar.strWidth(left)) - Number(statusbar.strWidth(right)) - 4);
|
|
818
|
+
const goalText = goal ? ` ⚑ ${oneLine(goal, goalBudget)}` : '';
|
|
819
|
+
const rightText = `${right}${goalText}`;
|
|
820
|
+
const gap = width - Number(statusbar.strWidth(left)) - Number(statusbar.strWidth(rightText));
|
|
442
821
|
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(
|
|
444
|
-
: `{bold}maw{/bold}${active ? ` {${COLOR.accent}-fg}${active} active{/${COLOR.accent}-fg}` : ''}`);
|
|
822
|
+
? `{bold}maw{/bold} {${COLOR().muted}-fg}${safe(activeModel)}{/${COLOR().muted}-fg} {${COLOR().muted}-fg}${safe(cwdText)}{/${COLOR().muted}-fg}${isBtw() ? ` {${COLOR().accent}-fg}[side]{/${COLOR().accent}-fg}` : ''}${accessBadge}${instanceBadge}${' '.repeat(Math.max(0, gap))}{${active ? COLOR().accent : COLOR().muted}-fg}${safe(rightText)}{/${active ? COLOR().accent : COLOR().muted}-fg}`
|
|
823
|
+
: `{bold}maw{/bold}${goal ? ` {${COLOR().accent}-fg}⚑ ${safe(oneLine(goal, goalBudget))}{/${COLOR().accent}-fg}` : ''}${active ? ` {${COLOR().accent}-fg}${active} active{/${COLOR().accent}-fg}` : ''}${accessBadge}${instanceBadge}`);
|
|
445
824
|
};
|
|
446
825
|
const conversationAtBottom = () => {
|
|
447
826
|
const viewportHeight = Math.max(0, Number(conversation.height) - Number(conversation.iheight));
|
|
@@ -471,15 +850,25 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
471
850
|
const metrics = tuiLayout(screenWidth, activityVisible);
|
|
472
851
|
const markdownCols = Math.max(10, Math.min(120, metrics.conversationWidth - metrics.horizontalPadding * 2 - 2));
|
|
473
852
|
thinkingBlockLines.clear();
|
|
474
|
-
|
|
853
|
+
// The welcome screen yields to any conversation content — messages,
|
|
854
|
+
// streaming output, thinking, or transcript entries like shell runs and
|
|
855
|
+
// queued asides.
|
|
856
|
+
const welcomeVisible = !session.messages.length && !streams.size && thinkingBlocks.size === 0 && !(session.timeline?.length);
|
|
475
857
|
if (welcomeVisible) {
|
|
476
858
|
for (const line of renderWelcome(Number(conversation.width) - Number(conversation.iwidth) - 1, Number(conversation.height) - Number(conversation.iheight), Number(screen.height), welcomeFrame))
|
|
477
859
|
pushConversationLine(line);
|
|
478
860
|
if (!welcomeTimer) {
|
|
861
|
+
welcomeStartedAt = performance.now();
|
|
479
862
|
welcomeTimer = setInterval(() => {
|
|
480
|
-
if (nativeSelection || hasSelection() || screen.focused !== composer)
|
|
863
|
+
if (!windowFocused || nativeSelection || hasSelection() || screen.focused !== composer)
|
|
481
864
|
return;
|
|
482
|
-
|
|
865
|
+
// The frame derives from the monotonic clock instead of a counter:
|
|
866
|
+
// after sleep or background suspension the animation lands on the
|
|
867
|
+
// correct phase in one step, with no backlog of missed ticks.
|
|
868
|
+
welcomeFrame = Math.floor((performance.now() - welcomeStartedAt) / 50);
|
|
869
|
+
// The frame only reaches the screen if the conversation actually
|
|
870
|
+
// re-renders; a bare refresh would early-return on a clean buffer.
|
|
871
|
+
conversationDirty = true;
|
|
483
872
|
scheduleRefresh();
|
|
484
873
|
}, 50);
|
|
485
874
|
welcomeTimer.unref?.();
|
|
@@ -493,40 +882,70 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
493
882
|
if (session.timeline) {
|
|
494
883
|
const { entries: visibleTimeline, omitted } = visibleTimelineEntries(session.timeline);
|
|
495
884
|
if (omitted) {
|
|
496
|
-
pushConversationLine(`{${COLOR.subtle}-fg} ${omitted} earlier activity entries omitted from this view{/${COLOR.subtle}-fg}`);
|
|
885
|
+
pushConversationLine(`{${COLOR().subtle}-fg} ${omitted} earlier activity entries omitted from this view{/${COLOR().subtle}-fg}`);
|
|
497
886
|
pushConversationLine('');
|
|
498
887
|
}
|
|
499
888
|
for (const entry of visibleTimeline) {
|
|
500
889
|
if (entry.kind === 'message') {
|
|
501
890
|
pushConversationLine('');
|
|
502
891
|
if (entry.role === 'user') {
|
|
503
|
-
pushConversationLine(`{${COLOR.accent}-fg}{bold}You{/bold}{/${COLOR.accent}-fg}`);
|
|
892
|
+
pushConversationLine(`{${COLOR().accent}-fg}{bold}You{/bold}{/${COLOR().accent}-fg}`);
|
|
504
893
|
pushConversationLine(safe(entry.content));
|
|
505
894
|
}
|
|
506
895
|
else if (entry.role === 'system') {
|
|
507
|
-
pushConversationLine(`{${COLOR.warning}-fg}! ${safe(entry.content)}{/${COLOR.warning}-fg}`);
|
|
896
|
+
pushConversationLine(`{${COLOR().warning}-fg}! ${safe(entry.content)}{/${COLOR().warning}-fg}`);
|
|
508
897
|
}
|
|
509
898
|
else {
|
|
510
|
-
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
899
|
+
pushConversationLine(`{${COLOR().muted}-fg}{bold}TokenMaw{/bold}{/${COLOR().muted}-fg}`);
|
|
511
900
|
pushConversationLine(renderTuiMarkdown(entry.content, markdownCols));
|
|
512
901
|
}
|
|
513
902
|
pushConversationLine('');
|
|
514
903
|
continue;
|
|
515
904
|
}
|
|
905
|
+
if (entry.kind === 'shell') {
|
|
906
|
+
// User-typed shell run: the command line wears a dedicated color so
|
|
907
|
+
// it reads as a user action, not agent activity; the status glyph
|
|
908
|
+
// keeps its own tone. Output streams beneath as plain text.
|
|
909
|
+
pushConversationLine('');
|
|
910
|
+
const running = entry.status === 'running';
|
|
911
|
+
const commandColor = COLOR().warning;
|
|
912
|
+
const stateLabel = running
|
|
913
|
+
? waitingIndicatorFrame(shellAnimationFrame, { accent: COLOR().accent, subtle: COLOR().subtle })
|
|
914
|
+
: entry.status === 'failed'
|
|
915
|
+
? `✗ exit ${entry.exitCode ?? 1}`
|
|
916
|
+
: entry.status === 'cancelled'
|
|
917
|
+
? '× stopped'
|
|
918
|
+
: '✓';
|
|
919
|
+
const iconColor = running ? COLOR().accent
|
|
920
|
+
: entry.status === 'failed' ? COLOR().error
|
|
921
|
+
: entry.status === 'cancelled' ? COLOR().muted : COLOR().success;
|
|
922
|
+
pushConversationLine(`{${commandColor}-fg}{bold}! ${safe(entry.input ?? '')}{/bold}{/${commandColor}-fg} ${running ? stateLabel : `{${iconColor}-fg}${stateLabel}{/${iconColor}-fg}`}`);
|
|
923
|
+
const outputLines = safe(entry.content).split('\n');
|
|
924
|
+
const maxOutputLines = 400;
|
|
925
|
+
if (outputLines.length > maxOutputLines) {
|
|
926
|
+
pushConversationLine(` {${COLOR().subtle}-fg}… ${outputLines.length - maxOutputLines} earlier output lines hidden{/${COLOR().subtle}-fg}`);
|
|
927
|
+
}
|
|
928
|
+
for (const line of outputLines.slice(-maxOutputLines)) {
|
|
929
|
+
if (line.length > 0)
|
|
930
|
+
pushConversationLine(` ${line}`);
|
|
931
|
+
}
|
|
932
|
+
pushConversationLine('');
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
516
935
|
const expanded = thinkingBlocks.get(entry.id)?.expanded ?? false;
|
|
517
936
|
const previous = thinkingBlocks.get(entry.id);
|
|
518
937
|
const block = { turnId: entry.id, expanded, content: previous?.content ?? [],
|
|
519
938
|
status: entry.status === 'running' ? 'active' : 'completed',
|
|
520
939
|
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 };
|
|
940
|
+
startedAt: previous?.startedAt ?? entry.startedAt ?? thinkingStartedAt.get(entry.turnId ?? '') ?? (entry.status === 'running' ? Date.now() : undefined),
|
|
941
|
+
finishedAt: entry.status === 'running' ? undefined : previous?.finishedAt ?? entry.endedAt ?? Date.now() };
|
|
523
942
|
thinkingBlocks.set(entry.id, block);
|
|
524
943
|
if (entry.kind === 'thinking') {
|
|
525
944
|
renderThinkingBlock(block);
|
|
526
945
|
}
|
|
527
946
|
else {
|
|
528
947
|
const headerLine = lineCursor;
|
|
529
|
-
thinkingBlockLines.set(entry.id, { headerLine });
|
|
948
|
+
thinkingBlockLines.set(entry.id, { headerLine, lastLine: headerLine });
|
|
530
949
|
latestThinkingTurnId = entry.id;
|
|
531
950
|
const agent = entry.instanceId ? instanceCache.get(entry.instanceId)?.agentId : undefined;
|
|
532
951
|
const state = entry.status === 'running'
|
|
@@ -536,18 +955,18 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
536
955
|
: entry.status === 'cancelled'
|
|
537
956
|
? STATUS_PRESENTATION.cancelled
|
|
538
957
|
: STATUS_PRESENTATION.idle;
|
|
539
|
-
const color = TONE_COLOR
|
|
958
|
+
const color = TONE_COLOR(state.tone);
|
|
540
959
|
const presentation = toolPresentation(entry.tool ?? '', entry.input);
|
|
541
960
|
const owner = agent && agent !== 'main' ? `${agent} · ` : '';
|
|
542
961
|
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}`);
|
|
962
|
+
pushConversationLine(`{${color}-fg}${expanded ? '▼' : '▶'} ${state.icon}{/${color}-fg} {${COLOR().muted}-fg}${safe(owner)}${safe(presentation.label)}${safe(detail)}{/${COLOR().muted}-fg}`);
|
|
544
963
|
if (expanded) {
|
|
545
964
|
if (entry.input) {
|
|
546
|
-
pushConversationLine(` {${COLOR.subtle}-fg}Input{/${COLOR.subtle}-fg}`);
|
|
965
|
+
pushConversationLine(` {${COLOR().subtle}-fg}Input{/${COLOR().subtle}-fg}`);
|
|
547
966
|
for (const line of safe(entry.input).split('\n'))
|
|
548
967
|
pushConversationLine(` ${line}`);
|
|
549
968
|
}
|
|
550
|
-
pushConversationLine(` {${COLOR.subtle}-fg}${entry.status === 'running' ? 'Output · running' : 'Output'}{/${COLOR.subtle}-fg}`);
|
|
969
|
+
pushConversationLine(` {${COLOR().subtle}-fg}${entry.status === 'running' ? 'Output · running' : 'Output'}{/${COLOR().subtle}-fg}`);
|
|
551
970
|
for (const line of renderTuiMarkdown(entry.content || 'Waiting for output…', Math.max(10, markdownCols - 2)).split('\n'))
|
|
552
971
|
pushConversationLine(` ${line}`);
|
|
553
972
|
}
|
|
@@ -556,7 +975,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
556
975
|
if (patch)
|
|
557
976
|
pushConversationLine(renderTuiMarkdown(diffPreview(patch), markdownCols));
|
|
558
977
|
if (entry.status === 'failed')
|
|
559
|
-
pushConversationLine(` {${COLOR.error}-fg}${safe(oneLine(entry.content, markdownCols - 2))}{/${COLOR.error}-fg}`);
|
|
978
|
+
pushConversationLine(` {${COLOR().error}-fg}${safe(oneLine(entry.content, markdownCols - 2))}{/${COLOR().error}-fg}`);
|
|
560
979
|
}
|
|
561
980
|
pushConversationLine('');
|
|
562
981
|
}
|
|
@@ -569,15 +988,15 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
569
988
|
: safe(message.content);
|
|
570
989
|
pushConversationLine('');
|
|
571
990
|
if (user) {
|
|
572
|
-
pushConversationLine(`{${COLOR.accent}-fg}{bold}You{/bold}{/${COLOR.accent}-fg}`);
|
|
991
|
+
pushConversationLine(`{${COLOR().accent}-fg}{bold}You{/bold}{/${COLOR().accent}-fg}`);
|
|
573
992
|
pushConversationLine(content);
|
|
574
993
|
}
|
|
575
994
|
else if (message.role === 'assistant') {
|
|
576
|
-
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
995
|
+
pushConversationLine(`{${COLOR().muted}-fg}{bold}TokenMaw{/bold}{/${COLOR().muted}-fg}`);
|
|
577
996
|
pushConversationLine(content);
|
|
578
997
|
}
|
|
579
998
|
else {
|
|
580
|
-
pushConversationLine(`{${COLOR.warning}-fg}! ${content}{/${COLOR.warning}-fg}`);
|
|
999
|
+
pushConversationLine(`{${COLOR().warning}-fg}! ${content}{/${COLOR().warning}-fg}`);
|
|
581
1000
|
}
|
|
582
1001
|
pushConversationLine('');
|
|
583
1002
|
if (message.role === 'user' && message.turnId && thinkingBlocks.has(message.turnId)) {
|
|
@@ -585,10 +1004,32 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
585
1004
|
renderThinkingBlock(thinkingBlocks.get(message.turnId));
|
|
586
1005
|
}
|
|
587
1006
|
}
|
|
1007
|
+
if (isWaitingForFirstToken({
|
|
1008
|
+
pendingTurns: pendingTurns.size,
|
|
1009
|
+
streamingEntries: streams.size,
|
|
1010
|
+
runningTimelineEntries: [...(session.timeline ?? [])].filter((entry) => entry.status === 'running' && entry.kind !== 'tool' && entry.kind !== 'shell').length,
|
|
1011
|
+
sessionHasTimeline: Boolean(session.timeline),
|
|
1012
|
+
})) {
|
|
1013
|
+
// Only a confirmed thinking delta switches the slot to Thinking; until
|
|
1014
|
+
// then the ellipsis stands. Deltas emit through onEvent, which always
|
|
1015
|
+
// coalesces into a refresh via scheduleRefresh, so the very next frame
|
|
1016
|
+
// after the first reasoning token shows the Thinking header.
|
|
1017
|
+
const pendingBlock = [...thinkingBlocks.values()].reverse().find((block) => block.status === 'active' && block.thinking);
|
|
1018
|
+
if (pendingBlock) {
|
|
1019
|
+
renderThinkingBlock(pendingBlock);
|
|
1020
|
+
}
|
|
1021
|
+
else {
|
|
1022
|
+
pushConversationLine('');
|
|
1023
|
+
pushConversationLine(`{${COLOR().muted}-fg}{bold}TokenMaw{/bold}{/${COLOR().muted}-fg}`);
|
|
1024
|
+
pushConversationLine(waitingIndicatorFrame(waitingFrame, { accent: COLOR().accent, subtle: COLOR().subtle }));
|
|
1025
|
+
pushConversationLine('');
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
588
1028
|
if (!session.timeline && pendingTurns.size > 0) {
|
|
589
1029
|
const turnId = [...pendingTurns][0];
|
|
590
1030
|
if (!thinkingBlocks.has(turnId)) {
|
|
591
1031
|
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
1032
|
+
markThinkingStart(turnId);
|
|
592
1033
|
}
|
|
593
1034
|
if (!renderedBlocks.has(turnId)) {
|
|
594
1035
|
renderThinkingBlock(thinkingBlocks.get(turnId));
|
|
@@ -598,13 +1039,13 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
598
1039
|
if (!text.trim())
|
|
599
1040
|
continue;
|
|
600
1041
|
pushConversationLine('');
|
|
601
|
-
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
1042
|
+
pushConversationLine(`{${COLOR().muted}-fg}{bold}TokenMaw{/bold}{/${COLOR().muted}-fg}`);
|
|
602
1043
|
pushConversationLine(renderTuiMarkdown(text, markdownCols));
|
|
603
1044
|
pushConversationLine('');
|
|
604
1045
|
}
|
|
605
1046
|
if (notice) {
|
|
606
1047
|
pushConversationLine('');
|
|
607
|
-
const noticeColor = /^Error\b|failed/i.test(notice) ? COLOR.error : COLOR.warning;
|
|
1048
|
+
const noticeColor = /^Error\b|failed/i.test(notice) ? COLOR().error : COLOR().warning;
|
|
608
1049
|
pushConversationLine(`{${noticeColor}-fg}! ${safe(notice)}{/${noticeColor}-fg}`);
|
|
609
1050
|
pushConversationLine('');
|
|
610
1051
|
}
|
|
@@ -625,29 +1066,137 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
625
1066
|
finally {
|
|
626
1067
|
restoringConversationScroll = false;
|
|
627
1068
|
}
|
|
1069
|
+
applyStickyHeader(conversation.childBase);
|
|
628
1070
|
conversationFollowOutput = shouldFollowOutput;
|
|
629
1071
|
conversationDirty = false;
|
|
630
1072
|
};
|
|
1073
|
+
// Pin the collapse header of an expanded block to the conversation top while
|
|
1074
|
+
// the user is reading that block's body. The pinned row is a fixed overlay on
|
|
1075
|
+
// the viewport's first row, and it disappears again as soon as the block's
|
|
1076
|
+
// real header scrolls back into view or the whole block scrolls past the top.
|
|
1077
|
+
const applyStickyHeader = (viewportTop) => {
|
|
1078
|
+
const visibleRows = Math.max(1, Number(conversation.height) - Number(conversation.iheight));
|
|
1079
|
+
const scrollHeight = conversation.getScrollHeight();
|
|
1080
|
+
// Nothing is scrolled out of view when the content fits, so no block can
|
|
1081
|
+
// need a pinned header — fall through to the hide branch below so any
|
|
1082
|
+
// sticky row left over from before the content shrank is cleared too.
|
|
1083
|
+
let sticky;
|
|
1084
|
+
if (scrollHeight > visibleRows) {
|
|
1085
|
+
const topRow = Math.max(0, Math.min(scrollHeight - visibleRows, viewportTop));
|
|
1086
|
+
const clines = conversation._clines;
|
|
1087
|
+
// Block positions are logical content rows, but scrolling (childBase) is
|
|
1088
|
+
// counted in rendered rows: long wrapped lines make the two diverge.
|
|
1089
|
+
// Translate through ftor so the viewport is never considered to have
|
|
1090
|
+
// left a block it is still inside.
|
|
1091
|
+
const logicalSpan = (real) => {
|
|
1092
|
+
const bucket = clines?.ftor?.[real];
|
|
1093
|
+
if (!bucket || bucket.length === 0)
|
|
1094
|
+
return { first: real, last: real };
|
|
1095
|
+
return { first: Number(bucket[0]), last: Number(bucket[bucket.length - 1]) };
|
|
1096
|
+
};
|
|
1097
|
+
const pinFor = (turnId, block, position) => {
|
|
1098
|
+
const line = stickyHeaderLine(block);
|
|
1099
|
+
return {
|
|
1100
|
+
turnId,
|
|
1101
|
+
line,
|
|
1102
|
+
lastLine: logicalSpan(position.lastLine).last,
|
|
1103
|
+
// The redraw key excludes the spinner glyph: its 60ms animation must
|
|
1104
|
+
// not force full-screen reallocations, while label/duration changes
|
|
1105
|
+
// (which only grow or switch) still do.
|
|
1106
|
+
redrawKey: `${turnId} :: ${line.replace(spinnerGlyph(spinnerFrame), '')}`,
|
|
1107
|
+
};
|
|
1108
|
+
};
|
|
1109
|
+
// Keep the current sticky row only while its block still occupies the
|
|
1110
|
+
// viewport top: the real header sits above the top row and the block
|
|
1111
|
+
// body has not fully scrolled past it yet.
|
|
1112
|
+
const pos = stickyHeader ? thinkingBlockLines.get(stickyHeader.turnId) : undefined;
|
|
1113
|
+
const keptBlock = stickyHeader && pos ? thinkingBlocks.get(stickyHeader.turnId) : undefined;
|
|
1114
|
+
if (stickyHeader && pos && keptBlock?.expanded) {
|
|
1115
|
+
const header = logicalSpan(pos.headerLine);
|
|
1116
|
+
const tail = logicalSpan(pos.lastLine);
|
|
1117
|
+
if (header.first < topRow && tail.last >= topRow) {
|
|
1118
|
+
// Regenerate the row so a live block's spinner glyph and elapsed
|
|
1119
|
+
// seconds keep updating instead of freezing at the pinning frame.
|
|
1120
|
+
sticky = pinFor(stickyHeader.turnId, keptBlock, pos);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (!sticky) {
|
|
1124
|
+
// Several expanded blocks may sit above the viewport; the one to pin
|
|
1125
|
+
// is the unique block whose rendered span still contains the top row.
|
|
1126
|
+
for (const [turnId, position] of thinkingBlockLines) {
|
|
1127
|
+
const block = thinkingBlocks.get(turnId);
|
|
1128
|
+
if (!block?.expanded)
|
|
1129
|
+
continue;
|
|
1130
|
+
const header = logicalSpan(position.headerLine);
|
|
1131
|
+
const tail = logicalSpan(position.lastLine);
|
|
1132
|
+
if (header.first < topRow && tail.last >= topRow) {
|
|
1133
|
+
sticky = pinFor(turnId, block, position);
|
|
1134
|
+
break;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
invalidateStickyIfChanged(sticky);
|
|
1140
|
+
const conversationExt = conversation;
|
|
1141
|
+
if (!sticky) {
|
|
1142
|
+
conversationExt._listWrapper?.hide();
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
// The pinned header is a fixed overlay anchored at the viewport's first
|
|
1146
|
+
// row; it does not consume a logical content row.
|
|
1147
|
+
conversationExt._listWrapper ??= (() => {
|
|
1148
|
+
// Full parent width plus the same left/right padding as the content
|
|
1149
|
+
// stream keeps the pinned header aligned with the real header row.
|
|
1150
|
+
// `fixed` exempts the overlay from the scrollable parent's childBase
|
|
1151
|
+
// offset, so it stays anchored at the viewport's top row instead of
|
|
1152
|
+
// scrolling out of view together with the conversation content.
|
|
1153
|
+
const wrapper = blessed.box({ parent: conversation, top: 0, left: 0, width: '100%', height: 1, tags: true, mouse: true, fixed: true, padding: { left: 2, right: 2 }, style: { bg: COLOR().background } });
|
|
1154
|
+
wrapper.on('click', () => {
|
|
1155
|
+
// Blessed bubbles this click up to the conversation box; the flag
|
|
1156
|
+
// consumes the bubbled copy so the block is toggled exactly once.
|
|
1157
|
+
stickyClickHandled = true;
|
|
1158
|
+
if (hasSelection())
|
|
1159
|
+
return;
|
|
1160
|
+
focusConversation();
|
|
1161
|
+
if (stickyHeader)
|
|
1162
|
+
toggleThinkingBlock(stickyHeader.turnId);
|
|
1163
|
+
});
|
|
1164
|
+
return wrapper;
|
|
1165
|
+
})();
|
|
1166
|
+
stickyHeader = sticky;
|
|
1167
|
+
conversationExt._listWrapper.show();
|
|
1168
|
+
conversationExt._listWrapper.setContent(sticky.line);
|
|
1169
|
+
};
|
|
1170
|
+
const stickyHeaderLine = (block) => {
|
|
1171
|
+
const toggle = block.expanded ? '▼' : '▶';
|
|
1172
|
+
const icon = block.status === 'active' ? spinnerGlyph(spinnerFrame) : toggle;
|
|
1173
|
+
const color = block.status === 'active' ? COLOR().accent : COLOR().muted;
|
|
1174
|
+
const label = block.status === 'active'
|
|
1175
|
+
? (block.thinking || block.content.length === 0 ? 'Thinking' : 'Working')
|
|
1176
|
+
: (block.thinking ? 'Thought' : 'Activity');
|
|
1177
|
+
const duration = elapsedLabel(block.startedAt, block.finishedAt);
|
|
1178
|
+
const durationText = duration ? ` ${duration}` : '';
|
|
1179
|
+
return `{${color}-fg}${toggle} ${icon} ${label}${durationText}{/${color}-fg}`;
|
|
1180
|
+
};
|
|
631
1181
|
const renderThinkingBlock = (block) => {
|
|
632
1182
|
const headerLine = lineCursor;
|
|
633
1183
|
const toggle = block.expanded ? '▼' : '▶';
|
|
634
|
-
const icon = block.status === 'active'
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const label = block.
|
|
639
|
-
? (block.
|
|
640
|
-
: (block.
|
|
1184
|
+
const icon = block.status === 'active' ? spinnerGlyph(spinnerFrame) : toggle;
|
|
1185
|
+
const color = block.status === 'active' ? COLOR().accent : COLOR().muted;
|
|
1186
|
+
// An active block with nothing to show yet is the pre-first-token state:
|
|
1187
|
+
// the model is reasoning, so label it Thinking, not Working.
|
|
1188
|
+
const label = block.status === 'active'
|
|
1189
|
+
? (block.thinking || block.content.length === 0 ? 'Thinking' : 'Working')
|
|
1190
|
+
: (block.thinking ? 'Thought' : 'Activity');
|
|
641
1191
|
const duration = elapsedLabel(block.startedAt, block.finishedAt);
|
|
642
1192
|
const durationText = duration ? ` ${duration}` : '';
|
|
643
1193
|
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}`);
|
|
1194
|
+
const scanLabel = `{${COLOR().accent}-fg}${label}{/${COLOR().accent}-fg}`;
|
|
1195
|
+
pushConversationLine(`{${color}-fg}${toggle} ${icon}{/${color}-fg} ${scanLabel}{${COLOR().subtle}-fg}${durationText}{/${COLOR().subtle}-fg}`);
|
|
646
1196
|
}
|
|
647
1197
|
else {
|
|
648
1198
|
pushConversationLine(`{${color}-fg}${icon} ${label}${durationText}{/${color}-fg}`);
|
|
649
1199
|
}
|
|
650
|
-
thinkingBlockLines.set(block.turnId, { headerLine });
|
|
651
1200
|
latestThinkingTurnId = block.turnId;
|
|
652
1201
|
if (block.expanded) {
|
|
653
1202
|
if (block.thinking) {
|
|
@@ -665,17 +1214,27 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
665
1214
|
}
|
|
666
1215
|
}
|
|
667
1216
|
pushConversationLine('');
|
|
1217
|
+
// Recorded after the whole block is pushed so `lastLine` covers the body;
|
|
1218
|
+
// the pinned header must vanish once this row scrolls past the viewport top.
|
|
1219
|
+
thinkingBlockLines.set(block.turnId, { headerLine, lastLine: Math.max(0, lineCursor - 1) });
|
|
668
1220
|
};
|
|
669
1221
|
const renderActivity = () => {
|
|
1222
|
+
if (activityDetail) {
|
|
1223
|
+
const instance = instanceCache.get(activityDetail.instanceId);
|
|
1224
|
+
if (instance)
|
|
1225
|
+
activityDetail.body.setContent(activityDetailContent(instance));
|
|
1226
|
+
}
|
|
670
1227
|
if (!activityDirty)
|
|
671
1228
|
return;
|
|
672
1229
|
const current = instances();
|
|
673
1230
|
const activeCount = current.filter((item) => ['running', 'waiting', 'queued'].includes(item.status)).length;
|
|
674
|
-
activityHeader.setContent(`{bold}
|
|
1231
|
+
activityHeader.setContent(`{bold}Activity{/bold}{${COLOR().muted}-fg}${activeCount ? ` ${activeCount} active` : ''}{/${COLOR().muted}-fg}\n{${COLOR().subtle}-fg}Click an agent to view progress{/${COLOR().subtle}-fg}\n{${COLOR().subtle}-fg}${'─'.repeat(Math.max(0, Number(activity.width) - 2))}{/${COLOR().subtle}-fg}`);
|
|
675
1232
|
activity.setItems(current.map((instance) => {
|
|
676
1233
|
const state = STATUS_PRESENTATION[instance.status];
|
|
677
1234
|
const summary = instance.lastError || activityLog.get(instance.instanceId)?.at(-1) || instance.lastOutput;
|
|
678
|
-
|
|
1235
|
+
const fixedWidth = instance.depth * 2 + instance.agentId.length + state.label.length + 6;
|
|
1236
|
+
const detail = oneLine(summary, Math.max(0, Number(activity.width) - fixedWidth));
|
|
1237
|
+
return `${depthPrefix(instance)} {bold}${safe(instance.agentId)}{/bold} {${TONE_COLOR(state.tone)}-fg}${state.label}{/${TONE_COLOR(state.tone)}-fg}${detail ? ` {${COLOR().subtle}-fg}${safe(detail)}{/${COLOR().subtle}-fg}` : ''}`;
|
|
679
1238
|
}));
|
|
680
1239
|
activityDirty = false;
|
|
681
1240
|
};
|
|
@@ -686,6 +1245,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
686
1245
|
lastLayoutKey = layoutKey;
|
|
687
1246
|
conversationDirty = true;
|
|
688
1247
|
activityDirty = true;
|
|
1248
|
+
requestFullRedraw();
|
|
689
1249
|
}
|
|
690
1250
|
const conversationBox = conversation;
|
|
691
1251
|
conversationBox.padding.left = metrics.horizontalPadding;
|
|
@@ -736,12 +1296,29 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
736
1296
|
composer.focus();
|
|
737
1297
|
if (composerFocused)
|
|
738
1298
|
screen.program.hideCursor();
|
|
739
|
-
|
|
1299
|
+
// Overlay scrollbars must be positioned before the render pass that
|
|
1300
|
+
// paints them.
|
|
1301
|
+
conversationScrollbar.sync();
|
|
1302
|
+
activityScrollbar.sync();
|
|
1303
|
+
activityDetailScrollbar.current?.sync();
|
|
1304
|
+
renderScreen();
|
|
740
1305
|
if (composerFocused) {
|
|
741
1306
|
placeComposerCursor();
|
|
742
1307
|
screen.program.showCursor();
|
|
743
1308
|
}
|
|
744
1309
|
};
|
|
1310
|
+
// Focus regained is treated like a resize: one invalidate + full redraw so
|
|
1311
|
+
// blessed's diff buffers, the viewport, and the overlay scrollbar positions
|
|
1312
|
+
// are all rebuilt from the live state instead of a stale frame.
|
|
1313
|
+
screen.program.on('focus', () => {
|
|
1314
|
+
windowFocused = true;
|
|
1315
|
+
requestFullRedraw();
|
|
1316
|
+
conversationDirty = true;
|
|
1317
|
+
scheduleRefresh();
|
|
1318
|
+
});
|
|
1319
|
+
screen.program.on('blur', () => {
|
|
1320
|
+
windowFocused = false;
|
|
1321
|
+
});
|
|
745
1322
|
const applyModel = async (alias) => {
|
|
746
1323
|
const resolved = options.resolveModel(alias);
|
|
747
1324
|
if (!resolved.config.model)
|
|
@@ -765,7 +1342,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
765
1342
|
label: `${alias}${alias === activeModel ? ' ✓' : ''}`,
|
|
766
1343
|
detail: entry ? `${providerName(entry)} · ${entry.model}` : 'Session model',
|
|
767
1344
|
};
|
|
768
|
-
}));
|
|
1345
|
+
}), { searchable: true });
|
|
769
1346
|
if (index >= 0)
|
|
770
1347
|
await applyModel(aliases[index]);
|
|
771
1348
|
};
|
|
@@ -793,7 +1370,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
793
1370
|
notice = 'Could not load models. Enter a model name manually.';
|
|
794
1371
|
let model;
|
|
795
1372
|
if (remoteModels.length) {
|
|
796
|
-
const index = await choose('Provider model', [...remoteModels, 'Type manually…']);
|
|
1373
|
+
const index = await choose('Provider model', [...remoteModels, 'Type manually…'], { searchable: true });
|
|
797
1374
|
if (index < 0)
|
|
798
1375
|
return;
|
|
799
1376
|
model = index < remoteModels.length ? remoteModels[index] : await ask('Provider model name');
|
|
@@ -876,7 +1453,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
876
1453
|
const index = await choose('Agent specs', specs.map((spec) => ({
|
|
877
1454
|
label: spec.id,
|
|
878
1455
|
detail: `${spec.scope} · ${spec.model ?? 'inherit'} · ${oneLine(spec.description, 42)}`,
|
|
879
|
-
})));
|
|
1456
|
+
})), { searchable: true });
|
|
880
1457
|
if (index < 0)
|
|
881
1458
|
return;
|
|
882
1459
|
const spec = specs[index];
|
|
@@ -888,7 +1465,12 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
888
1465
|
'Close',
|
|
889
1466
|
]);
|
|
890
1467
|
};
|
|
891
|
-
const switchSession = async (id) => {
|
|
1468
|
+
const switchSession = async (id, opts = {}) => {
|
|
1469
|
+
if (opts.forkFrom) {
|
|
1470
|
+
// /btw and /fork both start as a full copy of the current conversation,
|
|
1471
|
+
// so the side model keeps the whole picture from message one.
|
|
1472
|
+
await runtime.forkSession(opts.forkFrom, id);
|
|
1473
|
+
}
|
|
892
1474
|
const next = await runtime.openSession(id);
|
|
893
1475
|
sessionId = id;
|
|
894
1476
|
session = next;
|
|
@@ -901,7 +1483,11 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
901
1483
|
activityLog.clear();
|
|
902
1484
|
thinkingBlocks.clear();
|
|
903
1485
|
thinkingBlockLines.clear();
|
|
1486
|
+
thinkingStartedAt.clear();
|
|
904
1487
|
pendingTurns.clear();
|
|
1488
|
+
stickyHeader = undefined;
|
|
1489
|
+
lastStickyKey = undefined;
|
|
1490
|
+
conversation._listWrapper?.hide();
|
|
905
1491
|
notice = '';
|
|
906
1492
|
conversationDirty = true;
|
|
907
1493
|
conversationFollowOutput = true;
|
|
@@ -911,32 +1497,159 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
911
1497
|
if (instance.instanceId === session.mainInstanceId && instance.activeTurnId)
|
|
912
1498
|
pendingTurns.add(instance.activeTurnId);
|
|
913
1499
|
}
|
|
1500
|
+
sideParentSessionId = opts.parentSessionId;
|
|
914
1501
|
startSpinner();
|
|
1502
|
+
startStreamTimer();
|
|
915
1503
|
refresh();
|
|
916
1504
|
};
|
|
917
1505
|
const openSessions = async () => {
|
|
918
1506
|
const sessions = await runtime.listSessions();
|
|
919
1507
|
const index = await choose('Sessions', [
|
|
920
|
-
...sessions.map((item) => ({ label: item.sessionId, detail: `${item.messages} messages` })),
|
|
1508
|
+
...sessions.map((item) => ({ label: item.sessionId, detail: `${item.messages} messages${item.sessionId.startsWith('btw-') ? ' [side]' : ''}` })),
|
|
921
1509
|
{ label: 'New session', detail: 'Start a blank conversation' },
|
|
922
|
-
]);
|
|
1510
|
+
], { searchable: true });
|
|
923
1511
|
if (index < 0)
|
|
924
1512
|
return;
|
|
925
1513
|
await switchSession(index === sessions.length ? `session-${Date.now()}` : sessions[index].sessionId);
|
|
926
1514
|
};
|
|
1515
|
+
const activityDetailContent = (instance) => {
|
|
1516
|
+
const state = STATUS_PRESENTATION[instance.status];
|
|
1517
|
+
const stateColor = TONE_COLOR(state.tone);
|
|
1518
|
+
const source = runtime.registry.get(instance.agentId)?.source ?? 'built in';
|
|
1519
|
+
const lines = [
|
|
1520
|
+
`{${stateColor}-fg}${state.icon} ${state.label}{/${stateColor}-fg} {${COLOR().subtle}-fg}${safe(instance.instanceId.slice(0, 8))}{/${COLOR().subtle}-fg}`,
|
|
1521
|
+
`{${COLOR().subtle}-fg}Source{/${COLOR().subtle}-fg} ${safe(source)}`,
|
|
1522
|
+
`{${COLOR().subtle}-fg}Updated{/${COLOR().subtle}-fg} ${safe(new Date(instance.updatedAt).toLocaleTimeString())}`,
|
|
1523
|
+
'',
|
|
1524
|
+
'{bold}Progress{/bold}',
|
|
1525
|
+
];
|
|
1526
|
+
const entries = (session.timeline ?? []).filter((entry) => entry.instanceId === instance.instanceId && entry.kind !== 'message').slice(-30);
|
|
1527
|
+
if (entries.length) {
|
|
1528
|
+
for (const entry of entries) {
|
|
1529
|
+
if (entry.kind === 'thinking') {
|
|
1530
|
+
lines.push(`{${entry.status === 'running' ? COLOR().accent : COLOR().subtle}-fg}… Thinking{/${entry.status === 'running' ? COLOR().accent : COLOR().subtle}-fg} ${safe(oneLine(entry.content, 140) || 'Waiting…')}`);
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
const itemState = entry.status === 'running'
|
|
1534
|
+
? STATUS_PRESENTATION.running
|
|
1535
|
+
: entry.status === 'failed'
|
|
1536
|
+
? STATUS_PRESENTATION.failed
|
|
1537
|
+
: entry.status === 'cancelled'
|
|
1538
|
+
? STATUS_PRESENTATION.cancelled
|
|
1539
|
+
: STATUS_PRESENTATION.idle;
|
|
1540
|
+
const presentation = toolPresentation(entry.tool ?? '', entry.input);
|
|
1541
|
+
const detail = presentation.detail || oneLine(entry.content, 120);
|
|
1542
|
+
lines.push(`{${TONE_COLOR(itemState.tone)}-fg}${itemState.icon} ${safe(presentation.label)}{/${TONE_COLOR(itemState.tone)}-fg}${detail ? ` {${COLOR().muted}-fg}${safe(oneLine(detail, 140))}{/${COLOR().muted}-fg}` : ''}`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
const log = activityLog.get(instance.instanceId) ?? [];
|
|
1547
|
+
if (log.length)
|
|
1548
|
+
lines.push(...log.slice(-20).map((item) => `{${COLOR().muted}-fg}· ${safe(item)}{/${COLOR().muted}-fg}`));
|
|
1549
|
+
else
|
|
1550
|
+
lines.push(`{${COLOR().subtle}-fg}No progress events yet.{/${COLOR().subtle}-fg}`);
|
|
1551
|
+
}
|
|
1552
|
+
if (instance.lastError)
|
|
1553
|
+
lines.push('', `{${COLOR().error}-fg}! ${safe(oneLine(instance.lastError, 240))}{/${COLOR().error}-fg}`);
|
|
1554
|
+
else if (instance.lastOutput)
|
|
1555
|
+
lines.push('', `{${COLOR().subtle}-fg}Latest output{/${COLOR().subtle}-fg}`, safe(oneLine(instance.lastOutput, 240)));
|
|
1556
|
+
return lines.join('\n');
|
|
1557
|
+
};
|
|
927
1558
|
const showActivityDetail = async () => {
|
|
928
1559
|
const instance = instances()[selectedActivityIndex];
|
|
929
1560
|
if (!instance)
|
|
930
1561
|
return;
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1562
|
+
activityDetail?.modal.destroy();
|
|
1563
|
+
composerPinned = false;
|
|
1564
|
+
const width = Math.min(88, Math.max(36, Number(screen.width) - 6));
|
|
1565
|
+
const height = Math.min(24, Math.max(9, Number(screen.height) - 4));
|
|
1566
|
+
const modal = blessed.box({
|
|
1567
|
+
parent: screen, top: 'center', left: 'center', width, height,
|
|
1568
|
+
tags: true, style: { bg: COLOR().modal, fg: COLOR().text },
|
|
1569
|
+
});
|
|
1570
|
+
blessed.box({
|
|
1571
|
+
parent: modal, top: 0, left: 2, right: 2, height: 1, tags: true,
|
|
1572
|
+
content: `{bold}${safe(instance.agentId)} progress{/bold}`,
|
|
1573
|
+
style: { bg: COLOR().modal, fg: COLOR().text },
|
|
1574
|
+
});
|
|
1575
|
+
blessed.box({
|
|
1576
|
+
parent: modal, top: 1, left: 2, right: 2, height: 1,
|
|
1577
|
+
content: '─'.repeat(Math.max(0, width - 4)), style: { bg: COLOR().modal, fg: COLOR().modalRule },
|
|
1578
|
+
});
|
|
1579
|
+
const body = blessed.box({
|
|
1580
|
+
parent: modal, top: 3, left: 2, right: 2, bottom: 2,
|
|
1581
|
+
tags: true, keys: true, vi: true, mouse: true, scrollable: true, alwaysScroll: true,
|
|
1582
|
+
style: { bg: COLOR().modal, fg: COLOR().text },
|
|
1583
|
+
content: activityDetailContent(instance),
|
|
1584
|
+
});
|
|
1585
|
+
blessed.box({
|
|
1586
|
+
parent: modal, bottom: 0, left: 2, right: 2, height: 1,
|
|
1587
|
+
content: 'Scroll to browse · Esc close', style: { bg: COLOR().modal, fg: COLOR().subtle },
|
|
1588
|
+
});
|
|
1589
|
+
const closeDetail = () => {
|
|
1590
|
+
if (activityDetail?.modal !== modal)
|
|
1591
|
+
return;
|
|
1592
|
+
activityDetailScrollbar.current?.destroy();
|
|
1593
|
+
activityDetailScrollbar.current = undefined;
|
|
1594
|
+
activityDetail = undefined;
|
|
1595
|
+
modal.destroy();
|
|
1596
|
+
requestFullRedraw();
|
|
1597
|
+
composerPinned = true;
|
|
1598
|
+
focusComposer();
|
|
1599
|
+
};
|
|
1600
|
+
body.key(['escape', 'q'], closeDetail);
|
|
1601
|
+
attachCloseButton(modal, closeDetail);
|
|
1602
|
+
activityDetailScrollbar.current = attachPillScrollbar(body, pillColors);
|
|
1603
|
+
activityDetail = { instanceId: instance.instanceId, modal, body };
|
|
1604
|
+
body.focus();
|
|
1605
|
+
requestFullRedraw();
|
|
1606
|
+
activityDetailScrollbar.current.sync();
|
|
1607
|
+
renderScreen();
|
|
1608
|
+
};
|
|
1609
|
+
// Swaps the active palette and repaints every surface without persisting;
|
|
1610
|
+
// used both to commit a choice and to preview while browsing the picker.
|
|
1611
|
+
const applyThemeVisuals = (name) => {
|
|
1612
|
+
const next = setActiveTheme(name);
|
|
1613
|
+
resetTuiMarkdownCache();
|
|
1614
|
+
applyWidgetTheme();
|
|
1615
|
+
conversationDirty = true;
|
|
1616
|
+
activityDirty = true;
|
|
1617
|
+
requestFullRedraw();
|
|
1618
|
+
return next;
|
|
1619
|
+
};
|
|
1620
|
+
const applyTheme = async (name) => {
|
|
1621
|
+
const next = applyThemeVisuals(name);
|
|
1622
|
+
notice = `Theme set to ${next.label}`;
|
|
1623
|
+
await options.configManager.saveConfig({ ...options.configManager.getConfig(), theme: next.name });
|
|
1624
|
+
refresh();
|
|
1625
|
+
};
|
|
1626
|
+
const openTheme = async () => {
|
|
1627
|
+
const original = activeTuiTheme().name;
|
|
1628
|
+
const names = themeNames();
|
|
1629
|
+
const index = await choose('Theme', names.map((name) => ({
|
|
1630
|
+
label: `${name}${name === original ? ' ✓' : ''}`,
|
|
1631
|
+
detail: resolveTheme(name).label,
|
|
1632
|
+
})), {
|
|
1633
|
+
searchable: true,
|
|
1634
|
+
// Open with the active theme preselected so browsing starts from where
|
|
1635
|
+
// the user is, not from the top of an arbitrary list.
|
|
1636
|
+
initial: Math.max(0, names.indexOf(original)),
|
|
1637
|
+
onHighlight: (highlight) => {
|
|
1638
|
+
const name = names[highlight];
|
|
1639
|
+
if (name && name !== activeTuiTheme().name) {
|
|
1640
|
+
applyThemeVisuals(name);
|
|
1641
|
+
refresh();
|
|
1642
|
+
}
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1645
|
+
if (index >= 0) {
|
|
1646
|
+
await applyTheme(names[index]);
|
|
1647
|
+
}
|
|
1648
|
+
else if (activeTuiTheme().name !== original) {
|
|
1649
|
+
// Picker dismissed: roll back to the theme chosen before previewing.
|
|
1650
|
+
applyThemeVisuals(original);
|
|
1651
|
+
refresh();
|
|
1652
|
+
}
|
|
940
1653
|
};
|
|
941
1654
|
const command = async (raw) => {
|
|
942
1655
|
const [name = '', ...args] = raw.slice(1).trim().split(/\s+/);
|
|
@@ -950,6 +1663,9 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
950
1663
|
case 'agents':
|
|
951
1664
|
await showAgents();
|
|
952
1665
|
break;
|
|
1666
|
+
case 'theme':
|
|
1667
|
+
await openTheme();
|
|
1668
|
+
break;
|
|
953
1669
|
case 'sessions':
|
|
954
1670
|
await openSessions();
|
|
955
1671
|
break;
|
|
@@ -967,7 +1683,11 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
967
1683
|
streams.clear();
|
|
968
1684
|
thinkingBlocks.clear();
|
|
969
1685
|
thinkingBlockLines.clear();
|
|
1686
|
+
thinkingStartedAt.clear();
|
|
970
1687
|
pendingTurns.clear();
|
|
1688
|
+
stickyHeader = undefined;
|
|
1689
|
+
lastStickyKey = undefined;
|
|
1690
|
+
conversation._listWrapper?.hide();
|
|
971
1691
|
notice = '';
|
|
972
1692
|
conversationDirty = true;
|
|
973
1693
|
refresh();
|
|
@@ -995,6 +1715,166 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
995
1715
|
refresh();
|
|
996
1716
|
break;
|
|
997
1717
|
}
|
|
1718
|
+
// /aside queues a side note without starting a turn; it folds into the
|
|
1719
|
+
// next submitted message. /btw opens a self-contained side conversation
|
|
1720
|
+
// forked from this one (/back or Ctrl+C returns); /fork copies the whole
|
|
1721
|
+
// conversation into a new saved session. /goal sets a standing directive
|
|
1722
|
+
// shown in the status bar and injected into every agent's prompt until
|
|
1723
|
+
// cleared.
|
|
1724
|
+
case 'aside': {
|
|
1725
|
+
const note = args.join(' ');
|
|
1726
|
+
if (!note) {
|
|
1727
|
+
notice = 'Usage: /aside <note>';
|
|
1728
|
+
refresh();
|
|
1729
|
+
break;
|
|
1730
|
+
}
|
|
1731
|
+
const result = await runtime.addAside(sessionId, note);
|
|
1732
|
+
notice = result.detail;
|
|
1733
|
+
break;
|
|
1734
|
+
}
|
|
1735
|
+
case 'btw': {
|
|
1736
|
+
const question = args.join(' ').trim();
|
|
1737
|
+
if (!question) {
|
|
1738
|
+
notice = 'Usage: /btw <question> - opens a side conversation; /back or Ctrl+C returns';
|
|
1739
|
+
refresh();
|
|
1740
|
+
break;
|
|
1741
|
+
}
|
|
1742
|
+
await switchSession(`btw-${Date.now()}`, { forkFrom: sessionId, parentSessionId: sessionId });
|
|
1743
|
+
await runtime.submitMessage(sessionId, question);
|
|
1744
|
+
break;
|
|
1745
|
+
}
|
|
1746
|
+
case 'back': {
|
|
1747
|
+
if (!sideParentSessionId) {
|
|
1748
|
+
notice = 'Not in a /btw side conversation.';
|
|
1749
|
+
refresh();
|
|
1750
|
+
break;
|
|
1751
|
+
}
|
|
1752
|
+
await switchSession(sideParentSessionId);
|
|
1753
|
+
break;
|
|
1754
|
+
}
|
|
1755
|
+
case 'fork': {
|
|
1756
|
+
await switchSession(`session-${Date.now()}`, { forkFrom: sessionId });
|
|
1757
|
+
break;
|
|
1758
|
+
}
|
|
1759
|
+
case 'goal': {
|
|
1760
|
+
const result = await runtime.setSessionGoal(sessionId, args.join(' '));
|
|
1761
|
+
session.goal = runtime.getSession(sessionId)?.goal;
|
|
1762
|
+
notice = result.detail;
|
|
1763
|
+
refresh();
|
|
1764
|
+
break;
|
|
1765
|
+
}
|
|
1766
|
+
case 'cd': {
|
|
1767
|
+
const target = args.join(' ').trim();
|
|
1768
|
+
if (!target) {
|
|
1769
|
+
notice = `Working directory: ${runtime.workspace()}`;
|
|
1770
|
+
refresh();
|
|
1771
|
+
break;
|
|
1772
|
+
}
|
|
1773
|
+
try {
|
|
1774
|
+
const result = await runtime.changeWorkspace(target, { sessionId });
|
|
1775
|
+
notice = `Working directory: ${result.to}`;
|
|
1776
|
+
refresh();
|
|
1777
|
+
}
|
|
1778
|
+
catch (error) {
|
|
1779
|
+
notice = `cd failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1780
|
+
refresh();
|
|
1781
|
+
}
|
|
1782
|
+
break;
|
|
1783
|
+
}
|
|
1784
|
+
// Managed worktrees: /worktree <name> creates (or reopens) an isolated
|
|
1785
|
+
// checkout under .coder/worktrees/<name> and moves this session into it,
|
|
1786
|
+
// /worktree-list shows status, /worktree-exit returns to the main
|
|
1787
|
+
// checkout, /worktree-remove <name> drops a clean worktree.
|
|
1788
|
+
case 'worktree': {
|
|
1789
|
+
const name = args.join(' ').trim();
|
|
1790
|
+
if (!name) {
|
|
1791
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1792
|
+
const here = await manager.containing(runtime.workspace());
|
|
1793
|
+
notice = here ? `In worktree ${here.name} (${here.branch})${here.dirty ? ' · dirty' : ''}` : 'Usage: /worktree <name>';
|
|
1794
|
+
refresh();
|
|
1795
|
+
break;
|
|
1796
|
+
}
|
|
1797
|
+
try {
|
|
1798
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1799
|
+
if (!await manager.isGitRepository())
|
|
1800
|
+
throw new Error('not inside a git repository');
|
|
1801
|
+
const info = await manager.create(name);
|
|
1802
|
+
await runtime.changeWorkspace(info.path, { sessionId });
|
|
1803
|
+
notice = `Worktree ready: ${info.path} (${info.branch})`;
|
|
1804
|
+
refresh();
|
|
1805
|
+
}
|
|
1806
|
+
catch (error) {
|
|
1807
|
+
notice = `worktree failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1808
|
+
refresh();
|
|
1809
|
+
}
|
|
1810
|
+
break;
|
|
1811
|
+
}
|
|
1812
|
+
case 'worktree-list': {
|
|
1813
|
+
try {
|
|
1814
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1815
|
+
const all = await manager.list();
|
|
1816
|
+
const here = await manager.containing(runtime.workspace());
|
|
1817
|
+
if (!all.length) {
|
|
1818
|
+
notice = 'No managed worktrees. /worktree <name> creates one.';
|
|
1819
|
+
refresh();
|
|
1820
|
+
break;
|
|
1821
|
+
}
|
|
1822
|
+
const lines = all.map((info) => `${info.name === here?.name ? '▸' : ' '} ${info.name} ${info.branch}${info.dirty ? ' [dirty]' : ''}${info.locked ? ' [locked]' : ''}`);
|
|
1823
|
+
notice = lines.join(' · ');
|
|
1824
|
+
refresh();
|
|
1825
|
+
}
|
|
1826
|
+
catch (error) {
|
|
1827
|
+
notice = `worktree-list failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1828
|
+
refresh();
|
|
1829
|
+
}
|
|
1830
|
+
break;
|
|
1831
|
+
}
|
|
1832
|
+
case 'worktree-exit': {
|
|
1833
|
+
try {
|
|
1834
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1835
|
+
const here = await manager.containing(runtime.workspace());
|
|
1836
|
+
if (!here) {
|
|
1837
|
+
notice = 'Not inside a managed worktree.';
|
|
1838
|
+
refresh();
|
|
1839
|
+
break;
|
|
1840
|
+
}
|
|
1841
|
+
await manager.unlock(here.name);
|
|
1842
|
+
const main = await manager.mainRoot();
|
|
1843
|
+
await runtime.changeWorkspace(main, { sessionId });
|
|
1844
|
+
notice = `Back in main checkout: ${main} (worktree ${here.name} kept on disk)`;
|
|
1845
|
+
refresh();
|
|
1846
|
+
}
|
|
1847
|
+
catch (error) {
|
|
1848
|
+
notice = `worktree-exit failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1849
|
+
refresh();
|
|
1850
|
+
}
|
|
1851
|
+
break;
|
|
1852
|
+
}
|
|
1853
|
+
case 'worktree-remove': {
|
|
1854
|
+
const name = args.join(' ').trim();
|
|
1855
|
+
if (!name) {
|
|
1856
|
+
notice = 'Usage: /worktree-remove <name>';
|
|
1857
|
+
refresh();
|
|
1858
|
+
break;
|
|
1859
|
+
}
|
|
1860
|
+
try {
|
|
1861
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1862
|
+
await manager.remove(name);
|
|
1863
|
+
notice = `Removed worktree ${name} (branch kept).`;
|
|
1864
|
+
refresh();
|
|
1865
|
+
}
|
|
1866
|
+
catch (error) {
|
|
1867
|
+
notice = `worktree-remove failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1868
|
+
refresh();
|
|
1869
|
+
}
|
|
1870
|
+
break;
|
|
1871
|
+
}
|
|
1872
|
+
// Alias for `/cd` with no argument; arguments are ignored, like pwd.
|
|
1873
|
+
case 'pwd': {
|
|
1874
|
+
notice = `Working directory: ${runtime.workspace()}`;
|
|
1875
|
+
refresh();
|
|
1876
|
+
break;
|
|
1877
|
+
}
|
|
998
1878
|
case 'help':
|
|
999
1879
|
await commandPalette();
|
|
1000
1880
|
break;
|
|
@@ -1015,6 +1895,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1015
1895
|
const actions = [
|
|
1016
1896
|
{ label: 'Provider', detail: 'Manage model endpoints' },
|
|
1017
1897
|
{ label: 'Model', detail: 'Choose the session model' },
|
|
1898
|
+
{ label: 'Theme', detail: 'Switch the color theme' },
|
|
1018
1899
|
{ label: 'Agent specs', detail: 'Inspect effective roles and permissions' },
|
|
1019
1900
|
{ label: 'Sessions', detail: 'Open a saved conversation' },
|
|
1020
1901
|
{ label: 'New session', detail: 'Start a blank conversation' },
|
|
@@ -1023,26 +1904,28 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1023
1904
|
{ label: 'Toggle activity', detail: 'Show or hide the agent tree' },
|
|
1024
1905
|
{ label: 'Exit', detail: 'Close TokenMaw' },
|
|
1025
1906
|
];
|
|
1026
|
-
const index = await choose('Command palette', actions);
|
|
1907
|
+
const index = await choose('Command palette', actions, { searchable: true });
|
|
1027
1908
|
if (index === 0)
|
|
1028
1909
|
await openProvider();
|
|
1029
1910
|
if (index === 1)
|
|
1030
1911
|
await openModel();
|
|
1031
1912
|
if (index === 2)
|
|
1032
|
-
await
|
|
1913
|
+
await openTheme();
|
|
1033
1914
|
if (index === 3)
|
|
1034
|
-
await
|
|
1915
|
+
await showAgents();
|
|
1035
1916
|
if (index === 4)
|
|
1036
|
-
await
|
|
1917
|
+
await openSessions();
|
|
1037
1918
|
if (index === 5)
|
|
1038
|
-
await
|
|
1919
|
+
await switchSession(`session-${Date.now()}`);
|
|
1039
1920
|
if (index === 6)
|
|
1921
|
+
await command('/clear');
|
|
1922
|
+
if (index === 7)
|
|
1040
1923
|
await command('/compact');
|
|
1041
|
-
if (index ===
|
|
1924
|
+
if (index === 8) {
|
|
1042
1925
|
activityVisible = !activityVisible;
|
|
1043
1926
|
refresh();
|
|
1044
1927
|
}
|
|
1045
|
-
if (index ===
|
|
1928
|
+
if (index === 9)
|
|
1046
1929
|
close();
|
|
1047
1930
|
};
|
|
1048
1931
|
const submit = async () => {
|
|
@@ -1051,6 +1934,10 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1051
1934
|
focusComposer();
|
|
1052
1935
|
return;
|
|
1053
1936
|
}
|
|
1937
|
+
// A submit and a Ctrl+C park can interleave: if the draft changed under
|
|
1938
|
+
// us, the user just parked a new draft — drop this stale submit.
|
|
1939
|
+
if (value !== composerValue().trim())
|
|
1940
|
+
return;
|
|
1054
1941
|
if (!inputHistory.includes(value))
|
|
1055
1942
|
inputHistory.push(value);
|
|
1056
1943
|
setComposerValue('');
|
|
@@ -1058,6 +1945,16 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1058
1945
|
try {
|
|
1059
1946
|
if (value.startsWith('/'))
|
|
1060
1947
|
await command(value);
|
|
1948
|
+
// Shell mode: `!cmd` runs directly in the workspace, outside the agent
|
|
1949
|
+
// loop and tool policy. Output streams in a popup and never reaches the
|
|
1950
|
+
// model context.
|
|
1951
|
+
else if (value.startsWith('!')) {
|
|
1952
|
+
const shellCommand = value.slice(1).trim();
|
|
1953
|
+
if (!shellCommand)
|
|
1954
|
+
notice = 'Usage: !<command> runs it in the workspace shell.';
|
|
1955
|
+
else
|
|
1956
|
+
await runShellMode(shellCommand);
|
|
1957
|
+
}
|
|
1061
1958
|
else {
|
|
1062
1959
|
notice = '';
|
|
1063
1960
|
conversationFollowOutput = true;
|
|
@@ -1067,8 +1964,10 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1067
1964
|
pendingTurns.add(turnId);
|
|
1068
1965
|
if (pendingTurns.has(turnId) && !thinkingBlocks.has(turnId)) {
|
|
1069
1966
|
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
1967
|
+
markThinkingStart(turnId);
|
|
1070
1968
|
}
|
|
1071
1969
|
startSpinner();
|
|
1970
|
+
startStreamTimer();
|
|
1072
1971
|
refresh();
|
|
1073
1972
|
}
|
|
1074
1973
|
}
|
|
@@ -1092,16 +1991,28 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1092
1991
|
if (event.type === 'assistant_message' && !session.messages.some((message) => message.messageId === event.message.messageId)) {
|
|
1093
1992
|
session.messages.push({ ...event.message });
|
|
1094
1993
|
}
|
|
1994
|
+
if (event.type === 'system_message') {
|
|
1995
|
+
// System notices render only in the timeline stream, never in the
|
|
1996
|
+
// persisted message list; recordTimeline dedupes by messageId.
|
|
1997
|
+
conversationDirty = true;
|
|
1998
|
+
}
|
|
1095
1999
|
recordTimeline(session, event);
|
|
1096
2000
|
if (event.type === 'thinking_delta') {
|
|
1097
2001
|
conversationDirty = true;
|
|
2002
|
+
markThinkingStart(event.turnId);
|
|
1098
2003
|
const block = thinkingBlocks.get(event.turnId) ?? [...thinkingBlocks.values()].reverse().find((item) => item.status === 'active') ?? thinkingBlocks.get(latestThinkingTurnId ?? '');
|
|
1099
2004
|
if (block)
|
|
1100
2005
|
block.thinking = `${block.thinking ?? ''}${event.text}`;
|
|
2006
|
+
// Deltas can burst faster than a usable frame rate; the turn timer paints
|
|
2007
|
+
// them at a steady cadence so a markdown re-render runs at most ~10fps.
|
|
2008
|
+
if (streamTimer)
|
|
2009
|
+
return;
|
|
1101
2010
|
}
|
|
1102
2011
|
if (event.type === 'assistant_delta') {
|
|
1103
2012
|
conversationDirty = true;
|
|
1104
2013
|
streams.set(event.turnId, `${streams.get(event.turnId) ?? ''}${event.text}`);
|
|
2014
|
+
if (streamTimer)
|
|
2015
|
+
return;
|
|
1105
2016
|
}
|
|
1106
2017
|
if (event.type === 'assistant_message') {
|
|
1107
2018
|
conversationDirty = true;
|
|
@@ -1143,6 +2054,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1143
2054
|
notice = `Error: ${event.error}`;
|
|
1144
2055
|
pendingTurns.clear();
|
|
1145
2056
|
stopSpinner();
|
|
2057
|
+
stopStreamTimer();
|
|
1146
2058
|
}
|
|
1147
2059
|
}
|
|
1148
2060
|
if (event.type === 'instance_updated' && event.instance.instanceId === session.mainInstanceId
|
|
@@ -1160,7 +2072,9 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1160
2072
|
}
|
|
1161
2073
|
if (!thinkingBlocks.has(turnId))
|
|
1162
2074
|
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
2075
|
+
markThinkingStart(turnId);
|
|
1163
2076
|
startSpinner();
|
|
2077
|
+
startStreamTimer();
|
|
1164
2078
|
}
|
|
1165
2079
|
if (event.type === 'instance_updated'
|
|
1166
2080
|
&& event.instance.sessionId === sessionId
|
|
@@ -1175,9 +2089,61 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1175
2089
|
pendingTurns.clear();
|
|
1176
2090
|
streams.clear();
|
|
1177
2091
|
stopSpinner();
|
|
2092
|
+
stopStreamTimer();
|
|
1178
2093
|
}
|
|
1179
2094
|
scheduleRefresh();
|
|
1180
2095
|
};
|
|
2096
|
+
// `!command` shell mode. Output streams inline into the conversation as a
|
|
2097
|
+
// timeline entry (transcript-only — never sent to the model); Ctrl+C stops
|
|
2098
|
+
// the run. Like a real shell, a nonzero exit is shown, not treated as an
|
|
2099
|
+
// error: the user is the authorizer.
|
|
2100
|
+
const runShellMode = async (shellCommand) => {
|
|
2101
|
+
const entry = recordShellRun(session, shellCommand);
|
|
2102
|
+
const keepTail = (text) => (text.length > 48_000 ? text.slice(-48_000) : text);
|
|
2103
|
+
const controller = new AbortController();
|
|
2104
|
+
shellAbort = controller;
|
|
2105
|
+
// Long-running commands animate the header's ellipsis (same gradient
|
|
2106
|
+
// frames as the waiting indicator) so a live job is obvious at a glance.
|
|
2107
|
+
const animation = setInterval(() => {
|
|
2108
|
+
if (closed) {
|
|
2109
|
+
clearInterval(animation);
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
if (!windowFocused)
|
|
2113
|
+
return;
|
|
2114
|
+
shellAnimationFrame += 1;
|
|
2115
|
+
conversationDirty = true;
|
|
2116
|
+
scheduleRefresh();
|
|
2117
|
+
}, 60);
|
|
2118
|
+
animation.unref?.();
|
|
2119
|
+
conversationDirty = true;
|
|
2120
|
+
refresh();
|
|
2121
|
+
try {
|
|
2122
|
+
const result = await runShellCommand(shellCommand, {
|
|
2123
|
+
workspaceRoot: runtime.workspace(),
|
|
2124
|
+
signal: controller.signal,
|
|
2125
|
+
onChunk: (text) => {
|
|
2126
|
+
const cleaned = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
|
|
2127
|
+
if (!cleaned)
|
|
2128
|
+
return;
|
|
2129
|
+
entry.content = keepTail(entry.content + cleaned);
|
|
2130
|
+
conversationDirty = true;
|
|
2131
|
+
scheduleRefresh();
|
|
2132
|
+
},
|
|
2133
|
+
});
|
|
2134
|
+
entry.content = keepTail(result.output.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ''));
|
|
2135
|
+
entry.exitCode = result.exitCode;
|
|
2136
|
+
entry.status = result.exitCode === 0 ? 'completed' : result.exitCode === undefined ? 'cancelled' : 'failed';
|
|
2137
|
+
entry.endedAt = Date.now();
|
|
2138
|
+
}
|
|
2139
|
+
finally {
|
|
2140
|
+
clearInterval(animation);
|
|
2141
|
+
if (shellAbort === controller)
|
|
2142
|
+
shellAbort = undefined;
|
|
2143
|
+
conversationDirty = true;
|
|
2144
|
+
refresh();
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
1181
2147
|
const unsubscribe = runtime.subscribe(onEvent);
|
|
1182
2148
|
let finish;
|
|
1183
2149
|
const done = new Promise((resolveDone) => { finish = resolveDone; });
|
|
@@ -1186,10 +2152,33 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1186
2152
|
return;
|
|
1187
2153
|
closed = true;
|
|
1188
2154
|
stopSpinner();
|
|
2155
|
+
stopStreamTimer();
|
|
1189
2156
|
if (welcomeTimer)
|
|
1190
2157
|
clearInterval(welcomeTimer);
|
|
2158
|
+
clearInterval(instancePoll);
|
|
1191
2159
|
unsubscribe();
|
|
2160
|
+
// Release bracketed paste mode before the screen goes away so the shell
|
|
2161
|
+
// after exit does not keep accumulating pasted text without newlines.
|
|
2162
|
+
// Leave the alternate buffer for the same reason: the forced ?1049 entry
|
|
2163
|
+
// must not outlive the TUI even where terminfo's rmcup is empty.
|
|
2164
|
+
try {
|
|
2165
|
+
screen.program.write(BRACKETED_PASTE_DISABLE);
|
|
2166
|
+
screen.program.decrst('1004');
|
|
2167
|
+
screen.program.decrst('1049');
|
|
2168
|
+
screen.program.flush();
|
|
2169
|
+
}
|
|
2170
|
+
catch {
|
|
2171
|
+
// The program may already be torn down; the reset is best effort.
|
|
2172
|
+
}
|
|
1192
2173
|
screen.destroy();
|
|
2174
|
+
// Blessed only removes its own listeners on destroy; release our proxy's
|
|
2175
|
+
// forwarding too so the real stdin is left with no lingering listeners.
|
|
2176
|
+
try {
|
|
2177
|
+
pasteInput.destroy?.();
|
|
2178
|
+
}
|
|
2179
|
+
catch {
|
|
2180
|
+
// Already torn down; nothing left to release.
|
|
2181
|
+
}
|
|
1193
2182
|
finish?.();
|
|
1194
2183
|
}
|
|
1195
2184
|
composer.on('keypress', handleComposerKey);
|
|
@@ -1211,6 +2200,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1211
2200
|
};
|
|
1212
2201
|
const toggleActivity = () => {
|
|
1213
2202
|
activityVisible = !activityVisible;
|
|
2203
|
+
requestFullRedraw();
|
|
1214
2204
|
refresh();
|
|
1215
2205
|
focusComposer();
|
|
1216
2206
|
};
|
|
@@ -1226,6 +2216,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1226
2216
|
conversationScrollOffset = conversation.childBase;
|
|
1227
2217
|
block.expanded = !block.expanded;
|
|
1228
2218
|
conversationDirty = true;
|
|
2219
|
+
requestFullRedraw();
|
|
1229
2220
|
refresh();
|
|
1230
2221
|
};
|
|
1231
2222
|
const focusConversation = () => {
|
|
@@ -1258,6 +2249,14 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1258
2249
|
return;
|
|
1259
2250
|
const contentTop = lpos.yi + Number(conversation.itop);
|
|
1260
2251
|
const relY = data.y - contentTop;
|
|
2252
|
+
// A click on the sticky overlay already toggled the block; the bubbled
|
|
2253
|
+
// copy must not toggle it back.
|
|
2254
|
+
if (stickyClickHandled) {
|
|
2255
|
+
stickyClickHandled = false;
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
// The pinned header is an overlay, not an inserted row: logical line
|
|
2259
|
+
// indices still map 1:1 onto rendered rows, so no extra offset applies.
|
|
1261
2260
|
const row = Math.floor(relY) + conversation.childBase;
|
|
1262
2261
|
// RenderThinkingBlock records indices in the raw `lines` array, but blessed
|
|
1263
2262
|
// re-parses/wraps content into `_clines`. Translate via ftor so the click
|
|
@@ -1278,7 +2277,26 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1278
2277
|
if (latestThinkingTurnId)
|
|
1279
2278
|
toggleThinkingBlock(latestThinkingTurnId);
|
|
1280
2279
|
});
|
|
1281
|
-
activity.on('click',
|
|
2280
|
+
activity.on('click', (data) => {
|
|
2281
|
+
const list = activity;
|
|
2282
|
+
const selectedItem = list.items[list.selected];
|
|
2283
|
+
const bounds = selectedItem?.lpos;
|
|
2284
|
+
if (!bounds || data.y === undefined || data.y < bounds.yi || data.y >= bounds.yl)
|
|
2285
|
+
return;
|
|
2286
|
+
selectedActivityIndex = list.selected;
|
|
2287
|
+
runAction(showActivityDetail);
|
|
2288
|
+
});
|
|
2289
|
+
// Blessed routes clicks that land on a rendered list row to the row element
|
|
2290
|
+
// itself; the list only sees the bubbled `element click`. Resolve the row
|
|
2291
|
+
// back to its agent so a click opens that agent's progress directly.
|
|
2292
|
+
activity.on('element click', (el) => {
|
|
2293
|
+
const list = activity;
|
|
2294
|
+
const index = list.items.indexOf(el);
|
|
2295
|
+
if (index < 0)
|
|
2296
|
+
return;
|
|
2297
|
+
selectedActivityIndex = index;
|
|
2298
|
+
runAction(showActivityDetail);
|
|
2299
|
+
});
|
|
1282
2300
|
conversation.on('mousedown', focusConversation);
|
|
1283
2301
|
conversation.on('wheelup', () => {
|
|
1284
2302
|
selection = undefined;
|
|
@@ -1299,6 +2317,12 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1299
2317
|
return;
|
|
1300
2318
|
conversationScrollOffset = conversation.childBase;
|
|
1301
2319
|
conversationFollowOutput = conversationAtBottom();
|
|
2320
|
+
// Pure scrolling does not mark the content dirty; the pinned header still
|
|
2321
|
+
// needs to appear/disappear as the viewport moves.
|
|
2322
|
+
if (stickyHeader || thinkingBlockLines.size > 0) {
|
|
2323
|
+
conversationDirty = true;
|
|
2324
|
+
scheduleRefresh();
|
|
2325
|
+
}
|
|
1302
2326
|
});
|
|
1303
2327
|
screen.key(['pageup', 'pagedown'], (_ch, key) => {
|
|
1304
2328
|
selection = undefined;
|
|
@@ -1367,10 +2391,61 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1367
2391
|
selection.dragging = false;
|
|
1368
2392
|
}
|
|
1369
2393
|
});
|
|
2394
|
+
// Ctrl+C semantics depend on mode: inside a /btw side conversation it
|
|
2395
|
+
// returns to the parent session; elsewhere a bare press arms a quit
|
|
2396
|
+
// confirmation and a second press within 2s exits, so a stray Ctrl+C never
|
|
2397
|
+
// kills the session by accident.
|
|
2398
|
+
let ctrlCAt = 0;
|
|
2399
|
+
const handleBareCtrlC = () => {
|
|
2400
|
+
// A running !command is the first thing Ctrl+C stops; the quit
|
|
2401
|
+
// confirmation must not fire while the user is just killing a job.
|
|
2402
|
+
if (shellAbort) {
|
|
2403
|
+
shellAbort.abort();
|
|
2404
|
+
notice = 'Stopping command…';
|
|
2405
|
+
refresh();
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
if (isBtw()) {
|
|
2409
|
+
const parent = sideParentSessionId;
|
|
2410
|
+
void switchSession(parent).then(() => {
|
|
2411
|
+
notice = 'Returned from /btw side conversation.';
|
|
2412
|
+
refresh();
|
|
2413
|
+
});
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
// A non-empty draft changes the first press: park it in history and
|
|
2417
|
+
// clear the composer. The second press (or a bare press on an empty
|
|
2418
|
+
// composer) arms the quit as before.
|
|
2419
|
+
if (composerValue().trim()) {
|
|
2420
|
+
const draft = composerValue().trim();
|
|
2421
|
+
if (inputHistory[inputHistory.length - 1] !== draft)
|
|
2422
|
+
inputHistory.push(draft);
|
|
2423
|
+
// setComposerValue resets historyIndex, so the next Up naturally lands
|
|
2424
|
+
// on the freshly parked draft.
|
|
2425
|
+
setComposerValue('');
|
|
2426
|
+
notice = 'Draft saved — press Up to restore.';
|
|
2427
|
+
// The notice renders through the conversation timeline; without this
|
|
2428
|
+
// flag the repaint skips the stale transcript and the user never sees
|
|
2429
|
+
// the confirmation.
|
|
2430
|
+
conversationDirty = true;
|
|
2431
|
+
renderComposerFrame();
|
|
2432
|
+
refresh();
|
|
2433
|
+
focusComposer();
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
const pressedAt = Date.now();
|
|
2437
|
+
if (pressedAt - ctrlCAt > 2000) {
|
|
2438
|
+
ctrlCAt = pressedAt;
|
|
2439
|
+
notice = 'Press Ctrl+C again to quit.';
|
|
2440
|
+
refresh();
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
close();
|
|
2444
|
+
};
|
|
1370
2445
|
screen.key(['C-c'], () => {
|
|
1371
2446
|
const range = orderedSelection();
|
|
1372
2447
|
if (!range || !selection) {
|
|
1373
|
-
|
|
2448
|
+
handleBareCtrlC();
|
|
1374
2449
|
return;
|
|
1375
2450
|
}
|
|
1376
2451
|
const [start, end] = range;
|