tokenmaw 0.3.0 → 0.4.1
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 +22 -2
- package/agents/coordinator.md +2 -3
- package/agents/main.md +2 -2
- package/dist/backend.js +31 -1
- package/dist/cli.js +46 -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 +309 -19
- package/dist/runtime/agent-store.js +52 -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 +11 -0
- package/dist/ui/fullscreen-tui.js +1282 -122
- 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 +555 -11
- package/dist/update-check.js +332 -0
- package/docs/architecture-revision.md +1 -1
- package/package.json +9 -3
|
@@ -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,45 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
73
74
|
let spinnerTimer;
|
|
74
75
|
let welcomeTimer;
|
|
75
76
|
let welcomeFrame = 0;
|
|
77
|
+
let welcomeStartedAt = 0;
|
|
78
|
+
// Set between a committed theme change and the next renderConversation():
|
|
79
|
+
// the welcome clock is then rebased so the mark replays its one-second
|
|
80
|
+
// opening act under the new palette. Preview highlights and Esc/✕ rollbacks
|
|
81
|
+
// restore the previous palette without a change and never set this.
|
|
82
|
+
let themeIntroReplay = false;
|
|
83
|
+
// Terminal focus lifecycle (DECSET 1004). While the window is unfocused the
|
|
84
|
+
// app must be frugal with PTY writes: a refocusing terminal replays the
|
|
85
|
+
// bytes it did not render, and a backlog of pending updates is what users
|
|
86
|
+
// see as the "crazy scrolling" burst on focus regain. Two rules:
|
|
87
|
+
// - Decorative animation (spinner glyphs, welcome shine, shell ellipsis) is
|
|
88
|
+
// suppressed outright while blurred: it is invisible in an unfocused
|
|
89
|
+
// window, and every frame is a multi-row byte burst.
|
|
90
|
+
// - Streaming text keeps flowing on a ~400ms heartbeat so a background
|
|
91
|
+
// window still shows the transcript growing. Event-driven repaints (tool
|
|
92
|
+
// calls, finished messages) are never throttled.
|
|
93
|
+
// Frames skipped for blur set `blurredStale`; focus regain then issues
|
|
94
|
+
// exactly one full redraw (like a resize) instead of replaying a backlog.
|
|
95
|
+
let windowFocused = true;
|
|
96
|
+
let blurredStale = false;
|
|
97
|
+
const BLURRED_STREAM_MS = 400;
|
|
98
|
+
let lastStreamFrame = 0;
|
|
99
|
+
// Heartbeat gate for the stream repaint timer: true at most once every
|
|
100
|
+
// BLURRED_STREAM_MS while unfocused, always while focused.
|
|
101
|
+
const throttledFrame = () => {
|
|
102
|
+
if (windowFocused)
|
|
103
|
+
return true;
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (now - lastStreamFrame < BLURRED_STREAM_MS)
|
|
106
|
+
return false;
|
|
107
|
+
lastStreamFrame = now;
|
|
108
|
+
blurredStale = true;
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
let streamTimer;
|
|
112
|
+
let shellAbort;
|
|
113
|
+
let shellAnimationFrame = 0;
|
|
114
|
+
let waitingFrame = 0;
|
|
115
|
+
let lastPaintedStreamText = '';
|
|
76
116
|
const composerChars = [];
|
|
77
117
|
const inputHistory = [];
|
|
78
118
|
const pendingTurns = new Set();
|
|
@@ -80,7 +120,24 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
80
120
|
const activityLog = new Map();
|
|
81
121
|
const thinkingBlocks = new Map();
|
|
82
122
|
const thinkingBlockLines = new Map();
|
|
123
|
+
// Sticky collapse header: when an expanded block's own header has scrolled
|
|
124
|
+
// above the viewport while the block body is still on screen, the header is
|
|
125
|
+
// redrawn pinned to the first conversation row so it can always be clicked
|
|
126
|
+
// to collapse. Sticky rows do not exist in the logical content; they are
|
|
127
|
+
// inserted at the viewport top after scrolling is applied.
|
|
128
|
+
// Streaming events can batch: a thinking segment may be rendered only after
|
|
129
|
+
// it already finished, so the start time is tracked per turn, not per block.
|
|
130
|
+
const thinkingStartedAt = new Map();
|
|
131
|
+
const markThinkingStart = (turnId) => {
|
|
132
|
+
if (!thinkingStartedAt.has(turnId))
|
|
133
|
+
thinkingStartedAt.set(turnId, Date.now());
|
|
134
|
+
};
|
|
83
135
|
let latestThinkingTurnId;
|
|
136
|
+
let stickyHeader;
|
|
137
|
+
let lastStickyKey;
|
|
138
|
+
// Blessed bubbles a click from the sticky overlay up to the conversation
|
|
139
|
+
// box; the flag consumes the bubbled copy so the block is toggled once.
|
|
140
|
+
let stickyClickHandled = false;
|
|
84
141
|
let conversationFollowOutput = true;
|
|
85
142
|
let conversationScrollOffset = 0;
|
|
86
143
|
let restoringConversationScroll = false;
|
|
@@ -94,8 +151,22 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
94
151
|
let completionQuery = '';
|
|
95
152
|
let dismissedCompletion = '';
|
|
96
153
|
let nativeSelection = false;
|
|
154
|
+
// /btw side conversation state. Inside a side session, `sideParentSessionId`
|
|
155
|
+
// points at the conversation /back and Ctrl+C return to. /fork works from
|
|
156
|
+
// anywhere but never changes the mode.
|
|
157
|
+
let sideParentSessionId;
|
|
158
|
+
const isBtw = () => sideParentSessionId !== undefined;
|
|
97
159
|
let selection;
|
|
98
160
|
const hasSelection = () => Boolean(selection && (selection.start.x !== selection.end.x || selection.start.y !== selection.end.y));
|
|
161
|
+
// When the sticky row's identity or text changes, a full redraw avoids
|
|
162
|
+
// blessed CSR diff artifacts around the shifted top row.
|
|
163
|
+
const invalidateStickyIfChanged = (next) => {
|
|
164
|
+
const key = next ? `${next.turnId} :: ${next.line}` : '';
|
|
165
|
+
if (key !== lastStickyKey) {
|
|
166
|
+
lastStickyKey = key || undefined;
|
|
167
|
+
requestFullRedraw();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
99
170
|
const restoreThinking = () => {
|
|
100
171
|
const restored = new Map();
|
|
101
172
|
for (const message of session.messages) {
|
|
@@ -108,58 +179,125 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
108
179
|
}
|
|
109
180
|
};
|
|
110
181
|
restoreThinking();
|
|
182
|
+
setActiveTheme(options.configManager.getConfig().theme);
|
|
183
|
+
// Bracketed paste: the terminal wraps pasted text in `\x1b[200~ ... \x1b[201~`.
|
|
184
|
+
// The filter turns each wrapped chunk into one paste event, so line breaks
|
|
185
|
+
// inside a paste insert literally instead of being read as Enter (which
|
|
186
|
+
// used to submit the half-pasted draft).
|
|
187
|
+
const pasteInput = enableBracketedPaste(process.stdin);
|
|
111
188
|
const screen = blessed.screen({
|
|
189
|
+
input: pasteInput,
|
|
112
190
|
smartCSR: true, fullUnicode: true, title: 'TokenMaw',
|
|
113
|
-
style: { bg: COLOR.background, fg: COLOR.text },
|
|
191
|
+
style: { bg: COLOR().background, fg: COLOR().text },
|
|
114
192
|
});
|
|
193
|
+
screen.program.write(BRACKETED_PASTE_ENABLE);
|
|
194
|
+
// Blessed defers alt-buffer entry to terminfo's smcup, which is empty on
|
|
195
|
+
// several TERM entries — the app then paints into the scrollback and every
|
|
196
|
+
// animated repaint shoves the native scrollbar around. Force ?1049 so the
|
|
197
|
+
// TUI owns the alternate screen (restored on exit) regardless of terminfo.
|
|
198
|
+
screen.program.decset('1049');
|
|
199
|
+
screen.program.decset('1004');
|
|
200
|
+
let fullRedrawPending = true;
|
|
201
|
+
const requestFullRedraw = () => { fullRedrawPending = true; };
|
|
202
|
+
const renderScreen = () => {
|
|
203
|
+
if (fullRedrawPending) {
|
|
204
|
+
// Blessed's smart CSR occasionally leaves the tail of a wide/long line
|
|
205
|
+
// behind when an element shrinks or disappears. Reallocating only for
|
|
206
|
+
// structural transitions clears both its current and previous buffers.
|
|
207
|
+
screen.realloc();
|
|
208
|
+
fullRedrawPending = false;
|
|
209
|
+
}
|
|
210
|
+
screen.render();
|
|
211
|
+
};
|
|
115
212
|
const screenBuffer = screen;
|
|
116
213
|
const statusbar = blessed.box({
|
|
117
214
|
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 },
|
|
215
|
+
padding: { left: 1, right: 1 }, style: { bg: COLOR().background, fg: COLOR().muted },
|
|
119
216
|
});
|
|
120
217
|
const conversation = blessed.box({
|
|
121
218
|
parent: screen, top: 0, left: 0, width: '100%', bottom: 3,
|
|
122
219
|
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
220
|
padding: { left: 2, right: 2 },
|
|
129
|
-
style: { bg: COLOR.background, fg: COLOR.text },
|
|
221
|
+
style: { bg: COLOR().background, fg: COLOR().text },
|
|
222
|
+
});
|
|
223
|
+
// Full-width surface keeps the composer visually continuous at both edges;
|
|
224
|
+
// the editable text box is inset on top of this backdrop.
|
|
225
|
+
const composerBackdrop = blessed.box({
|
|
226
|
+
parent: screen, bottom: 1, left: 0, width: '100%', height: 2,
|
|
227
|
+
style: { bg: COLOR().composer },
|
|
130
228
|
});
|
|
131
229
|
const activity = blessed.list({
|
|
132
|
-
parent: screen, top:
|
|
230
|
+
parent: screen, top: 3, right: 0, width: '28%', bottom: 2,
|
|
133
231
|
tags: true, keys: true, vi: true, mouse: true,
|
|
134
232
|
scrollable: true, padding: { left: 1, right: 1 },
|
|
135
233
|
style: {
|
|
136
|
-
bg: COLOR.
|
|
137
|
-
selected: { bg: COLOR.elevated, fg: COLOR.accent, bold: true },
|
|
234
|
+
bg: COLOR().activity, fg: COLOR().muted,
|
|
235
|
+
selected: { bg: COLOR().elevated, fg: COLOR().accent, bold: true },
|
|
138
236
|
},
|
|
139
237
|
});
|
|
140
238
|
const composer = blessed.box({
|
|
141
239
|
parent: screen, bottom: 1, left: 3, width: '100%-4', height: 2,
|
|
142
240
|
input: true, keys: true, mouse: true, padding: { left: 0, right: 1 },
|
|
143
|
-
|
|
241
|
+
tags: true,
|
|
242
|
+
style: { bg: COLOR().composer, fg: COLOR().text },
|
|
144
243
|
});
|
|
145
244
|
const divider = blessed.box({
|
|
146
|
-
|
|
147
|
-
|
|
245
|
+
// This row is part of the composer surface. Keeping it full width makes
|
|
246
|
+
// the input area read as one continuous band instead of a boxed field
|
|
247
|
+
// separated by a decorative rule.
|
|
248
|
+
parent: screen, bottom: 3, left: 0, width: '100%', height: 1,
|
|
249
|
+
style: { fg: COLOR().composer, bg: COLOR().composer },
|
|
148
250
|
});
|
|
149
251
|
const composerPrompt = blessed.box({
|
|
150
252
|
parent: screen, bottom: 1, left: 1, width: 2, height: 2,
|
|
151
|
-
content: '›', style: { bg: COLOR.
|
|
253
|
+
content: '›', style: { bg: COLOR().composer, fg: COLOR().accent },
|
|
152
254
|
});
|
|
153
255
|
const completions = blessed.list({
|
|
154
256
|
parent: screen, left: 2, bottom: 4, width: '100%-4', height: 5,
|
|
155
257
|
hidden: true, tags: true, mouse: true, keys: false, autoFocus: false,
|
|
156
258
|
padding: { left: 1, right: 1 },
|
|
157
|
-
style: { bg: COLOR.panel, fg: COLOR.muted, selected: { bg: COLOR.elevated, fg: COLOR.accent, bold: true } },
|
|
259
|
+
style: { bg: COLOR().panel, fg: COLOR().muted, selected: { bg: COLOR().elevated, fg: COLOR().accent, bold: true } },
|
|
158
260
|
});
|
|
159
261
|
const activityHeader = blessed.box({
|
|
160
|
-
parent: screen, top: 0, right: 0, width: '28%', height:
|
|
161
|
-
padding: { left: 1, right: 1 }, style: { bg: COLOR.
|
|
262
|
+
parent: screen, top: 0, right: 0, width: '28%', height: 3, hidden: true, tags: true,
|
|
263
|
+
padding: { left: 1, right: 1 }, style: { bg: COLOR().activity, fg: COLOR().text },
|
|
162
264
|
});
|
|
265
|
+
const activityDetailScrollbar = {};
|
|
266
|
+
let activityDetail;
|
|
267
|
+
// Pill scrollbars are screen-level overlay elements; they resolve theme
|
|
268
|
+
// colors on every sync, so a theme switch needs no extra patching.
|
|
269
|
+
const pillColors = () => pillScrollbarColors(COLOR());
|
|
270
|
+
const conversationScrollbar = attachPillScrollbar(conversation, pillColors);
|
|
271
|
+
const activityScrollbar = attachPillScrollbar(activity, pillColors);
|
|
272
|
+
// Persistent widgets capture style objects at creation time; a theme switch
|
|
273
|
+
// must patch them in place so the repaint picks up the new palette.
|
|
274
|
+
const applyWidgetTheme = () => {
|
|
275
|
+
const c = COLOR();
|
|
276
|
+
statusbar.style.bg = c.background;
|
|
277
|
+
statusbar.style.fg = c.muted;
|
|
278
|
+
conversation.style.bg = c.background;
|
|
279
|
+
conversation.style.fg = c.text;
|
|
280
|
+
activity.style.bg = c.activity;
|
|
281
|
+
activity.style.fg = c.muted;
|
|
282
|
+
// blessed's List copies style.item from the constructor palette and reads
|
|
283
|
+
// it per unselected row on every render, so it must be re-created here.
|
|
284
|
+
activity.style.item = { bg: c.activity, fg: c.muted };
|
|
285
|
+
activity.style.selected = { bg: c.elevated, fg: c.accent, bold: true };
|
|
286
|
+
composer.style.bg = c.composer;
|
|
287
|
+
composer.style.fg = c.text;
|
|
288
|
+
composerBackdrop.style.bg = c.composer;
|
|
289
|
+
divider.style.fg = c.composer;
|
|
290
|
+
divider.style.bg = c.composer;
|
|
291
|
+
composerPrompt.style.bg = c.composer;
|
|
292
|
+
composerPrompt.style.fg = c.accent;
|
|
293
|
+
completions.style.bg = c.panel;
|
|
294
|
+
completions.style.fg = c.muted;
|
|
295
|
+
completions.style.item = { bg: c.panel, fg: c.muted };
|
|
296
|
+
completions.style.selected = { bg: c.elevated, fg: c.accent, bold: true };
|
|
297
|
+
activityHeader.style.bg = c.activity;
|
|
298
|
+
activityHeader.style.fg = c.text;
|
|
299
|
+
};
|
|
300
|
+
applyWidgetTheme();
|
|
163
301
|
screen.program.setMouse({ vt200Mouse: true, sgrMouse: true, utfMouse: false, cellMotion: true, allMotion: true }, true);
|
|
164
302
|
const placeComposerCursor = () => {
|
|
165
303
|
if (closed || screen.focused !== composer)
|
|
@@ -181,37 +319,51 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
181
319
|
const result = layoutComposer(composerValue(), composerCursor, width, (text) => Number(composer.strWidth(text)));
|
|
182
320
|
const height = Math.min(Math.max(2, result.rows.length), Math.max(2, Math.min(6, Number(screen.height) - 7)));
|
|
183
321
|
const start = Math.max(0, result.cursor.row - height + 1);
|
|
322
|
+
// A draft starting with `!` is shell mode: the prompt becomes `$` and the
|
|
323
|
+
// command text wears the shell color so the submit target is unambiguous.
|
|
324
|
+
const shellMode = composerValue().startsWith('!');
|
|
325
|
+
composerPrompt.setContent(shellMode ? '$' : '›');
|
|
326
|
+
composerPrompt.style.fg = shellMode ? COLOR().warning : COLOR().accent;
|
|
184
327
|
composer.height = height;
|
|
185
328
|
composerPrompt.height = height;
|
|
329
|
+
composerBackdrop.height = height;
|
|
186
330
|
conversation.bottom = height + 2;
|
|
187
331
|
activity.bottom = height + 2;
|
|
188
332
|
divider.bottom = height + 1;
|
|
189
|
-
divider.setContent('
|
|
333
|
+
divider.setContent(' '.repeat(Math.max(0, Number(screen.width))));
|
|
190
334
|
composerRow = result.cursor.row - start;
|
|
191
335
|
composerColumn = result.cursor.column;
|
|
192
|
-
|
|
336
|
+
// Rows are laid out from plain text; escape them for the tag parser so
|
|
337
|
+
// commands containing literal braces render as typed.
|
|
338
|
+
const visibleRows = result.rows.slice(start, start + height).map((row) => safe(row));
|
|
339
|
+
composer.setContent(shellMode
|
|
340
|
+
? visibleRows.map((row) => `{${COLOR().warning}-fg}${row}{/${COLOR().warning}-fg}`).join('\n')
|
|
341
|
+
: visibleRows.join('\n'));
|
|
193
342
|
const query = composerValue();
|
|
194
343
|
if (query !== completionQuery) {
|
|
195
344
|
completionIndex = 0;
|
|
196
345
|
completionQuery = query;
|
|
197
346
|
}
|
|
198
347
|
const matches = query === dismissedCompletion ? [] : commandMatches(query);
|
|
348
|
+
const completionsWereHidden = completions.hidden;
|
|
199
349
|
if (!matches.length)
|
|
200
350
|
completions.hide();
|
|
201
351
|
else {
|
|
202
352
|
completions.bottom = height + 2;
|
|
203
353
|
completions.height = Math.min(matches.length, 6, Math.max(1, Number(screen.height) - height - 3));
|
|
204
354
|
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}`));
|
|
355
|
+
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
356
|
completions.select(completionIndex);
|
|
207
357
|
completions.show();
|
|
208
358
|
completions.setFront();
|
|
209
359
|
}
|
|
360
|
+
if (completionsWereHidden !== completions.hidden)
|
|
361
|
+
requestFullRedraw();
|
|
210
362
|
};
|
|
211
363
|
const renderComposerFrame = () => {
|
|
212
364
|
renderComposer();
|
|
213
365
|
screen.program.hideCursor();
|
|
214
|
-
|
|
366
|
+
renderScreen();
|
|
215
367
|
placeComposerCursor();
|
|
216
368
|
screen.program.showCursor();
|
|
217
369
|
};
|
|
@@ -243,9 +395,26 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
243
395
|
renderComposer();
|
|
244
396
|
renderComposerFrame();
|
|
245
397
|
};
|
|
398
|
+
const insertPaste = (text) => {
|
|
399
|
+
const chars = Array.from(text);
|
|
400
|
+
composerChars.splice(composerCursor, 0, ...chars);
|
|
401
|
+
composerCursor += chars.length;
|
|
402
|
+
};
|
|
246
403
|
const handleComposerKey = (ch, key) => {
|
|
247
404
|
if (closed)
|
|
248
405
|
return;
|
|
406
|
+
if (pasteInput.pasteActive) {
|
|
407
|
+
// Paste content is literal: line breaks included, never a submit.
|
|
408
|
+
// CR is normalized to LF so browser-style CRLF pastes stay clean.
|
|
409
|
+
if (ch === '\r' || ch === '\n')
|
|
410
|
+
insertPaste('\n');
|
|
411
|
+
else if (ch && !key.ctrl && !key.meta && !/^[\x00-\x1f\x7f]$/.test(ch))
|
|
412
|
+
insertPaste(ch);
|
|
413
|
+
// Long pastes span many reads: keep repainting so the composer does
|
|
414
|
+
// not appear frozen while the paste streams in.
|
|
415
|
+
scheduleRefresh();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
249
418
|
const matches = completions.hidden ? [] : commandMatches(composerValue());
|
|
250
419
|
if (matches.length && (key.name === 'up' || key.name === 'down')) {
|
|
251
420
|
completionIndex = (completionIndex + (key.name === 'up' ? -1 : 1) + matches.length) % matches.length;
|
|
@@ -257,6 +426,17 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
257
426
|
renderComposerFrame();
|
|
258
427
|
return;
|
|
259
428
|
}
|
|
429
|
+
// A bare Escape stops the running turn, same as Ctrl+X / `/cancel`. The
|
|
430
|
+
// completion menu and the screen-level bindings keep their Esc semantics
|
|
431
|
+
// (dismiss suggestions, leave a focused pane), so stop only when nothing
|
|
432
|
+
// else claims the key and something is actually running — a stray press
|
|
433
|
+
// while idle stays a no-op instead of wiping queued work.
|
|
434
|
+
if (key.name === 'escape') {
|
|
435
|
+
const active = instances().some((item) => ['running', 'waiting', 'queued'].includes(item.status));
|
|
436
|
+
if (active)
|
|
437
|
+
void command('/cancel').catch((error) => { notice = String(error); refresh(); });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
260
440
|
if (matches.length && (key.name === 'tab' || ((!key.meta) && (key.name === 'enter' || key.name === 'return')))) {
|
|
261
441
|
setComposerValue(matches[completionIndex].name + (key.name === 'tab' ? ' ' : ''));
|
|
262
442
|
if (key.name === 'tab')
|
|
@@ -268,7 +448,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
268
448
|
if ((key.name === 'enter' || key.name === 'return') && !key.meta) {
|
|
269
449
|
void submit();
|
|
270
450
|
return;
|
|
271
|
-
}
|
|
451
|
+
} // guarded above: never fires inside a paste
|
|
272
452
|
if ((key.meta && (key.name === 'enter' || key.name === 'return')) || (key.ctrl && key.name === 'j')) {
|
|
273
453
|
composerChars.splice(composerCursor++, 0, '\n');
|
|
274
454
|
}
|
|
@@ -312,6 +492,9 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
312
492
|
renderComposer();
|
|
313
493
|
renderComposerFrame();
|
|
314
494
|
};
|
|
495
|
+
// The thinking header spins on a fixed 60ms cadence; spinnerGlyphFrame maps
|
|
496
|
+
// each tick onto an eased burst-and-pause rhythm (fast, then slow) so the
|
|
497
|
+
// animation feels alive instead of metronome-slow.
|
|
315
498
|
const startSpinner = () => {
|
|
316
499
|
if (spinnerTimer || pendingTurns.size === 0)
|
|
317
500
|
return;
|
|
@@ -320,12 +503,17 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
320
503
|
stopSpinner();
|
|
321
504
|
return;
|
|
322
505
|
}
|
|
506
|
+
// Decoration only: frozen while blurred. Skipping a frame leaves the
|
|
507
|
+
// screen untouched and still valid, so it must not mark the frame stale
|
|
508
|
+
// — a quiet refocus after decoration-only blur is the whole point.
|
|
509
|
+
if (!windowFocused)
|
|
510
|
+
return;
|
|
323
511
|
if (nativeSelection || hasSelection())
|
|
324
512
|
return;
|
|
325
|
-
spinnerFrame
|
|
513
|
+
spinnerFrame += 1;
|
|
326
514
|
conversationDirty = true;
|
|
327
515
|
scheduleRefresh();
|
|
328
|
-
},
|
|
516
|
+
}, 60);
|
|
329
517
|
spinnerTimer.unref?.();
|
|
330
518
|
};
|
|
331
519
|
const stopSpinner = () => {
|
|
@@ -334,6 +522,55 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
334
522
|
clearInterval(spinnerTimer);
|
|
335
523
|
spinnerTimer = undefined;
|
|
336
524
|
};
|
|
525
|
+
// While a turn is live, deltas alone cannot be trusted to defeat blessed's
|
|
526
|
+
// row-diff suppression or the viewport-clamped trailing line, so a slow
|
|
527
|
+
// repaint cadence forces the growing transcript onto the screen. The same
|
|
528
|
+
// tick animates the waiting indicator shown before the first token arrives.
|
|
529
|
+
const stopStreamTimer = () => {
|
|
530
|
+
if (!streamTimer)
|
|
531
|
+
return;
|
|
532
|
+
clearInterval(streamTimer);
|
|
533
|
+
streamTimer = undefined;
|
|
534
|
+
};
|
|
535
|
+
const startStreamTimer = () => {
|
|
536
|
+
if (streamTimer || closed)
|
|
537
|
+
return;
|
|
538
|
+
streamTimer = setInterval(() => {
|
|
539
|
+
if (closed || pendingTurns.size === 0) {
|
|
540
|
+
stopStreamTimer();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// Native text selection owns the screen; never dirty or repaint under it.
|
|
544
|
+
if (nativeSelection || hasSelection())
|
|
545
|
+
return;
|
|
546
|
+
// Content heartbeat: while blurred, repaint live text at most once per
|
|
547
|
+
// BLURRED_STREAM_MS so the background window keeps up without flooding.
|
|
548
|
+
if (!throttledFrame())
|
|
549
|
+
return;
|
|
550
|
+
const runningEntry = [...(session.timeline ?? [])].find((entry) => entry.status === 'running' && entry.kind !== 'tool' && entry.kind !== 'shell');
|
|
551
|
+
if (streams.size > 0 || runningEntry) {
|
|
552
|
+
const liveText = [...streams.values()].join('')
|
|
553
|
+
+ [...(session.timeline ?? [])].filter((entry) => entry.status === 'running' && entry.kind === 'message').map((entry) => entry.content).join('');
|
|
554
|
+
if (liveText !== lastPaintedStreamText) {
|
|
555
|
+
// Only a real content change earns a structural repaint; plain diffs
|
|
556
|
+
// stay cheap and flicker-free.
|
|
557
|
+
fullRedrawPending = true;
|
|
558
|
+
lastPaintedStreamText = liveText;
|
|
559
|
+
conversationDirty = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
// The waiting ellipsis is decoration: frozen while blurred. A skipped
|
|
564
|
+
// frame leaves the screen untouched, so it must not mark it stale.
|
|
565
|
+
if (!windowFocused)
|
|
566
|
+
return;
|
|
567
|
+
waitingFrame = (waitingFrame + 1) % 24;
|
|
568
|
+
conversationDirty = true;
|
|
569
|
+
}
|
|
570
|
+
scheduleRefresh();
|
|
571
|
+
}, 90);
|
|
572
|
+
streamTimer.unref?.();
|
|
573
|
+
};
|
|
337
574
|
const focusComposer = () => {
|
|
338
575
|
if (closed)
|
|
339
576
|
return;
|
|
@@ -342,72 +579,208 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
342
579
|
composer.focus();
|
|
343
580
|
renderComposerFrame();
|
|
344
581
|
};
|
|
345
|
-
|
|
582
|
+
/** A small clickable ✕ pinned to a modal's top-right corner. Modals already
|
|
583
|
+
* close on Escape; this gives mouse users the same affordance. */
|
|
584
|
+
const attachCloseButton = (modal, onClose) => {
|
|
585
|
+
const button = blessed.box({
|
|
586
|
+
parent: modal, top: 0, right: 0, width: 3, height: 1, tags: true, mouse: true,
|
|
587
|
+
content: ' {bold}✕{/bold} ',
|
|
588
|
+
style: { bg: COLOR().modal, fg: COLOR().muted, hover: { bg: COLOR().modal, fg: COLOR().error } },
|
|
589
|
+
});
|
|
590
|
+
button.on('click', onClose);
|
|
591
|
+
return button;
|
|
592
|
+
};
|
|
593
|
+
const choose = (title, items, options = {}) => new Promise((resolveChoice) => {
|
|
346
594
|
composerPinned = false;
|
|
347
|
-
const
|
|
595
|
+
const searchable = options.searchable === true;
|
|
596
|
+
const renderItem = (item) => typeof item === 'string'
|
|
348
597
|
? safe(item)
|
|
349
|
-
: `{bold}${safe(item.label)}{/bold}${item.detail ? ` {${COLOR.muted}-fg}${safe(item.detail)}{/${COLOR.muted}-fg}` : ''}
|
|
598
|
+
: `{bold}${safe(item.label)}{/bold}${item.detail ? ` {${COLOR().muted}-fg}${safe(item.detail)}{/${COLOR().muted}-fg}` : ''}`;
|
|
350
599
|
const itemWidths = items.map((item) => typeof item === 'string' ? item.length : Math.max(item.label.length, item.detail?.length ?? 0));
|
|
351
600
|
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));
|
|
601
|
+
const height = Math.min(items.length + (searchable ? 5 : 4), 22, Math.max(searchable ? 7 : 6, Number(screen.height) - 2));
|
|
353
602
|
const modal = blessed.box({
|
|
354
603
|
parent: screen, top: 'center', left: 'center', width, height,
|
|
355
|
-
tags: true, style: { bg: COLOR.modal, fg: COLOR.text },
|
|
604
|
+
tags: true, style: { bg: COLOR().modal, fg: COLOR().text },
|
|
356
605
|
});
|
|
357
606
|
const heading = blessed.box({
|
|
358
607
|
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 },
|
|
608
|
+
content: `{bold}${safe(title)}{/bold}`, style: { bg: COLOR().modal, fg: COLOR().text },
|
|
360
609
|
});
|
|
610
|
+
const filterRow = searchable
|
|
611
|
+
? blessed.box({
|
|
612
|
+
parent: modal, top: 1, left: 1, right: 1, height: 1, tags: true,
|
|
613
|
+
style: { bg: COLOR().modal, fg: COLOR().subtle },
|
|
614
|
+
})
|
|
615
|
+
: undefined;
|
|
361
616
|
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 },
|
|
617
|
+
parent: modal, top: searchable ? 2 : 1, left: 1, right: 1, height: 1, tags: true,
|
|
618
|
+
content: `{${COLOR().modalRule}-fg}${'─'.repeat(Math.max(0, width - 2))}{/${COLOR().modalRule}-fg}`,
|
|
619
|
+
style: { bg: COLOR().modal, fg: COLOR().modalRule },
|
|
365
620
|
});
|
|
366
621
|
const list = blessed.list({
|
|
367
|
-
parent: modal, top: 2, left: 1, right: 1, bottom: 1,
|
|
368
|
-
|
|
622
|
+
parent: modal, top: searchable ? 3 : 2, left: 1, right: 1, bottom: 1,
|
|
623
|
+
items: items.map(renderItem), tags: true, keys: true, vi: !searchable, mouse: true,
|
|
624
|
+
scrollable: true, style: { bg: COLOR().modal, fg: COLOR().text, selected: { bg: COLOR().modal, fg: COLOR().accent, bold: true } },
|
|
369
625
|
});
|
|
626
|
+
// Type-to-filter state: `currentMap` maps displayed rows back to the
|
|
627
|
+
// original items order so selection and highlight stay stable under
|
|
628
|
+
// filtering.
|
|
629
|
+
let filterText = '';
|
|
630
|
+
let currentMap = items.map((_, index) => index);
|
|
631
|
+
const renderFilterRow = () => {
|
|
632
|
+
if (!filterRow)
|
|
633
|
+
return;
|
|
634
|
+
const c = COLOR();
|
|
635
|
+
filterRow.setContent(filterText
|
|
636
|
+
? `{${c.accent}-fg}/ ${safe(filterText)}{/${c.accent}-fg}{${c.subtle}-fg}▌{/${c.subtle}-fg}`
|
|
637
|
+
: `{${c.subtle}-fg}type to filter…{/${c.subtle}-fg}`);
|
|
638
|
+
};
|
|
639
|
+
const applyFilter = () => {
|
|
640
|
+
const query = filterText.trim().toLowerCase();
|
|
641
|
+
currentMap = query
|
|
642
|
+
? items.map((_, index) => index).filter((index) => {
|
|
643
|
+
const item = items[index];
|
|
644
|
+
const haystack = typeof item === 'string' ? item : `${item.label} ${item.detail ?? ''}`;
|
|
645
|
+
return haystack.toLowerCase().includes(query);
|
|
646
|
+
})
|
|
647
|
+
: items.map((_, index) => index);
|
|
648
|
+
if (currentMap.length) {
|
|
649
|
+
list.setItems(currentMap.map((index) => renderItem(items[index])));
|
|
650
|
+
list.select(0);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
list.setItems([`{${COLOR().subtle}-fg} no matches{/${COLOR().subtle}-fg}`]);
|
|
654
|
+
}
|
|
655
|
+
renderFilterRow();
|
|
656
|
+
screen.render();
|
|
657
|
+
};
|
|
658
|
+
if (searchable && filterRow) {
|
|
659
|
+
list.on('keypress', (ch, key) => {
|
|
660
|
+
if (done || key?.ctrl || key?.meta)
|
|
661
|
+
return;
|
|
662
|
+
if (key?.name === 'backspace') {
|
|
663
|
+
if (!filterText)
|
|
664
|
+
return;
|
|
665
|
+
filterText = filterText.slice(0, -1);
|
|
666
|
+
applyFilter();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const printable = typeof ch === 'string' && ch.length === 1 && ch >= ' ' && ch !== '\x7f';
|
|
670
|
+
if (!printable)
|
|
671
|
+
return;
|
|
672
|
+
filterText += ch;
|
|
673
|
+
applyFilter();
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
const closeButton = attachCloseButton(modal, () => finish(-1));
|
|
677
|
+
// The modal captures style objects at creation time; while a live preview
|
|
678
|
+
// swaps the active palette, re-patch it so it does not keep the palette
|
|
679
|
+
// it was opened with.
|
|
680
|
+
const restyleModal = () => {
|
|
681
|
+
const c = COLOR();
|
|
682
|
+
modal.style.bg = c.modal;
|
|
683
|
+
modal.style.fg = c.text;
|
|
684
|
+
heading.style.bg = c.modal;
|
|
685
|
+
heading.style.fg = c.text;
|
|
686
|
+
rule.style.bg = c.modal;
|
|
687
|
+
rule.style.fg = c.modalRule;
|
|
688
|
+
rule.setContent(`{${c.modalRule}-fg}${'─'.repeat(Math.max(0, width - 2))}{/${c.modalRule}-fg}`);
|
|
689
|
+
if (filterRow) {
|
|
690
|
+
filterRow.style.bg = c.modal;
|
|
691
|
+
renderFilterRow();
|
|
692
|
+
}
|
|
693
|
+
list.style.bg = c.modal;
|
|
694
|
+
list.style.fg = c.text;
|
|
695
|
+
// Row elements resolve their palette from list.style.item on render;
|
|
696
|
+
// re-create it or the picker keeps the palette it opened with.
|
|
697
|
+
list.style.item = { bg: c.modal, fg: c.text };
|
|
698
|
+
list.style.selected = { bg: c.modal, fg: c.accent, bold: true };
|
|
699
|
+
closeButton.style.bg = c.modal;
|
|
700
|
+
closeButton.style.fg = c.muted;
|
|
701
|
+
closeButton.style.hover = { bg: c.modal, fg: c.error };
|
|
702
|
+
// Respect the active filter instead of resetting to the full list.
|
|
703
|
+
if (currentMap.length)
|
|
704
|
+
list.setItems(currentMap.map((index) => renderItem(items[index])));
|
|
705
|
+
else
|
|
706
|
+
list.setItems([`{${c.subtle}-fg} no matches{/${c.subtle}-fg}`]);
|
|
707
|
+
};
|
|
370
708
|
let done = false;
|
|
371
709
|
const finish = (value) => {
|
|
372
710
|
if (done)
|
|
373
711
|
return;
|
|
374
712
|
done = true;
|
|
375
713
|
modal.destroy();
|
|
714
|
+
requestFullRedraw();
|
|
376
715
|
composerPinned = true;
|
|
377
716
|
focusComposer();
|
|
378
717
|
resolveChoice(value);
|
|
379
718
|
};
|
|
380
|
-
list.on('select', (_item, index) =>
|
|
381
|
-
|
|
719
|
+
list.on('select', (_item, index) => {
|
|
720
|
+
const mapped = typeof index === 'number' ? currentMap[index] : undefined;
|
|
721
|
+
if (typeof mapped === 'number')
|
|
722
|
+
finish(mapped);
|
|
723
|
+
});
|
|
724
|
+
// setItems() re-emits 'select item' while restoring the selection, so the
|
|
725
|
+
// preview handler must be re-entrancy guarded or it recurses forever.
|
|
726
|
+
let restyling = false;
|
|
727
|
+
list.on('select item', (_item, index) => {
|
|
728
|
+
if (done || restyling || !options.onHighlight || typeof index !== 'number' || index < 0)
|
|
729
|
+
return;
|
|
730
|
+
const mapped = currentMap[index];
|
|
731
|
+
if (typeof mapped !== 'number')
|
|
732
|
+
return;
|
|
733
|
+
restyling = true;
|
|
734
|
+
try {
|
|
735
|
+
options.onHighlight(mapped);
|
|
736
|
+
restyleModal();
|
|
737
|
+
renderScreen();
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
restyling = false;
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
if (searchable) {
|
|
744
|
+
// `q` types into the filter here, so Escape (and ✕) are the only
|
|
745
|
+
// dismissal shortcuts.
|
|
746
|
+
list.key(['escape'], () => finish(-1));
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
list.key(['escape', 'q'], () => finish(-1));
|
|
750
|
+
}
|
|
382
751
|
list.focus();
|
|
752
|
+
if (typeof options.initial === 'number' && options.initial > 0)
|
|
753
|
+
list.select(options.initial);
|
|
754
|
+
renderFilterRow();
|
|
383
755
|
void heading;
|
|
384
756
|
void rule;
|
|
385
|
-
|
|
757
|
+
renderScreen();
|
|
386
758
|
});
|
|
387
759
|
const ask = (label, initial = '', secret = false) => new Promise((resolveAnswer) => {
|
|
388
760
|
composerPinned = false;
|
|
389
761
|
const width = Math.min(76, Math.max(28, Number(screen.width) - 4));
|
|
390
762
|
const modal = blessed.box({
|
|
391
763
|
parent: screen, top: 'center', left: 'center', width, height: 7,
|
|
392
|
-
style: { bg: COLOR.modal, fg: COLOR.text },
|
|
764
|
+
style: { bg: COLOR().modal, fg: COLOR().text },
|
|
393
765
|
});
|
|
394
766
|
blessed.box({
|
|
395
767
|
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 },
|
|
768
|
+
content: `{bold}${safe(label)}{/bold}`, style: { bg: COLOR().modal, fg: COLOR().text },
|
|
397
769
|
});
|
|
398
770
|
blessed.box({
|
|
399
771
|
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 },
|
|
772
|
+
content: '─'.repeat(Math.max(0, width - 2)), style: { bg: COLOR().modal, fg: COLOR().modalRule },
|
|
401
773
|
});
|
|
402
774
|
const input = blessed.textbox({
|
|
403
775
|
parent: modal, top: 3, left: 1, right: 1, height: 1,
|
|
404
776
|
inputOnFocus: true, keys: true, mouse: true, censor: secret,
|
|
405
|
-
style: { bg: COLOR.modal, fg: COLOR.text, focus: { bg: COLOR.modal, fg: COLOR.text } },
|
|
777
|
+
style: { bg: COLOR().modal, fg: COLOR().text, focus: { bg: COLOR().modal, fg: COLOR().text } },
|
|
406
778
|
});
|
|
407
779
|
blessed.box({
|
|
408
780
|
parent: modal, bottom: 0, left: 1, right: 1, height: 1,
|
|
409
|
-
content: 'Enter confirm · Esc cancel', style: { bg: COLOR.modal, fg: COLOR.modalRule },
|
|
781
|
+
content: 'Enter confirm · Esc cancel', style: { bg: COLOR().modal, fg: COLOR().modalRule },
|
|
410
782
|
});
|
|
783
|
+
attachCloseButton(modal, () => finish(''));
|
|
411
784
|
input.setValue(initial);
|
|
412
785
|
let done = false;
|
|
413
786
|
const finish = (value) => {
|
|
@@ -415,6 +788,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
415
788
|
return;
|
|
416
789
|
done = true;
|
|
417
790
|
modal.destroy();
|
|
791
|
+
requestFullRedraw();
|
|
418
792
|
composerPinned = true;
|
|
419
793
|
focusComposer();
|
|
420
794
|
resolveAnswer(value.trim());
|
|
@@ -424,24 +798,82 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
424
798
|
input.key('escape', () => finish(''));
|
|
425
799
|
input.focus();
|
|
426
800
|
input.readInput();
|
|
427
|
-
|
|
801
|
+
renderScreen();
|
|
428
802
|
});
|
|
429
803
|
const instances = () => [...instanceCache.values()];
|
|
804
|
+
// Cross-process awareness: periodically look for other live maw instances
|
|
805
|
+
// in the same workspace so the status bar can warn before edits collide.
|
|
806
|
+
let otherInstances = [];
|
|
807
|
+
const instancePoll = setInterval(() => {
|
|
808
|
+
void otherWorkspaceInstances(runtime.workspace()).then((found) => {
|
|
809
|
+
const changed = found.length !== otherInstances.length
|
|
810
|
+
|| found.some((item, index) => item.pid !== otherInstances[index]?.pid);
|
|
811
|
+
otherInstances = found;
|
|
812
|
+
if (changed) {
|
|
813
|
+
renderStatus();
|
|
814
|
+
screen.render();
|
|
815
|
+
}
|
|
816
|
+
}).catch(() => undefined);
|
|
817
|
+
}, 15_000);
|
|
818
|
+
instancePoll.unref?.();
|
|
819
|
+
void otherWorkspaceInstances(runtime.workspace()).then((found) => {
|
|
820
|
+
otherInstances = found;
|
|
821
|
+
renderStatus();
|
|
822
|
+
}).catch(() => undefined);
|
|
430
823
|
const depthPrefix = (instance) => {
|
|
431
824
|
const status = STATUS_PRESENTATION[instance.status];
|
|
432
|
-
const color = TONE_COLOR
|
|
825
|
+
const color = TONE_COLOR(status.tone);
|
|
433
826
|
return `${' '.repeat(instance.depth)}{${color}-fg}${status.icon}{/${color}-fg}`;
|
|
434
827
|
};
|
|
435
828
|
const renderStatus = () => {
|
|
436
829
|
const active = instances().filter((item) => item.status === 'running' || item.status === 'waiting' || item.status === 'queued').length;
|
|
437
830
|
const activityText = active ? `${active} active` : 'Ready';
|
|
831
|
+
const usage = instances().reduce((total, item) => {
|
|
832
|
+
total.input += item.usage?.inputTokens ?? 0;
|
|
833
|
+
total.output += item.usage?.outputTokens ?? 0;
|
|
834
|
+
total.cached += item.usage?.cachedInputTokens ?? 0;
|
|
835
|
+
if (item.usage?.firstTokenMs !== undefined)
|
|
836
|
+
total.firstTokenMs = total.firstTokenMs === undefined ? item.usage.firstTokenMs : Math.min(total.firstTokenMs, item.usage.firstTokenMs);
|
|
837
|
+
return total;
|
|
838
|
+
}, { input: 0, output: 0, cached: 0, firstTokenMs: undefined });
|
|
839
|
+
const usageText = usage.input || usage.output ? ` · ${usage.input + usage.output} tok${usage.cached ? ` (${usage.cached} cached)` : ''}${usage.firstTokenMs !== undefined ? ` · first ${usage.firstTokenMs}ms` : ''}` : '';
|
|
438
840
|
const width = Math.max(1, Number(screen.width) - 2);
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
const
|
|
841
|
+
const home = process.env.HOME ? resolvePath(process.env.HOME) : undefined;
|
|
842
|
+
const cwd = runtime.workspace();
|
|
843
|
+
const cwdText = home && cwd.startsWith(home + sep) ? `~${cwd.slice(home.length)}` : cwd;
|
|
844
|
+
const left = `maw ${activeModel} ${cwdText}`;
|
|
845
|
+
// Surface the mode-specific Ctrl+C semantics so the double-press quit is
|
|
846
|
+
// never a surprise, and [side] marks a /btw conversation.
|
|
847
|
+
const ctrlHint = isBtw() ? 'Ctrl+C back' : 'Ctrl+C x2 quit';
|
|
848
|
+
// Cross-process state: a read-only badge when another process owns this
|
|
849
|
+
// session, and a warning when other maw instances are live in the
|
|
850
|
+
// same workspace (file conflicts are detected, not hidden).
|
|
851
|
+
const access = runtime.sessionAccess(sessionId);
|
|
852
|
+
const accessBadge = access.writable
|
|
853
|
+
? ''
|
|
854
|
+
: ` {${COLOR().error}-fg}[read-only${access.holderPid ? ` pid ${access.holderPid}` : ''}]{/${COLOR().error}-fg}`;
|
|
855
|
+
const instanceBadge = otherInstances.length
|
|
856
|
+
? ` {${COLOR().warning}-fg}⚠ ${otherInstances.length} other maw${otherInstances.length === 1 ? '' : 's'}{/${COLOR().warning}-fg}`
|
|
857
|
+
: '';
|
|
858
|
+
// A standing goal takes priority over the quit hint; the hint yields so
|
|
859
|
+
// the goal never gets truncated below its floor.
|
|
860
|
+
const right = session.goal
|
|
861
|
+
? `${activityText}${usageText}`
|
|
862
|
+
: Number(screen.width) >= 78
|
|
863
|
+
? `${activityText}${usageText} · Ctrl+K commands · ${ctrlHint}`
|
|
864
|
+
: `${activityText}${usageText} · ${ctrlHint}`;
|
|
865
|
+
const goal = session.goal;
|
|
866
|
+
// The standing goal rides the right cluster so the layout stays a single
|
|
867
|
+
// left/right split; a separate child widget would fight the statusbar's
|
|
868
|
+
// setContent-based repaint. Its budget is whatever the left and right
|
|
869
|
+
// clusters leave over, floored so it never collapses to nothing.
|
|
870
|
+
const goalBudget = Math.max(8, width - Number(statusbar.strWidth(left)) - Number(statusbar.strWidth(right)) - 4);
|
|
871
|
+
const goalText = goal ? ` ⚑ ${oneLine(goal, goalBudget)}` : '';
|
|
872
|
+
const rightText = `${right}${goalText}`;
|
|
873
|
+
const gap = width - Number(statusbar.strWidth(left)) - Number(statusbar.strWidth(rightText));
|
|
442
874
|
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}` : ''}`);
|
|
875
|
+
? `{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}`
|
|
876
|
+
: `{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
877
|
};
|
|
446
878
|
const conversationAtBottom = () => {
|
|
447
879
|
const viewportHeight = Math.max(0, Number(conversation.height) - Number(conversation.iheight));
|
|
@@ -471,62 +903,121 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
471
903
|
const metrics = tuiLayout(screenWidth, activityVisible);
|
|
472
904
|
const markdownCols = Math.max(10, Math.min(120, metrics.conversationWidth - metrics.horizontalPadding * 2 - 2));
|
|
473
905
|
thinkingBlockLines.clear();
|
|
474
|
-
|
|
906
|
+
// The welcome screen yields to any conversation content — messages,
|
|
907
|
+
// streaming output, thinking, or transcript entries like shell runs.
|
|
908
|
+
const welcomeVisible = !session.messages.length && !streams.size && thinkingBlocks.size === 0 && !(session.timeline?.length);
|
|
475
909
|
if (welcomeVisible) {
|
|
910
|
+
// A committed theme change rebases the welcome clock so the mark replays
|
|
911
|
+
// its opening act under the new palette; preview highlights and Esc/✕
|
|
912
|
+
// rollbacks never set the flag, so they only recolor in place.
|
|
913
|
+
if (themeIntroReplay) {
|
|
914
|
+
themeIntroReplay = false;
|
|
915
|
+
// Rebase the welcome clock: the next timer tick derives frame 1 from
|
|
916
|
+
// this moment, and the loop's own shine phase restarts seamlessly
|
|
917
|
+
// because every intro lands on the settled frame-20 state.
|
|
918
|
+
welcomeStartedAt = performance.now();
|
|
919
|
+
welcomeFrame = 0;
|
|
920
|
+
}
|
|
476
921
|
for (const line of renderWelcome(Number(conversation.width) - Number(conversation.iwidth) - 1, Number(conversation.height) - Number(conversation.iheight), Number(screen.height), welcomeFrame))
|
|
477
922
|
pushConversationLine(line);
|
|
478
923
|
if (!welcomeTimer) {
|
|
924
|
+
welcomeStartedAt = performance.now();
|
|
479
925
|
welcomeTimer = setInterval(() => {
|
|
926
|
+
// Decoration only: frozen while blurred. A skipped frame leaves the
|
|
927
|
+
// screen untouched, so it must not mark the frame stale.
|
|
928
|
+
if (!windowFocused)
|
|
929
|
+
return;
|
|
480
930
|
if (nativeSelection || hasSelection() || screen.focused !== composer)
|
|
481
931
|
return;
|
|
482
|
-
|
|
932
|
+
// The frame derives from the monotonic clock instead of a counter:
|
|
933
|
+
// after sleep or background suspension the animation lands on the
|
|
934
|
+
// correct phase in one step, with no backlog of missed ticks.
|
|
935
|
+
welcomeFrame = Math.floor((performance.now() - welcomeStartedAt) / 50);
|
|
936
|
+
// The frame only reaches the screen if the conversation actually
|
|
937
|
+
// re-renders; a bare refresh would early-return on a clean buffer.
|
|
938
|
+
conversationDirty = true;
|
|
483
939
|
scheduleRefresh();
|
|
484
940
|
}, 50);
|
|
485
941
|
welcomeTimer.unref?.();
|
|
486
942
|
}
|
|
487
943
|
}
|
|
488
|
-
else
|
|
489
|
-
|
|
490
|
-
|
|
944
|
+
else {
|
|
945
|
+
// The welcome screen is hidden: a pending replay would otherwise fire
|
|
946
|
+
// stale months later (e.g. when /clear finally reveals the banner).
|
|
947
|
+
themeIntroReplay = false;
|
|
948
|
+
if (welcomeTimer) {
|
|
949
|
+
clearInterval(welcomeTimer);
|
|
950
|
+
welcomeTimer = undefined;
|
|
951
|
+
}
|
|
491
952
|
}
|
|
492
953
|
const renderedBlocks = new Set();
|
|
493
954
|
if (session.timeline) {
|
|
494
955
|
const { entries: visibleTimeline, omitted } = visibleTimelineEntries(session.timeline);
|
|
495
956
|
if (omitted) {
|
|
496
|
-
pushConversationLine(`{${COLOR.subtle}-fg} ${omitted} earlier activity entries omitted from this view{/${COLOR.subtle}-fg}`);
|
|
957
|
+
pushConversationLine(`{${COLOR().subtle}-fg} ${omitted} earlier activity entries omitted from this view{/${COLOR().subtle}-fg}`);
|
|
497
958
|
pushConversationLine('');
|
|
498
959
|
}
|
|
499
960
|
for (const entry of visibleTimeline) {
|
|
500
961
|
if (entry.kind === 'message') {
|
|
501
962
|
pushConversationLine('');
|
|
502
963
|
if (entry.role === 'user') {
|
|
503
|
-
pushConversationLine(`{${COLOR.accent}-fg}{bold}You{/bold}{/${COLOR.accent}-fg}`);
|
|
964
|
+
pushConversationLine(`{${COLOR().accent}-fg}{bold}You{/bold}{/${COLOR().accent}-fg}`);
|
|
504
965
|
pushConversationLine(safe(entry.content));
|
|
505
966
|
}
|
|
506
967
|
else if (entry.role === 'system') {
|
|
507
|
-
pushConversationLine(`{${COLOR.warning}-fg}! ${safe(entry.content)}{/${COLOR.warning}-fg}`);
|
|
968
|
+
pushConversationLine(`{${COLOR().warning}-fg}! ${safe(entry.content)}{/${COLOR().warning}-fg}`);
|
|
508
969
|
}
|
|
509
970
|
else {
|
|
510
|
-
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
971
|
+
pushConversationLine(`{${COLOR().muted}-fg}{bold}TokenMaw{/bold}{/${COLOR().muted}-fg}`);
|
|
511
972
|
pushConversationLine(renderTuiMarkdown(entry.content, markdownCols));
|
|
512
973
|
}
|
|
513
974
|
pushConversationLine('');
|
|
514
975
|
continue;
|
|
515
976
|
}
|
|
977
|
+
if (entry.kind === 'shell') {
|
|
978
|
+
// User-typed shell run: the command line wears a dedicated color so
|
|
979
|
+
// it reads as a user action, not agent activity; the status glyph
|
|
980
|
+
// keeps its own tone. Output streams beneath as plain text.
|
|
981
|
+
pushConversationLine('');
|
|
982
|
+
const running = entry.status === 'running';
|
|
983
|
+
const commandColor = COLOR().warning;
|
|
984
|
+
const stateLabel = running
|
|
985
|
+
? waitingIndicatorFrame(shellAnimationFrame, { accent: COLOR().accent, subtle: COLOR().subtle })
|
|
986
|
+
: entry.status === 'failed'
|
|
987
|
+
? `✗ exit ${entry.exitCode ?? 1}`
|
|
988
|
+
: entry.status === 'cancelled'
|
|
989
|
+
? '× stopped'
|
|
990
|
+
: '✓';
|
|
991
|
+
const iconColor = running ? COLOR().accent
|
|
992
|
+
: entry.status === 'failed' ? COLOR().error
|
|
993
|
+
: entry.status === 'cancelled' ? COLOR().muted : COLOR().success;
|
|
994
|
+
pushConversationLine(`{${commandColor}-fg}{bold}! ${safe(entry.input ?? '')}{/bold}{/${commandColor}-fg} ${running ? stateLabel : `{${iconColor}-fg}${stateLabel}{/${iconColor}-fg}`}`);
|
|
995
|
+
const outputLines = safe(entry.content).split('\n');
|
|
996
|
+
const maxOutputLines = 400;
|
|
997
|
+
if (outputLines.length > maxOutputLines) {
|
|
998
|
+
pushConversationLine(` {${COLOR().subtle}-fg}… ${outputLines.length - maxOutputLines} earlier output lines hidden{/${COLOR().subtle}-fg}`);
|
|
999
|
+
}
|
|
1000
|
+
for (const line of outputLines.slice(-maxOutputLines)) {
|
|
1001
|
+
if (line.length > 0)
|
|
1002
|
+
pushConversationLine(` ${line}`);
|
|
1003
|
+
}
|
|
1004
|
+
pushConversationLine('');
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
516
1007
|
const expanded = thinkingBlocks.get(entry.id)?.expanded ?? false;
|
|
517
1008
|
const previous = thinkingBlocks.get(entry.id);
|
|
518
1009
|
const block = { turnId: entry.id, expanded, content: previous?.content ?? [],
|
|
519
1010
|
status: entry.status === 'running' ? 'active' : 'completed',
|
|
520
1011
|
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 };
|
|
1012
|
+
startedAt: previous?.startedAt ?? entry.startedAt ?? thinkingStartedAt.get(entry.turnId ?? '') ?? (entry.status === 'running' ? Date.now() : undefined),
|
|
1013
|
+
finishedAt: entry.status === 'running' ? undefined : previous?.finishedAt ?? entry.endedAt ?? Date.now() };
|
|
523
1014
|
thinkingBlocks.set(entry.id, block);
|
|
524
1015
|
if (entry.kind === 'thinking') {
|
|
525
1016
|
renderThinkingBlock(block);
|
|
526
1017
|
}
|
|
527
1018
|
else {
|
|
528
1019
|
const headerLine = lineCursor;
|
|
529
|
-
thinkingBlockLines.set(entry.id, { headerLine });
|
|
1020
|
+
thinkingBlockLines.set(entry.id, { headerLine, lastLine: headerLine });
|
|
530
1021
|
latestThinkingTurnId = entry.id;
|
|
531
1022
|
const agent = entry.instanceId ? instanceCache.get(entry.instanceId)?.agentId : undefined;
|
|
532
1023
|
const state = entry.status === 'running'
|
|
@@ -536,18 +1027,18 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
536
1027
|
: entry.status === 'cancelled'
|
|
537
1028
|
? STATUS_PRESENTATION.cancelled
|
|
538
1029
|
: STATUS_PRESENTATION.idle;
|
|
539
|
-
const color = TONE_COLOR
|
|
1030
|
+
const color = TONE_COLOR(state.tone);
|
|
540
1031
|
const presentation = toolPresentation(entry.tool ?? '', entry.input);
|
|
541
1032
|
const owner = agent && agent !== 'main' ? `${agent} · ` : '';
|
|
542
1033
|
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}`);
|
|
1034
|
+
pushConversationLine(`{${color}-fg}${expanded ? '▼' : '▶'} ${state.icon}{/${color}-fg} {${COLOR().muted}-fg}${safe(owner)}${safe(presentation.label)}${safe(detail)}{/${COLOR().muted}-fg}`);
|
|
544
1035
|
if (expanded) {
|
|
545
1036
|
if (entry.input) {
|
|
546
|
-
pushConversationLine(` {${COLOR.subtle}-fg}Input{/${COLOR.subtle}-fg}`);
|
|
1037
|
+
pushConversationLine(` {${COLOR().subtle}-fg}Input{/${COLOR().subtle}-fg}`);
|
|
547
1038
|
for (const line of safe(entry.input).split('\n'))
|
|
548
1039
|
pushConversationLine(` ${line}`);
|
|
549
1040
|
}
|
|
550
|
-
pushConversationLine(` {${COLOR.subtle}-fg}${entry.status === 'running' ? 'Output · running' : 'Output'}{/${COLOR.subtle}-fg}`);
|
|
1041
|
+
pushConversationLine(` {${COLOR().subtle}-fg}${entry.status === 'running' ? 'Output · running' : 'Output'}{/${COLOR().subtle}-fg}`);
|
|
551
1042
|
for (const line of renderTuiMarkdown(entry.content || 'Waiting for output…', Math.max(10, markdownCols - 2)).split('\n'))
|
|
552
1043
|
pushConversationLine(` ${line}`);
|
|
553
1044
|
}
|
|
@@ -556,7 +1047,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
556
1047
|
if (patch)
|
|
557
1048
|
pushConversationLine(renderTuiMarkdown(diffPreview(patch), markdownCols));
|
|
558
1049
|
if (entry.status === 'failed')
|
|
559
|
-
pushConversationLine(` {${COLOR.error}-fg}${safe(oneLine(entry.content, markdownCols - 2))}{/${COLOR.error}-fg}`);
|
|
1050
|
+
pushConversationLine(` {${COLOR().error}-fg}${safe(oneLine(entry.content, markdownCols - 2))}{/${COLOR().error}-fg}`);
|
|
560
1051
|
}
|
|
561
1052
|
pushConversationLine('');
|
|
562
1053
|
}
|
|
@@ -569,15 +1060,15 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
569
1060
|
: safe(message.content);
|
|
570
1061
|
pushConversationLine('');
|
|
571
1062
|
if (user) {
|
|
572
|
-
pushConversationLine(`{${COLOR.accent}-fg}{bold}You{/bold}{/${COLOR.accent}-fg}`);
|
|
1063
|
+
pushConversationLine(`{${COLOR().accent}-fg}{bold}You{/bold}{/${COLOR().accent}-fg}`);
|
|
573
1064
|
pushConversationLine(content);
|
|
574
1065
|
}
|
|
575
1066
|
else if (message.role === 'assistant') {
|
|
576
|
-
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
1067
|
+
pushConversationLine(`{${COLOR().muted}-fg}{bold}TokenMaw{/bold}{/${COLOR().muted}-fg}`);
|
|
577
1068
|
pushConversationLine(content);
|
|
578
1069
|
}
|
|
579
1070
|
else {
|
|
580
|
-
pushConversationLine(`{${COLOR.warning}-fg}! ${content}{/${COLOR.warning}-fg}`);
|
|
1071
|
+
pushConversationLine(`{${COLOR().warning}-fg}! ${content}{/${COLOR().warning}-fg}`);
|
|
581
1072
|
}
|
|
582
1073
|
pushConversationLine('');
|
|
583
1074
|
if (message.role === 'user' && message.turnId && thinkingBlocks.has(message.turnId)) {
|
|
@@ -585,10 +1076,32 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
585
1076
|
renderThinkingBlock(thinkingBlocks.get(message.turnId));
|
|
586
1077
|
}
|
|
587
1078
|
}
|
|
1079
|
+
if (isWaitingForFirstToken({
|
|
1080
|
+
pendingTurns: pendingTurns.size,
|
|
1081
|
+
streamingEntries: streams.size,
|
|
1082
|
+
runningTimelineEntries: [...(session.timeline ?? [])].filter((entry) => entry.status === 'running' && entry.kind !== 'tool' && entry.kind !== 'shell').length,
|
|
1083
|
+
sessionHasTimeline: Boolean(session.timeline),
|
|
1084
|
+
})) {
|
|
1085
|
+
// Only a confirmed thinking delta switches the slot to Thinking; until
|
|
1086
|
+
// then the ellipsis stands. Deltas emit through onEvent, which always
|
|
1087
|
+
// coalesces into a refresh via scheduleRefresh, so the very next frame
|
|
1088
|
+
// after the first reasoning token shows the Thinking header.
|
|
1089
|
+
const pendingBlock = [...thinkingBlocks.values()].reverse().find((block) => block.status === 'active' && block.thinking);
|
|
1090
|
+
if (pendingBlock) {
|
|
1091
|
+
renderThinkingBlock(pendingBlock);
|
|
1092
|
+
}
|
|
1093
|
+
else {
|
|
1094
|
+
pushConversationLine('');
|
|
1095
|
+
pushConversationLine(`{${COLOR().muted}-fg}{bold}TokenMaw{/bold}{/${COLOR().muted}-fg}`);
|
|
1096
|
+
pushConversationLine(waitingIndicatorFrame(waitingFrame, { accent: COLOR().accent, subtle: COLOR().subtle }));
|
|
1097
|
+
pushConversationLine('');
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
588
1100
|
if (!session.timeline && pendingTurns.size > 0) {
|
|
589
1101
|
const turnId = [...pendingTurns][0];
|
|
590
1102
|
if (!thinkingBlocks.has(turnId)) {
|
|
591
1103
|
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
1104
|
+
markThinkingStart(turnId);
|
|
592
1105
|
}
|
|
593
1106
|
if (!renderedBlocks.has(turnId)) {
|
|
594
1107
|
renderThinkingBlock(thinkingBlocks.get(turnId));
|
|
@@ -598,13 +1111,13 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
598
1111
|
if (!text.trim())
|
|
599
1112
|
continue;
|
|
600
1113
|
pushConversationLine('');
|
|
601
|
-
pushConversationLine(`{${COLOR.muted}-fg}{bold}TokenMaw{/bold}{/${COLOR.muted}-fg}`);
|
|
1114
|
+
pushConversationLine(`{${COLOR().muted}-fg}{bold}TokenMaw{/bold}{/${COLOR().muted}-fg}`);
|
|
602
1115
|
pushConversationLine(renderTuiMarkdown(text, markdownCols));
|
|
603
1116
|
pushConversationLine('');
|
|
604
1117
|
}
|
|
605
1118
|
if (notice) {
|
|
606
1119
|
pushConversationLine('');
|
|
607
|
-
const noticeColor = /^Error\b|failed/i.test(notice) ? COLOR.error : COLOR.warning;
|
|
1120
|
+
const noticeColor = /^Error\b|failed/i.test(notice) ? COLOR().error : COLOR().warning;
|
|
608
1121
|
pushConversationLine(`{${noticeColor}-fg}! ${safe(notice)}{/${noticeColor}-fg}`);
|
|
609
1122
|
pushConversationLine('');
|
|
610
1123
|
}
|
|
@@ -625,29 +1138,141 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
625
1138
|
finally {
|
|
626
1139
|
restoringConversationScroll = false;
|
|
627
1140
|
}
|
|
1141
|
+
applyStickyHeader(conversation.childBase);
|
|
628
1142
|
conversationFollowOutput = shouldFollowOutput;
|
|
629
1143
|
conversationDirty = false;
|
|
630
1144
|
};
|
|
1145
|
+
// Pin the collapse header of an expanded block to the conversation top while
|
|
1146
|
+
// the user is reading that block's body. The pinned row is a fixed overlay on
|
|
1147
|
+
// the viewport's first row, and it disappears again as soon as the block's
|
|
1148
|
+
// real header scrolls back into view or the whole block scrolls past the top.
|
|
1149
|
+
const applyStickyHeader = (viewportTop) => {
|
|
1150
|
+
const visibleRows = Math.max(1, Number(conversation.height) - Number(conversation.iheight));
|
|
1151
|
+
const scrollHeight = conversation.getScrollHeight();
|
|
1152
|
+
// Nothing is scrolled out of view when the content fits, so no block can
|
|
1153
|
+
// need a pinned header — fall through to the hide branch below so any
|
|
1154
|
+
// sticky row left over from before the content shrank is cleared too.
|
|
1155
|
+
let sticky;
|
|
1156
|
+
if (scrollHeight > visibleRows) {
|
|
1157
|
+
const topRow = Math.max(0, Math.min(scrollHeight - visibleRows, viewportTop));
|
|
1158
|
+
const clines = conversation._clines;
|
|
1159
|
+
// Block positions are logical content rows, but scrolling (childBase) is
|
|
1160
|
+
// counted in rendered rows: long wrapped lines make the two diverge.
|
|
1161
|
+
// Translate through ftor so the viewport is never considered to have
|
|
1162
|
+
// left a block it is still inside.
|
|
1163
|
+
const logicalSpan = (real) => {
|
|
1164
|
+
const bucket = clines?.ftor?.[real];
|
|
1165
|
+
if (!bucket || bucket.length === 0)
|
|
1166
|
+
return { first: real, last: real };
|
|
1167
|
+
return { first: Number(bucket[0]), last: Number(bucket[bucket.length - 1]) };
|
|
1168
|
+
};
|
|
1169
|
+
const pinFor = (turnId, block, position) => {
|
|
1170
|
+
const line = stickyHeaderLine(block);
|
|
1171
|
+
return {
|
|
1172
|
+
turnId,
|
|
1173
|
+
line,
|
|
1174
|
+
lastLine: logicalSpan(position.lastLine).last,
|
|
1175
|
+
// The redraw key excludes the spinner glyph: its 60ms animation must
|
|
1176
|
+
// not force full-screen reallocations, while label/duration changes
|
|
1177
|
+
// (which only grow or switch) still do.
|
|
1178
|
+
redrawKey: `${turnId} :: ${line.replace(spinnerGlyph(spinnerFrame), '')}`,
|
|
1179
|
+
};
|
|
1180
|
+
};
|
|
1181
|
+
// Keep the current sticky row only while its block still occupies the
|
|
1182
|
+
// viewport top: the real header sits above the top row and the block
|
|
1183
|
+
// body has not fully scrolled past it yet.
|
|
1184
|
+
const pos = stickyHeader ? thinkingBlockLines.get(stickyHeader.turnId) : undefined;
|
|
1185
|
+
const keptBlock = stickyHeader && pos ? thinkingBlocks.get(stickyHeader.turnId) : undefined;
|
|
1186
|
+
if (stickyHeader && pos && keptBlock?.expanded) {
|
|
1187
|
+
const header = logicalSpan(pos.headerLine);
|
|
1188
|
+
const tail = logicalSpan(pos.lastLine);
|
|
1189
|
+
if (header.first < topRow && tail.last >= topRow) {
|
|
1190
|
+
// Regenerate the row so a live block's spinner glyph and elapsed
|
|
1191
|
+
// seconds keep updating instead of freezing at the pinning frame.
|
|
1192
|
+
sticky = pinFor(stickyHeader.turnId, keptBlock, pos);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (!sticky) {
|
|
1196
|
+
// Several expanded blocks may sit above the viewport; the one to pin
|
|
1197
|
+
// is the unique block whose rendered span still contains the top row.
|
|
1198
|
+
for (const [turnId, position] of thinkingBlockLines) {
|
|
1199
|
+
const block = thinkingBlocks.get(turnId);
|
|
1200
|
+
if (!block?.expanded)
|
|
1201
|
+
continue;
|
|
1202
|
+
const header = logicalSpan(position.headerLine);
|
|
1203
|
+
const tail = logicalSpan(position.lastLine);
|
|
1204
|
+
if (header.first < topRow && tail.last >= topRow) {
|
|
1205
|
+
sticky = pinFor(turnId, block, position);
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
invalidateStickyIfChanged(sticky);
|
|
1212
|
+
const conversationExt = conversation;
|
|
1213
|
+
if (!sticky) {
|
|
1214
|
+
conversationExt._listWrapper?.hide();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
// The pinned header is a fixed overlay anchored at the viewport's first
|
|
1218
|
+
// row; it does not consume a logical content row.
|
|
1219
|
+
conversationExt._listWrapper ??= (() => {
|
|
1220
|
+
// Full parent width plus the same left/right padding as the content
|
|
1221
|
+
// stream keeps the pinned header aligned with the real header row.
|
|
1222
|
+
// `fixed` exempts the overlay from the scrollable parent's childBase
|
|
1223
|
+
// offset, so it stays anchored at the viewport's top row instead of
|
|
1224
|
+
// scrolling out of view together with the conversation content.
|
|
1225
|
+
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 } });
|
|
1226
|
+
wrapper.on('click', () => {
|
|
1227
|
+
// Blessed bubbles this click up to the conversation box; the flag
|
|
1228
|
+
// consumes the bubbled copy so the block is toggled exactly once.
|
|
1229
|
+
stickyClickHandled = true;
|
|
1230
|
+
if (hasSelection())
|
|
1231
|
+
return;
|
|
1232
|
+
focusConversation();
|
|
1233
|
+
if (stickyHeader)
|
|
1234
|
+
toggleThinkingBlock(stickyHeader.turnId);
|
|
1235
|
+
});
|
|
1236
|
+
return wrapper;
|
|
1237
|
+
})();
|
|
1238
|
+
stickyHeader = sticky;
|
|
1239
|
+
conversationExt._listWrapper.show();
|
|
1240
|
+
conversationExt._listWrapper.setContent(sticky.line);
|
|
1241
|
+
};
|
|
1242
|
+
const stickyHeaderLine = (block) => {
|
|
1243
|
+
const toggle = block.expanded ? '▼' : '▶';
|
|
1244
|
+
// Completed blocks reuse the toggle glyph as their icon; keep only one so
|
|
1245
|
+
// the pinned header never shows "▼ ▼".
|
|
1246
|
+
const icon = block.status === 'active' ? spinnerGlyph(spinnerFrame) : '';
|
|
1247
|
+
const color = block.status === 'active' ? COLOR().accent : COLOR().muted;
|
|
1248
|
+
const label = block.status === 'active'
|
|
1249
|
+
? (block.thinking || block.content.length === 0 ? 'Thinking' : 'Working')
|
|
1250
|
+
: (block.thinking ? 'Thought' : 'Activity');
|
|
1251
|
+
const duration = elapsedLabel(block.startedAt, block.finishedAt);
|
|
1252
|
+
const durationText = duration ? ` ${duration}` : '';
|
|
1253
|
+
return `{${color}-fg}${toggle}${icon ? ` ${icon}` : ''} ${label}${durationText}{/${color}-fg}`;
|
|
1254
|
+
};
|
|
631
1255
|
const renderThinkingBlock = (block) => {
|
|
632
1256
|
const headerLine = lineCursor;
|
|
633
1257
|
const toggle = block.expanded ? '▼' : '▶';
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
const color = block.status === 'active' ? COLOR.accent : COLOR.muted;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1258
|
+
// Completed blocks reuse the toggle glyph as their icon; keep only one so
|
|
1259
|
+
// the header never shows "▼ ▼" or "▶ ▶".
|
|
1260
|
+
const icon = block.status === 'active' ? spinnerGlyph(spinnerFrame) : '';
|
|
1261
|
+
const color = block.status === 'active' ? COLOR().accent : COLOR().muted;
|
|
1262
|
+
// An active block with nothing to show yet is the pre-first-token state:
|
|
1263
|
+
// the model is reasoning, so label it Thinking, not Working.
|
|
1264
|
+
const label = block.status === 'active'
|
|
1265
|
+
? (block.thinking || block.content.length === 0 ? 'Thinking' : 'Working')
|
|
1266
|
+
: (block.thinking ? 'Thought' : 'Activity');
|
|
641
1267
|
const duration = elapsedLabel(block.startedAt, block.finishedAt);
|
|
642
1268
|
const durationText = duration ? ` ${duration}` : '';
|
|
643
1269
|
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}`);
|
|
1270
|
+
const scanLabel = `{${COLOR().accent}-fg}${label}{/${COLOR().accent}-fg}`;
|
|
1271
|
+
pushConversationLine(`{${color}-fg}${toggle} ${icon}{/${color}-fg} ${scanLabel}{${COLOR().subtle}-fg}${durationText}{/${COLOR().subtle}-fg}`);
|
|
646
1272
|
}
|
|
647
1273
|
else {
|
|
648
|
-
pushConversationLine(`{${color}-fg}${icon} ${label}${durationText}{/${color}-fg}`);
|
|
1274
|
+
pushConversationLine(`{${color}-fg}${toggle}${icon ? ` ${icon}` : ''} ${label}${durationText}{/${color}-fg}`);
|
|
649
1275
|
}
|
|
650
|
-
thinkingBlockLines.set(block.turnId, { headerLine });
|
|
651
1276
|
latestThinkingTurnId = block.turnId;
|
|
652
1277
|
if (block.expanded) {
|
|
653
1278
|
if (block.thinking) {
|
|
@@ -665,17 +1290,27 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
665
1290
|
}
|
|
666
1291
|
}
|
|
667
1292
|
pushConversationLine('');
|
|
1293
|
+
// Recorded after the whole block is pushed so `lastLine` covers the body;
|
|
1294
|
+
// the pinned header must vanish once this row scrolls past the viewport top.
|
|
1295
|
+
thinkingBlockLines.set(block.turnId, { headerLine, lastLine: Math.max(0, lineCursor - 1) });
|
|
668
1296
|
};
|
|
669
1297
|
const renderActivity = () => {
|
|
1298
|
+
if (activityDetail) {
|
|
1299
|
+
const instance = instanceCache.get(activityDetail.instanceId);
|
|
1300
|
+
if (instance)
|
|
1301
|
+
activityDetail.body.setContent(activityDetailContent(instance));
|
|
1302
|
+
}
|
|
670
1303
|
if (!activityDirty)
|
|
671
1304
|
return;
|
|
672
1305
|
const current = instances();
|
|
673
1306
|
const activeCount = current.filter((item) => ['running', 'waiting', 'queued'].includes(item.status)).length;
|
|
674
|
-
activityHeader.setContent(`{bold}
|
|
1307
|
+
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
1308
|
activity.setItems(current.map((instance) => {
|
|
676
1309
|
const state = STATUS_PRESENTATION[instance.status];
|
|
677
1310
|
const summary = instance.lastError || activityLog.get(instance.instanceId)?.at(-1) || instance.lastOutput;
|
|
678
|
-
|
|
1311
|
+
const fixedWidth = instance.depth * 2 + instance.agentId.length + state.label.length + 6;
|
|
1312
|
+
const detail = oneLine(summary, Math.max(0, Number(activity.width) - fixedWidth));
|
|
1313
|
+
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
1314
|
}));
|
|
680
1315
|
activityDirty = false;
|
|
681
1316
|
};
|
|
@@ -686,6 +1321,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
686
1321
|
lastLayoutKey = layoutKey;
|
|
687
1322
|
conversationDirty = true;
|
|
688
1323
|
activityDirty = true;
|
|
1324
|
+
requestFullRedraw();
|
|
689
1325
|
}
|
|
690
1326
|
const conversationBox = conversation;
|
|
691
1327
|
conversationBox.padding.left = metrics.horizontalPadding;
|
|
@@ -736,12 +1372,34 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
736
1372
|
composer.focus();
|
|
737
1373
|
if (composerFocused)
|
|
738
1374
|
screen.program.hideCursor();
|
|
739
|
-
|
|
1375
|
+
// Overlay scrollbars must be positioned before the render pass that
|
|
1376
|
+
// paints them.
|
|
1377
|
+
conversationScrollbar.sync();
|
|
1378
|
+
activityScrollbar.sync();
|
|
1379
|
+
activityDetailScrollbar.current?.sync();
|
|
1380
|
+
renderScreen();
|
|
740
1381
|
if (composerFocused) {
|
|
741
1382
|
placeComposerCursor();
|
|
742
1383
|
screen.program.showCursor();
|
|
743
1384
|
}
|
|
744
1385
|
};
|
|
1386
|
+
// Focus regained is treated like a resize, and only when the blur window
|
|
1387
|
+
// actually skipped frames: one invalidate + full redraw rebuilds blessed's
|
|
1388
|
+
// diff buffers, the viewport, and the overlay scrollbar positions from the
|
|
1389
|
+
// live state instead of a stale frame. Skipping it when nothing was skipped
|
|
1390
|
+
// keeps short refocuses byte-quiet (no replay burst, no scrolling flash).
|
|
1391
|
+
screen.program.on('focus', () => {
|
|
1392
|
+
windowFocused = true;
|
|
1393
|
+
if (!blurredStale)
|
|
1394
|
+
return;
|
|
1395
|
+
blurredStale = false;
|
|
1396
|
+
requestFullRedraw();
|
|
1397
|
+
conversationDirty = true;
|
|
1398
|
+
scheduleRefresh();
|
|
1399
|
+
});
|
|
1400
|
+
screen.program.on('blur', () => {
|
|
1401
|
+
windowFocused = false;
|
|
1402
|
+
});
|
|
745
1403
|
const applyModel = async (alias) => {
|
|
746
1404
|
const resolved = options.resolveModel(alias);
|
|
747
1405
|
if (!resolved.config.model)
|
|
@@ -765,7 +1423,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
765
1423
|
label: `${alias}${alias === activeModel ? ' ✓' : ''}`,
|
|
766
1424
|
detail: entry ? `${providerName(entry)} · ${entry.model}` : 'Session model',
|
|
767
1425
|
};
|
|
768
|
-
}));
|
|
1426
|
+
}), { searchable: true });
|
|
769
1427
|
if (index >= 0)
|
|
770
1428
|
await applyModel(aliases[index]);
|
|
771
1429
|
};
|
|
@@ -793,7 +1451,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
793
1451
|
notice = 'Could not load models. Enter a model name manually.';
|
|
794
1452
|
let model;
|
|
795
1453
|
if (remoteModels.length) {
|
|
796
|
-
const index = await choose('Provider model', [...remoteModels, 'Type manually…']);
|
|
1454
|
+
const index = await choose('Provider model', [...remoteModels, 'Type manually…'], { searchable: true });
|
|
797
1455
|
if (index < 0)
|
|
798
1456
|
return;
|
|
799
1457
|
model = index < remoteModels.length ? remoteModels[index] : await ask('Provider model name');
|
|
@@ -876,7 +1534,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
876
1534
|
const index = await choose('Agent specs', specs.map((spec) => ({
|
|
877
1535
|
label: spec.id,
|
|
878
1536
|
detail: `${spec.scope} · ${spec.model ?? 'inherit'} · ${oneLine(spec.description, 42)}`,
|
|
879
|
-
})));
|
|
1537
|
+
})), { searchable: true });
|
|
880
1538
|
if (index < 0)
|
|
881
1539
|
return;
|
|
882
1540
|
const spec = specs[index];
|
|
@@ -888,7 +1546,12 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
888
1546
|
'Close',
|
|
889
1547
|
]);
|
|
890
1548
|
};
|
|
891
|
-
const switchSession = async (id) => {
|
|
1549
|
+
const switchSession = async (id, opts = {}) => {
|
|
1550
|
+
if (opts.forkFrom) {
|
|
1551
|
+
// /btw and /fork both start as a full copy of the current conversation,
|
|
1552
|
+
// so the side model keeps the whole picture from message one.
|
|
1553
|
+
await runtime.forkSession(opts.forkFrom, id);
|
|
1554
|
+
}
|
|
892
1555
|
const next = await runtime.openSession(id);
|
|
893
1556
|
sessionId = id;
|
|
894
1557
|
session = next;
|
|
@@ -901,7 +1564,11 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
901
1564
|
activityLog.clear();
|
|
902
1565
|
thinkingBlocks.clear();
|
|
903
1566
|
thinkingBlockLines.clear();
|
|
1567
|
+
thinkingStartedAt.clear();
|
|
904
1568
|
pendingTurns.clear();
|
|
1569
|
+
stickyHeader = undefined;
|
|
1570
|
+
lastStickyKey = undefined;
|
|
1571
|
+
conversation._listWrapper?.hide();
|
|
905
1572
|
notice = '';
|
|
906
1573
|
conversationDirty = true;
|
|
907
1574
|
conversationFollowOutput = true;
|
|
@@ -911,32 +1578,170 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
911
1578
|
if (instance.instanceId === session.mainInstanceId && instance.activeTurnId)
|
|
912
1579
|
pendingTurns.add(instance.activeTurnId);
|
|
913
1580
|
}
|
|
1581
|
+
sideParentSessionId = opts.parentSessionId;
|
|
914
1582
|
startSpinner();
|
|
1583
|
+
startStreamTimer();
|
|
915
1584
|
refresh();
|
|
916
1585
|
};
|
|
917
1586
|
const openSessions = async () => {
|
|
918
1587
|
const sessions = await runtime.listSessions();
|
|
919
1588
|
const index = await choose('Sessions', [
|
|
920
|
-
...sessions.map((item) => ({
|
|
1589
|
+
...sessions.map((item) => ({
|
|
1590
|
+
label: oneLine(item.preview, 52) || item.sessionId,
|
|
1591
|
+
detail: `${item.messages} msg · ${item.relativeUpdatedAt ?? ''} · ${item.sessionId}${item.sessionId.startsWith('btw-') ? ' [side]' : ''}`,
|
|
1592
|
+
})),
|
|
921
1593
|
{ label: 'New session', detail: 'Start a blank conversation' },
|
|
922
|
-
]);
|
|
1594
|
+
], { searchable: true });
|
|
923
1595
|
if (index < 0)
|
|
924
1596
|
return;
|
|
925
1597
|
await switchSession(index === sessions.length ? `session-${Date.now()}` : sessions[index].sessionId);
|
|
926
1598
|
};
|
|
1599
|
+
const activityDetailContent = (instance) => {
|
|
1600
|
+
const state = STATUS_PRESENTATION[instance.status];
|
|
1601
|
+
const stateColor = TONE_COLOR(state.tone);
|
|
1602
|
+
const source = runtime.registry.get(instance.agentId)?.source ?? 'built in';
|
|
1603
|
+
const lines = [
|
|
1604
|
+
`{${stateColor}-fg}${state.icon} ${state.label}{/${stateColor}-fg} {${COLOR().subtle}-fg}${safe(instance.instanceId.slice(0, 8))}{/${COLOR().subtle}-fg}`,
|
|
1605
|
+
`{${COLOR().subtle}-fg}Source{/${COLOR().subtle}-fg} ${safe(source)}`,
|
|
1606
|
+
`{${COLOR().subtle}-fg}Updated{/${COLOR().subtle}-fg} ${safe(new Date(instance.updatedAt).toLocaleTimeString())}`,
|
|
1607
|
+
'',
|
|
1608
|
+
'{bold}Progress{/bold}',
|
|
1609
|
+
];
|
|
1610
|
+
const entries = (session.timeline ?? []).filter((entry) => entry.instanceId === instance.instanceId && entry.kind !== 'message').slice(-30);
|
|
1611
|
+
if (entries.length) {
|
|
1612
|
+
for (const entry of entries) {
|
|
1613
|
+
if (entry.kind === 'thinking') {
|
|
1614
|
+
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…')}`);
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
const itemState = entry.status === 'running'
|
|
1618
|
+
? STATUS_PRESENTATION.running
|
|
1619
|
+
: entry.status === 'failed'
|
|
1620
|
+
? STATUS_PRESENTATION.failed
|
|
1621
|
+
: entry.status === 'cancelled'
|
|
1622
|
+
? STATUS_PRESENTATION.cancelled
|
|
1623
|
+
: STATUS_PRESENTATION.idle;
|
|
1624
|
+
const presentation = toolPresentation(entry.tool ?? '', entry.input);
|
|
1625
|
+
const detail = presentation.detail || oneLine(entry.content, 120);
|
|
1626
|
+
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}` : ''}`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
else {
|
|
1630
|
+
const log = activityLog.get(instance.instanceId) ?? [];
|
|
1631
|
+
if (log.length)
|
|
1632
|
+
lines.push(...log.slice(-20).map((item) => `{${COLOR().muted}-fg}· ${safe(item)}{/${COLOR().muted}-fg}`));
|
|
1633
|
+
else
|
|
1634
|
+
lines.push(`{${COLOR().subtle}-fg}No progress events yet.{/${COLOR().subtle}-fg}`);
|
|
1635
|
+
}
|
|
1636
|
+
if (instance.lastError)
|
|
1637
|
+
lines.push('', `{${COLOR().error}-fg}! ${safe(oneLine(instance.lastError, 240))}{/${COLOR().error}-fg}`);
|
|
1638
|
+
else if (instance.lastOutput)
|
|
1639
|
+
lines.push('', `{${COLOR().subtle}-fg}Latest output{/${COLOR().subtle}-fg}`, safe(oneLine(instance.lastOutput, 240)));
|
|
1640
|
+
return lines.join('\n');
|
|
1641
|
+
};
|
|
927
1642
|
const showActivityDetail = async () => {
|
|
928
1643
|
const instance = instances()[selectedActivityIndex];
|
|
929
1644
|
if (!instance)
|
|
930
1645
|
return;
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1646
|
+
activityDetail?.modal.destroy();
|
|
1647
|
+
composerPinned = false;
|
|
1648
|
+
const width = Math.min(88, Math.max(36, Number(screen.width) - 6));
|
|
1649
|
+
const height = Math.min(24, Math.max(9, Number(screen.height) - 4));
|
|
1650
|
+
const modal = blessed.box({
|
|
1651
|
+
parent: screen, top: 'center', left: 'center', width, height,
|
|
1652
|
+
tags: true, style: { bg: COLOR().modal, fg: COLOR().text },
|
|
1653
|
+
});
|
|
1654
|
+
blessed.box({
|
|
1655
|
+
parent: modal, top: 0, left: 2, right: 2, height: 1, tags: true,
|
|
1656
|
+
content: `{bold}${safe(instance.agentId)} progress{/bold}`,
|
|
1657
|
+
style: { bg: COLOR().modal, fg: COLOR().text },
|
|
1658
|
+
});
|
|
1659
|
+
blessed.box({
|
|
1660
|
+
parent: modal, top: 1, left: 2, right: 2, height: 1,
|
|
1661
|
+
content: '─'.repeat(Math.max(0, width - 4)), style: { bg: COLOR().modal, fg: COLOR().modalRule },
|
|
1662
|
+
});
|
|
1663
|
+
const body = blessed.box({
|
|
1664
|
+
parent: modal, top: 3, left: 2, right: 2, bottom: 2,
|
|
1665
|
+
tags: true, keys: true, vi: true, mouse: true, scrollable: true, alwaysScroll: true,
|
|
1666
|
+
style: { bg: COLOR().modal, fg: COLOR().text },
|
|
1667
|
+
content: activityDetailContent(instance),
|
|
1668
|
+
});
|
|
1669
|
+
blessed.box({
|
|
1670
|
+
parent: modal, bottom: 0, left: 2, right: 2, height: 1,
|
|
1671
|
+
content: 'Scroll to browse · Esc close', style: { bg: COLOR().modal, fg: COLOR().subtle },
|
|
1672
|
+
});
|
|
1673
|
+
const closeDetail = () => {
|
|
1674
|
+
if (activityDetail?.modal !== modal)
|
|
1675
|
+
return;
|
|
1676
|
+
activityDetailScrollbar.current?.destroy();
|
|
1677
|
+
activityDetailScrollbar.current = undefined;
|
|
1678
|
+
activityDetail = undefined;
|
|
1679
|
+
modal.destroy();
|
|
1680
|
+
requestFullRedraw();
|
|
1681
|
+
composerPinned = true;
|
|
1682
|
+
focusComposer();
|
|
1683
|
+
};
|
|
1684
|
+
body.key(['escape', 'q'], closeDetail);
|
|
1685
|
+
attachCloseButton(modal, closeDetail);
|
|
1686
|
+
activityDetailScrollbar.current = attachPillScrollbar(body, pillColors);
|
|
1687
|
+
activityDetail = { instanceId: instance.instanceId, modal, body };
|
|
1688
|
+
body.focus();
|
|
1689
|
+
requestFullRedraw();
|
|
1690
|
+
activityDetailScrollbar.current.sync();
|
|
1691
|
+
renderScreen();
|
|
1692
|
+
};
|
|
1693
|
+
// Swaps the active palette and repaints every surface without persisting;
|
|
1694
|
+
// used both to commit a choice and to preview while browsing the picker.
|
|
1695
|
+
const applyThemeVisuals = (name) => {
|
|
1696
|
+
const next = setActiveTheme(name);
|
|
1697
|
+
resetTuiMarkdownCache();
|
|
1698
|
+
applyWidgetTheme();
|
|
1699
|
+
conversationDirty = true;
|
|
1700
|
+
activityDirty = true;
|
|
1701
|
+
requestFullRedraw();
|
|
1702
|
+
return next;
|
|
1703
|
+
};
|
|
1704
|
+
// Commits a theme choice. `changed` must be decided against the theme the
|
|
1705
|
+
// picker was opened with, not the live one: previewing already swaps the
|
|
1706
|
+
// active palette, so by Enter/click time it equals the chosen name. Any
|
|
1707
|
+
// commit path that actually changes the theme (Enter, mouse click, or a
|
|
1708
|
+
// future caller) replays the welcome logo's opening act under the new
|
|
1709
|
+
// palette; preview highlights and Esc/✕ rollbacks never do.
|
|
1710
|
+
const applyTheme = async (name, changed = activeTuiTheme().name !== name) => {
|
|
1711
|
+
const next = applyThemeVisuals(name);
|
|
1712
|
+
if (changed)
|
|
1713
|
+
themeIntroReplay = true;
|
|
1714
|
+
notice = `Theme set to ${next.label}`;
|
|
1715
|
+
await options.configManager.saveConfig({ ...options.configManager.getConfig(), theme: next.name });
|
|
1716
|
+
refresh();
|
|
1717
|
+
};
|
|
1718
|
+
const openTheme = async () => {
|
|
1719
|
+
const original = activeTuiTheme().name;
|
|
1720
|
+
const names = themeNames();
|
|
1721
|
+
const index = await choose('Theme', names.map((name) => ({
|
|
1722
|
+
label: `${name}${name === original ? ' ✓' : ''}`,
|
|
1723
|
+
detail: resolveTheme(name).label,
|
|
1724
|
+
})), {
|
|
1725
|
+
searchable: true,
|
|
1726
|
+
// Open with the active theme preselected so browsing starts from where
|
|
1727
|
+
// the user is, not from the top of an arbitrary list.
|
|
1728
|
+
initial: Math.max(0, names.indexOf(original)),
|
|
1729
|
+
onHighlight: (highlight) => {
|
|
1730
|
+
const name = names[highlight];
|
|
1731
|
+
if (name && name !== activeTuiTheme().name) {
|
|
1732
|
+
applyThemeVisuals(name);
|
|
1733
|
+
refresh();
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
});
|
|
1737
|
+
if (index >= 0) {
|
|
1738
|
+
await applyTheme(names[index], names[index] !== original);
|
|
1739
|
+
}
|
|
1740
|
+
else if (activeTuiTheme().name !== original) {
|
|
1741
|
+
// Picker dismissed: roll back to the theme chosen before previewing.
|
|
1742
|
+
applyThemeVisuals(original);
|
|
1743
|
+
refresh();
|
|
1744
|
+
}
|
|
940
1745
|
};
|
|
941
1746
|
const command = async (raw) => {
|
|
942
1747
|
const [name = '', ...args] = raw.slice(1).trim().split(/\s+/);
|
|
@@ -950,6 +1755,9 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
950
1755
|
case 'agents':
|
|
951
1756
|
await showAgents();
|
|
952
1757
|
break;
|
|
1758
|
+
case 'theme':
|
|
1759
|
+
await openTheme();
|
|
1760
|
+
break;
|
|
953
1761
|
case 'sessions':
|
|
954
1762
|
await openSessions();
|
|
955
1763
|
break;
|
|
@@ -967,14 +1775,22 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
967
1775
|
streams.clear();
|
|
968
1776
|
thinkingBlocks.clear();
|
|
969
1777
|
thinkingBlockLines.clear();
|
|
1778
|
+
thinkingStartedAt.clear();
|
|
970
1779
|
pendingTurns.clear();
|
|
1780
|
+
stickyHeader = undefined;
|
|
1781
|
+
lastStickyKey = undefined;
|
|
1782
|
+
conversation._listWrapper?.hide();
|
|
971
1783
|
notice = '';
|
|
972
1784
|
conversationDirty = true;
|
|
973
1785
|
refresh();
|
|
974
1786
|
break;
|
|
975
1787
|
case 'cancel': {
|
|
1788
|
+
// The notice renders in the conversation stream; without flagging the
|
|
1789
|
+
// dirty bit renderConversation() skips the repaint entirely and the
|
|
1790
|
+
// acknowledgement never shows.
|
|
976
1791
|
if (!args[0]) {
|
|
977
1792
|
await runtime.cancelSession(sessionId);
|
|
1793
|
+
conversationDirty = true;
|
|
978
1794
|
notice = 'Stopped. Send a message to continue.';
|
|
979
1795
|
refresh();
|
|
980
1796
|
break;
|
|
@@ -995,6 +1811,153 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
995
1811
|
refresh();
|
|
996
1812
|
break;
|
|
997
1813
|
}
|
|
1814
|
+
// /btw opens a self-contained side conversation forked from this one
|
|
1815
|
+
// (/back or Ctrl+C returns); /fork copies the whole conversation into a
|
|
1816
|
+
// new saved session. /goal sets a standing directive shown in the status
|
|
1817
|
+
// bar and injected into every agent's prompt until cleared.
|
|
1818
|
+
case 'btw': {
|
|
1819
|
+
const question = args.join(' ').trim();
|
|
1820
|
+
if (!question) {
|
|
1821
|
+
notice = 'Usage: /btw <question> - opens a side conversation; /back or Ctrl+C returns';
|
|
1822
|
+
refresh();
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
await switchSession(`btw-${Date.now()}`, { forkFrom: sessionId, parentSessionId: sessionId });
|
|
1826
|
+
await runtime.submitMessage(sessionId, question);
|
|
1827
|
+
break;
|
|
1828
|
+
}
|
|
1829
|
+
case 'back': {
|
|
1830
|
+
if (!sideParentSessionId) {
|
|
1831
|
+
notice = 'Not in a /btw side conversation.';
|
|
1832
|
+
refresh();
|
|
1833
|
+
break;
|
|
1834
|
+
}
|
|
1835
|
+
await switchSession(sideParentSessionId);
|
|
1836
|
+
break;
|
|
1837
|
+
}
|
|
1838
|
+
case 'fork': {
|
|
1839
|
+
await switchSession(`session-${Date.now()}`, { forkFrom: sessionId });
|
|
1840
|
+
break;
|
|
1841
|
+
}
|
|
1842
|
+
case 'goal': {
|
|
1843
|
+
const result = await runtime.setSessionGoal(sessionId, args.join(' '));
|
|
1844
|
+
session.goal = runtime.getSession(sessionId)?.goal;
|
|
1845
|
+
notice = result.detail;
|
|
1846
|
+
refresh();
|
|
1847
|
+
break;
|
|
1848
|
+
}
|
|
1849
|
+
case 'cd': {
|
|
1850
|
+
const target = args.join(' ').trim();
|
|
1851
|
+
if (!target) {
|
|
1852
|
+
notice = `Working directory: ${runtime.workspace()}`;
|
|
1853
|
+
refresh();
|
|
1854
|
+
break;
|
|
1855
|
+
}
|
|
1856
|
+
try {
|
|
1857
|
+
const result = await runtime.changeWorkspace(target, { sessionId });
|
|
1858
|
+
notice = `Working directory: ${result.to}`;
|
|
1859
|
+
refresh();
|
|
1860
|
+
}
|
|
1861
|
+
catch (error) {
|
|
1862
|
+
notice = `cd failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1863
|
+
refresh();
|
|
1864
|
+
}
|
|
1865
|
+
break;
|
|
1866
|
+
}
|
|
1867
|
+
// Managed worktrees: /worktree <name> creates (or reopens) an isolated
|
|
1868
|
+
// checkout under .coder/worktrees/<name> and moves this session into it,
|
|
1869
|
+
// /worktree-list shows status, /worktree-exit returns to the main
|
|
1870
|
+
// checkout, /worktree-remove <name> drops a clean worktree.
|
|
1871
|
+
case 'worktree': {
|
|
1872
|
+
const name = args.join(' ').trim();
|
|
1873
|
+
if (!name) {
|
|
1874
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1875
|
+
const here = await manager.containing(runtime.workspace());
|
|
1876
|
+
notice = here ? `In worktree ${here.name} (${here.branch})${here.dirty ? ' · dirty' : ''}` : 'Usage: /worktree <name>';
|
|
1877
|
+
refresh();
|
|
1878
|
+
break;
|
|
1879
|
+
}
|
|
1880
|
+
try {
|
|
1881
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1882
|
+
if (!await manager.isGitRepository())
|
|
1883
|
+
throw new Error('not inside a git repository');
|
|
1884
|
+
const info = await manager.create(name);
|
|
1885
|
+
await runtime.changeWorkspace(info.path, { sessionId });
|
|
1886
|
+
notice = `Worktree ready: ${info.path} (${info.branch})`;
|
|
1887
|
+
refresh();
|
|
1888
|
+
}
|
|
1889
|
+
catch (error) {
|
|
1890
|
+
notice = `worktree failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1891
|
+
refresh();
|
|
1892
|
+
}
|
|
1893
|
+
break;
|
|
1894
|
+
}
|
|
1895
|
+
case 'worktree-list': {
|
|
1896
|
+
try {
|
|
1897
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1898
|
+
const all = await manager.list();
|
|
1899
|
+
const here = await manager.containing(runtime.workspace());
|
|
1900
|
+
if (!all.length) {
|
|
1901
|
+
notice = 'No managed worktrees. /worktree <name> creates one.';
|
|
1902
|
+
refresh();
|
|
1903
|
+
break;
|
|
1904
|
+
}
|
|
1905
|
+
const lines = all.map((info) => `${info.name === here?.name ? '▸' : ' '} ${info.name} ${info.branch}${info.dirty ? ' [dirty]' : ''}${info.locked ? ' [locked]' : ''}`);
|
|
1906
|
+
notice = lines.join(' · ');
|
|
1907
|
+
refresh();
|
|
1908
|
+
}
|
|
1909
|
+
catch (error) {
|
|
1910
|
+
notice = `worktree-list failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1911
|
+
refresh();
|
|
1912
|
+
}
|
|
1913
|
+
break;
|
|
1914
|
+
}
|
|
1915
|
+
case 'worktree-exit': {
|
|
1916
|
+
try {
|
|
1917
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1918
|
+
const here = await manager.containing(runtime.workspace());
|
|
1919
|
+
if (!here) {
|
|
1920
|
+
notice = 'Not inside a managed worktree.';
|
|
1921
|
+
refresh();
|
|
1922
|
+
break;
|
|
1923
|
+
}
|
|
1924
|
+
await manager.unlock(here.name);
|
|
1925
|
+
const main = await manager.mainRoot();
|
|
1926
|
+
await runtime.changeWorkspace(main, { sessionId });
|
|
1927
|
+
notice = `Back in main checkout: ${main} (worktree ${here.name} kept on disk)`;
|
|
1928
|
+
refresh();
|
|
1929
|
+
}
|
|
1930
|
+
catch (error) {
|
|
1931
|
+
notice = `worktree-exit failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1932
|
+
refresh();
|
|
1933
|
+
}
|
|
1934
|
+
break;
|
|
1935
|
+
}
|
|
1936
|
+
case 'worktree-remove': {
|
|
1937
|
+
const name = args.join(' ').trim();
|
|
1938
|
+
if (!name) {
|
|
1939
|
+
notice = 'Usage: /worktree-remove <name>';
|
|
1940
|
+
refresh();
|
|
1941
|
+
break;
|
|
1942
|
+
}
|
|
1943
|
+
try {
|
|
1944
|
+
const manager = new WorktreeManager(runtime.workspace());
|
|
1945
|
+
await manager.remove(name);
|
|
1946
|
+
notice = `Removed worktree ${name} (branch kept).`;
|
|
1947
|
+
refresh();
|
|
1948
|
+
}
|
|
1949
|
+
catch (error) {
|
|
1950
|
+
notice = `worktree-remove failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1951
|
+
refresh();
|
|
1952
|
+
}
|
|
1953
|
+
break;
|
|
1954
|
+
}
|
|
1955
|
+
// Alias for `/cd` with no argument; arguments are ignored, like pwd.
|
|
1956
|
+
case 'pwd': {
|
|
1957
|
+
notice = `Working directory: ${runtime.workspace()}`;
|
|
1958
|
+
refresh();
|
|
1959
|
+
break;
|
|
1960
|
+
}
|
|
998
1961
|
case 'help':
|
|
999
1962
|
await commandPalette();
|
|
1000
1963
|
break;
|
|
@@ -1015,6 +1978,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1015
1978
|
const actions = [
|
|
1016
1979
|
{ label: 'Provider', detail: 'Manage model endpoints' },
|
|
1017
1980
|
{ label: 'Model', detail: 'Choose the session model' },
|
|
1981
|
+
{ label: 'Theme', detail: 'Switch the color theme' },
|
|
1018
1982
|
{ label: 'Agent specs', detail: 'Inspect effective roles and permissions' },
|
|
1019
1983
|
{ label: 'Sessions', detail: 'Open a saved conversation' },
|
|
1020
1984
|
{ label: 'New session', detail: 'Start a blank conversation' },
|
|
@@ -1023,26 +1987,28 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1023
1987
|
{ label: 'Toggle activity', detail: 'Show or hide the agent tree' },
|
|
1024
1988
|
{ label: 'Exit', detail: 'Close TokenMaw' },
|
|
1025
1989
|
];
|
|
1026
|
-
const index = await choose('Command palette', actions);
|
|
1990
|
+
const index = await choose('Command palette', actions, { searchable: true });
|
|
1027
1991
|
if (index === 0)
|
|
1028
1992
|
await openProvider();
|
|
1029
1993
|
if (index === 1)
|
|
1030
1994
|
await openModel();
|
|
1031
1995
|
if (index === 2)
|
|
1032
|
-
await
|
|
1996
|
+
await openTheme();
|
|
1033
1997
|
if (index === 3)
|
|
1034
|
-
await
|
|
1998
|
+
await showAgents();
|
|
1035
1999
|
if (index === 4)
|
|
1036
|
-
await
|
|
2000
|
+
await openSessions();
|
|
1037
2001
|
if (index === 5)
|
|
1038
|
-
await
|
|
2002
|
+
await switchSession(`session-${Date.now()}`);
|
|
1039
2003
|
if (index === 6)
|
|
2004
|
+
await command('/clear');
|
|
2005
|
+
if (index === 7)
|
|
1040
2006
|
await command('/compact');
|
|
1041
|
-
if (index ===
|
|
2007
|
+
if (index === 8) {
|
|
1042
2008
|
activityVisible = !activityVisible;
|
|
1043
2009
|
refresh();
|
|
1044
2010
|
}
|
|
1045
|
-
if (index ===
|
|
2011
|
+
if (index === 9)
|
|
1046
2012
|
close();
|
|
1047
2013
|
};
|
|
1048
2014
|
const submit = async () => {
|
|
@@ -1051,6 +2017,10 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1051
2017
|
focusComposer();
|
|
1052
2018
|
return;
|
|
1053
2019
|
}
|
|
2020
|
+
// A submit and a Ctrl+C park can interleave: if the draft changed under
|
|
2021
|
+
// us, the user just parked a new draft — drop this stale submit.
|
|
2022
|
+
if (value !== composerValue().trim())
|
|
2023
|
+
return;
|
|
1054
2024
|
if (!inputHistory.includes(value))
|
|
1055
2025
|
inputHistory.push(value);
|
|
1056
2026
|
setComposerValue('');
|
|
@@ -1058,6 +2028,16 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1058
2028
|
try {
|
|
1059
2029
|
if (value.startsWith('/'))
|
|
1060
2030
|
await command(value);
|
|
2031
|
+
// Shell mode: `!cmd` runs directly in the workspace, outside the agent
|
|
2032
|
+
// loop and tool policy. Output streams in a popup and never reaches the
|
|
2033
|
+
// model context.
|
|
2034
|
+
else if (value.startsWith('!')) {
|
|
2035
|
+
const shellCommand = value.slice(1).trim();
|
|
2036
|
+
if (!shellCommand)
|
|
2037
|
+
notice = 'Usage: !<command> runs it in the workspace shell.';
|
|
2038
|
+
else
|
|
2039
|
+
await runShellMode(shellCommand);
|
|
2040
|
+
}
|
|
1061
2041
|
else {
|
|
1062
2042
|
notice = '';
|
|
1063
2043
|
conversationFollowOutput = true;
|
|
@@ -1067,8 +2047,10 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1067
2047
|
pendingTurns.add(turnId);
|
|
1068
2048
|
if (pendingTurns.has(turnId) && !thinkingBlocks.has(turnId)) {
|
|
1069
2049
|
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
2050
|
+
markThinkingStart(turnId);
|
|
1070
2051
|
}
|
|
1071
2052
|
startSpinner();
|
|
2053
|
+
startStreamTimer();
|
|
1072
2054
|
refresh();
|
|
1073
2055
|
}
|
|
1074
2056
|
}
|
|
@@ -1092,16 +2074,28 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1092
2074
|
if (event.type === 'assistant_message' && !session.messages.some((message) => message.messageId === event.message.messageId)) {
|
|
1093
2075
|
session.messages.push({ ...event.message });
|
|
1094
2076
|
}
|
|
2077
|
+
if (event.type === 'system_message') {
|
|
2078
|
+
// System notices render only in the timeline stream, never in the
|
|
2079
|
+
// persisted message list; recordTimeline dedupes by messageId.
|
|
2080
|
+
conversationDirty = true;
|
|
2081
|
+
}
|
|
1095
2082
|
recordTimeline(session, event);
|
|
1096
2083
|
if (event.type === 'thinking_delta') {
|
|
1097
2084
|
conversationDirty = true;
|
|
2085
|
+
markThinkingStart(event.turnId);
|
|
1098
2086
|
const block = thinkingBlocks.get(event.turnId) ?? [...thinkingBlocks.values()].reverse().find((item) => item.status === 'active') ?? thinkingBlocks.get(latestThinkingTurnId ?? '');
|
|
1099
2087
|
if (block)
|
|
1100
2088
|
block.thinking = `${block.thinking ?? ''}${event.text}`;
|
|
2089
|
+
// Deltas can burst faster than a usable frame rate; the turn timer paints
|
|
2090
|
+
// them at a steady cadence so a markdown re-render runs at most ~10fps.
|
|
2091
|
+
if (streamTimer)
|
|
2092
|
+
return;
|
|
1101
2093
|
}
|
|
1102
2094
|
if (event.type === 'assistant_delta') {
|
|
1103
2095
|
conversationDirty = true;
|
|
1104
2096
|
streams.set(event.turnId, `${streams.get(event.turnId) ?? ''}${event.text}`);
|
|
2097
|
+
if (streamTimer)
|
|
2098
|
+
return;
|
|
1105
2099
|
}
|
|
1106
2100
|
if (event.type === 'assistant_message') {
|
|
1107
2101
|
conversationDirty = true;
|
|
@@ -1143,6 +2137,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1143
2137
|
notice = `Error: ${event.error}`;
|
|
1144
2138
|
pendingTurns.clear();
|
|
1145
2139
|
stopSpinner();
|
|
2140
|
+
stopStreamTimer();
|
|
1146
2141
|
}
|
|
1147
2142
|
}
|
|
1148
2143
|
if (event.type === 'instance_updated' && event.instance.instanceId === session.mainInstanceId
|
|
@@ -1160,7 +2155,9 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1160
2155
|
}
|
|
1161
2156
|
if (!thinkingBlocks.has(turnId))
|
|
1162
2157
|
thinkingBlocks.set(turnId, { turnId, expanded: false, content: [], status: 'active', startedAt: Date.now() });
|
|
2158
|
+
markThinkingStart(turnId);
|
|
1163
2159
|
startSpinner();
|
|
2160
|
+
startStreamTimer();
|
|
1164
2161
|
}
|
|
1165
2162
|
if (event.type === 'instance_updated'
|
|
1166
2163
|
&& event.instance.sessionId === sessionId
|
|
@@ -1175,9 +2172,63 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1175
2172
|
pendingTurns.clear();
|
|
1176
2173
|
streams.clear();
|
|
1177
2174
|
stopSpinner();
|
|
2175
|
+
stopStreamTimer();
|
|
1178
2176
|
}
|
|
1179
2177
|
scheduleRefresh();
|
|
1180
2178
|
};
|
|
2179
|
+
// `!command` shell mode. Output streams inline into the conversation as a
|
|
2180
|
+
// timeline entry (transcript-only — never sent to the model); Ctrl+C stops
|
|
2181
|
+
// the run. Like a real shell, a nonzero exit is shown, not treated as an
|
|
2182
|
+
// error: the user is the authorizer.
|
|
2183
|
+
const runShellMode = async (shellCommand) => {
|
|
2184
|
+
const entry = recordShellRun(session, shellCommand);
|
|
2185
|
+
const keepTail = (text) => (text.length > 48_000 ? text.slice(-48_000) : text);
|
|
2186
|
+
const controller = new AbortController();
|
|
2187
|
+
shellAbort = controller;
|
|
2188
|
+
// Long-running commands animate the header's ellipsis (same gradient
|
|
2189
|
+
// frames as the waiting indicator) so a live job is obvious at a glance.
|
|
2190
|
+
const animation = setInterval(() => {
|
|
2191
|
+
if (closed) {
|
|
2192
|
+
clearInterval(animation);
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
// Decoration only: frozen while blurred. A skipped frame leaves the
|
|
2196
|
+
// screen untouched, so it must not mark the frame stale.
|
|
2197
|
+
if (!windowFocused)
|
|
2198
|
+
return;
|
|
2199
|
+
shellAnimationFrame += 1;
|
|
2200
|
+
conversationDirty = true;
|
|
2201
|
+
scheduleRefresh();
|
|
2202
|
+
}, 60);
|
|
2203
|
+
animation.unref?.();
|
|
2204
|
+
conversationDirty = true;
|
|
2205
|
+
refresh();
|
|
2206
|
+
try {
|
|
2207
|
+
const result = await runShellCommand(shellCommand, {
|
|
2208
|
+
workspaceRoot: runtime.workspace(),
|
|
2209
|
+
signal: controller.signal,
|
|
2210
|
+
onChunk: (text) => {
|
|
2211
|
+
const cleaned = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
|
|
2212
|
+
if (!cleaned)
|
|
2213
|
+
return;
|
|
2214
|
+
entry.content = keepTail(entry.content + cleaned);
|
|
2215
|
+
conversationDirty = true;
|
|
2216
|
+
scheduleRefresh();
|
|
2217
|
+
},
|
|
2218
|
+
});
|
|
2219
|
+
entry.content = keepTail(result.output.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ''));
|
|
2220
|
+
entry.exitCode = result.exitCode;
|
|
2221
|
+
entry.status = result.exitCode === 0 ? 'completed' : result.exitCode === undefined ? 'cancelled' : 'failed';
|
|
2222
|
+
entry.endedAt = Date.now();
|
|
2223
|
+
}
|
|
2224
|
+
finally {
|
|
2225
|
+
clearInterval(animation);
|
|
2226
|
+
if (shellAbort === controller)
|
|
2227
|
+
shellAbort = undefined;
|
|
2228
|
+
conversationDirty = true;
|
|
2229
|
+
refresh();
|
|
2230
|
+
}
|
|
2231
|
+
};
|
|
1181
2232
|
const unsubscribe = runtime.subscribe(onEvent);
|
|
1182
2233
|
let finish;
|
|
1183
2234
|
const done = new Promise((resolveDone) => { finish = resolveDone; });
|
|
@@ -1186,10 +2237,33 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1186
2237
|
return;
|
|
1187
2238
|
closed = true;
|
|
1188
2239
|
stopSpinner();
|
|
2240
|
+
stopStreamTimer();
|
|
1189
2241
|
if (welcomeTimer)
|
|
1190
2242
|
clearInterval(welcomeTimer);
|
|
2243
|
+
clearInterval(instancePoll);
|
|
1191
2244
|
unsubscribe();
|
|
2245
|
+
// Release bracketed paste mode before the screen goes away so the shell
|
|
2246
|
+
// after exit does not keep accumulating pasted text without newlines.
|
|
2247
|
+
// Leave the alternate buffer for the same reason: the forced ?1049 entry
|
|
2248
|
+
// must not outlive the TUI even where terminfo's rmcup is empty.
|
|
2249
|
+
try {
|
|
2250
|
+
screen.program.write(BRACKETED_PASTE_DISABLE);
|
|
2251
|
+
screen.program.decrst('1004');
|
|
2252
|
+
screen.program.decrst('1049');
|
|
2253
|
+
screen.program.flush();
|
|
2254
|
+
}
|
|
2255
|
+
catch {
|
|
2256
|
+
// The program may already be torn down; the reset is best effort.
|
|
2257
|
+
}
|
|
1192
2258
|
screen.destroy();
|
|
2259
|
+
// Blessed only removes its own listeners on destroy; release our proxy's
|
|
2260
|
+
// forwarding too so the real stdin is left with no lingering listeners.
|
|
2261
|
+
try {
|
|
2262
|
+
pasteInput.destroy?.();
|
|
2263
|
+
}
|
|
2264
|
+
catch {
|
|
2265
|
+
// Already torn down; nothing left to release.
|
|
2266
|
+
}
|
|
1193
2267
|
finish?.();
|
|
1194
2268
|
}
|
|
1195
2269
|
composer.on('keypress', handleComposerKey);
|
|
@@ -1211,6 +2285,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1211
2285
|
};
|
|
1212
2286
|
const toggleActivity = () => {
|
|
1213
2287
|
activityVisible = !activityVisible;
|
|
2288
|
+
requestFullRedraw();
|
|
1214
2289
|
refresh();
|
|
1215
2290
|
focusComposer();
|
|
1216
2291
|
};
|
|
@@ -1226,6 +2301,7 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1226
2301
|
conversationScrollOffset = conversation.childBase;
|
|
1227
2302
|
block.expanded = !block.expanded;
|
|
1228
2303
|
conversationDirty = true;
|
|
2304
|
+
requestFullRedraw();
|
|
1229
2305
|
refresh();
|
|
1230
2306
|
};
|
|
1231
2307
|
const focusConversation = () => {
|
|
@@ -1258,6 +2334,14 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1258
2334
|
return;
|
|
1259
2335
|
const contentTop = lpos.yi + Number(conversation.itop);
|
|
1260
2336
|
const relY = data.y - contentTop;
|
|
2337
|
+
// A click on the sticky overlay already toggled the block; the bubbled
|
|
2338
|
+
// copy must not toggle it back.
|
|
2339
|
+
if (stickyClickHandled) {
|
|
2340
|
+
stickyClickHandled = false;
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
// The pinned header is an overlay, not an inserted row: logical line
|
|
2344
|
+
// indices still map 1:1 onto rendered rows, so no extra offset applies.
|
|
1261
2345
|
const row = Math.floor(relY) + conversation.childBase;
|
|
1262
2346
|
// RenderThinkingBlock records indices in the raw `lines` array, but blessed
|
|
1263
2347
|
// re-parses/wraps content into `_clines`. Translate via ftor so the click
|
|
@@ -1278,7 +2362,26 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1278
2362
|
if (latestThinkingTurnId)
|
|
1279
2363
|
toggleThinkingBlock(latestThinkingTurnId);
|
|
1280
2364
|
});
|
|
1281
|
-
activity.on('click',
|
|
2365
|
+
activity.on('click', (data) => {
|
|
2366
|
+
const list = activity;
|
|
2367
|
+
const selectedItem = list.items[list.selected];
|
|
2368
|
+
const bounds = selectedItem?.lpos;
|
|
2369
|
+
if (!bounds || data.y === undefined || data.y < bounds.yi || data.y >= bounds.yl)
|
|
2370
|
+
return;
|
|
2371
|
+
selectedActivityIndex = list.selected;
|
|
2372
|
+
runAction(showActivityDetail);
|
|
2373
|
+
});
|
|
2374
|
+
// Blessed routes clicks that land on a rendered list row to the row element
|
|
2375
|
+
// itself; the list only sees the bubbled `element click`. Resolve the row
|
|
2376
|
+
// back to its agent so a click opens that agent's progress directly.
|
|
2377
|
+
activity.on('element click', (el) => {
|
|
2378
|
+
const list = activity;
|
|
2379
|
+
const index = list.items.indexOf(el);
|
|
2380
|
+
if (index < 0)
|
|
2381
|
+
return;
|
|
2382
|
+
selectedActivityIndex = index;
|
|
2383
|
+
runAction(showActivityDetail);
|
|
2384
|
+
});
|
|
1282
2385
|
conversation.on('mousedown', focusConversation);
|
|
1283
2386
|
conversation.on('wheelup', () => {
|
|
1284
2387
|
selection = undefined;
|
|
@@ -1299,6 +2402,12 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1299
2402
|
return;
|
|
1300
2403
|
conversationScrollOffset = conversation.childBase;
|
|
1301
2404
|
conversationFollowOutput = conversationAtBottom();
|
|
2405
|
+
// Pure scrolling does not mark the content dirty; the pinned header still
|
|
2406
|
+
// needs to appear/disappear as the viewport moves.
|
|
2407
|
+
if (stickyHeader || thinkingBlockLines.size > 0) {
|
|
2408
|
+
conversationDirty = true;
|
|
2409
|
+
scheduleRefresh();
|
|
2410
|
+
}
|
|
1302
2411
|
});
|
|
1303
2412
|
screen.key(['pageup', 'pagedown'], (_ch, key) => {
|
|
1304
2413
|
selection = undefined;
|
|
@@ -1367,10 +2476,61 @@ export async function runFullscreenTui(runtime, options) {
|
|
|
1367
2476
|
selection.dragging = false;
|
|
1368
2477
|
}
|
|
1369
2478
|
});
|
|
2479
|
+
// Ctrl+C semantics depend on mode: inside a /btw side conversation it
|
|
2480
|
+
// returns to the parent session; elsewhere a bare press arms a quit
|
|
2481
|
+
// confirmation and a second press within 2s exits, so a stray Ctrl+C never
|
|
2482
|
+
// kills the session by accident.
|
|
2483
|
+
let ctrlCAt = 0;
|
|
2484
|
+
const handleBareCtrlC = () => {
|
|
2485
|
+
// A running !command is the first thing Ctrl+C stops; the quit
|
|
2486
|
+
// confirmation must not fire while the user is just killing a job.
|
|
2487
|
+
if (shellAbort) {
|
|
2488
|
+
shellAbort.abort();
|
|
2489
|
+
notice = 'Stopping command…';
|
|
2490
|
+
refresh();
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
if (isBtw()) {
|
|
2494
|
+
const parent = sideParentSessionId;
|
|
2495
|
+
void switchSession(parent).then(() => {
|
|
2496
|
+
notice = 'Returned from /btw side conversation.';
|
|
2497
|
+
refresh();
|
|
2498
|
+
});
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
// A non-empty draft changes the first press: park it in history and
|
|
2502
|
+
// clear the composer. The second press (or a bare press on an empty
|
|
2503
|
+
// composer) arms the quit as before.
|
|
2504
|
+
if (composerValue().trim()) {
|
|
2505
|
+
const draft = composerValue().trim();
|
|
2506
|
+
if (inputHistory[inputHistory.length - 1] !== draft)
|
|
2507
|
+
inputHistory.push(draft);
|
|
2508
|
+
// setComposerValue resets historyIndex, so the next Up naturally lands
|
|
2509
|
+
// on the freshly parked draft.
|
|
2510
|
+
setComposerValue('');
|
|
2511
|
+
notice = 'Draft saved — press Up to restore.';
|
|
2512
|
+
// The notice renders through the conversation timeline; without this
|
|
2513
|
+
// flag the repaint skips the stale transcript and the user never sees
|
|
2514
|
+
// the confirmation.
|
|
2515
|
+
conversationDirty = true;
|
|
2516
|
+
renderComposerFrame();
|
|
2517
|
+
refresh();
|
|
2518
|
+
focusComposer();
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
const pressedAt = Date.now();
|
|
2522
|
+
if (pressedAt - ctrlCAt > 2000) {
|
|
2523
|
+
ctrlCAt = pressedAt;
|
|
2524
|
+
notice = 'Press Ctrl+C again to quit.';
|
|
2525
|
+
refresh();
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
close();
|
|
2529
|
+
};
|
|
1370
2530
|
screen.key(['C-c'], () => {
|
|
1371
2531
|
const range = orderedSelection();
|
|
1372
2532
|
if (!range || !selection) {
|
|
1373
|
-
|
|
2533
|
+
handleBareCtrlC();
|
|
1374
2534
|
return;
|
|
1375
2535
|
}
|
|
1376
2536
|
const [start, end] = range;
|