huddle-cli 0.1.0 → 0.1.3
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/CHANGELOG.md +69 -0
- package/QUICKSTART.md +135 -0
- package/README.md +26 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +9 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +701 -0
- package/dist/app/server.js +262 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +7 -4
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +21 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +134 -42
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +57 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +16 -4
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +434 -87
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +31 -13
- package/dist/orchestrator/learn.js +101 -0
- package/dist/orchestrator/planner.js +54 -9
- package/dist/orchestrator/qa.js +71 -15
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +575 -50
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +377 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +225 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +220 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/room/user.js +54 -0
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +963 -159
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +269 -55
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/export.js +1194 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +716 -94
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +182 -16
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +149 -48
- package/package.json +6 -3
package/dist/ui/talk.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// You and the room, during a run: the agents' questions for you
|
|
2
|
+
// (huddle_ask_user) and what you type to them.
|
|
3
|
+
//
|
|
4
|
+
// A question shows like the ones Claude Code, Codex and Cursor ask, with
|
|
5
|
+
// numbered options, the agent's recommendation first:
|
|
6
|
+
//
|
|
7
|
+
// ⚑ claude asks you · should it also convert Rankine?
|
|
8
|
+
// 1. No, just C, F and K (recommended)
|
|
9
|
+
// 2. Yes, add Rankine too
|
|
10
|
+
// type 1-2 · Enter = 1 · or answer in your own words · picks 1 in 90s
|
|
11
|
+
//
|
|
12
|
+
// Unanswered, it goes with option 1 when the time runs out: the run never
|
|
13
|
+
// waits on someone who walked away. Anything else you type during a run goes
|
|
14
|
+
// to every working agent, sent as "you" (room/user.ts); "@codex …" to one.
|
|
15
|
+
import { fmtDuration } from "../adapters/usage.js";
|
|
16
|
+
import { C } from "./term.js";
|
|
17
|
+
export class Talk {
|
|
18
|
+
now;
|
|
19
|
+
queue = [];
|
|
20
|
+
/** A question is on screen (the first one asked; the rest wait their turn). */
|
|
21
|
+
onShow = () => { };
|
|
22
|
+
/** It was answered, by you or by the clock. */
|
|
23
|
+
onSettled = () => { };
|
|
24
|
+
/** Set by the run while it's on: what you typed for the agents, and `details`. */
|
|
25
|
+
say = null;
|
|
26
|
+
details = null;
|
|
27
|
+
constructor(now = () => Date.now()) {
|
|
28
|
+
this.now = now;
|
|
29
|
+
}
|
|
30
|
+
/** The question on screen and when it times out, if any. */
|
|
31
|
+
current() {
|
|
32
|
+
const p = this.queue[0];
|
|
33
|
+
return p ? { q: p.q, until: p.until } : null;
|
|
34
|
+
}
|
|
35
|
+
ask(q) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const p = { q, until: 0, settle: resolve, timer: undefined };
|
|
38
|
+
this.queue.push(p);
|
|
39
|
+
if (this.queue.length === 1)
|
|
40
|
+
this.show();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** The time starts when a question reaches the screen, not when it was asked. */
|
|
44
|
+
show() {
|
|
45
|
+
const p = this.queue[0];
|
|
46
|
+
if (!p)
|
|
47
|
+
return;
|
|
48
|
+
p.until = this.now() + p.q.waitMs;
|
|
49
|
+
p.timer = setTimeout(() => this.settle({ choice: 1, text: "", by: "default" }), p.q.waitMs);
|
|
50
|
+
this.onShow(p.q, p.until);
|
|
51
|
+
}
|
|
52
|
+
settle(a) {
|
|
53
|
+
const p = this.queue.shift();
|
|
54
|
+
if (!p)
|
|
55
|
+
return;
|
|
56
|
+
clearTimeout(p.timer);
|
|
57
|
+
p.settle(a);
|
|
58
|
+
this.onSettled(p.q, a);
|
|
59
|
+
this.show();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A line you typed. With a question up, it's the answer: a number picks that
|
|
63
|
+
* option, Enter picks 1, anything else is your answer in your own words.
|
|
64
|
+
* Returns false when no question is up (the line is for someone else).
|
|
65
|
+
*/
|
|
66
|
+
answer(line) {
|
|
67
|
+
const p = this.queue[0];
|
|
68
|
+
if (!p)
|
|
69
|
+
return false;
|
|
70
|
+
const t = line.trim();
|
|
71
|
+
const k = Number(t);
|
|
72
|
+
if (!t)
|
|
73
|
+
this.settle({ choice: 1, text: "", by: "you" });
|
|
74
|
+
else if (/^\d$/.test(t) && k >= 1 && k <= p.q.options.length)
|
|
75
|
+
this.settle({ choice: k, text: "", by: "you" });
|
|
76
|
+
else
|
|
77
|
+
this.settle({ choice: null, text: t, by: "you" });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/** Every open question goes with option 1 (the run ended or was stopped). */
|
|
81
|
+
close() {
|
|
82
|
+
while (this.queue.length)
|
|
83
|
+
this.settle({ choice: 1, text: "", by: "default" });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The question as it's printed. */
|
|
87
|
+
export function questionLines(q, until, now = Date.now()) {
|
|
88
|
+
const out = [` ${C.yellow("⚑")} ${C.bold(q.from)} ${C.yellow("asks you")} ${C.faint("·")} ${q.question}`];
|
|
89
|
+
if (q.why)
|
|
90
|
+
out.push(C.faint(` ${q.why}`));
|
|
91
|
+
q.options.forEach((o, i) => out.push(` ${C.bold(String(i + 1) + ".")} ${o}${i === 0 ? C.faint(" (recommended)") : ""}`));
|
|
92
|
+
out.push(C.faint(` type 1-${q.options.length} · Enter = 1 · or answer in your own words · picks 1 in ${fmtDuration(Math.max(0, until - now))}`));
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** " → you chose 2: Yes, add Rankine too" / " → no answer in 1m30s: claude goes with 1: …" */
|
|
96
|
+
export function settledLine(q, a) {
|
|
97
|
+
if (a.by === "default")
|
|
98
|
+
return C.faint(` → no answer in ${fmtDuration(q.waitMs)}: ${q.from} goes with 1 · ${q.options[0]}`);
|
|
99
|
+
return ` ${C.green("→")} ${a.choice ? `you chose ${a.choice} · ${q.options[a.choice - 1]}` : `you said: ${a.text}`} ${C.faint(`· sent to ${q.from}`)}`;
|
|
100
|
+
}
|
|
101
|
+
/** "@codex use 2 decimals" → to codex; anything else → everyone working. */
|
|
102
|
+
export function addressee(line) {
|
|
103
|
+
const m = /^@([\w#./-]+)[,:]?\s+([\s\S]+)$/.exec(line.trim());
|
|
104
|
+
return m ? { to: m[1], text: m[2].trim() } : { text: line.trim() };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A typing line under a one-shot run's pinned block (`huddle join` on a
|
|
108
|
+
* terminal), so you can answer questions and talk to the agents there too.
|
|
109
|
+
* Raw keys: Enter sends, Backspace and ctrl-u edit, ctrl-c stops the run.
|
|
110
|
+
*/
|
|
111
|
+
export class KeyLine {
|
|
112
|
+
onLine;
|
|
113
|
+
onChange;
|
|
114
|
+
onInterrupt;
|
|
115
|
+
input;
|
|
116
|
+
buf = "";
|
|
117
|
+
on = false;
|
|
118
|
+
onData = (d) => this.feed(String(d));
|
|
119
|
+
constructor(onLine, onChange,
|
|
120
|
+
/** ctrl-c: raw mode keeps the terminal from sending SIGINT, so the run decides. */
|
|
121
|
+
onInterrupt = () => process.exit(130), input = process.stdin) {
|
|
122
|
+
this.onLine = onLine;
|
|
123
|
+
this.onChange = onChange;
|
|
124
|
+
this.onInterrupt = onInterrupt;
|
|
125
|
+
this.input = input;
|
|
126
|
+
}
|
|
127
|
+
start() {
|
|
128
|
+
if (this.on || !this.input.isTTY)
|
|
129
|
+
return this;
|
|
130
|
+
this.on = true;
|
|
131
|
+
this.input.setRawMode?.(true);
|
|
132
|
+
this.input.on("data", this.onData);
|
|
133
|
+
this.input.resume();
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
stop() {
|
|
137
|
+
if (!this.on)
|
|
138
|
+
return;
|
|
139
|
+
this.on = false;
|
|
140
|
+
this.input.off("data", this.onData);
|
|
141
|
+
this.input.setRawMode?.(false);
|
|
142
|
+
this.input.pause();
|
|
143
|
+
}
|
|
144
|
+
/** What the line shows: what you've typed so far, or how to use it. */
|
|
145
|
+
line() {
|
|
146
|
+
if (!this.on)
|
|
147
|
+
return "";
|
|
148
|
+
return this.buf
|
|
149
|
+
? ` ${C.violet("›")} ${this.buf}${C.faint("▏")}`
|
|
150
|
+
: ` ${C.violet("›")} ${C.faint("type to talk to the agents (@claude for one) · details · ctrl-c stops")}`;
|
|
151
|
+
}
|
|
152
|
+
feed(s) {
|
|
153
|
+
// Arrows and other escape keys: not text.
|
|
154
|
+
for (const ch of s.replace(/\x1b\[[0-9;?]*[A-Za-z~]|\x1bO?./g, "")) {
|
|
155
|
+
if (ch === "\r" || ch === "\n") {
|
|
156
|
+
const l = this.buf;
|
|
157
|
+
this.buf = "";
|
|
158
|
+
this.onLine(l);
|
|
159
|
+
}
|
|
160
|
+
else if (ch === "\x7f" || ch === "\b")
|
|
161
|
+
this.buf = [...this.buf].slice(0, -1).join("");
|
|
162
|
+
else if (ch === "\x15")
|
|
163
|
+
this.buf = "";
|
|
164
|
+
else if (ch === "\x03") {
|
|
165
|
+
this.buf = "";
|
|
166
|
+
this.onInterrupt();
|
|
167
|
+
if (!this.on)
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
else if (ch >= " ")
|
|
171
|
+
this.buf += ch;
|
|
172
|
+
}
|
|
173
|
+
this.onChange();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=talk.js.map
|
package/dist/ui/term.js
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
// Terminal primitives: color, width-aware padding, the rounded box, spinners.
|
|
2
2
|
// Huddle lives in the terminal only, so this file is the whole "UI toolkit".
|
|
3
3
|
const color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
4
|
+
/**
|
|
5
|
+
* A console that can't draw Huddle's glyphs: the old Windows console host
|
|
6
|
+
* (conhost: cmd.exe or PowerShell outside Windows Terminal) shows ⤳, ⇉,
|
|
7
|
+
* braille spinners and emoji as boxes or "?". HUDDLE_ASCII=1 forces plain
|
|
8
|
+
* ASCII anywhere; HUDDLE_ASCII=0 turns it off. Windows Terminal sets
|
|
9
|
+
* WT_SESSION, VS Code's terminal sets TERM_PROGRAM, ConEmu sets ConEmuANSI.
|
|
10
|
+
*/
|
|
11
|
+
export function legacyConsole(env = process.env, platform = process.platform) {
|
|
12
|
+
if (env.HUDDLE_ASCII === "1")
|
|
13
|
+
return true;
|
|
14
|
+
if (env.HUDDLE_ASCII === "0")
|
|
15
|
+
return false;
|
|
16
|
+
return platform === "win32" && !env.WT_SESSION && !env.TERM_PROGRAM && env.ConEmuANSI !== "ON";
|
|
17
|
+
}
|
|
18
|
+
export const ASCII = legacyConsole();
|
|
19
|
+
const ASCII_MAP = {
|
|
20
|
+
"●": "*", "○": "o", "✓": "v", "✗": "x", "▸": ">", "◎": "@", "◆": "#", "◇": "#", "⚠": "!", "⇉": "=>", "⇢": "->",
|
|
21
|
+
"⤳": "~>", "⇄": "<>", "→": "->", "←": "<-", "↑": "^", "↓": "v", "…": "...", "·": "-", "—": "-", "–": "-", "“": '"', "”": '"',
|
|
22
|
+
"‘": "'", "’": "'", "─": "-", "━": "=", "│": "|", "╭": "+", "╮": "+", "╰": "+", "╯": "+", "┌": "+", "┐": "+", "└": "+", "┘": "+",
|
|
23
|
+
"█": "#", "░": ".", "▶": ">", "■": "#", "✎": "*", "⬢": "#", "⏱": "", "⚡": "!", "×": "x", "≥": ">=", "≤": "<=", "\uFE0F": "",
|
|
24
|
+
};
|
|
25
|
+
const ASCII_RE = new RegExp(`[${Object.keys(ASCII_MAP).join("")}]|[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]\uFE0F?|\uFE0F|[\u2800-\u28FF]`, "gu");
|
|
26
|
+
/** Huddle's glyphs → ASCII, for legacy consoles (escape codes pass through). */
|
|
27
|
+
export function toAscii(s) {
|
|
28
|
+
return s.replace(ASCII_RE, (c) => ASCII_MAP[c] ?? ASCII_MAP[c.replace("\uFE0F", "")] ?? (c >= "\u2800" && c <= "\u28FF" ? "|" : "*"));
|
|
29
|
+
}
|
|
30
|
+
/** In a legacy console, every write to stdout/stderr goes through toAscii (once, at startup). */
|
|
31
|
+
export function useAsciiOutput(on = ASCII) {
|
|
32
|
+
if (!on)
|
|
33
|
+
return;
|
|
34
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
35
|
+
const write = stream.write.bind(stream);
|
|
36
|
+
stream.write = (chunk, ...rest) => write(typeof chunk === "string" ? toAscii(chunk) : chunk, ...rest);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
4
39
|
const sgr = (open) => (s) => (color ? `\x1b[${open}m${s}\x1b[0m` : s);
|
|
5
40
|
const hex = (h) => {
|
|
6
41
|
const n = parseInt(h.slice(1), 16);
|
|
@@ -33,9 +68,21 @@ export const AGENT_COLOR = {
|
|
|
33
68
|
};
|
|
34
69
|
/** Colored dot for an agent; "claude#2" gets Claude's color. */
|
|
35
70
|
export const dot = (agentId) => hex(AGENT_COLOR[agentId.split("#")[0]] ?? "#8A857C")("●");
|
|
36
|
-
/**
|
|
71
|
+
/** Terminal cells for one character: emoji and CJK take two, combining marks none. */
|
|
72
|
+
function cells(ch) {
|
|
73
|
+
const c = ch.codePointAt(0);
|
|
74
|
+
if (c === 0xfe0f || c === 0x200d || (c >= 0x300 && c <= 0x36f))
|
|
75
|
+
return 0;
|
|
76
|
+
if ((c >= 0x1f300 && c <= 0x1faff) || (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60))
|
|
77
|
+
return 2;
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
/** Visible width of a string in terminal cells (ignores ANSI escapes). */
|
|
37
81
|
export function width(s) {
|
|
38
|
-
|
|
82
|
+
let n = 0;
|
|
83
|
+
for (const ch of s.replace(/\x1b\[[0-9;]*m/g, ""))
|
|
84
|
+
n += cells(ch);
|
|
85
|
+
return n;
|
|
39
86
|
}
|
|
40
87
|
/** Cut to `n` visible characters, keeping color codes intact. */
|
|
41
88
|
export function truncate(s, n) {
|
|
@@ -51,17 +98,71 @@ export function truncate(s, n) {
|
|
|
51
98
|
continue;
|
|
52
99
|
}
|
|
53
100
|
const ch = String.fromCodePoint(s.codePointAt(i));
|
|
54
|
-
|
|
55
|
-
|
|
101
|
+
const w = cells(ch);
|
|
102
|
+
if (seen + w > n - 1)
|
|
103
|
+
return out + "…" + (out.includes("\x1b[") ? "\x1b[0m" : "");
|
|
56
104
|
out += ch;
|
|
57
|
-
seen
|
|
105
|
+
seen += w;
|
|
58
106
|
i += ch.length;
|
|
59
107
|
}
|
|
60
108
|
return out;
|
|
61
109
|
}
|
|
110
|
+
/** Word-wrap plain or colored text to `n` cells; `indent` starts every line after the first. */
|
|
111
|
+
export function wrap(s, n, indent = "") {
|
|
112
|
+
const lead = /^ */.exec(s)[0];
|
|
113
|
+
if (!indent.startsWith(lead))
|
|
114
|
+
indent = lead + indent;
|
|
115
|
+
const out = [];
|
|
116
|
+
let line = lead;
|
|
117
|
+
for (const word of s.slice(lead.length).split(/ +/)) {
|
|
118
|
+
if (line.trim() && width(line) + 1 + width(word) > n) {
|
|
119
|
+
out.push(line);
|
|
120
|
+
line = indent + word;
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
line = line.trim() ? line + " " + word : line + word;
|
|
124
|
+
}
|
|
125
|
+
out.push(line);
|
|
126
|
+
return out.map((l) => truncate(l, n + 1));
|
|
127
|
+
}
|
|
128
|
+
/** Columns to fit printed lines to: the terminal's (minus one, so nothing auto-wraps), or none when piped. */
|
|
129
|
+
export function lineWidth(out = process.stdout) {
|
|
130
|
+
return out.isTTY ? Math.max(20, (out.columns || 80) - 1) : null;
|
|
131
|
+
}
|
|
132
|
+
/** One printed line, cut to the terminal (no-op when piped: logs keep everything). */
|
|
133
|
+
export function fit(s, out = process.stdout) {
|
|
134
|
+
const w = lineWidth(out);
|
|
135
|
+
return w === null ? s : s.split("\n").map((l) => truncate(l, w)).join("\n");
|
|
136
|
+
}
|
|
62
137
|
export function pad(s, n) {
|
|
63
138
|
return s + " ".repeat(Math.max(0, n - width(s)));
|
|
64
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether progress bars draw block characters. Off when colors are (NO_COLOR,
|
|
142
|
+
* or output piped to a log): there a bar is just its label, "6/9 tasks".
|
|
143
|
+
* Tests and samples flip it to see the bars.
|
|
144
|
+
*/
|
|
145
|
+
export const BARS = { on: color, ascii: ASCII };
|
|
146
|
+
/**
|
|
147
|
+
* A progress bar exactly `w` cells wide — "█████░░░" — or "" when bars are off
|
|
148
|
+
* or there's no room (under 2 cells). `frac` null = unknown: a faint dotted
|
|
149
|
+
* track, never a made-up 0%. Anything above 0 shows at least one block and
|
|
150
|
+
* anything below 1 leaves at least one empty, so 1% isn't nothing and 99%
|
|
151
|
+
* isn't done. Legacy consoles get "#####...".
|
|
152
|
+
*/
|
|
153
|
+
export function bar(frac, w, o = {}) {
|
|
154
|
+
w = Math.floor(w);
|
|
155
|
+
if (!(o.on ?? BARS.on) || !(w >= 2))
|
|
156
|
+
return "";
|
|
157
|
+
const ascii = o.ascii ?? BARS.ascii;
|
|
158
|
+
const [full, empty, unknown] = ascii ? ["#", ".", "-"] : ["█", "░", "·"];
|
|
159
|
+
if (frac === null || !Number.isFinite(frac))
|
|
160
|
+
return C.faint(unknown.repeat(w));
|
|
161
|
+
const f = o.tone === "done" ? 1 : Math.min(1, Math.max(0, frac));
|
|
162
|
+
const n = f <= 0 ? 0 : f >= 1 ? w : Math.min(w - 1, Math.max(1, Math.round(f * w)));
|
|
163
|
+
const fill = o.tone === "done" ? C.green : o.tone === "failed" ? C.red : C.violet;
|
|
164
|
+
return (n ? fill(full.repeat(n)) : "") + (n < w ? C.faint(empty.repeat(w - n)) : "");
|
|
165
|
+
}
|
|
65
166
|
/** Rounded box with a colored border, Claude-Code style. */
|
|
66
167
|
export function box(lines, opts = {}) {
|
|
67
168
|
const border = opts.border ?? C.violet;
|
|
@@ -71,44 +172,109 @@ export function box(lines, opts = {}) {
|
|
|
71
172
|
const mid = lines.map((l) => border("│") + " " + pad(l, inner - 2) + " " + border("│"));
|
|
72
173
|
return [top, ...mid, bot].join("\n");
|
|
73
174
|
}
|
|
74
|
-
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
75
|
-
/**
|
|
175
|
+
const FRAMES = ASCII ? ["|", "/", "-", "\\"] : ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
176
|
+
/**
|
|
177
|
+
* A set of lines that re-render in place while async work settles. Each row
|
|
178
|
+
* is cut to the terminal's width: a row that wraps takes two screen lines,
|
|
179
|
+
* so moving the cursor up "one per row" would land mid-list and every redraw
|
|
180
|
+
* would print the list again further down (seen on Windows: rows doubled and
|
|
181
|
+
* spliced, "API_KEYodex · openai").
|
|
182
|
+
*/
|
|
76
183
|
export class LiveLines {
|
|
77
184
|
render;
|
|
185
|
+
out;
|
|
78
186
|
lines;
|
|
79
187
|
frame = 0;
|
|
80
188
|
timer;
|
|
81
189
|
drawn = false;
|
|
82
|
-
constructor(render) {
|
|
190
|
+
constructor(render, out = process.stdout) {
|
|
83
191
|
this.render = render;
|
|
192
|
+
this.out = out;
|
|
84
193
|
this.lines = [];
|
|
85
194
|
}
|
|
86
195
|
start() {
|
|
87
196
|
this.draw();
|
|
88
|
-
if (
|
|
197
|
+
if (this.out.isTTY)
|
|
89
198
|
this.timer = setInterval(() => this.draw(), 80);
|
|
90
199
|
return this;
|
|
91
200
|
}
|
|
201
|
+
/** One screen line per row: never wider than the terminal (minus one, so no auto-wrap). */
|
|
202
|
+
fit(l) {
|
|
203
|
+
return truncate(l.replace(/[\r\n]+/g, " "), Math.max(10, (this.out.columns || 80) - 1));
|
|
204
|
+
}
|
|
92
205
|
draw() {
|
|
93
|
-
if (!
|
|
206
|
+
if (!this.out.isTTY)
|
|
94
207
|
return; // non-TTY: draw once at stop()
|
|
95
|
-
const next = this.render(C.violet(FRAMES[this.frame++ % FRAMES.length]));
|
|
96
|
-
|
|
97
|
-
process.stdout.write(`\x1b[${this.lines.length}A`);
|
|
208
|
+
const next = this.render(C.violet(FRAMES[this.frame++ % FRAMES.length])).map((l) => this.fit(l));
|
|
209
|
+
let s = this.drawn && this.lines.length ? `\x1b[${this.lines.length}A` : "";
|
|
98
210
|
for (const l of next)
|
|
99
|
-
|
|
211
|
+
s += "\x1b[2K" + l + "\n";
|
|
212
|
+
this.out.write(s); // one write: no half-drawn frame
|
|
100
213
|
this.lines = next;
|
|
101
214
|
this.drawn = true;
|
|
102
215
|
}
|
|
103
216
|
stop() {
|
|
104
217
|
if (this.timer)
|
|
105
218
|
clearInterval(this.timer);
|
|
106
|
-
if (!
|
|
219
|
+
if (!this.out.isTTY) {
|
|
107
220
|
for (const l of this.render(" "))
|
|
108
|
-
|
|
221
|
+
this.out.write(l + "\n");
|
|
109
222
|
return;
|
|
110
223
|
}
|
|
111
224
|
this.draw();
|
|
112
225
|
}
|
|
113
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Lines pinned under scrolling output — the run's status block. log() prints
|
|
229
|
+
* a line above them and redraws them; nothing else should write to `out`
|
|
230
|
+
* while the dock is up. TTY only: piped output (a log file) gets the logged
|
|
231
|
+
* lines and never the dock.
|
|
232
|
+
*/
|
|
233
|
+
export class Dock {
|
|
234
|
+
render;
|
|
235
|
+
out;
|
|
236
|
+
n = 0;
|
|
237
|
+
timer;
|
|
238
|
+
up = false;
|
|
239
|
+
constructor(render, out = process.stdout) {
|
|
240
|
+
this.render = render;
|
|
241
|
+
this.out = out;
|
|
242
|
+
}
|
|
243
|
+
paint() {
|
|
244
|
+
const w = Math.max(10, (this.out.columns || 80) - 1);
|
|
245
|
+
const ls = this.render().map((l) => truncate(l.replace(/[\r\n]+/g, " "), w));
|
|
246
|
+
this.n = ls.length;
|
|
247
|
+
return ls.map((l) => "\x1b[2K" + l + "\n").join("");
|
|
248
|
+
}
|
|
249
|
+
clear() {
|
|
250
|
+
return this.n ? `\x1b[${this.n}A\x1b[J` : "";
|
|
251
|
+
}
|
|
252
|
+
start() {
|
|
253
|
+
if (!this.out.isTTY || this.up)
|
|
254
|
+
return this;
|
|
255
|
+
this.up = true;
|
|
256
|
+
this.out.write(this.paint());
|
|
257
|
+
this.timer = setInterval(() => this.out.write(this.clear() + this.paint()), 1000);
|
|
258
|
+
this.timer.unref?.();
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
/** Repaint now (something it shows changed, e.g. a key was typed). */
|
|
262
|
+
redraw() {
|
|
263
|
+
if (this.up)
|
|
264
|
+
this.out.write(this.clear() + this.paint());
|
|
265
|
+
}
|
|
266
|
+
log(line) {
|
|
267
|
+
if (!this.up)
|
|
268
|
+
return void this.out.write(line + "\n");
|
|
269
|
+
this.out.write(this.clear() + line + "\n" + this.paint());
|
|
270
|
+
}
|
|
271
|
+
stop() {
|
|
272
|
+
if (this.timer)
|
|
273
|
+
clearInterval(this.timer);
|
|
274
|
+
if (this.up)
|
|
275
|
+
this.out.write(this.clear());
|
|
276
|
+
this.up = false;
|
|
277
|
+
this.n = 0;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
114
280
|
//# sourceMappingURL=term.js.map
|