tokenmaw 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +31,6 @@ Inside the TUI:
31
31
  - `F2` (or `/select`) optionally releases app mouse capture for the terminal's native selection. `F2` again restores app clicks, drag selection and wheel scrolling. `Ctrl+Y` and `PageUp` / `PageDown` also work without the mouse.
32
32
  - `Ctrl+X` or `/cancel` stops the current session's agents; send another message to continue.
33
33
  - `/compact` summarizes and archives older context of the main agent; an optional argument focuses the digest (e.g. `/compact file changes and pending work`).
34
- - `/aside <note>` queues a side note without starting a turn; it folds into the next message you send and is announced in the conversation stream.
35
34
  - `/btw <question>` opens a side conversation forked from the current session (full context included) and sends your question there. `/back` or `Ctrl+C` returns to the main conversation; side sessions are marked `[side]` in the status bar and the `/sessions` list.
36
35
  - `/fork` copies the current conversation into a new saved session. `/sessions` lists both; the original stays untouched.
37
36
  - `/goal <text>` sets a standing goal for the session: it is injected into every agent's prompt until cleared, shows in the status bar, and survives across sessions. `/goal clear` removes it.
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { AgentRuntime } from './runtime/agent-runtime.js';
9
9
  import { WorktreeManager } from './runtime/worktree.js';
10
10
  import { registerWorkspaceInstance } from './runtime/workspace-instances.js';
11
11
  import { runFullscreenTui } from './ui/fullscreen-tui.js';
12
+ import { checkForUpdate, formatUpdateNotice, offerSelfUpdate } from './update-check.js';
12
13
  import { CODER_VERSION } from './version.js';
13
14
  async function main() {
14
15
  const program = new Command();
@@ -16,6 +17,18 @@ async function main() {
16
17
  program.allowExcessArguments(false).showSuggestionAfterError();
17
18
  program.option('--model <name>', 'default model name or .agentrc alias');
18
19
  program.option('--worktree [name]', 'start inside an isolated managed git worktree (.coder/worktrees/<name>)');
20
+ // Query npm for a newer release while the command runs; afterwards a y/N
21
+ // prompt offers a self-update (China mirror first) in interactive sessions.
22
+ const updateNotice = checkForUpdate().catch(() => null);
23
+ const showUpdateNotice = async () => {
24
+ const result = await updateNotice;
25
+ if (!result?.updateAvailable || !process.stderr.isTTY)
26
+ return;
27
+ process.stderr.write(`\n${formatUpdateNotice(result)}\n`);
28
+ const message = await offerSelfUpdate(result).catch(() => null);
29
+ if (message)
30
+ process.stderr.write(`\n${message}\n`);
31
+ };
19
32
  let config = await loadConfig();
20
33
  const selectedFromCli = () => program.opts().model;
21
34
  setToolPolicy(defaultPolicy(config.policyLevel ?? 'moderate', process.cwd()));
@@ -70,6 +83,7 @@ async function main() {
70
83
  const response = session.messages.slice(submitted + 1).reverse().find((message) => message.role === 'assistant');
71
84
  if (response)
72
85
  process.stdout.write(`${response.content}\n`);
86
+ await showUpdateNotice();
73
87
  }
74
88
  finally {
75
89
  await runtime.shutdown();
@@ -83,6 +97,7 @@ async function main() {
83
97
  for (const spec of runtime.listAgentSpecs()) {
84
98
  process.stdout.write(`${spec.id}\t${spec.scope}\t${spec.model ?? 'inherit'}\t${spec.description}\n`);
85
99
  }
100
+ await showUpdateNotice();
86
101
  await runtime.shutdown();
87
102
  });
88
103
  program.action(async () => {
@@ -115,6 +130,7 @@ async function main() {
115
130
  },
116
131
  configManager,
117
132
  });
133
+ await showUpdateNotice();
118
134
  await runtime.shutdown();
119
135
  });
