@vecteur/cli 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vecteur/cli",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vecteur": "bin/vecteur.js"
package/src/input.js CHANGED
@@ -80,6 +80,20 @@ export class ComposerInput extends EventEmitter {
80
80
  this.pasted = ""; this.pasting = false; this.pasteOverflow = false; continue;
81
81
  }
82
82
  if (this.pending.startsWith(START)) { this.pasting = true; this.pending = this.pending.slice(START.length); continue; }
83
+ // Terminal replies to the writer's own queries are never keys, early or late: a colour
84
+ // report (OSC 10/11) is recognised by its own form, any OSC while the writer awaits one.
85
+ // A bare Alt+] keeps its meaning.
86
+ const report = /^\x1b\](?:1(?:[01](?:;(?:r(?:g(?:b(?::[0-9a-f/]*)?)?)?)?)?)?)?/i.exec(this.pending)?.[0] ?? "";
87
+ if (this.pending.startsWith("\x1b]") && (this.expectReports || report.length > 2)) {
88
+ const end = /\x07|\x1b\\/.exec(this.pending);
89
+ if (!end && this.pending.length < 256 && (this.expectReports || report.length === this.pending.length)) {
90
+ this.escapeTimer = setTimeout(() => { this.pending = ""; }, 300); return;
91
+ }
92
+ const length = end ? end.index + end[0].length : 2;
93
+ const reply = this.pending.slice(0, length); this.pending = this.pending.slice(length);
94
+ if (end) this.emit("report", reply);
95
+ continue;
96
+ }
83
97
  if (this.pending.startsWith("\x1b")) {
84
98
  if (this.pending.startsWith("\x1b\x1b")) {
85
99
  // Escape followed by an arrow/Alt-Enter keeps that encoded key intact.
@@ -100,6 +114,7 @@ export class ComposerInput extends EventEmitter {
100
114
  const escape = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(this.pending)?.[0];
101
115
  if (escape) {
102
116
  this.pending = this.pending.slice(escape.length);
117
+ if (/^\x1b\[\d+;\d+R$/.test(escape)) { this.emit("report", escape); continue; }
103
118
  if (this.handleKey?.(escape)) continue;
104
119
  if (escape === "\x1b[D") this.cursor = this.boundary(-1);
105
120
  else if (escape === "\x1b[C") this.cursor = this.boundary(1);
package/src/session.js CHANGED
@@ -13,6 +13,7 @@ import { humanCost, runSections } from "./format.js";
13
13
  import { CATALOGUE_ENVELOPE_SHA256, MODEL_CATALOGUE, PRODUCT_ROUTES } from "./contract.js";
14
14
  import { openBrowser } from "./browser.js";
15
15
 
16
+ export const NEW_FOLDER = "New folder — send an ask or /link an existing Project";
16
17
  const AUTH_ERRORS = new Set(["pat_expired", "pat_invalid", "pat_revoked"]);
17
18
  const TRANSIENT = new Set(["request_failed", "request_timeout", "terminal_invalid"]);
18
19
  const MEDIA = { ".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv", ".json": "application/json",
@@ -45,6 +46,8 @@ export class Session extends EventEmitter {
45
46
  projectLabel: this.project?.name ?? (this.binding ? "Linked Project unavailable" : "New Project on first send"),
46
47
  status: activity + (canEdit ? "" : " · Read-only — Viewer") + (projection.terminal ? ` · ${projection.elapsed}` : ""), draft: this.draft, prompt: this.prompt, busy: this.busy,
47
48
  offline: this.offline, issue: this.issue, login: this.loginChallenge, release: this.release,
49
+ // First use: signed in and online, the folder resolved to no binding/Project/Run, nothing pending.
50
+ firstUse: !!this.account && !this.offline && !this.busy && !this.project && !this.run && this.status === NEW_FOLDER,
48
51
  attachments: this.attachments.map(item => ({ path: item.path, size: item.bytes.length })),
49
52
  lead: "Laplace", location: "Vecteur cloud", model: modelLabel,
50
53
  run: projection, available: { submit: !this.offline && !!this.account && canEdit && !this.busy && !this.run?.open,
@@ -252,7 +255,7 @@ export class Session extends EventEmitter {
252
255
  this.project = this.projects.find(project => project.id === binding.projectId) ?? null;
253
256
  if (!this.project) { this.status = "Linked Project unavailable — /project or /unlink"; this.change(); return; }
254
257
  }
255
- this.status = this.project ? "Ready" : "New folder — send an ask or /link an existing Project";
258
+ this.status = this.project ? "Ready" : NEW_FOLDER;
256
259
  const pending = this.registry.state();
257
260
  if (pending.pendingRun || pending.pendingCreate) {
258
261
  this.followController?.abort(); this.generation++; this.run = null;
package/src/terminal.js CHANGED
@@ -6,17 +6,36 @@ import { preferences } from "./preferences.js";
6
6
  import { openBrowser } from "./browser.js";
7
7
  import { readFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
+ import { stripVTControlCharacters } from "node:util";
10
+ import stringWidth from "string-width";
9
11
 
10
- /** The only stdout writer. It never clears the screen/scrollback or enters alternate screen. */
12
+ const measure = value => /^[\x20-\x7e]*$/.test(value) ? value.length : stringWidth(value);
13
+
14
+ /**
15
+ * The only stdout writer. It never clears the screen/scrollback or enters alternate screen.
16
+ * Rich mode parks the cursor (hidden) at column 1 of the first live row: everything from there
17
+ * down is the live region, so one erase-below removes exactly it, even after the terminal has
18
+ * reflowed it on resize. A cursor-position report places that row; blank rows between the
19
+ * transcript and the bottom-anchored composer are cursor movement, never printed history.
20
+ */
11
21
  export class TerminalWriter {
12
- constructor(stdout, plain) { this.stdout = stdout; this.plain = plain; this.height = 0; }
22
+ constructor(stdout, plain) {
23
+ this.stdout = stdout; this.plain = plain; this.drawn = false; this.frame = null;
24
+ this.row = null; this.awaiting = 0; this.since = 0;
25
+ }
13
26
  erase() {
14
- if (!this.height) return "";
15
- const move = `\x1b[${this.height}A\r`, bytes = move + "\x1b[2K\n".repeat(this.height) + move;
16
- this.height = 0;
17
- return bytes;
27
+ if (!this.drawn) return "";
28
+ this.drawn = false;
29
+ return "\r\x1b[J";
30
+ }
31
+ clearLive() { this.frame = null; const bytes = this.erase(); if (bytes) this.stdout.write(bytes); }
32
+ // Display rows the terminal used for appended text; rich lines are narrower than the window.
33
+ advance(text) {
34
+ const columns = Math.max(1, this.stdout.columns ?? 80);
35
+ const rows = text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(measure(stripVTControlCharacters(line)) / columns)), 0);
36
+ if (this.awaiting) this.since += rows;
37
+ else if (this.row) this.row = Math.min(this.stdout.rows ?? 24, this.row + rows);
18
38
  }
19
- clearLive() { const bytes = this.erase(); if (bytes) this.stdout.write(bytes); }
20
39
  progress(view) {
21
40
  const subject = view.run?.subject?.id;
22
41
  if (subject !== this.subject) { this.subject = subject; this.lastProgress = null; this.lastQuestion = null; }
@@ -30,17 +49,59 @@ export class TerminalWriter {
30
49
  if (question) this.append(safe(`Vecteur needs your answer — /answer\n${question.questions.map(row => row.question).join("\n")}`));
31
50
  }
32
51
  }
33
- append(text) { this.stdout.write(`${this.erase()}${text.replace(/\n+$/, "")}\n`); }
34
- draw(text) {
52
+ /** Scrollback text, then the given live frame in the same write: the composer never blinks out. */
53
+ append(text, frame = null) {
54
+ const body = text.replace(/\n+$/, "");
55
+ let bytes = `${this.erase()}${body}\n`;
56
+ if (!this.plain) this.advance(body);
57
+ if (frame) { this.frame = frame; bytes += this.render(); }
58
+ this.stdout.write(bytes);
59
+ }
60
+ draw(frame) {
35
61
  if (this.plain) return;
62
+ this.frame = typeof frame === "string" ? { top: [], bottom: frame.replace(/\n+$/, "").split("\n") } : frame;
36
63
  // One write per frame: erase and redraw land together, so the spinner never flickers.
37
- const lines = text.replace(/\n+$/, "");
38
- this.stdout.write(`${this.erase()}${lines}\n`); this.height = lines.split("\n").length;
64
+ const bytes = this.render();
65
+ if (bytes) this.stdout.write(bytes);
66
+ }
67
+ render() {
68
+ if (this.plain || !this.frame || this.awaiting) return "";
69
+ const rows = this.stdout.rows ?? 24;
70
+ const lines = [...this.frame.top, ...this.frame.bottom].slice(-Math.max(1, rows - 1));
71
+ const gap = this.row ? Math.max(0, rows - this.row + 1 - lines.length) : 0;
72
+ const at = Math.max(0, lines.length - this.frame.bottom.length);
73
+ const body = [...lines.slice(0, at), ...Array(gap).fill(""), ...lines.slice(at)];
74
+ const up = body.length - 1;
75
+ if (this.row) this.row = Math.max(1, Math.min(this.row, rows - body.length + 1));
76
+ const erase = this.erase(); this.drawn = true;
77
+ return `${erase}${body.join("\n")}${up ? `\x1b[${up}A` : ""}\r`;
78
+ }
79
+ /** Ask the terminal where the live region starts; frames wait for the answer (or 300 ms). */
80
+ locate(query = "") {
81
+ if (this.plain) return;
82
+ this.awaiting++; this.since = 0;
83
+ this.stdout.write(`${query}\x1b[6n`);
84
+ clearTimeout(this.locateTimer);
85
+ this.locateTimer = setTimeout(() => { this.awaiting = 0; this.row = null; this.onLocated?.(); }, 300);
86
+ }
87
+ located(row, column) {
88
+ if (!this.awaiting || --this.awaiting) return;
89
+ clearTimeout(this.locateTimer);
90
+ const rows = this.stdout.rows ?? 24;
91
+ let bytes = "";
92
+ // Another program left the cursor mid-line: start the live region on the next row.
93
+ if (column > 1 && !this.since) { bytes = "\r\n"; row++; }
94
+ this.row = Math.max(1, Math.min(rows, row + this.since));
95
+ if (bytes) this.stdout.write(bytes);
96
+ this.onLocated?.();
39
97
  }
40
98
  resized(plain) {
41
- // Reflow can collapse old wrapped lines: their old height is no longer a safe erase bound.
42
- this.height = 0; this.plain = plain; this.stdout.write("\n");
99
+ // Reflow moved the old frame but not the parked cursor: erase below it, then re-locate.
100
+ const bytes = this.drawn ? "\r\x1b[J" : "";
101
+ this.drawn = false; this.frame = null; this.row = null; this.plain = plain;
102
+ if (bytes) this.stdout.write(bytes);
43
103
  }
104
+ close() { clearTimeout(this.locateTimer); this.awaiting = 0; }
44
105
  }
45
106
 
46
107
  export async function startTerminal(options = {}, { stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
@@ -51,6 +112,8 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
51
112
  }
52
113
  let rich = !options.plain && !prefs.plain && env.TERM !== "dumb";
53
114
  const color = !options.noColor && env.NO_COLOR === undefined;
115
+ // Colour roles start palette-relative; a terminal that reports its own colours gets exact blends.
116
+ let theme = color; const reported = {};
54
117
  const layout = await import("./tui/view.js");
55
118
  const isPlain = () => !rich || ((stdout.columns ?? 80) < 40 || (stdout.rows ?? 24) < 8);
56
119
  const writer = new TerminalWriter(stdout, isPlain());
@@ -67,6 +130,9 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
67
130
  let vendorOwnsTerminal = false, resizedDuringHandoff = false; const heldMessages = [];
68
131
  // Rich scrollback streams each finished step as one line; the answer block then skips them.
69
132
  const streamed = { run: null, events: 0, steps: 0 };
133
+ // The identity header is live until the first transcript block, then committed once.
134
+ let headerLive = true, cursorHidden = false;
135
+ const directory = () => session.cwd === homedir() || session.cwd.startsWith(homedir() + "/") ? "~" + session.cwd.slice(homedir().length) : session.cwd;
70
136
  const history = []; let historyIndex = 0, historyDraft = "", historyAccount;
71
137
  const syncInput = (value, cursor = value === input.text ? input.cursor : value.length) => { suppressed = true; input.set(value); input.cursor = cursor; suppressed = false; };
72
138
  const focusChanged = () => {
@@ -82,15 +148,41 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
82
148
  if (!input.text || /^[\d, ]+$/.test(input.text)) return prompt.options;
83
149
  return prompt.options.filter(item => item.label.toLowerCase().includes(input.text.toLowerCase()));
84
150
  };
85
- const frameView = () => ({ ...session.view(), version, channel,
151
+ const frameView = () => ({ ...session.view(), version, channel, header: headerLive, directory: directory(),
86
152
  focus: localPicker ?? session.prompt, choices: choices(), selected,
87
153
  palette: palette(), paletteSelected, loginAction });
154
+ const live = () => layout.richLayout(frameView(), input.text, input.cursor,
155
+ { columns: stdout.columns ?? 80, rows: stdout.rows ?? 24, color: theme, tick: prefs.reducedMotion ? null : tick });
88
156
  const draw = () => {
89
157
  clearTimeout(frameTimer); frameTimer = null;
90
158
  if (vendorOwnsTerminal) return;
91
- if (!session.closed && !writer.plain) writer.draw(layout.richFrame(frameView(), input.text, input.cursor,
92
- { columns: stdout.columns ?? 80, rows: stdout.rows ?? 24, color, tick: prefs.reducedMotion ? null : tick }));
159
+ if (!session.closed && !writer.plain) writer.draw(live());
93
160
  };
161
+ // The real cursor stays hidden in rich mode (the composer draws its own caret) and always
162
+ // returns on exit, error, plain fallback and vendor handoff.
163
+ const showCursor = visible => {
164
+ if (env.TERM === "dumb" || visible !== cursorHidden) return;
165
+ cursorHidden = !visible; stdout.write(visible ? "\x1b[0m\x1b[?25h" : "\x1b[?25l");
166
+ };
167
+ const restoreCursor = () => showCursor(true);
168
+ const commitHeader = () => {
169
+ if (writer.plain || !headerLive) return;
170
+ headerLive = false; writer.append(layout.richHeader(frameView(), stdout.columns ?? 80, theme).join("\n"));
171
+ };
172
+ // Transcript blocks (asks, answers, steps, usage) commit the header above them; notices printed
173
+ // before the first ask stay above the live identity, which keeps owning the current facts.
174
+ const print = (text, transcript = true) => {
175
+ if (transcript) commitHeader();
176
+ writer.append(text, writer.plain || session.closed || vendorOwnsTerminal ? null : live());
177
+ };
178
+ writer.onLocated = () => {
179
+ input.expectReports = false;
180
+ if (theme && reported.fg && reported.bg && theme === color) {
181
+ theme = layout.paletteTheme(reported, ["truecolor", "24bit"].includes(env.COLORTERM));
182
+ }
183
+ draw();
184
+ };
185
+ const locate = (query = "") => { if (!writer.plain) { input.expectReports ||= !!query; writer.locate(query); } };
94
186
  const schedule = () => { if (!frameTimer && !vendorOwnsTerminal) frameTimer = setTimeout(() => { frameTimer = null; draw(); }, 40); };
95
187
  const printPlainPrompt = prompt => writer.append(safe(`${prompt.title}\n${prompt.options.map((row, i) => `${i + 1}. ${row.label}`).join("\n")}\nEnter a choice; Esc returns to your draft.`));
96
188
  const maybeUpdate = () => {
@@ -111,8 +203,8 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
111
203
  if (event?.kind !== "step_finished") continue;
112
204
  const [row] = taskLines([event]);
113
205
  if (!row) continue;
114
- if (!streamed.steps++) writer.append("");
115
- writer.append(layout.richMessage({ kind: "step", text: row.split("\n").map(part => part.trim().replace(/^[▸·]\s*/, "")).filter(Boolean).join(" · ") }, stdout.columns ?? 80, color));
206
+ if (!streamed.steps++) print("");
207
+ print(layout.richMessage({ kind: "step", text: row.split("\n").map(part => part.trim().replace(/^[▸·]\s*/, "")).filter(Boolean).join(" · ") }, stdout.columns ?? 80, theme));
116
208
  }
117
209
  }
118
210
  if (process.connected && process.send && env.VECTEUR_UPDATE_SESSION && session.release?.compatible
@@ -146,13 +238,13 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
146
238
  });
147
239
  session.on("preferences", value => {
148
240
  prefs = value; rich = !options.plain && !prefs.plain && env.TERM !== "dumb";
149
- writer.resized(isPlain()); session.change(); draw();
241
+ writer.resized(isPlain()); showCursor(writer.plain); locate(); session.change(); draw();
150
242
  });
151
243
  session.on("message", message => {
152
244
  if (vendorOwnsTerminal) { heldMessages.push(message); return; }
153
- writer.append(writer.plain ? safe(message.text) : layout.richMessage(message, stdout.columns ?? 80, color,
154
- { stepsShown: message.runId && message.runId === streamed.run ? streamed.steps : 0 }));
155
- plainDraft = ""; draw();
245
+ print(writer.plain ? safe(message.text) : layout.richMessage(message, stdout.columns ?? 80, theme,
246
+ { stepsShown: message.runId && message.runId === streamed.run ? streamed.steps : 0 }), ["user", "answer", "cost"].includes(message.kind));
247
+ plainDraft = "";
156
248
  });
157
249
  session.on("copy", answer => {
158
250
  if (vendorOwnsTerminal) return;
@@ -218,7 +310,7 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
218
310
  clearTimeout(frameTimer); frameTimer = null; clearInterval(clockTimer); clockTimer = null;
219
311
  stdin.off("data", onData); stdin.setRawMode(false); stdin.pause();
220
312
  process.off("SIGINT", onSignal);
221
- writer.clearLive(); if (env.TERM !== "dumb") stdout.write("\x1b[?2004l");
313
+ writer.clearLive(); commitHeader(); showCursor(true); if (env.TERM !== "dumb") stdout.write("\x1b[?2004l");
222
314
  const { VENDORS, executingServer, runVendor, vendorArgv } = await import("./vendors.js");
223
315
  writer.append(`Opening your ${VENDORS[vendor]} terminal with Vecteur attached for this session only. Exit it to return.`);
224
316
  let note;
@@ -231,6 +323,8 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
231
323
  if (env.TERM !== "dumb") stdout.write("\x1b[?2004h");
232
324
  stdin.setRawMode(true); stdin.resume(); stdin.on("data", onData);
233
325
  }
326
+ // The vendor's output is history now; find where it left the cursor before drawing again.
327
+ writer.resized(isPlain()); showCursor(writer.plain); locate();
234
328
  if (resizedDuringHandoff) { resizedDuringHandoff = false; onResize(); }
235
329
  for (const message of heldMessages.splice(0)) session.emit("message", message);
236
330
  session.message(note); session.change();
@@ -295,7 +389,13 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
295
389
  return false;
296
390
  };
297
391
  input.on("details", () => { if (!session.prompt) void execute("/details"); });
298
- input.on("redraw", () => { writer.clearLive(); draw(); });
392
+ input.on("redraw", () => { writer.clearLive(); locate(); draw(); });
393
+ input.on("report", report => {
394
+ const position = /^\x1b\[(\d+);(\d+)R$/.exec(report);
395
+ if (position) { writer.located(Number(position[1]), Number(position[2])); return; }
396
+ const value = /^\x1b\]1([01]);rgb:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i.exec(report);
397
+ if (value) reported[value[1] === "0" ? "fg" : "bg"] = value.slice(2).map(hex => Math.round(parseInt(hex, 16) / (16 ** hex.length - 1) * 255));
398
+ });
299
399
  input.on("exit", () => { try { session.close(); } catch (error) { session.report(error); } });
300
400
  const onData = chunk => {
301
401
  if (env.TERM === "dumb" && !input.pasting && (String(chunk).match(/[\r\n]/g)?.length ?? 0) > 1) {
@@ -307,8 +407,8 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
307
407
  // The vendor owns the TTY: not one byte; the new geometry is applied once on restore.
308
408
  if (vendorOwnsTerminal) { resizedDuringHandoff = true; return; }
309
409
  const plain = isPlain(), changed = plain !== writer.plain;
310
- writer.resized(plain); lastPlainIdentity = "";
311
- if (changed) writer.append(plain ? "Plain view — terminal is small; all actions remain available." : "Rich view restored.");
410
+ writer.resized(plain); lastPlainIdentity = ""; showCursor(plain); locate();
411
+ if (changed) print(plain ? "Plain view — terminal is small; all actions remain available." : "Rich view restored.", false);
312
412
  session.change(); draw();
313
413
  };
314
414
  const onSignal = () => { try { session.close(); } catch {} };
@@ -316,15 +416,17 @@ export async function startTerminal(options = {}, { stdin = process.stdin, stdou
316
416
  stdin.setRawMode(true); stdin.resume(); stdin.on("data", onData); stdout.on("resize", onResize);
317
417
  process.once("SIGTERM", onSignal); process.once("SIGHUP", onSignal); process.once("SIGINT", onSignal);
318
418
  if (env.TERM !== "dumb") stdout.write("\x1b[?2004h");
319
- writer.append(writer.plain ? `Vecteur ${version}${channel === "preview" ? " · Preview" : ""} · ${safe(session.origin)}` : layout.welcome(version, session.origin, stdout.columns ?? 80, color, channel,
320
- session.cwd.startsWith(homedir() + "/") ? "~" + session.cwd.slice(homedir().length) : session.cwd));
419
+ process.on("exit", restoreCursor);
420
+ if (writer.plain) writer.append(`Vecteur ${version}${channel === "preview" ? " · Preview" : ""} · ${safe(session.origin)}`);
421
+ else { showCursor(false); locate(color ? "\x1b]10;?\x1b\\\x1b]11;?\x1b\\" : ""); }
321
422
  draw();
322
423
  void session.initialize().then(() => { if (!input.text) syncInput(session.draft); draw(); }).catch(error => session.report(error));
323
424
  try { await done; }
324
425
  finally {
325
426
  clearTimeout(frameTimer); clearInterval(clockTimer); input.close(); stdin.off("data", onData); stdout.off("resize", onResize);
326
427
  process.off("SIGTERM", onSignal); process.off("SIGHUP", onSignal); process.off("SIGINT", onSignal);
327
- writer.clearLive(); if (env.TERM !== "dumb") stdout.write("\x1b[?2004l");
428
+ writer.clearLive(); writer.close(); commitHeader(); showCursor(true); process.off("exit", restoreCursor);
429
+ if (env.TERM !== "dumb") stdout.write("\x1b[?2004l");
328
430
  stdin.setRawMode(false); stdin.pause();
329
431
  stdout.write(session.restart ? "Restarting Vecteur…\n" : session.run?.open ? "Detached. The cloud Run continues; run vecteur here to follow it.\n" : "Session closed.\n");
330
432
  }
package/src/tui/view.js CHANGED
@@ -2,19 +2,101 @@ import stringWidth from "string-width";
2
2
  import wrapAnsi from "wrap-ansi";
3
3
  import { parseMarkdown } from "../markdown.js";
4
4
  import { terminalText } from "../format.js";
5
- const style = (value, code, color) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
6
- // One accent (the web's sky cyan, xterm 81), one secondary grey; amber only for attention.
7
- const ACCENT = "38;5;81", DIM = "90", ATTENTION = "33";
5
+
6
+ // Colour roles, each one SGR parameter list. A theme of `null` is NO_COLOR / --no-color: no
7
+ // styling at all. Without a palette report every role stays relative to the user's own palette:
8
+ // faint for secondary/quiet text and rules, no input fill (a rule-only composer).
9
+ export const DEFAULT_THEME = Object.freeze({ accent: "38;5;81", attention: "33", secondary: "2", quiet: "2",
10
+ rule: "2", band: "", strong: "1", em: "3", link: "4" });
11
+ const themeOf = color => color === true ? DEFAULT_THEME : color || null;
12
+ const RESET = { 1: "22", 2: "22", 3: "23", 4: "24", 7: "27", 38: "39", 48: "49" };
13
+ // Targeted resets, never SGR 0: a role inside the input band must not clear the band.
14
+ const style = (value, code, theme) => {
15
+ if (!theme || !code || !value) return value;
16
+ const close = []; const parts = code.split(";");
17
+ for (let i = 0; i < parts.length; i++) {
18
+ const n = Number(parts[i]);
19
+ if (n === 38 || n === 48) { close.push(RESET[n]); i += parts[i + 1] === "5" ? 2 : 4; }
20
+ else close.push(RESET[n] ?? ((n >= 30 && n <= 37) || (n >= 90 && n <= 97) ? "39" : "0"));
21
+ }
22
+ return `\x1b[${code}m${value}\x1b[${[...new Set(close)].join(";")}m`;
23
+ };
24
+ const paint = (value, role, theme) => style(value, theme?.[role], theme);
25
+
26
+ // Terminal palette → roles. The reference values are blends of the terminal's own foreground
27
+ // toward its background; text roles are pulled back toward the foreground until they reach 4.5:1.
28
+ const channel = v => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
29
+ const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
30
+ export const contrast = (a, b) => { const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
31
+ const mix = (from, to, t) => from.map((v, i) => Math.round(v + (to[i] - v) * t));
32
+ const cube = [0, 95, 135, 175, 215, 255];
33
+ function xterm256([r, g, b]) {
34
+ const near = v => cube.reduce((best, c, i) => Math.abs(c - v) < Math.abs(cube[best] - v) ? i : best, 0);
35
+ const [i, j, k] = [near(r), near(g), near(b)], rgb = [cube[i], cube[j], cube[k]];
36
+ const level = Math.max(0, Math.min(23, Math.round(((r + g + b) / 3 - 8) / 10))), grey = 8 + level * 10;
37
+ const distance = c => (c[0] - r) ** 2 + (c[1] - g) ** 2 + (c[2] - b) ** 2;
38
+ return distance([grey, grey, grey]) < distance(rgb) ? 232 + level : 16 + 36 * i + 6 * j + k;
39
+ }
40
+ // The colour a 256-colour terminal actually shows for an index.
41
+ const shown = index => index >= 232 ? Array(3).fill(8 + (index - 232) * 10)
42
+ : [Math.floor((index - 16) / 36), Math.floor((index - 16) / 6) % 6, (index - 16) % 6].map(i => cube[i]);
43
+ export function paletteTheme({ fg, bg }, truecolor = false) {
44
+ // Every check runs on the colour the terminal will render, never on the pre-rounding value.
45
+ const render = rgb => truecolor ? rgb : shown(xterm256(rgb));
46
+ const code = (rgb, layer = 38) => truecolor ? `${layer};2;${rgb.join(";")}` : `${layer};5;${xterm256(rgb)}`;
47
+ const band = render(mix(fg, bg, 0.88));
48
+ // Pull toward the foreground until legible on the background and, for roles drawn inside the
49
+ // input band (placeholder, caret), on the band too.
50
+ const readable = (from, grounds) => {
51
+ for (let t = 0; t <= 1; t += 0.05) {
52
+ const value = render(mix(from, fg, t));
53
+ if (grounds.every(ground => contrast(value, ground) >= 4.5)) return value;
54
+ }
55
+ return render(fg);
56
+ };
57
+ const dark = luminance(bg) < 0.4;
58
+ return Object.freeze({ ...DEFAULT_THEME,
59
+ accent: code(readable(dark ? [105, 215, 239] : [0, 120, 150], [bg, band])),
60
+ attention: code(readable(dark ? [233, 173, 12] : [154, 103, 0], [bg])),
61
+ secondary: code(readable(mix(fg, bg, 0.25), [bg, band])), quiet: code(readable(mix(fg, bg, 0.42), [bg])),
62
+ rule: code(mix(fg, bg, 0.62)), band: code(band, 48) });
63
+ }
64
+
8
65
  const SPINNER = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
66
+ const PROSE = 88; // readable measure on wide terminals; tables and code may use the full width
67
+ const NEW_PROJECT = "New Project on first send";
9
68
  const printableAscii = /^[\x20-\x7e]*$/;
10
69
  const measure = value => printableAscii.test(value) ? value.length : stringWidth(value);
70
+ // Each wrapped row closes whatever style is still open and the next row reopens it: a role never
71
+ // rides a line feed into a row that is later dropped or drawn by someone else (wrap-ansi only
72
+ // re-closes the 16-colour codes, not 256/truecolor ones).
73
+ const CLOSE = { 1: "22", 2: "22", 3: "23", 4: "24", 7: "27" };
74
+ function seal(lines) {
75
+ let open = [];
76
+ return lines.map(line => {
77
+ const head = open.length ? `\x1b[${open.join(";")}m` : "";
78
+ for (const [, list] of line.matchAll(/\x1b\[([0-9;]*)m/g)) {
79
+ const parts = list.split(";");
80
+ for (let i = 0; i < parts.length; i++) {
81
+ const n = Number(parts[i] || 0);
82
+ if (n === 38 || n === 48) { const width = parts[i + 1] === "5" ? 3 : 5; open = open.filter(g => !g.startsWith(`${n};`)); open.push(parts.slice(i, i + width).join(";")); i += width - 1; }
83
+ else if (n === 0) open = [];
84
+ else if (n === 39 || n === 49) open = open.filter(g => !g.startsWith(n === 39 ? "38;" : "48;") && !(n === 39 ? /^(3[0-7]|9[0-7])$/ : /^(4[0-7]|10[0-7])$/).test(g));
85
+ else if (["22", "23", "24", "27"].includes(String(n))) open = open.filter(g => CLOSE[g] !== String(n));
86
+ else open.push(String(n));
87
+ }
88
+ }
89
+ const tail = open.length ? `\x1b[${[...new Set(open.map(g => CLOSE[g] ?? (g.startsWith("48") || /^(4[0-7]|10[0-7])$/.test(g) ? "49" : "39")))].join(";")}m` : "";
90
+ return head + line + tail;
91
+ });
92
+ }
11
93
  const wrapped = new Map();
12
94
  const wrap = (value, width, wordWrap = true) => {
13
95
  width = Math.max(1, width);
14
96
  const key = value.length <= 512 ? `${width}:${wordWrap}:${value}` : null;
15
97
  if (key && wrapped.has(key)) return wrapped.get(key);
16
98
  const lines = !value.includes("\n") && measure(value) <= width ? [value]
17
- : wrapAnsi(value, width, { hard: true, trim: false, wordWrap }).split("\n");
99
+ : seal(wrapAnsi(value, width, { hard: true, trim: false, wordWrap }).split("\n"));
18
100
  if (key) {
19
101
  if (wrapped.size === 128) wrapped.delete(wrapped.keys().next().value);
20
102
  wrapped.set(key, lines);
@@ -22,62 +104,68 @@ const wrap = (value, width, wordWrap = true) => {
22
104
  return lines;
23
105
  };
24
106
  // Prose wraps at word boundaries and drops the break's leading space; the composer keeps every space.
25
- const prose = (value, width) => wrapAnsi(value, Math.max(1, width), { hard: true, trim: true }).split("\n");
107
+ const prose = (value, width) => seal(wrapAnsi(value, Math.max(1, width), { hard: true, trim: true }).split("\n"));
108
+ // Key hints fold between their " · " segments, never inside one ("? shortcuts" stays whole).
109
+ const fold = (text, width) => text.split("\n").flatMap(part => part.split(" · ").reduce((rows, segment) => {
110
+ if (rows.length && measure(rows.at(-1)) + 3 + measure(segment) <= width) rows[rows.length - 1] += " · " + segment;
111
+ else rows.push(segment);
112
+ return rows;
113
+ }, [])).flatMap(row => wrap(row, width));
26
114
  const pad = (value, width) => value + " ".repeat(Math.max(0, width - measure(value)));
27
115
 
28
- function inline(tokens, color) {
116
+ function inline(tokens, theme) {
29
117
  return tokens.map(token => {
30
118
  const href = token.href && /^https?:\/\//i.test(token.href) ? ` <${token.href}>` : "";
31
119
  const value = token.text + href;
32
- return token.kind === "strong" ? style(value, "1", color)
33
- : token.kind === "em" ? style(value, "3", color)
34
- : token.kind === "code" ? style(value, ACCENT, color)
35
- : token.kind === "link" ? style(value, "4", color) : value;
120
+ return token.kind === "strong" ? paint(value, "strong", theme)
121
+ : token.kind === "em" ? paint(value, "em", theme)
122
+ : token.kind === "code" ? paint(value, "accent", theme)
123
+ : token.kind === "link" ? paint(value, "link", theme) : value;
36
124
  }).join("");
37
125
  }
38
126
 
39
127
  const numeric = /^[-+−~≈<>≤≥±]?\s?\d[\d.,]*(?:\s?[^\s\d]\S*)?$/;
40
- function table(block, width, color) {
41
- const grid = [block.header, ...block.rows].map(row => row.map(cell => inline(cell, false)));
128
+ function table(block, width, theme) {
129
+ const grid = [block.header, ...block.rows].map(row => row.map(cell => inline(cell, null)));
42
130
  const columns = Math.max(...grid.map(row => row.length));
43
131
  const widths = Array.from({ length: columns }, (_, i) => Math.max(1, ...grid.map(row => measure(row[i] ?? ""))));
44
132
  if (widths.reduce((a, b) => a + b, 0) + 3 * (columns - 1) > width) {
45
133
  // Too wide for the terminal: one record per row, never a clipped column.
46
134
  const lines = [];
47
135
  block.rows.forEach((row, index) => {
48
- lines.push(style(`Record ${index + 1}`, "1", color));
49
- row.forEach((cell, column) => lines.push(...wrap(`${style(grid[0]?.[column] || `Column ${column + 1}`, DIM, color)} ${inline(cell, color)}`, width)));
136
+ lines.push(paint(`Record ${index + 1}`, "strong", theme));
137
+ row.forEach((cell, column) => lines.push(...wrap(`${paint(grid[0]?.[column] || `Column ${column + 1}`, "secondary", theme)} ${inline(cell, theme)}`, width)));
50
138
  });
51
139
  return lines;
52
140
  }
53
- const right = widths.map((_, i) => block.rows.length > 0 && block.rows.every(row => numeric.test(inline(row[i] ?? [], false).trim() || "0")));
141
+ const right = widths.map((_, i) => block.rows.length > 0 && block.rows.every(row => numeric.test(inline(row[i] ?? [], null).trim() || "0")));
54
142
  const cell = (value, i) => right[i] ? " ".repeat(Math.max(0, widths[i] - measure(value))) + value : pad(value, widths[i]);
55
- const bar = style(" │ ", DIM, color);
56
- return [style(grid[0].map((value, i) => pad(value, widths[i])).join(" │ "), "1", color),
57
- style(widths.map(w => "─".repeat(w)).join("─┼─"), DIM, color),
143
+ const bar = paint(" │ ", "rule", theme);
144
+ return [paint(grid[0].map((value, i) => pad(value, widths[i])).join(" │ "), "strong", theme),
145
+ paint(widths.map(w => "─".repeat(w)).join("─┼─"), "rule", theme),
58
146
  ...grid.slice(1).map(row => widths.map((_, i) => cell(row[i] ?? "", i)).join(bar))];
59
147
  }
60
148
 
61
149
  /** Shared web grammar; only trusted renderer styling can emit terminal controls. */
62
150
  export function markdownView(source, width, color = true) {
63
- const lines = [];
151
+ const theme = themeOf(color), lines = [], measureWidth = Math.min(width, PROSE);
64
152
  for (const block of parseMarkdown(terminalText(source))) {
65
- if (block.kind === "table") lines.push(...table(block, width, color));
153
+ if (block.kind === "table") lines.push(...table(block, width, theme));
66
154
  else if (block.kind === "codeblock") {
67
- if (block.language) lines.push(style(block.language, DIM, color));
68
- lines.push(...wrap(block.text, width - 2, false).map(line => style("│ ", DIM, color) + line));
155
+ if (block.language) lines.push(paint(block.language, "quiet", theme));
156
+ lines.push(...wrap(block.text, width - 2, false).map(line => paint("│ ", "rule", theme) + line));
69
157
  } else if (block.kind === "hr") {
70
- lines.push(style("─".repeat(Math.min(width, 48)), DIM, color));
158
+ lines.push(paint("─".repeat(Math.min(width, 48)), "rule", theme));
71
159
  } else if (block.kind === "list") {
72
160
  block.items.forEach((item, index) => {
73
161
  const prefix = block.ordered ? `${index + 1}. ` : "• ";
74
- prose(inline(item, color), width - prefix.length).forEach((line, i) => lines.push((i ? " ".repeat(prefix.length) : style(prefix, DIM, color)) + line));
162
+ prose(inline(item, theme), measureWidth - prefix.length).forEach((line, i) => lines.push((i ? " ".repeat(prefix.length) : paint(prefix, "quiet", theme)) + line));
75
163
  });
76
164
  } else {
77
- let value = inline(block.inline, color);
78
- if (block.kind === "heading") value = style(value, block.level <= 2 ? `1;${ACCENT}` : "1", color);
79
- if (block.kind === "quote") lines.push(...prose(value, width - 2).map(line => style("│ ", DIM, color) + line));
80
- else lines.push(...prose(value, width));
165
+ let value = inline(block.inline, theme);
166
+ if (block.kind === "heading") value = paint(value, "strong", theme);
167
+ if (block.kind === "quote") lines.push(...prose(value, measureWidth - 2).map(line => paint("│ ", "rule", theme) + line));
168
+ else lines.push(...prose(value, measureWidth));
81
169
  }
82
170
  lines.push("");
83
171
  }
@@ -88,7 +176,7 @@ export function markdownView(source, width, color = true) {
88
176
  // One terminal owns this cache. Cursor movement never rewraps unrelated paragraphs.
89
177
  let composerText;
90
178
  const composerLayouts = new Map();
91
- function composerViewport(composer, cursor, width, height) {
179
+ function composerViewport(composer, cursor, width, height, theme) {
92
180
  if (composerText !== composer) { composerLayouts.clear(); composerText = composer; }
93
181
  let composerLayout = composerLayouts.get(width);
94
182
  if (!composerLayout) {
@@ -105,7 +193,9 @@ function composerViewport(composer, cursor, width, height) {
105
193
  const paragraph = paragraphs.findLast(row => row.offset <= cursor) ?? paragraphs[0];
106
194
  const local = cursor - paragraph.offset;
107
195
  const before = terminalText(paragraph.text.slice(0, local));
108
- const active = wrap(before + "▌" + terminalText(paragraph.text.slice(local)), width, false);
196
+ // The caret is the one accent mark inside the input: wrapped on its plain glyph, painted after.
197
+ const active = wrap(before + "▌" + terminalText(paragraph.text.slice(local)), width, false)
198
+ .map(line => line.replace("▌", paint("▌", "accent", theme)));
109
199
  const cursorRow = paragraph.row + wrap(before + "▌", width, false).length - 1;
110
200
  const start = Math.max(0, cursorRow - height + 1);
111
201
  const next = paragraphs[paragraphs.indexOf(paragraph) + 1]?.row ?? lines.length;
@@ -115,27 +205,14 @@ function composerViewport(composer, cursor, width, height) {
115
205
  scrolling: lines.length > height };
116
206
  }
117
207
 
118
- /** One rounded box, the same frame as the prompt: a label may sit in the top or bottom border. */
119
- function box(rows, width, color, { top = "", bottom = "" } = {}) {
120
- const inner = Math.max(1, width - 4);
121
- const edge = (left, label, right) => {
122
- const text = label ? `─ ${trimEnd(label, inner - 2)} ` : "";
123
- return style(left + text + "─".repeat(Math.max(0, width - 2 - measure(text))) + right, DIM, color);
208
+ /** The composer: a filled input band between two rules; a label may sit in either rule. */
209
+ function composerBand(rows, width, theme, { top = "", bottom = "" } = {}) {
210
+ const rule = label => {
211
+ const text = label ? `── ${trimEnd(label, width - 4)} ` : "";
212
+ return paint(text + "─".repeat(Math.max(0, width - measure(text))), "rule", theme);
124
213
  };
125
- return [edge("╭", top, "╮"), ...rows.map(row => `${style("│", DIM, color)} ${row}${" ".repeat(Math.max(0, inner - measure(row)))} ${style("│", DIM, color)}`),
126
- edge("╰", bottom, "╯")];
127
- }
128
-
129
- /** The once-printed welcome: one calm box, a single accent glyph, no ASCII logo. */
130
- export function welcome(version, origin, columns = 80, color = true, channel, folder) {
131
- const width = Math.max(20, columns - 1), inner = width - 4;
132
- const label = `${version}${channel === "preview" ? " · Preview" : ""}`;
133
- const where = [folder, origin === "https://vecteur.space" ? "" : origin].filter(Boolean).map(terminalText).join(" · ");
134
- const head = `${style("▼", ACCENT, color)} ${style("Vecteur", "1", color)} ${style(label, DIM, color)}`;
135
- const lines = measure(`▼ Vecteur ${label} · Laplace · Vecteur cloud`) <= inner
136
- ? [`${head} ${style("· Laplace · Vecteur cloud", DIM, color)}`] : [head];
137
- if (where) lines.push(style(trimEnd(` ${where}`, inner), DIM, color));
138
- return box(lines, width, color).join("\n") + "\n";
214
+ const band = theme?.band;
215
+ return [rule(top), ...rows.map(row => band ? style(pad(row, width), band, theme) : row), rule(bottom)];
139
216
  }
140
217
 
141
218
  // Display-cell truncation with an ellipsis; never more work than the visible width needs.
@@ -155,8 +232,46 @@ function trimStart(value, max) {
155
232
  return "…" + chars.join("");
156
233
  }
157
234
 
158
- /** Bottom-anchored live area: activity, focus, the prompt box, then one or two footer lines. */
159
- export function richFrame(view, composer, cursor, { columns = 80, rows = 24, color = true, tick = null } = {}) {
235
+ const execution = view => view.location && view.location !== "Vecteur cloud" ? view.location : "Cloud";
236
+ const routeOf = view => `${view.lead ? `${view.lead} · ` : ""}${view.model} · ${execution(view)}`;
237
+ const customOrigin = view => view.origin && view.origin !== "https://vecteur.space" ? view.origin : "";
238
+
239
+ /**
240
+ * Identity: `▼ Vecteur <version>` and the folder, the route group on the right (beneath when
241
+ * narrow). Live while the transcript is empty, then committed once to scrollback.
242
+ */
243
+ export function richHeader(view, columns = 80, color = true) {
244
+ const theme = themeOf(color), width = Math.max(20, columns - 1);
245
+ const label = `${view.version ?? ""}${view.channel === "preview" ? " · Preview" : ""}`;
246
+ const brand = `${paint("▼", "accent", theme)} ${paint("Vecteur", "strong", theme)} ${paint(label, "quiet", theme)}`;
247
+ const brandWidth = measure(`▼ Vecteur ${label}`);
248
+ // Only a signed-in, online session earns the filled dot; it claims nothing about a server.
249
+ const live = !!view.account && !view.offline;
250
+ const route = trimEnd(routeOf(view), width - 4);
251
+ const routeText = `${paint(live ? "●" : "○", live ? "accent" : "quiet", theme)} ${paint(route, "secondary", theme)}`;
252
+ const routeWidth = measure(route) + 2;
253
+ const lines = ["", brandWidth + 4 + routeWidth <= width
254
+ ? brand + " ".repeat(width - brandWidth - routeWidth) + routeText : brand];
255
+ // A non-production origin is never hidden for visual simplicity.
256
+ const origin = customOrigin(view), directory = view.directory ? terminalText(view.directory) : "";
257
+ if (directory && origin && measure(directory) + 3 + measure(origin) <= width - 2) {
258
+ lines.push(` ${paint(directory, "quiet", theme)}${paint(" · ", "quiet", theme)}${paint(terminalText(origin), "accent", theme)}`);
259
+ } else {
260
+ if (directory) lines.push(" " + paint(trimStart(directory, width - 2), "quiet", theme));
261
+ if (origin) lines.push(" " + paint(trimEnd(origin, width - 2), "accent", theme));
262
+ }
263
+ if (lines[1] === brand) lines.push(" " + routeText);
264
+ return lines;
265
+ }
266
+
267
+ // First-use suggestions are plain text, never a menu; existing commands stay authoritative.
268
+ const ONBOARDING = ["Review a mission brief and find the missing inputs",
269
+ "Compare orbit options for an Earth-observation mission", "Turn requirements into an analysis plan"];
270
+
271
+ /** The live region: `top` (identity, first-use note) above a flexible gap, `bottom` anchored. */
272
+ export function richLayout(view, composer, cursor, options = {}) {
273
+ const { columns = 80, rows = 24, color = true, tick = null } = options;
274
+ const theme = themeOf(color);
160
275
  const width = Math.max(20, columns - 1), budget = Math.max(1, rows - 3);
161
276
  const active = !!view.run?.subject && !view.run.terminal;
162
277
  const readOnly = view.project?.role === "VIEWER", signedOut = view.offline || !view.account;
@@ -164,6 +279,7 @@ export function richFrame(view, composer, cursor, { columns = 80, rows = 24, col
164
279
  const runHint = readOnly ? "Read-only · /help · Ctrl+D detach"
165
280
  : signedOut ? `${view.offline ? "/resume" : "/login"} · /help · Ctrl+D detach`
166
281
  : `${view.run?.question ? "/answer" : "/steer · /answer-now"} · /help${width < 60 ? "\n" : " · "}Esc twice cancel · Ctrl+D detach`;
282
+ const idleHint = `${readOnly ? "Read-only · /project" : signedOut ? "Enter sign in" : view.busy ? "Please wait" : "Enter send"} · / commands · ? shortcuts`;
167
283
  if (rows < 12) {
168
284
  const focus = view.focus ?? view.prompt;
169
285
  const choices = view.choices ?? focus?.options ?? view.palette ?? [];
@@ -174,17 +290,18 @@ export function richFrame(view, composer, cursor, { columns = 80, rows = 24, col
174
290
  const status = wrap(terminalText(content), width).slice(0, 1);
175
291
  if (choice) status.push(wrap(terminalText(`› ${selected + 1}/${choices.length} ${choice.label ?? '/' + choice.name}`), width)[0]);
176
292
  else if (view.login) status.push(['› Open browser', '› Copy link', '› Work offline'][view.loginAction ?? 0]);
177
- const draft = composerViewport(composer, cursor, width - 2, 1).lines[0];
293
+ const draft = composerViewport(composer, cursor, width - 2, 1, theme).lines[0];
178
294
  const hint = focus || choices.length ? '↑↓ Enter choose · Esc back' : view.login ? 'Tab actions · Enter open · Esc offline'
179
- : active ? runHint : '? keys · /help · Ctrl+D exit';
180
- return [...status, `${style("›", ACCENT, color)} ${draft}`, ...wrap(style(hint, DIM, color), width).slice(0, 2)].slice(0, budget).join("\n");
295
+ : active ? runHint : idleHint;
296
+ return { top: [], bottom: [...status, `${paint("›", "accent", theme)} ${draft}`,
297
+ ...fold(hint, width).slice(0, 2).map(row => paint(row, "secondary", theme))].slice(0, budget) };
181
298
  }
182
- if (rows < 8) return wrap(`${view.status} · Ctrl+D exit`, width).slice(0, budget).join("\n");
183
- const project = view.projectLabel ?? view.project?.name ?? "New Project on first send";
184
299
  // The steady footer names the Workspace, never a personal address; /status shows the account.
185
300
  const workspaces = view.account?.workspaces ?? [];
186
301
  const account = view.account ? (workspaces.find(row => row.id === view.project?.workspace_id) ?? workspaces[0])?.name ?? "Signed in"
187
302
  : view.offline ? "Offline" : "Not signed in";
303
+ const projectLabel = view.projectLabel ?? view.project?.name ?? NEW_PROJECT;
304
+ const identity = `${account} · ${projectLabel === NEW_PROJECT ? "New project" : projectLabel}`;
188
305
  const compact = width < 60 || rows < 18;
189
306
  const prompt = view.focus ?? view.prompt;
190
307
  const compactFocus = rows < 24 && (view.focus || view.prompt || view.login || view.palette?.length || view.issue);
@@ -194,44 +311,39 @@ export function richFrame(view, composer, cursor, { columns = 80, rows = 24, col
194
311
  const hint = prompt ? prompt.multiple ? "Space toggle · ↑↓ move · Enter confirm · Esc back" : "↑↓ choose · Enter confirm · Esc back · Ctrl+D exit"
195
312
  : view.login ? "Tab actions · Enter on empty activates · Esc offline"
196
313
  : view.palette?.length ? "↑↓ choose · Tab complete · Enter open · Esc close"
197
- : active ? runHint
198
- : `${readOnly ? "Read-only · /project" : signedOut ? "Enter sign in" : view.busy ? "Please wait" : "Enter send"}`
199
- + (width < 60 ? " · /help · Ctrl+D exit" : " · Alt+Enter newline · / commands · ? keys · /help · Ctrl+D exit");
200
- const execution = view.location && view.location !== "Vecteur cloud" ? view.location : "Cloud";
201
- const context = `${view.lead ? `${view.lead} · ` : ""}${view.model} · ${execution}`;
202
- const footer = wrap(style(hint, DIM, color), width - 1).slice(0, 2).map(line => " " + line);
203
- const identity = trimEnd(`${project === "New Project on first send" ? "New Project" : project} · ${account}`, width);
204
- const where = trimStart(view.folder ?? ".", Math.max(6, width - measure(identity) - measure(context) - 7));
205
- const left = where === "." ? identity : `${identity} · ${where}`;
206
- if (!compact && measure(left) + measure(context) + 4 <= width) {
207
- footer.push(" " + style(left + " ".repeat(width - 2 - measure(left) - measure(context)) + context, DIM, color));
314
+ : active ? runHint : idleHint;
315
+ // While the identity header is live it owns the route and origin; afterwards the footer does.
316
+ const context = view.header ? identity : compact ? routeOf(view) : `${identity} · ${routeOf(view)}`;
317
+ const footer = [];
318
+ if (!compact && !hint.includes("\n") && measure(context) + 3 + measure(hint) <= width) {
319
+ footer.push(paint(context, "secondary", theme) + " ".repeat(width - measure(context) - measure(hint)) + paint(hint, "quiet", theme));
208
320
  } else {
209
- footer.push(" " + style(trimEnd(context, width - 1), DIM, color));
210
- if (!compact && !compactFocus) footer.push(" " + style(trimEnd(left, width - 1), DIM, color));
321
+ footer.push(...fold(hint, width).slice(0, 2).map(row => paint(row, "quiet", theme)));
322
+ footer.push(paint(trimEnd(context, width), "secondary", theme));
211
323
  }
212
- if (view.origin && view.origin !== "https://vecteur.space") footer.push(" " + style(trimEnd(view.origin, width - 1), ACCENT, color));
324
+ if (!view.header && customOrigin(view)) footer.push(paint(trimEnd(customOrigin(view), width), "accent", theme));
213
325
 
214
326
  // A breathing line between the scrollback and the live area when the window has room for it.
215
327
  const lines = rows >= 18 && !compactFocus ? [""] : [];
216
328
  const room = () => budget - footer.length - 3 - lines.length;
217
- const line = (value, code, limit = 2) => {
329
+ const line = (value, role, limit = 2) => {
218
330
  const text = terminalText(value);
219
- lines.push(...wrap(code ? style(text, code, color) : text, width).slice(0, Math.max(0, Math.min(limit, room()))));
331
+ lines.push(...wrap(text, width).slice(0, Math.max(0, Math.min(limit, room()))).map(row => paint(row, role, theme)));
220
332
  };
221
333
  // Activity: the one animated glyph, the current step and the server-anchored clock.
222
334
  if (active && !compactFocus) {
223
335
  const clock = view.run.elapsed ?? "Elapsed unavailable";
224
- const doing = compactRun ? view.status : (view.run.progress || "Working").replace(/^Starting: (.+)$/, "$1") + "…";
225
- const head = `${style(glyph, ACCENT, color)} ${terminalText(doing)}`, tail = style(terminalText(clock), DIM, color);
226
- if (measure(`${glyph} ${terminalText(doing)} · ${terminalText(clock)}`) <= width) lines.push(`${head} ${style("·", DIM, color)} ${tail}`);
336
+ const doing = compactRun ? view.status : (view.run.progress || "Working").replace(/^Starting: (.+)$/, "$1").replace(/[.…]+$/, "") + "…";
337
+ const head = `${paint(glyph, "accent", theme)} ${terminalText(doing)}`, tail = paint(terminalText(clock), "quiet", theme);
338
+ if (measure(`${glyph} ${terminalText(doing)} · ${terminalText(clock)}`) <= width) lines.push(`${head} ${paint("·", "quiet", theme)} ${tail}`);
227
339
  else if (compactRun) lines.push(wrap(head, width)[0], ` ${tail}`);
228
- else lines.push(wrap(`${style(glyph, ACCENT, color)} ${tail} · ${terminalText(doing)}`, width)[0]);
229
- if (!compactRun && view.status && view.status !== "Running") line(view.status, DIM, 1);
340
+ else lines.push(wrap(`${paint(glyph, "accent", theme)} ${tail} · ${terminalText(doing)}`, width)[0]);
341
+ if (!compactRun && view.status && view.status !== "Running") line(` ${view.status}`, "secondary", 1);
230
342
  }
231
343
  if (!compactFocus && !compactRun && view.run?.question) line(readOnly ? "Waiting for an editor to answer"
232
- : signedOut ? "Question pending — reconnect to answer" : "Your answer is needed · /answer", `1;${ATTENTION}`, 1);
344
+ : signedOut ? "Question pending — reconnect to answer" : "Your answer is needed · /answer", "attention", 1);
233
345
  if (prompt) {
234
- line(prompt.title, "1", compactFocus ? 1 : 3);
346
+ line(prompt.title, "strong", compactFocus ? 1 : 3);
235
347
  if (!prompt.typed) {
236
348
  const choices = view.choices ?? prompt.options, selected = Math.min(view.selected ?? 0, Math.max(0, choices.length - 1));
237
349
  const more = choices.length > 1 && !compactFocus ? 1 : 0;
@@ -240,14 +352,14 @@ export function richFrame(view, composer, cursor, { columns = 80, rows = 24, col
240
352
  choices.slice(offset, offset + count).forEach((item, i) => {
241
353
  const index = prompt.options.indexOf(item) + 1, current = i + offset === selected;
242
354
  const checked = prompt.multiple ? `${composer.split(/[, ]+/).includes(String(index)) ? "[x]" : "[ ]"} ` : "";
243
- lines.push(wrap(`${current ? style("›", ACCENT, color) : " "} ${index}. ${checked}${current ? style(terminalText(item.label), ACCENT, color) : terminalText(item.label)}`, width)[0]);
355
+ lines.push(wrap(`${current ? paint("›", "accent", theme) : " "} ${index}. ${checked}${current ? paint(terminalText(item.label), "accent", theme) : terminalText(item.label)}`, width)[0]);
244
356
  });
245
- if (!choices.length) line("No matching choices; clear the filter", ATTENTION, 1);
246
- if (choices.length > count && more) line(`${selected + 1}/${choices.length} · ↑↓ more choices`, DIM, 1);
357
+ if (!choices.length) line("No matching choices; clear the filter", "attention", 1);
358
+ if (choices.length > count && more) line(`${selected + 1}/${choices.length} · ↑↓ more choices`, "quiet", 1);
247
359
  }
248
360
  } else if (view.login) {
249
- line(`Approve code ${view.login.user_code} in your browser`, "1", 1);
250
- if (!compactFocus) line(view.login.verification_uri_complete, ACCENT, 2);
361
+ line(`Approve code ${view.login.user_code} in your browser`, "strong", 1);
362
+ if (!compactFocus) line(view.login.verification_uri_complete, "accent", 2);
251
363
  const actions = ["Open browser", "Copy link", "Work offline"];
252
364
  line(compactFocus ? `› ${actions[view.loginAction ?? 0]} (${(view.loginAction ?? 0) + 1}/3)`
253
365
  : actions.map((text, i) => `${i === view.loginAction ? "› " : ""}${text}`).join(" · "), undefined, compactFocus ? 1 : 2);
@@ -258,51 +370,84 @@ export function richFrame(view, composer, cursor, { columns = 80, rows = 24, col
258
370
  const name = Math.min(16, Math.max(...view.palette.map(row => row.name.length + 1)));
259
371
  view.palette.slice(offset, offset + count).forEach((row, i) => {
260
372
  const current = i + offset === selected, label = pad(`/${row.name}`, name);
261
- lines.push(wrap(`${current ? style("›", ACCENT, color) : " "} ${current ? style(label, ACCENT, color) : label} ${style(terminalText(row.disabled || row.description), DIM, color)}`, width)[0]);
373
+ lines.push(wrap(`${current ? paint("›", "accent", theme) : " "} ${current ? paint(label, "accent", theme) : label} ${paint(terminalText(row.disabled || row.description), "secondary", theme)}`, width)[0]);
262
374
  });
263
375
  }
264
376
  const compactAttachments = rows < 24 && view.attachments?.length;
265
- if (view.attachments?.length && !compactAttachments) line(`${view.attachments.length} file(s) selected · uploaded only on send`, DIM, 1);
266
- if (view.issue && !prompt) line(view.issue.text + (view.issue.count > 1 ? ` (${view.issue.count} attempts)` : ""), ATTENTION, Math.max(1, Math.min(3, room())));
267
- if (!active && !view.issue && !prompt && !view.login && !view.palette?.length) line(view.status, DIM, 1);
377
+ if (view.attachments?.length && !compactAttachments) line(`${view.attachments.length} file(s) selected · uploaded only on send`, "secondary", 1);
378
+ if (view.issue && !prompt) line(view.issue.text + (view.issue.count > 1 ? ` (${view.issue.count} attempts)` : ""), "attention", Math.max(1, Math.min(3, room())));
379
+ // First use: signed in, idle, an unbound folder, an empty transcript and an empty draft.
380
+ const firstUse = view.header && view.firstUse && !active && !composer && !prompt
381
+ && !view.login && !view.issue && !view.palette?.length && !view.attachments?.length;
382
+ const idle = !active && !view.issue && !prompt && !view.login && !view.palette?.length;
383
+ // "Ready" is what the idle hint already says; every other state keeps its line.
384
+ if (idle && !firstUse && view.status !== "Ready") line(view.status, "secondary", 1);
268
385
  const maxDraft = Math.max(1, Math.min(6, budget - lines.length - footer.length - 2));
269
- const draft = composerViewport(composer, cursor, width - 6, maxDraft);
386
+ const draft = composerViewport(composer, cursor, width - 4, maxDraft, theme);
270
387
  const selection = compactAttachments ? `${view.attachments.length} file(s) · upload on send` : "";
271
388
  const placeholder = prompt ? (prompt.typed ? "Type your answer…" : "Filter or type a choice number…")
272
389
  : readOnly ? "Read-only Project — draft here…" : view.offline ? "Draft offline…"
273
390
  : !view.account ? "Sign in to send — draft here…"
274
391
  : active ? "Draft your next question…" : "Ask Laplace…";
275
- lines.push(...box(draft.lines.map((value, index) => `${index ? " " : style("›", ACCENT, color) + " "}${composer ? value : style("▌" + placeholder, DIM, color)}`),
276
- width, color, { top: selection, bottom: draft.scrolling ? draft.position : "" }));
392
+ lines.push(...composerBand(draft.lines.map((value, index) => ` ${index ? " " : paint("›", "accent", theme)} ${composer ? value
393
+ : paint("▌", "accent", theme) + paint(placeholder, "secondary", theme)}`), width, theme,
394
+ { top: selection, bottom: draft.scrolling ? draft.position : "" }));
277
395
  lines.push(...footer);
278
- return lines.slice(-budget).join("\n");
396
+ const bottom = lines.slice(-budget);
397
+
398
+ // Above the gap: identity while the transcript is empty, then the first-use note if it fits.
399
+ const space = rows - 1 - bottom.length;
400
+ const top = [];
401
+ if (view.header) {
402
+ const header = richHeader(view, columns, theme);
403
+ // No room for the identity: the footer owns the route and origin, as after the commit.
404
+ if (header.length > space) return richLayout({ ...view, header: false }, composer, cursor, options);
405
+ top.push(...header);
406
+ }
407
+ if (firstUse) {
408
+ const note = "New folder · A project will be created on your first message. /link existing";
409
+ const intro = [paint("What are we working on?", "strong", theme), paint("Ask Laplace about your mission, or start from a file.", "secondary", theme)];
410
+ const suggestions = ONBOARDING.map(text => `${paint("›", "accent", theme)} ${paint(text, "secondary", theme)}`);
411
+ const quiet = wrap(note, width).map(row => paint(row, "quiet", theme));
412
+ const body = [[...intro, "", ...suggestions, "", ...quiet], [...intro, "", ...quiet], quiet]
413
+ .map(rows => rows.map(row => measure(row) > width ? wrap(row, width)[0] : row))
414
+ .find(rows => top.length + 1 + rows.length + 1 <= space);
415
+ if (body) top.push("", ...body);
416
+ }
417
+ return { top, bottom };
418
+ }
419
+
420
+ /** Bottom-anchored live area as one string: identity, gap, activity, focus, composer, footer. */
421
+ export function richFrame(view, composer, cursor, options = {}) {
422
+ const { top, bottom } = richLayout(view, composer, cursor, options);
423
+ return [...top, ...bottom].join("\n");
279
424
  }
280
425
 
281
426
  const indent = (text, first, rest = " ") => text.split("\n").map((line, i) => (i ? (line ? rest : "") : first) + line).join("\n");
282
427
 
283
428
  /** One scrollback block per message: the ask, the answer with its record, or a quiet notice. */
284
429
  export function richMessage(message, columns, color, { stepsShown = false } = {}) {
285
- const width = Math.max(20, columns - 1);
430
+ const theme = themeOf(color), width = Math.max(20, columns - 1);
286
431
  if (message.kind === "user") {
287
- return "\n" + indent(wrap(terminalText(message.text), width - 2).join("\n"), style("›", ACCENT, color) + " ") + "\n";
432
+ return "\n" + indent(wrap(terminalText(message.text), width - 2).join("\n"), paint("›", "accent", theme) + " ") + "\n";
288
433
  }
289
- if (message.kind === "step") return style(trimEnd(` ⎿ ${message.text}`, width), DIM, color);
434
+ if (message.kind === "step") return paint(trimEnd(` ⎿ ${message.text}`, width), "secondary", theme);
290
435
  if (message.kind === "cost") {
291
436
  const display = message.value?.display ?? {};
292
437
  const parts = [["Used", display.used], ["Limit", display.limit], ["Remaining", display.remaining]].filter(([, v]) => v !== null && v !== undefined);
293
- return style(trimEnd(` Usage this UTC month · ${parts.length ? parts.map(([k, v]) => `${k} ${v}`).join(" · ") : "no exact amount available"} · /usage`, width), DIM, color);
438
+ return paint(trimEnd(` Usage this UTC month · ${parts.length ? parts.map(([k, v]) => `${k} ${v}`).join(" · ") : "no exact amount available"} · /usage`, width), "quiet", theme);
294
439
  }
295
440
  if (message.kind !== "answer") return wrap(terminalText(message.text), width - 2).map(line => " " + line).join("\n");
296
441
  const run = message.sections;
297
- if (!run) return "\n" + indent(markdownView(message.text, width - 2, color), style("●", ACCENT, color) + " ") + "\n";
442
+ if (!run) return "\n" + indent(markdownView(message.text, width - 2, theme), paint("●", "accent", theme) + " ") + "\n";
298
443
  const out = [];
299
- out.push(run.answer !== undefined ? indent(markdownView(run.answer, width - 2, color), style("●", ACCENT, color) + " ")
300
- : style("●", ATTENTION, color) + " No answer was published for this Run.");
301
- const section = (title, rows) => { if (rows.length) out.push("", " " + style(title, DIM, color), ...rows); };
444
+ out.push(run.answer !== undefined ? indent(markdownView(run.answer, width - 2, theme), paint("●", "accent", theme) + " ")
445
+ : paint("●", "attention", theme) + " No answer was published for this Run.");
446
+ const section = (title, rows) => { if (rows.length) out.push("", " " + paint(title, "strong", theme), ...rows); };
302
447
  const one = text => text.split("\n").map(part => part.trim()).filter(Boolean).join(" · ");
303
- section("Steps", run.tasks.slice(stepsShown).map(task => style(trimEnd(` ⎿ ${one(task.replace(/^\s*[▸·]\s*/, ""))}`, width), DIM, color)));
448
+ section("Steps", run.tasks.slice(stepsShown).map(task => paint(trimEnd(` ⎿ ${one(task.replace(/^\s*[▸·]\s*/, ""))}`, width), "secondary", theme)));
304
449
  // Evidence rows are never truncated: quantity, unit and origin wrap with a hanging indent.
305
- const full = (row, code = DIM) => prose(terminalText(row.trim()), width - 4).map((part, i) => (i ? " " : " ") + style(part, code, color));
450
+ const full = (row, role = "secondary") => prose(terminalText(row.trim()), width - 4).map((part, i) => (i ? " " : " ") + paint(part, role, theme));
306
451
  section("Inputs and origins", run.origins.flatMap(row => full(row)));
307
452
  section("Assumptions", run.assumptions.flatMap(row => full(row)));
308
453
  section("Transfer tables", run.tables.flatMap(rows => rows.flatMap(row => full(row))));
@@ -311,9 +456,9 @@ export function richMessage(message, columns, color, { stepsShown = false } = {}
311
456
  const digest = row => /^[0-9a-f]{32,}\./.test(row.artifact.filename);
312
457
  const shown = [...numbered.filter(row => !digest(row)), ...numbered.filter(digest)].slice(0, 3);
313
458
  const more = run.artifacts.length - shown.length;
314
- section("Files", shown.flatMap(({ artifact, number }) => prose(`${terminalText(artifact.filename)} ${style(`· ${artifact.kind} · /download ${number}`, DIM, color)}`, width - 4)
315
- .map((part, i) => ` ${i ? " " : style("↓", ACCENT, color)} ${part}`))
316
- .concat(more > 0 ? [style(` … ${more} more · /files`, DIM, color)] : []));
317
- out.push("", ...full(run.tail.map(terminalText).join(" · "), run.state === "ok" ? DIM : ATTENTION));
459
+ section("Files", shown.flatMap(({ artifact, number }) => prose(`${terminalText(artifact.filename)} ${paint(`· ${artifact.kind} · /download ${number}`, "quiet", theme)}`, width - 4)
460
+ .map((part, i) => ` ${i ? " " : paint("↓", "accent", theme)} ${part}`))
461
+ .concat(more > 0 ? [paint(` … ${more} more · /files`, "quiet", theme)] : []));
462
+ out.push("", ...full(run.tail.map(terminalText).join(" · "), run.state === "ok" ? "quiet" : "attention"));
318
463
  return "\n" + out.join("\n") + "\n";
319
464
  }