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 +0 -1
- package/dist/cli.js +16 -0
- package/dist/runtime/agent-runtime.js +2 -43
- package/dist/runtime/agent-store.js +29 -0
- package/dist/ui/commands.js +0 -1
- package/dist/ui/fullscreen-tui.js +123 -38
- package/dist/ui/welcome.js +430 -19
- package/dist/update-check.js +332 -0
- package/package.json +8 -2
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
|
|
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
|
-
|
|
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 */ }
|
package/dist/ui/commands.js
CHANGED
|
@@ -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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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
|
-
|
|
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()
|
|
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
|
|
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
|
-
|
|
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
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
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) => ({
|
|
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
|
-
|
|
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
|
-
// /
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
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;
|