120
136
  const shutdown = async () => {
@@ -19,9 +19,6 @@ function mergeUsage(previous, next) {
19
19
  }
20
20
  return result;
21
21
  }
22
- function asidePrefix() {
23
- return 'Additional context noted earlier (aside):';
24
- }
25
22
  function mergeAgentUsage(previous, usage, firstTokenMs, durationMs, requests = 1) {
26
23
  const merged = mergeUsage(previous, usage ?? {});
27
24
  return { ...merged, requests: (previous?.requests ?? 0) + requests, turns: (previous?.turns ?? 0) + 1, firstTokenMs, lastTurnMs: durationMs };
@@ -424,7 +421,6 @@ export class AgentRuntime {
424
421
  const session = this.sessions.get(sessionId);
425
422
  session.messages = [];
426
423
  session.timeline = [];
427
- session.pendingAsides = [];
428
424
  session.goal = undefined;
429
425
  session.updatedAt = now();
430
426
  const main = this.instances.get(session.mainInstanceId);
@@ -461,24 +457,6 @@ export class AgentRuntime {
461
457
  await this.persistSession(sessionId);
462
458
  this.notifyIdleWaiters();
463
459
  }
464
- /** Queue an aside (/aside): folds into the next submitted message without starting a turn. */
465
- async addAside(sessionId, content) {
466
- const text = content.trim();
467
- if (!text)
468
- throw new Error('Aside cannot be empty');
469
- await this.requireSessionWrite(sessionId);
470
- const session = this.sessions.get(sessionId);
471
- const hadAsides = (session.pendingAsides?.length ?? 0) > 0;
472
- (session.pendingAsides ??= []).push(text);
473
- session.updatedAt = now();
474
- await this.persistSession(sessionId);
475
- this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: hadAsides
476
- ? `Noted — another aside is already queued; both will be included with your next message.`
477
- : `Noted. This will be included with your next message without starting a turn.`, createdAt: now() } });
478
- return { queued: true, detail: hadAsides
479
- ? 'Queued behind one earlier aside; both will be included with the next message.'
480
- : 'Queued. It will be included with the next message without starting a turn.' };
481
- }
482
460
  /** Set or clear the standing session goal (/goal). Injected into every agent's prompt until cleared. */
