tokenmaw 0.3.0 → 0.4.0

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