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.
@@ -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 { diffPreview, elapsedLabel, STATUS_PRESENTATION, toolPresentation, tuiLayout, visibleTimelineEntries } from './tui-design.js';
9
- import { recordTimeline } from '../runtime/session-timeline.js';
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
- // Stick to the ANSI palette so Windows consoles do not quantize custom RGB
20
- // values into black. In blessed, `gray` is bright-black (color 8), so it can
21
- // disappear on a black background; `white` is the readable 8-color fallback.
22
- background: 'black', panel: 'black', elevated: '#20242a', modal: '#171a1f', modalRule: '#2b3139', line: 'gray',
23
- text: 'light-white', muted: 'white', subtle: 'gray', accent: 'light-cyan', success: 'light-green',
24
- warning: 'light-yellow', error: 'light-red',
25
- };
26
- const TONE_COLOR = { muted: COLOR.muted, accent: COLOR.accent, success: COLOR.success, warning: COLOR.warning, error: COLOR.error };
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: 2, right: 0, width: '28%', bottom: 2,
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.background, fg: COLOR.muted,
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
- style: { bg: COLOR.background, fg: COLOR.text },
241
+ tags: true,
242
+ style: { bg: COLOR().composer, fg: COLOR().text },
144
243
  });
145
244
  const divider = blessed.box({
146
- parent: screen, bottom: 3, left: 1, width: '100%-2', height: 1,
147
- style: { fg: COLOR.line, bg: COLOR.background },
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.background, fg: COLOR.accent },
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: 2, hidden: true, tags: true,
161
- padding: { left: 1, right: 1 }, style: { bg: COLOR.background, fg: COLOR.text },
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(''.repeat(Math.max(0, Number(screen.width) - 2)));
333
+ divider.setContent(' '.repeat(Math.max(0, Number(screen.width))));
190
334
  composerRow = result.cursor.row - start;
191
335
  composerColumn = result.cursor.column;
192
- composer.setContent(result.rows.slice(start, start + height).join('\n'));
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
- screen.render();
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 = (spinnerFrame + 1) % 20;
513
+ spinnerFrame += 1;
326
514
  conversationDirty = true;
327
515
  scheduleRefresh();
328
- }, 800);
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
- const choose = (title, items) => new Promise((resolveChoice) => {
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 renderedItems = items.map((item) => typeof item === 'string'
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, items: renderedItems, tags: true, keys: true, vi: true, mouse: true,
368
- scrollable: true, style: { bg: COLOR.modal, fg: COLOR.text, selected: { bg: COLOR.modal, fg: COLOR.accent, bold: true } },
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) => finish(index));
381
- list.key(['escape', 'q'], () => finish(-1));
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
- screen.render();
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
- screen.render();
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[status.tone];
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 left = `maw ${activeModel}`;
440
- const right = Number(screen.width) >= 78 ? `${activityText} · Ctrl+K commands` : activityText;
441
- const gap = width - Number(statusbar.strWidth(left)) - Number(statusbar.strWidth(right));
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(right)}{/${active ? COLOR.accent : COLOR.muted}-fg}`
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
- const welcomeVisible = !session.messages.length && !streams.size && thinkingBlocks.size === 0;
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
- welcomeFrame = (welcomeFrame + 1) % 80;
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 if (welcomeTimer) {
489
- clearInterval(welcomeTimer);
490
- welcomeTimer = undefined;
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[state.tone];
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
- const icon = block.status === 'active'
635
- ? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][spinnerFrame % 10]
636
- : toggle;
637
- const color = block.status === 'active' ? COLOR.accent : COLOR.muted;
638
- const label = block.thinking
639
- ? (block.status === 'active' ? 'Thinking' : 'Thought')
640
- : (block.status === 'active' ? 'Working' : 'Activity');
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}Agents{/bold}{${COLOR.muted}-fg}${activeCount ? ` ${activeCount} active` : ''}{/${COLOR.muted}-fg}\n{${COLOR.subtle}-fg}${'─'.repeat(Math.max(0, Number(activity.width) - 2))}{/${COLOR.subtle}-fg}`);
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
- return `${depthPrefix(instance)} {bold}${safe(instance.agentId)}{/bold} {${TONE_COLOR[state.tone]}-fg}${state.label}{/${TONE_COLOR[state.tone]}-fg}\n${' '.repeat(instance.depth + 1)}{${COLOR.muted}-fg}${safe(oneLine(summary, Math.max(16, Number(activity.width) - instance.depth * 2 - 4)) || 'No recent activity')}{/${COLOR.muted}-fg}`;
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
- screen.render();
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) => ({ label: item.sessionId, detail: `${item.messages} messages` })),
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
- const log = activityLog.get(instance.instanceId) ?? [];
932
- await choose(`${instance.agentId} · ${instance.instanceId.slice(0, 8)}`, [
933
- `status ${instance.status}`,
934
- `source ${runtime.registry.get(instance.agentId)?.source ?? ''}`,
935
- ...log.slice(-12),
936
- ...(instance.lastOutput ? [`output ${oneLine(instance.lastOutput, 120)}`] : []),
937
- ...(instance.lastError ? [`error ${oneLine(instance.lastError, 120)}`] : []),
938
- 'Close',
939
- ]);
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 showAgents();
1996
+ await openTheme();
1033
1997
  if (index === 3)
1034
- await openSessions();
1998
+ await showAgents();
1035
1999
  if (index === 4)
1036
- await switchSession(`session-${Date.now()}`);
2000
+ await openSessions();
1037
2001
  if (index === 5)
1038
- await command('/clear');
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 === 7) {
2007
+ if (index === 8) {
1042
2008
  activityVisible = !activityVisible;
1043
2009
  refresh();
1044
2010
  }
1045
- if (index === 8)
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', focusComposer);
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
- close();
2533
+ handleBareCtrlC();
1374
2534
  return;
1375
2535
  }
1376
2536
  const [start, end] = range;