483
461
  async setSessionGoal(sessionId, goal) {
484
462
  const text = goal.trim();
@@ -527,15 +505,7 @@ export class AgentRuntime {
527
505
  const session = this.sessions.get(sessionId);
528
506
  const main = this.instances.get(session.mainInstanceId);
529
507
  const turnId = randomUUID();
530
- const queuedAsides = session.pendingAsides ?? [];
531
- session.pendingAsides = [];
532
- const composed = queuedAsides.length
533
- ? `${text}
534
-
535
- ${asidePrefix()}
536
- ${queuedAsides.map((aside, index) => `${index + 1}. ${aside}`).join('\n')}`
537
- : text;
538
- const message = { messageId: randomUUID(), role: 'user', content: composed, createdAt: now(), turnId };
508
+ const message = { messageId: randomUUID(), role: 'user', content: text, createdAt: now(), turnId };
539
509
  session.messages.push(message);
540
510
  session.updatedAt = message.createdAt;
541
511
  if (main.status === 'running' || main.status === 'waiting') {
@@ -543,20 +513,9 @@ ${queuedAsides.map((aside, index) => `${index + 1}. ${aside}`).join('\n')}`
543
513
  }
544
514
  if (main.status === 'cancelled')
545
515
  main.status = 'idle';
546
- // The model must see the folded asides, so deliver the composed message.
547
- // The TUI renders the asides as separate system entries from the emitted
548
- // system_message events above, while this user message keeps them inline.
549
- this.deliver(main, composed, undefined, turnId);
516
+ this.deliver(main, text, undefined, turnId);
550
517
  await this.persistSession(sessionId);
551
518
  this.emit({ type: 'user_message', sessionId, message: { ...message } });
552
- if (queuedAsides.length) {
553
- // Timeline-only notices; the user message itself already carries the
554
- // asides inline for the model.
555
- this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: `Aside${queuedAsides.length > 1 ? 's' : ''} included with your message:`, createdAt: now() } });
556
- for (const aside of queuedAsides) {
557
- this.emit({ type: 'system_message', sessionId, message: { messageId: randomUUID(), role: 'system', content: `· ${aside}`, createdAt: now() } });
558
- }
559
- }
560
519
  this.enqueue(main.instanceId);
561
520
  return turnId;
562
521
  }
@@ -26,6 +26,33 @@ async function replaceFile(temp, target) {
26
26
  }
27
27
  throw lastError;
28
28
  }
29
+ /** First user message, flattened to one line and clipped for session-picker previews. */
30
+ function firstUserPreview(messages) {
31
+ const first = messages.find((message) => message.role === 'user');
32
+ const text = first?.content.replace(/\s+/g, ' ').trim();
33
+ if (!text)
34
+ return undefined;
35
+ return text.length > 60 ? `${text.slice(0, 59)}…` : text;
36
+ }
37
+ /** Coarse relative timestamp for session pickers: just now / Nm ago / Nh ago / Nd ago / date. */
38
+ function relativeTime(iso) {
39
+ const then = Date.parse(iso);
40
+ if (!Number.isFinite(then))
41
+ return iso;
42
+ const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
43
+ if (seconds < 60)
44
+ return 'just now';
45
+ const minutes = Math.floor(seconds / 60);
46
+ if (minutes < 60)
47
+ return `${minutes}m ago`;
48
+ const hours = Math.floor(minutes / 60);
49
+ if (hours < 24)
50
+ return `${hours}h ago`;
51
+ const days = Math.floor(hours / 24);
52
+ if (days < 30)
53
+ return `${days}d ago`;
54
+ return new Date(then).toISOString().slice(0, 10);
55
+ }
29
56
  export class AgentRuntimeStore {
30
57
  dir;
31
58
  constructor(baseDir = process.env.CODER_DATA_HOME?.trim() || resolve(homedir(), '.coder')) {
@@ -117,6 +144,8 @@ export class AgentRuntimeStore {
117
144
  sessionId: parsed.session.sessionId,
118
145
  messages: parsed.session.messages.length,
119
146
  updatedAt: parsed.session.updatedAt,
147
+ preview: firstUserPreview(parsed.session.messages),
148
+ relativeUpdatedAt: relativeTime(parsed.session.updatedAt),
120
149
  });
121
150
  }
122
151
  catch { /* skip invalid snapshots */ }
@@ -1,7 +1,6 @@
1
1
  export const SLASH_COMMANDS = [
2
2
  { name: '/provider', description: 'Manage providers' },
3
3
  { name: '/model', description: 'Choose a model' },
4
- { name: '/aside', description: 'Queue an aside to fold into the next message' },
5
4
  { name: '/btw', description: 'Ask in a side conversation forked from this one' },
6
5
  { name: '/back', description: 'Return from a side conversation to the parent session' },
7
6
  { name: '/fork', description: 'Copy this conversation into a new saved session' },
@@ -75,10 +75,39 @@ export async function runFullscreenTui(runtime, options) {
75
75
  let welcomeTimer;
76
76
  let welcomeFrame = 0;
77
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.
78
+ // Set between a committed theme change and the next renderConversation():
79
+ // the welcome clock is then rebased so the mark replays its one-second
80
+ // opening act under the new palette. Preview highlights and Esc/✕ rollbacks
81
+ // restore the previous palette without a change and never set this.
82
+ let themeIntroReplay = false;
83
+ // Terminal focus lifecycle (DECSET 1004). While the window is unfocused the
84
+ // app must be frugal with PTY writes: a refocusing terminal replays the
85
+ // bytes it did not render, and a backlog of pending updates is what users
86
+ // see as the "crazy scrolling" burst on focus regain. Two rules:
87
+ // - Decorative animation (spinner glyphs, welcome shine, shell ellipsis) is
88
+ // suppressed outright while blurred: it is invisible in an unfocused
89
+ // window, and every frame is a multi-row byte burst.
90
+ // - Streaming text keeps flowing on a ~400ms heartbeat so a background
91
+ // window still shows the transcript growing. Event-driven repaints (tool
92
+ // calls, finished messages) are never throttled.
93
+ // Frames skipped for blur set `blurredStale`; focus regain then issues
94
+ // exactly one full redraw (like a resize) instead of replaying a backlog.
81
95
  let windowFocused = true;
96
+ let blurredStale = false;
97
+ const BLURRED_STREAM_MS = 400;
98
+ let lastStreamFrame = 0;
99
+ // Heartbeat gate for the stream repaint timer: true at most once every
100
+ // BLURRED_STREAM_MS while unfocused, always while focused.
101
+ const throttledFrame = () => {
102
+ if (windowFocused)
103
+ return true;
104
+ const now = Date.now();
105
+ if (now - lastStreamFrame < BLURRED_STREAM_MS)
106
+ return false;
107
+ lastStreamFrame = now;
108
+ blurredStale = true;
109
+ return true;
110
+ };
82
111
  let streamTimer;
83
112
  let shellAbort;
84
113
  let shellAnimationFrame = 0;
@@ -397,6 +426,17 @@ export async function runFullscreenTui(runtime, options) {
397
426
  renderComposerFrame();
398
427
  return;
399
428
  }
429
+ // A bare Escape stops the running turn, same as Ctrl+X / `/cancel`. The
430
+ // completion menu and the screen-level bindings keep their Esc semantics
431
+ // (dismiss suggestions, leave a focused pane), so stop only when nothing
432
+ // else claims the key and something is actually running — a stray press
433
+ // while idle stays a no-op instead of wiping queued work.
434
+ if (key.name === 'escape') {
435
+ const active = instances().some((item) => ['running', 'waiting', 'queued'].includes(item.status));
436
+ if (active)
437
+ void command('/cancel').catch((error) => { notice = String(error); refresh(); });
438
+ return;
439
+ }
400
440
  if (matches.length && (key.name === 'tab' || ((!key.meta) && (key.name === 'enter' || key.name === 'return')))) {
401
441
  setComposerValue(matches[completionIndex].name + (key.name === 'tab' ? ' ' : ''));
402
442
  if (key.name === 'tab')
@@ -463,7 +503,12 @@ export async function runFullscreenTui(runtime, options) {
463
503
  stopSpinner();
464
504
  return;
465
505
  }
466
- if (nativeSelection || hasSelection() || !windowFocused)
506
+ // Decoration only: frozen while blurred. Skipping a frame leaves the
507
+ // screen untouched and still valid, so it must not mark the frame stale
508
+ // — a quiet refocus after decoration-only blur is the whole point.
509
+ if (!windowFocused)
510
+ return;
511
+ if (nativeSelection || hasSelection())
467
512
  return;
468
513
  spinnerFrame += 1;
469
514
  conversationDirty = true;
@@ -496,7 +541,11 @@ export async function runFullscreenTui(runtime, options) {
496
541
  return;
497
542
  }
498
543
  // Native text selection owns the screen; never dirty or repaint under it.
499
- if (nativeSelection || hasSelection() || !windowFocused)
544
+ if (nativeSelection || hasSelection())
545
+ return;
546
+ // Content heartbeat: while blurred, repaint live text at most once per
547
+ // BLURRED_STREAM_MS so the background window keeps up without flooding.
548
+ if (!throttledFrame())
500
549
  return;
501
550
  const runningEntry = [...(session.timeline ?? [])].find((entry) => entry.status === 'running' && entry.kind !== 'tool' && entry.kind !== 'shell');
502
551
  if (streams.size > 0 || runningEntry) {
@@ -511,6 +560,10 @@ export async function runFullscreenTui(runtime, options) {
511
560
  }
512
561
  }
513
562
  else {
563
+ // The waiting ellipsis is decoration: frozen while blurred. A skipped
564
+ // frame leaves the screen untouched, so it must not mark it stale.
565
+ if (!windowFocused)
566
+ return;
514
567
  waitingFrame = (waitingFrame + 1) % 24;
515
568
  conversationDirty = true;
516
569
  }
@@ -851,16 +904,30 @@ export async function runFullscreenTui(runtime, options) {
851
904
  const markdownCols = Math.max(10, Math.min(120, metrics.conversationWidth - metrics.horizontalPadding * 2 - 2));
852
905
  thinkingBlockLines.clear();
853
906
  // The welcome screen yields to any conversation content — messages,
854
- // streaming output, thinking, or transcript entries like shell runs and
855
- // queued asides.
907
+ // streaming output, thinking, or transcript entries like shell runs.
856
908
  const welcomeVisible = !session.messages.length && !streams.size && thinkingBlocks.size === 0 && !(session.timeline?.length);
857
909
  if (welcomeVisible) {
910
+ // A committed theme change rebases the welcome clock so the mark replays
911
+ // its opening act under the new palette; preview highlights and Esc/✕
912
+ // rollbacks never set the flag, so they only recolor in place.
913
+ if (themeIntroReplay) {
914
+ themeIntroReplay = false;
915
+ // Rebase the welcome clock: the next timer tick derives frame 1 from
916
+ // this moment, and the loop's own shine phase restarts seamlessly
917
+ // because every intro lands on the settled frame-20 state.
918
+ welcomeStartedAt = performance.now();
919
+ welcomeFrame = 0;
920
+ }
858
921
  for (const line of renderWelcome(Number(conversation.width) - Number(conversation.iwidth) - 1, Number(conversation.height) - Number(conversation.iheight), Number(screen.height), welcomeFrame))
859
922
  pushConversationLine(line);
860
923
  if (!welcomeTimer) {
861
924
  welcomeStartedAt = performance.now();
862
925
  welcomeTimer = setInterval(() => {
863
- if (!windowFocused || nativeSelection || hasSelection() || screen.focused !== composer)
926
+ // Decoration only: frozen while blurred. A skipped frame leaves the
927
+ // screen untouched, so it must not mark the frame stale.
928
+ if (!windowFocused)
929
+ return;
930
+ if (nativeSelection || hasSelection() || screen.focused !== composer)
864
931
  return;
865
932
  // The frame derives from the monotonic clock instead of a counter:
866
933
  // after sleep or background suspension the animation lands on the
@@ -874,9 +941,14 @@ export async function runFullscreenTui(runtime, options) {
874
941
  welcomeTimer.unref?.();
875
942
  }
876
943
  }
877
- else if (welcomeTimer) {
878
- clearInterval(welcomeTimer);
879
- welcomeTimer = undefined;
944
+ else {
945
+ // The welcome screen is hidden: a pending replay would otherwise fire
946
+ // stale months later (e.g. when /clear finally reveals the banner).
947
+ themeIntroReplay = false;
948
+ if (welcomeTimer) {
949
+ clearInterval(welcomeTimer);
950
+ welcomeTimer = undefined;
951
+ }
880
952
  }
881
953
  const renderedBlocks = new Set();
882
954
  if (session.timeline) {
@@ -1169,19 +1241,23 @@ export async function runFullscreenTui(runtime, options) {
1169
1241
  };
1170
1242
  const stickyHeaderLine = (block) => {
1171
1243
  const toggle = block.expanded ? '▼' : '▶';
1172
- const icon = block.status === 'active' ? spinnerGlyph(spinnerFrame) : toggle;
1244
+ // Completed blocks reuse the toggle glyph as their icon; keep only one so
1245
+ // the pinned header never shows "▼ ▼".
1246
+ const icon = block.status === 'active' ? spinnerGlyph(spinnerFrame) : '';
1173
1247
  const color = block.status === 'active' ? COLOR().accent : COLOR().muted;
1174
1248
  const label = block.status === 'active'
1175
1249
  ? (block.thinking || block.content.length === 0 ? 'Thinking' : 'Working')
1176
1250
  : (block.thinking ? 'Thought' : 'Activity');
1177
1251
  const duration = elapsedLabel(block.startedAt, block.finishedAt);
1178
1252
  const durationText = duration ? ` ${duration}` : '';
1179
- return `{${color}-fg}${toggle} ${icon} ${label}${durationText}{/${color}-fg}`;
1253
+ return `{${color}-fg}${toggle}${icon ? ` ${icon}` : ''} ${label}${durationText}{/${color}-fg}`;
1180
1254
  };
1181
1255
  const renderThinkingBlock = (block) => {
1182
1256
  const headerLine = lineCursor;
1183
1257
  const toggle = block.expanded ? '▼' : '▶';
1184
- const icon = block.status === 'active' ? spinnerGlyph(spinnerFrame) : toggle;
1258
+ // Completed blocks reuse the toggle glyph as their icon; keep only one so
1259
+ // the header never shows "▼ ▼" or "▶ ▶".
1260
+ const icon = block.status === 'active' ? spinnerGlyph(spinnerFrame) : '';
1185
1261
  const color = block.status === 'active' ? COLOR().accent : COLOR().muted;
1186
1262
  // An active block with nothing to show yet is the pre-first-token state:
1187
1263
  // the model is reasoning, so label it Thinking, not Working.
@@ -1195,7 +1271,7 @@ export async function runFullscreenTui(runtime, options) {
1195
1271
  pushConversationLine(`{${color}-fg}${toggle} ${icon}{/${color}-fg} ${scanLabel}{${COLOR().subtle}-fg}${durationText}{/${COLOR().subtle}-fg}`);
1196
1272
  }
1197
1273
  else {
1198
- pushConversationLine(`{${color}-fg}${icon} ${label}${durationText}{/${color}-fg}`);
1274
+ pushConversationLine(`{${color}-fg}${toggle}${icon ? ` ${icon}` : ''} ${label}${durationText}{/${color}-fg}`);
1199
1275
  }
1200
1276
  latestThinkingTurnId = block.turnId;
1201
1277
  if (block.expanded) {
@@ -1307,11 +1383,16 @@ export async function runFullscreenTui(runtime, options) {
1307
1383
  screen.program.showCursor();
1308
1384
  }
1309
1385
  };
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.
1386
+ // Focus regained is treated like a resize, and only when the blur window
1387
+ // actually skipped frames: one invalidate + full redraw rebuilds blessed's
1388
+ // diff buffers, the viewport, and the overlay scrollbar positions from the
1389
+ // live state instead of a stale frame. Skipping it when nothing was skipped
1390
+ // keeps short refocuses byte-quiet (no replay burst, no scrolling flash).
1313
1391
  screen.program.on('focus', () => {
1314
1392
  windowFocused = true;
1393
+ if (!blurredStale)
1394
+ return;
1395
+ blurredStale = false;
1315
1396
  requestFullRedraw();
1316
1397
  conversationDirty = true;
1317
1398
  scheduleRefresh();
@@ -1505,7 +1586,10 @@ export async function runFullscreenTui(runtime, options) {
1505
1586
  const openSessions = async () => {
1506
1587
  const sessions = await runtime.listSessions();
1507
1588
  const index = await choose('Sessions', [
1508
- ...sessions.map((item) => ({ label: item.sessionId, detail: `${item.messages} messages${item.sessionId.startsWith('btw-') ? ' [side]' : ''}` })),
1589
+ ...sessions.map((item) => ({
1590
+ label: oneLine(item.preview, 52) || item.sessionId,
1591
+ detail: `${item.messages} msg · ${item.relativeUpdatedAt ?? ''} · ${item.sessionId}${item.sessionId.startsWith('btw-') ? ' [side]' : ''}`,
1592
+ })),
1509
1593
  { label: 'New session', detail: 'Start a blank conversation' },
1510
1594
  ], { searchable: true });
1511
1595
  if (index < 0)
@@ -1617,8 +1701,16 @@ export async function runFullscreenTui(runtime, options) {
1617
1701
  requestFullRedraw();
1618
1702
  return next;
1619
1703
  };
1620
- const applyTheme = async (name) => {
1704
+ // Commits a theme choice. `changed` must be decided against the theme the
1705
+ // picker was opened with, not the live one: previewing already swaps the
1706
+ // active palette, so by Enter/click time it equals the chosen name. Any
1707
+ // commit path that actually changes the theme (Enter, mouse click, or a
1708
+ // future caller) replays the welcome logo's opening act under the new
1709
+ // palette; preview highlights and Esc/✕ rollbacks never do.
1710
+ const applyTheme = async (name, changed = activeTuiTheme().name !== name) => {
1621
1711
  const next = applyThemeVisuals(name);
1712
+ if (changed)
1713
+ themeIntroReplay = true;
1622
1714
  notice = `Theme set to ${next.label}`;
1623
1715
  await options.configManager.saveConfig({ ...options.configManager.getConfig(), theme: next.name });
1624
1716
  refresh();
@@ -1643,7 +1735,7 @@ export async function runFullscreenTui(runtime, options) {
1643
1735
  },
1644
1736
  });
1645
1737
  if (index >= 0) {
1646
- await applyTheme(names[index]);
1738
+ await applyTheme(names[index], names[index] !== original);
1647
1739
  }
1648
1740
  else if (activeTuiTheme().name !== original) {
1649
1741
  // Picker dismissed: roll back to the theme chosen before previewing.
@@ -1693,8 +1785,12 @@ export async function runFullscreenTui(runtime, options) {
1693
1785
  refresh();
1694
1786
  break;
1695
1787
  case 'cancel': {
1788
+ // The notice renders in the conversation stream; without flagging the
1789
+ // dirty bit renderConversation() skips the repaint entirely and the
1790
+ // acknowledgement never shows.
1696
1791
  if (!args[0]) {
1697
1792
  await runtime.cancelSession(sessionId);
1793
+ conversationDirty = true;
1698
1794
  notice = 'Stopped. Send a message to continue.';
1699
1795
  refresh();
1700
1796
  break;
@@ -1715,23 +1811,10 @@ export async function runFullscreenTui(runtime, options) {
1715
1811
  refresh();
1716
1812
  break;
1717
1813
  }
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
- }
1814
+ // /btw opens a self-contained side conversation forked from this one
1815
+ // (/back or Ctrl+C returns); /fork copies the whole conversation into a
1816
+ // new saved session. /goal sets a standing directive shown in the status
1817
+ // bar and injected into every agent's prompt until cleared.
1735
1818
  case 'btw': {
1736
1819
  const question = args.join(' ').trim();
1737
1820
  if (!question) {
@@ -2109,6 +2192,8 @@ export async function runFullscreenTui(runtime, options) {
2109
2192
  clearInterval(animation);
2110
2193
  return;
2111
2194
  }
2195
+ // Decoration only: frozen while blurred. A skipped frame leaves the
2196
+ // screen untouched, so it must not mark the frame stale.
2112
2197
  if (!windowFocused)
2113
2198
  return;
2114
2199
  shellAnimationFrame += 1;