swarm-code 0.1.16 → 0.1.18
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/dist/core/rlm.js +32 -1
- package/dist/interactive-swarm.js +16 -5
- package/dist/prompts/orchestrator.js +3 -2
- package/dist/swarm.js +1 -0
- package/dist/ui/dashboard.d.ts +10 -10
- package/dist/ui/dashboard.js +20 -40
- package/dist/ui/run-log.d.ts +50 -0
- package/dist/ui/run-log.js +60 -0
- package/dist/ui/spinner.d.ts +12 -1
- package/dist/ui/spinner.js +60 -10
- package/package.json +1 -1
package/dist/core/rlm.js
CHANGED
|
@@ -373,6 +373,15 @@ export async function runRlmLoop(options) {
|
|
|
373
373
|
stderr: execResult.stderr,
|
|
374
374
|
});
|
|
375
375
|
if (execResult.hasFinal && execResult.finalValue !== null) {
|
|
376
|
+
// Auto-merge any unmerged thread branches before returning
|
|
377
|
+
if (mergeHandler) {
|
|
378
|
+
try {
|
|
379
|
+
await mergeHandler();
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// Non-fatal — merge may have already been done by the LLM
|
|
383
|
+
}
|
|
384
|
+
}
|
|
376
385
|
return {
|
|
377
386
|
answer: execResult.finalValue,
|
|
378
387
|
iterations: iteration,
|
|
@@ -392,13 +401,35 @@ export async function runRlmLoop(options) {
|
|
|
392
401
|
parts.push("(No output produced. The code ran without printing anything.)");
|
|
393
402
|
}
|
|
394
403
|
parts.push(`\nIteration ${iteration}/${config.max_iterations}. Sub-queries used: ${totalSubQueries}/${config.max_sub_queries}.`);
|
|
395
|
-
|
|
404
|
+
const remaining = config.max_iterations - iteration;
|
|
405
|
+
if (remaining <= 2) {
|
|
406
|
+
parts.push("⚠️ CRITICAL: Only " +
|
|
407
|
+
remaining +
|
|
408
|
+
" iteration(s) remaining! Call merge_threads() then FINAL() NOW with your best result. Any unmerged work will be lost.");
|
|
409
|
+
}
|
|
410
|
+
else if (remaining <= Math.ceil(config.max_iterations * 0.25)) {
|
|
411
|
+
parts.push("⚠️ WARNING: " +
|
|
412
|
+
remaining +
|
|
413
|
+
" iterations remaining. Wrap up: merge_threads() → FINAL(). Don't start new threads.");
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
parts.push("Continue processing or call FINAL() when you have the answer.");
|
|
417
|
+
}
|
|
396
418
|
conversationHistory.push({
|
|
397
419
|
role: "user",
|
|
398
420
|
content: parts.join("\n\n"),
|
|
399
421
|
timestamp: Date.now(),
|
|
400
422
|
});
|
|
401
423
|
}
|
|
424
|
+
// Auto-merge any remaining thread branches even though FINAL was never called
|
|
425
|
+
if (mergeHandler) {
|
|
426
|
+
try {
|
|
427
|
+
await mergeHandler();
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Non-fatal
|
|
431
|
+
}
|
|
432
|
+
}
|
|
402
433
|
return {
|
|
403
434
|
answer: "[Maximum iterations reached without calling FINAL]",
|
|
404
435
|
iterations: config.max_iterations,
|
|
@@ -40,6 +40,7 @@ import { ThreadManager } from "./threads/manager.js";
|
|
|
40
40
|
import { ThreadDashboard } from "./ui/dashboard.js";
|
|
41
41
|
import { logError, logRouter, logSuccess, logVerbose, logWarn, setLogLevel } from "./ui/log.js";
|
|
42
42
|
import { runOnboarding } from "./ui/onboarding.js";
|
|
43
|
+
import { RunLogger } from "./ui/run-log.js";
|
|
43
44
|
// UI system
|
|
44
45
|
import { Spinner } from "./ui/spinner.js";
|
|
45
46
|
import { bold, coral, cyan, dim, green, isTTY, red, symbols, termWidth, truncate, yellow } from "./ui/theme.js";
|
|
@@ -784,6 +785,7 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
784
785
|
const repl = new PythonRepl();
|
|
785
786
|
const sessionAc = new AbortController();
|
|
786
787
|
const dashboard = new ThreadDashboard();
|
|
788
|
+
spinner.setDashboard(dashboard);
|
|
787
789
|
const threadProgress = (threadId, phase, detail) => {
|
|
788
790
|
if (phase === "completed" || phase === "failed" || phase === "cancelled") {
|
|
789
791
|
dashboard.complete(threadId, phase, detail);
|
|
@@ -943,6 +945,7 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
943
945
|
sessionAc.signal.addEventListener("abort", onSessionAbort, { once: true });
|
|
944
946
|
spinner.start();
|
|
945
947
|
const startTime = Date.now();
|
|
948
|
+
const runLog = new RunLogger(query, resolved.model.id, config.default_agent, args.dir, config.max_iterations || 20);
|
|
946
949
|
try {
|
|
947
950
|
// Update episodic memory hints per-task
|
|
948
951
|
let taskSystemPrompt = systemPrompt;
|
|
@@ -970,7 +973,7 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
970
973
|
mergeHandler,
|
|
971
974
|
onProgress: (info) => {
|
|
972
975
|
spinner.update(`iteration ${info.iteration}/${info.maxIterations}` +
|
|
973
|
-
(info.subQueries > 0 ? `
|
|
976
|
+
(info.subQueries > 0 ? ` \u00B7 ${info.subQueries} queries` : ""));
|
|
974
977
|
logVerbose(`Iteration ${info.iteration}/${info.maxIterations} | ` +
|
|
975
978
|
`Sub-queries: ${info.subQueries} | Phase: ${info.phase}`);
|
|
976
979
|
},
|
|
@@ -978,6 +981,9 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
978
981
|
spinner.stop();
|
|
979
982
|
dashboard.clear();
|
|
980
983
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
984
|
+
// Log the run
|
|
985
|
+
runLog.complete({ completed: result.completed, iterations: result.iterations, answer: result.answer }, Date.now() - startTime);
|
|
986
|
+
const logPath = runLog.save();
|
|
981
987
|
// Show concise result
|
|
982
988
|
process.stderr.write("\n");
|
|
983
989
|
const status = result.completed ? green("completed") : yellow("incomplete");
|
|
@@ -985,22 +991,27 @@ export async function runInteractiveSwarm(rawArgs) {
|
|
|
985
991
|
// Show answer
|
|
986
992
|
if (result.answer) {
|
|
987
993
|
process.stderr.write("\n");
|
|
988
|
-
const
|
|
989
|
-
for (const line of
|
|
994
|
+
const answerLines = result.answer.split("\n");
|
|
995
|
+
for (const line of answerLines) {
|
|
990
996
|
process.stderr.write(` ${line}\n`);
|
|
991
997
|
}
|
|
992
998
|
}
|
|
999
|
+
if (logPath) {
|
|
1000
|
+
process.stderr.write(` ${dim(`log: ${logPath}`)}\n`);
|
|
1001
|
+
}
|
|
993
1002
|
process.stderr.write("\n");
|
|
994
1003
|
}
|
|
995
1004
|
catch (err) {
|
|
996
1005
|
spinner.stop();
|
|
997
1006
|
dashboard.clear();
|
|
1007
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1008
|
+
runLog.complete({ completed: false, iterations: 0, error: errMsg }, Date.now() - startTime);
|
|
1009
|
+
runLog.save();
|
|
998
1010
|
if (currentTaskAc?.signal.aborted) {
|
|
999
1011
|
logWarn("Task cancelled");
|
|
1000
1012
|
}
|
|
1001
1013
|
else {
|
|
1002
|
-
|
|
1003
|
-
logError(`Task failed: ${msg}`);
|
|
1014
|
+
logError(`Task failed: ${errMsg}`);
|
|
1004
1015
|
}
|
|
1005
1016
|
}
|
|
1006
1017
|
finally {
|
|
@@ -77,9 +77,10 @@ ${agentDescriptions}
|
|
|
77
77
|
4. Use \`print()\` for intermediate output visible in the next iteration
|
|
78
78
|
5. Max ${config.max_threads} concurrent threads, ${config.max_total_threads} total per session
|
|
79
79
|
6. Thread timeout: ${config.thread_timeout_ms / 1000}s per thread
|
|
80
|
-
7.
|
|
80
|
+
7. After merging, try to run a quick verification thread if iterations allow. But don't endlessly loop on verification — one attempt is enough.
|
|
81
81
|
8. Prefer cheap models for sub-agent threads (haiku, gpt-4o-mini) — save premium models for complex work
|
|
82
|
-
|
|
82
|
+
9. The REPL persists state — variables survive across iterations
|
|
83
|
+
10. **Watch your iteration count.** If you're past 75% of max iterations, call \`merge_threads()\` then \`FINAL()\` with your best result. Don't waste iterations on repeated verification cycles.
|
|
83
84
|
|
|
84
85
|
## Examples
|
|
85
86
|
|
package/dist/swarm.js
CHANGED
|
@@ -288,6 +288,7 @@ export async function runSwarmMode(rawArgs) {
|
|
|
288
288
|
const ac = new AbortController();
|
|
289
289
|
// Thread dashboard for live status
|
|
290
290
|
const dashboard = new ThreadDashboard();
|
|
291
|
+
spinner.setDashboard(dashboard);
|
|
291
292
|
// Progress callback for thread events
|
|
292
293
|
const threadProgress = (threadId, phase, detail) => {
|
|
293
294
|
if (phase === "completed" || phase === "failed" || phase === "cancelled") {
|
package/dist/ui/dashboard.d.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live thread status dashboard — shows running/queued/completed threads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Rendering is owned by the Spinner — the dashboard builds lines but does NOT
|
|
5
|
+
* write directly to stderr. This prevents interleaving between the spinner's
|
|
6
|
+
* 80ms animation loop and async thread progress callbacks.
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
|
-
* Thread dashboard — manages
|
|
10
|
-
*
|
|
9
|
+
* Thread dashboard — manages thread state and builds status lines.
|
|
10
|
+
* Does NOT write to stderr directly — the Spinner calls getLines()
|
|
11
|
+
* on each render tick to include dashboard output atomically.
|
|
11
12
|
*/
|
|
12
13
|
export declare class ThreadDashboard {
|
|
13
14
|
private threads;
|
|
14
15
|
private pendingTimers;
|
|
15
|
-
private lastLineCount;
|
|
16
16
|
private enabled;
|
|
17
17
|
constructor();
|
|
18
18
|
/** Update a thread's status. */
|
|
@@ -22,12 +22,12 @@ export declare class ThreadDashboard {
|
|
|
22
22
|
model?: string;
|
|
23
23
|
filesChanged?: number;
|
|
24
24
|
}): void;
|
|
25
|
-
/**
|
|
25
|
+
/** Mark a thread as done. Auto-removes after 1.5s. */
|
|
26
26
|
complete(id: string, phase: string, detail?: string): void;
|
|
27
|
-
/** Clear all
|
|
27
|
+
/** Clear all state and cancel pending timers. */
|
|
28
28
|
clear(): void;
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
/** Get the current dashboard lines (called by Spinner.render). */
|
|
30
|
+
getLines(): string[];
|
|
31
31
|
private buildLines;
|
|
32
32
|
private formatPhase;
|
|
33
33
|
}
|
package/dist/ui/dashboard.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live thread status dashboard — shows running/queued/completed threads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Rendering is owned by the Spinner — the dashboard builds lines but does NOT
|
|
5
|
+
* write directly to stderr. This prevents interleaving between the spinner's
|
|
6
|
+
* 80ms animation loop and async thread progress callbacks.
|
|
7
7
|
*/
|
|
8
8
|
import { getLogLevel, isJsonMode } from "./log.js";
|
|
9
9
|
import { coral, cyan, dim, green, isTTY, red, stripAnsi, symbols, termWidth, truncate, yellow } from "./theme.js";
|
|
10
10
|
/**
|
|
11
|
-
* Thread dashboard — manages
|
|
12
|
-
*
|
|
11
|
+
* Thread dashboard — manages thread state and builds status lines.
|
|
12
|
+
* Does NOT write to stderr directly — the Spinner calls getLines()
|
|
13
|
+
* on each render tick to include dashboard output atomically.
|
|
13
14
|
*/
|
|
14
15
|
export class ThreadDashboard {
|
|
15
16
|
threads = new Map();
|
|
16
17
|
pendingTimers = new Set();
|
|
17
|
-
lastLineCount = 0;
|
|
18
18
|
enabled;
|
|
19
19
|
constructor() {
|
|
20
20
|
this.enabled = isTTY && !isJsonMode() && getLogLevel() !== "quiet";
|
|
@@ -41,9 +41,9 @@ export class ThreadDashboard {
|
|
|
41
41
|
detail,
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
// No direct render — spinner picks up changes on next tick
|
|
45
45
|
}
|
|
46
|
-
/**
|
|
46
|
+
/** Mark a thread as done. Auto-removes after 1.5s. */
|
|
47
47
|
complete(id, phase, detail) {
|
|
48
48
|
const existing = this.threads.get(id);
|
|
49
49
|
if (existing) {
|
|
@@ -51,47 +51,24 @@ export class ThreadDashboard {
|
|
|
51
51
|
if (detail)
|
|
52
52
|
existing.detail = detail;
|
|
53
53
|
}
|
|
54
|
-
this.render();
|
|
55
|
-
// Remove completed/failed threads after a brief display
|
|
56
54
|
const timer = setTimeout(() => {
|
|
57
55
|
this.pendingTimers.delete(timer);
|
|
58
56
|
this.threads.delete(id);
|
|
59
|
-
this.render();
|
|
60
57
|
}, 1500);
|
|
61
58
|
this.pendingTimers.add(timer);
|
|
62
59
|
}
|
|
63
|
-
/** Clear all
|
|
60
|
+
/** Clear all state and cancel pending timers. */
|
|
64
61
|
clear() {
|
|
65
62
|
for (const timer of this.pendingTimers)
|
|
66
63
|
clearTimeout(timer);
|
|
67
64
|
this.pendingTimers.clear();
|
|
68
|
-
if (!this.enabled)
|
|
69
|
-
return;
|
|
70
|
-
this.clearLines();
|
|
71
65
|
this.threads.clear();
|
|
72
|
-
this.lastLineCount = 0;
|
|
73
66
|
}
|
|
74
|
-
render
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
this.
|
|
79
|
-
const lines = this.buildLines();
|
|
80
|
-
if (lines.length === 0) {
|
|
81
|
-
this.lastLineCount = 0;
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
// Write new lines
|
|
85
|
-
process.stderr.write(`${lines.join("\n")}\n`);
|
|
86
|
-
this.lastLineCount = lines.length;
|
|
87
|
-
}
|
|
88
|
-
clearLines() {
|
|
89
|
-
if (this.lastLineCount <= 0)
|
|
90
|
-
return;
|
|
91
|
-
// Move up and clear each line
|
|
92
|
-
for (let i = 0; i < this.lastLineCount; i++) {
|
|
93
|
-
process.stderr.write("\x1b[1A\x1b[K");
|
|
94
|
-
}
|
|
67
|
+
/** Get the current dashboard lines (called by Spinner.render). */
|
|
68
|
+
getLines() {
|
|
69
|
+
if (!this.enabled || this.threads.size === 0)
|
|
70
|
+
return [];
|
|
71
|
+
return this.buildLines();
|
|
95
72
|
}
|
|
96
73
|
buildLines() {
|
|
97
74
|
const w = Math.min(termWidth(), 80);
|
|
@@ -115,9 +92,12 @@ export class ThreadDashboard {
|
|
|
115
92
|
else {
|
|
116
93
|
line = ` ${tag} ${phase} ${time} ${dim(task)}`;
|
|
117
94
|
}
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
95
|
+
// ANSI-safe truncation — count visible chars, not raw string length
|
|
96
|
+
const visible = stripAnsi(line);
|
|
97
|
+
if (visible.length > w) {
|
|
98
|
+
// Rebuild truncated: just trim the last visible portion
|
|
99
|
+
const ansiOverhead = line.length - visible.length;
|
|
100
|
+
line = `${line.slice(0, w - 1 + ansiOverhead)}\x1b[0m`;
|
|
121
101
|
}
|
|
122
102
|
lines.push(line);
|
|
123
103
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run logger — writes structured logs for each swarm run to ~/.swarm/logs/.
|
|
3
|
+
*
|
|
4
|
+
* Each run creates a timestamped JSON log file with:
|
|
5
|
+
* - Task, model, agent, config
|
|
6
|
+
* - Iterations, sub-queries, thread spawns
|
|
7
|
+
* - Timing, token usage, costs
|
|
8
|
+
* - Thread details (task, agent, model, result, duration)
|
|
9
|
+
* - Final answer and completion status
|
|
10
|
+
*
|
|
11
|
+
* Logs are kept for dev iteration — view with `ls ~/.swarm/logs/` or parse as JSON.
|
|
12
|
+
*/
|
|
13
|
+
export interface ThreadLogEntry {
|
|
14
|
+
id: string;
|
|
15
|
+
task: string;
|
|
16
|
+
agent: string;
|
|
17
|
+
model: string;
|
|
18
|
+
success: boolean;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
filesChanged: string[];
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RunLogEntry {
|
|
24
|
+
timestamp: string;
|
|
25
|
+
task: string;
|
|
26
|
+
orchestratorModel: string;
|
|
27
|
+
agent: string;
|
|
28
|
+
dir: string;
|
|
29
|
+
completed: boolean;
|
|
30
|
+
iterations: number;
|
|
31
|
+
maxIterations: number;
|
|
32
|
+
durationMs: number;
|
|
33
|
+
threads: ThreadLogEntry[];
|
|
34
|
+
answer?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
export declare class RunLogger {
|
|
38
|
+
private entry;
|
|
39
|
+
private threads;
|
|
40
|
+
constructor(task: string, orchestratorModel: string, agent: string, dir: string, maxIterations: number);
|
|
41
|
+
addThread(thread: ThreadLogEntry): void;
|
|
42
|
+
complete(result: {
|
|
43
|
+
completed: boolean;
|
|
44
|
+
iterations: number;
|
|
45
|
+
answer?: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
}, durationMs: number): void;
|
|
48
|
+
/** Write the log file. Call after complete(). */
|
|
49
|
+
save(): string | null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run logger — writes structured logs for each swarm run to ~/.swarm/logs/.
|
|
3
|
+
*
|
|
4
|
+
* Each run creates a timestamped JSON log file with:
|
|
5
|
+
* - Task, model, agent, config
|
|
6
|
+
* - Iterations, sub-queries, thread spawns
|
|
7
|
+
* - Timing, token usage, costs
|
|
8
|
+
* - Thread details (task, agent, model, result, duration)
|
|
9
|
+
* - Final answer and completion status
|
|
10
|
+
*
|
|
11
|
+
* Logs are kept for dev iteration — view with `ls ~/.swarm/logs/` or parse as JSON.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
const LOG_DIR = path.join(os.homedir(), ".swarm", "logs");
|
|
17
|
+
export class RunLogger {
|
|
18
|
+
entry;
|
|
19
|
+
threads = [];
|
|
20
|
+
constructor(task, orchestratorModel, agent, dir, maxIterations) {
|
|
21
|
+
this.entry = {
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
task,
|
|
24
|
+
orchestratorModel,
|
|
25
|
+
agent,
|
|
26
|
+
dir,
|
|
27
|
+
completed: false,
|
|
28
|
+
iterations: 0,
|
|
29
|
+
maxIterations,
|
|
30
|
+
durationMs: 0,
|
|
31
|
+
threads: [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
addThread(thread) {
|
|
35
|
+
this.threads.push(thread);
|
|
36
|
+
}
|
|
37
|
+
complete(result, durationMs) {
|
|
38
|
+
this.entry.completed = result.completed;
|
|
39
|
+
this.entry.iterations = result.iterations;
|
|
40
|
+
this.entry.answer = result.answer;
|
|
41
|
+
this.entry.error = result.error;
|
|
42
|
+
this.entry.durationMs = durationMs;
|
|
43
|
+
this.entry.threads = this.threads;
|
|
44
|
+
}
|
|
45
|
+
/** Write the log file. Call after complete(). */
|
|
46
|
+
save() {
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
49
|
+
const ts = this.entry.timestamp.replace(/[:.]/g, "-").slice(0, 19);
|
|
50
|
+
const filename = `run-${ts}.json`;
|
|
51
|
+
const filepath = path.join(LOG_DIR, filename);
|
|
52
|
+
fs.writeFileSync(filepath, JSON.stringify(this.entry, null, 2), "utf-8");
|
|
53
|
+
return filepath;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=run-log.js.map
|
package/dist/ui/spinner.d.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs on an isolated 80ms animation loop separated from other output.
|
|
5
5
|
* Displays a coral-colored spinner glyph + a rotating verb + optional detail.
|
|
6
|
+
*
|
|
7
|
+
* Coordinates with ThreadDashboard — the spinner owns the render cycle:
|
|
8
|
+
* spinner line first, then dashboard lines below. This prevents interleaving.
|
|
6
9
|
*/
|
|
10
|
+
import type { ThreadDashboard } from "./dashboard.js";
|
|
7
11
|
export declare class Spinner {
|
|
8
12
|
private static _exitHandlerRegistered;
|
|
9
13
|
private intervalId;
|
|
@@ -13,13 +17,20 @@ export declare class Spinner {
|
|
|
13
17
|
private detail;
|
|
14
18
|
private startTime;
|
|
15
19
|
private running;
|
|
20
|
+
private dashboard;
|
|
21
|
+
/** How many lines we wrote below the spinner (dashboard) */
|
|
22
|
+
private extraLines;
|
|
23
|
+
/** Link a dashboard so the spinner owns its render cycle. */
|
|
24
|
+
setDashboard(d: ThreadDashboard): void;
|
|
16
25
|
/** Start the spinner. */
|
|
17
26
|
start(detail?: string): void;
|
|
18
27
|
/** Update the detail text without restarting. */
|
|
19
28
|
update(detail: string): void;
|
|
20
|
-
/** Stop the spinner and clear
|
|
29
|
+
/** Stop the spinner and clear everything (spinner + dashboard lines). */
|
|
21
30
|
stop(): void;
|
|
22
31
|
/** Whether the spinner is currently active. */
|
|
23
32
|
get isActive(): boolean;
|
|
33
|
+
/** Called by the dashboard when it wants to re-render. */
|
|
34
|
+
requestDashboardRender(): void;
|
|
24
35
|
private render;
|
|
25
36
|
}
|
package/dist/ui/spinner.js
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs on an isolated 80ms animation loop separated from other output.
|
|
5
5
|
* Displays a coral-colored spinner glyph + a rotating verb + optional detail.
|
|
6
|
+
*
|
|
7
|
+
* Coordinates with ThreadDashboard — the spinner owns the render cycle:
|
|
8
|
+
* spinner line first, then dashboard lines below. This prevents interleaving.
|
|
6
9
|
*/
|
|
7
|
-
import { coral, dim, isTTY,
|
|
10
|
+
import { coral, dim, isTTY, termWidth } from "./theme.js";
|
|
8
11
|
/** Playful verbs shown while the spinner is active. */
|
|
9
12
|
const VERBS = [
|
|
10
13
|
"Orchestrating",
|
|
@@ -46,6 +49,13 @@ export class Spinner {
|
|
|
46
49
|
detail = "";
|
|
47
50
|
startTime = 0;
|
|
48
51
|
running = false;
|
|
52
|
+
dashboard = null;
|
|
53
|
+
/** How many lines we wrote below the spinner (dashboard) */
|
|
54
|
+
extraLines = 0;
|
|
55
|
+
/** Link a dashboard so the spinner owns its render cycle. */
|
|
56
|
+
setDashboard(d) {
|
|
57
|
+
this.dashboard = d;
|
|
58
|
+
}
|
|
49
59
|
/** Start the spinner. */
|
|
50
60
|
start(detail) {
|
|
51
61
|
if (!isTTY || this.running)
|
|
@@ -55,6 +65,7 @@ export class Spinner {
|
|
|
55
65
|
this.startTime = Date.now();
|
|
56
66
|
this.frameIdx = 0;
|
|
57
67
|
this.totalFrames = 0;
|
|
68
|
+
this.extraLines = 0;
|
|
58
69
|
this.verbIdx = Math.floor(Math.random() * VERBS.length);
|
|
59
70
|
// Hide cursor + register exit handler to restore it
|
|
60
71
|
process.stderr.write("\x1b[?25l");
|
|
@@ -80,7 +91,7 @@ export class Spinner {
|
|
|
80
91
|
update(detail) {
|
|
81
92
|
this.detail = detail;
|
|
82
93
|
}
|
|
83
|
-
/** Stop the spinner and clear
|
|
94
|
+
/** Stop the spinner and clear everything (spinner + dashboard lines). */
|
|
84
95
|
stop() {
|
|
85
96
|
if (!this.running)
|
|
86
97
|
return;
|
|
@@ -89,25 +100,64 @@ export class Spinner {
|
|
|
89
100
|
clearInterval(this.intervalId);
|
|
90
101
|
this.intervalId = null;
|
|
91
102
|
}
|
|
92
|
-
// Clear spinner line
|
|
93
|
-
process.stderr.write(
|
|
103
|
+
// Clear spinner line + all dashboard lines below using "erase to end of screen"
|
|
104
|
+
process.stderr.write(`\r\x1b[K\x1b[J\x1b[?25h`);
|
|
105
|
+
this.extraLines = 0;
|
|
94
106
|
}
|
|
95
107
|
/** Whether the spinner is currently active. */
|
|
96
108
|
get isActive() {
|
|
97
109
|
return this.running;
|
|
98
110
|
}
|
|
111
|
+
/** Called by the dashboard when it wants to re-render. */
|
|
112
|
+
requestDashboardRender() {
|
|
113
|
+
// Dashboard render happens on next spinner tick — no immediate write.
|
|
114
|
+
// This prevents interleaving.
|
|
115
|
+
}
|
|
99
116
|
render() {
|
|
100
117
|
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
|
|
101
118
|
const char = coral(SPINNER_CHARS[this.frameIdx]);
|
|
102
119
|
const verb = VERBS[this.verbIdx];
|
|
103
120
|
const time = dim(`${elapsed}s`);
|
|
104
|
-
const detail = this.detail ? dim(` ${this.detail}`) : "";
|
|
105
|
-
const line = ` ${char} ${verb}...${detail} ${time}`;
|
|
106
121
|
const maxW = termWidth();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
// Build detail string, truncating if needed (ANSI-safe)
|
|
123
|
+
let detailStr = "";
|
|
124
|
+
if (this.detail) {
|
|
125
|
+
const prefix = ` X ${verb}... ${elapsed}s`;
|
|
126
|
+
const available = maxW - prefix.length - 1;
|
|
127
|
+
if (available > 5) {
|
|
128
|
+
const raw = this.detail;
|
|
129
|
+
detailStr = raw.length > available ? dim(` ${raw.slice(0, available - 2)}\u2026`) : dim(` ${raw}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const line = ` ${char} ${verb}...${detailStr} ${time}`;
|
|
133
|
+
// Clear previous extra lines (dashboard) — move up from current position
|
|
134
|
+
// Current cursor is on spinner line after last render
|
|
135
|
+
if (this.extraLines > 0) {
|
|
136
|
+
// Move down to the last dashboard line, then clear upward
|
|
137
|
+
process.stderr.write(`\x1b[${this.extraLines}B`); // move down past dashboard
|
|
138
|
+
for (let i = 0; i < this.extraLines; i++) {
|
|
139
|
+
process.stderr.write("\x1b[1A\x1b[2K"); // move up + clear line
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Write spinner line (we're on the spinner line now)
|
|
143
|
+
process.stderr.write(`\r\x1b[2K${line}`);
|
|
144
|
+
// Write dashboard lines below if we have a linked dashboard
|
|
145
|
+
if (this.dashboard) {
|
|
146
|
+
const dashLines = this.dashboard.getLines();
|
|
147
|
+
if (dashLines.length > 0) {
|
|
148
|
+
process.stderr.write("\n");
|
|
149
|
+
process.stderr.write(dashLines.join("\n"));
|
|
150
|
+
this.extraLines = dashLines.length;
|
|
151
|
+
// Move cursor back up to the spinner line
|
|
152
|
+
if (this.extraLines > 0) {
|
|
153
|
+
process.stderr.write(`\x1b[${this.extraLines}A`);
|
|
154
|
+
}
|
|
155
|
+
process.stderr.write("\r");
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
this.extraLines = 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
111
161
|
}
|
|
112
162
|
}
|
|
113
163
|
//# sourceMappingURL=spinner.js.map
|
package/package.json
CHANGED