@tiens.nguyen/gu-cli 1.0.782 → 1.0.784

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/gu-repl.mjs CHANGED
@@ -23,9 +23,11 @@
23
23
  * Requires the worker daemon to be running (it claims the job).
24
24
  * Commands: /exit /model /new /help Ctrl-C stops following the current run.
25
25
  */
26
+ import { appendFileSync as __afs } from "node:fs";
27
+ const __dbg = (...a) => { try { __afs("/private/tmp/claude-501/-Users-joseph-Projects-gonext/bf6ac2f6-435f-4150-a01e-cea31d8ff4d2/scratchpad/r321/dbg.log", a.map((x) => JSON.stringify(x)).join(" ") + "\n"); } catch {} };
26
28
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
- import { readStepTokens } from "./step-tokens.mjs";
28
- import { snapshotInput, restoreInput, replaceStoppedQuestion, stopNoteLines } from "./turn-stop.mjs";
29
+ import { readStepTokens, readTurnCache } from "./step-tokens.mjs";
30
+ import { snapshotInput, restoreInput, replaceStoppedQuestion, restoredQuestionLetGo, stopNoteLines } from "./turn-stop.mjs";
29
31
  import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync, mkdirSync } from "node:fs";
30
32
  import { spawn } from "node:child_process";
31
33
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
@@ -66,6 +68,7 @@ import {
66
68
  formatThinkTime,
67
69
  plainAnswer,
68
70
  summarizeError,
71
+ tokenRowWithPrice,
69
72
  visibleLen,
70
73
  createProbeReplyReader,
71
74
  agentPickerItems,
@@ -179,6 +182,8 @@ import {
179
182
  viewportSlice,
180
183
  paintViewportSeq,
181
184
  jumpToBottomRow,
185
+ zoneResizePlan,
186
+ blockClosedPad,
182
187
  } from "./terminal-viewport.mjs";
183
188
 
184
189
  const DIR = dirname(fileURLToPath(import.meta.url));
@@ -636,7 +641,7 @@ function reloadWorkerEnv() {
636
641
  // first-run branch also runs one — without this the same walkthrough printed twice in a single
637
642
  // run, MongoDB instructions and all. Reported live on Windows 11.
638
643
  let clientExpertRanThisProcess = false;
639
- // #301: setup chose "Load a saved preset" — main() opens /load-presets before the first prompt.
644
+ // #301: setup chose "Load a saved preset" — main() opens /use-presets before the first prompt.
640
645
  // Declared HERE, above the first-run block: that block runs at module top level, before any
641
646
  // declaration further down exists.
642
647
  let openPresetsAtStart = false;
@@ -881,10 +886,12 @@ const COMMANDS = [
881
886
  // object {apiUrl, apiKey, agentModel, codingModel, embedModel} and validates every field
882
887
  // before writing. A preset file HOLDS its API key (0600, home folder — the settings files'
883
888
  // location), so it is marked "gu": "preset" and the two commands never offer each other's files.
884
- { name: "/save-preset", usage: "/save-preset <name> [{json}]", desc: "save a named preset — server, API key and the agent/coding/embedding models — in your editor ($EDITOR, nano…), or as one line of JSON; all validated" },
885
- { name: "/open-presets", aliases: ["/load-presets"], usage: "/open-presets [name]", desc: "pick a saved preset: Enter applies it (asks first) · v view · c copy as a command · j copy JSON · e edit" },
889
+ { name: "/save-preset", usage: "/save-preset [name]", desc: "paste a preset (from another computer's /open-presets) into your editor and save it under a name — nothing is applied" },
890
+ { name: "/open-presets", usage: "/open-presets [name]", desc: "your whole setup now, keys included, read-only in your editor — to copy to another computer; with a name, edit that preset" },
891
+ { name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
886
892
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
887
893
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
894
+ { name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
888
895
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
889
896
  // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
890
897
  { name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
@@ -997,6 +1004,7 @@ const OUT_RESTORE = "\x1b8";
997
1004
  const OUT_SAVE = "\x1b7";
998
1005
  /** Draw in-place UI that lives in the SCROLLING area (status block, pickers, menus). */
999
1006
  const regionUi = (seq) => {
1007
+ if (/\n/.test(seq)) __dbg("regionUi with newline", { nl: (seq.match(/\n/g) || []).length, head: seq.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").slice(0, 60), at: new Error().stack.split("\n").slice(2, 5).map((x) => x.trim().slice(0, 80)) });
1000
1008
  if (!barPinned()) {
1001
1009
  ui(seq);
1002
1010
  return;
@@ -1449,6 +1457,11 @@ function applyClick({ row, col }) {
1449
1457
  void runUpdate();
1450
1458
  return;
1451
1459
  }
1460
+ if (pricesRowTarget && row === updateRowAbs &&
1461
+ col - 1 >= pricesRowTarget.from && col - 1 < pricesRowTarget.to) {
1462
+ void runPricesUpdate();
1463
+ return;
1464
+ }
1452
1465
  if (agentsRowTarget && row === updateRowAbs &&
1453
1466
  col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
1454
1467
  void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
@@ -1560,7 +1573,8 @@ const pasteStore = new Map(); // token -> the real text
1560
1573
  // model, so a turn stopped with Ctrl+C can give the question back for editing.
1561
1574
  let lastSubmittedInput = null;
1562
1575
  // Task #302: set while the input holds a stopped turn's question; the next line sent from it
1563
- // REPLACES that question in history when it was edited. Cleared by "never mind" (Ctrl+C).
1576
+ // REPLACES that question in history when it was edited. Cleared by "never mind": Ctrl+C, or the
1577
+ // line emptied any other way (restoredQuestionLetGo) — what is typed next is a new message.
1564
1578
  let prefillFor = null;
1565
1579
  // Task #302: the backup run of the last stopped turn — what /undo reverts by default.
1566
1580
  let lastStoppedRun = null;
@@ -2453,6 +2467,12 @@ const identityLineText = () =>
2453
2467
  { orange, cyan: accent, dim, red },
2454
2468
  );
2455
2469
 
2470
+ /** "$0.334" / "~$0.334" (same rules as model-prices.mjs formatUsd, kept sync for the bar). */
2471
+ const fmtCost = (c) => {
2472
+ const v = Math.max(0, c.usd);
2473
+ return `${c.estimated ? "~" : ""}$${v >= 1 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v > 0 ? v.toFixed(4) : "0"}`;
2474
+ };
2475
+
2456
2476
  const tokenLineText = () => {
2457
2477
  // TWO different numbers, each labelled, because they are routinely off by 6×:
2458
2478
  // ctx = the NEWEST request — what the model is holding right now, the one to compare
@@ -2467,7 +2487,7 @@ const tokenLineText = () => {
2467
2487
  // a terminal — it was inline here, unasserted, and wrong for months. Both figures are TRUE
2468
2488
  // measurements: nothing is netted against a floor any more (see that function's comment).
2469
2489
  const { ctx, turn: sent } = tokenRowFigures({ ctxTrue, turnTrue: lastTurnInputTokens });
2470
- return (
2490
+ const head = (
2471
2491
  // IDENTITY IS NOT ON THIS ROW ANY MORE — it has its own (identityLineText), because five
2472
2492
  // measurements plus a coder and a folder name was ~110 columns and the clip falls on the
2473
2493
  // RIGHT, so the totals were the part being cut on any normal window.
@@ -2487,11 +2507,18 @@ const tokenLineText = () => {
2487
2507
  // `ctx` and `sent`, which do drive decisions, need more.
2488
2508
  ragSegmentText() +
2489
2509
  dim(" · ") +
2490
- accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`) +
2491
- dim(" · ") +
2492
- dim("total ") +
2493
- accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`)
2510
+ accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`)
2494
2511
  );
2512
+ const total = dim(" · ") + dim("total ") + accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`);
2513
+ // #323: what the last turn's prompt cost, LAST on the row after a "|" — "~" when the cached share
2514
+ // was assumed; nothing when the model has no price (never a misleading "| $0"). It survives a
2515
+ // narrow window: "total ↓" is dropped before it is (tokenRowWithPrice).
2516
+ return tokenRowWithPrice({
2517
+ withTotal: head + total,
2518
+ withoutTotal: head,
2519
+ price: lastTurnCost?.usd != null ? dim(" | ") + accent(fmtCost(lastTurnCost)) : "",
2520
+ room: (process.stdout.columns || 80) - 1 - visibleLen(GUTTER),
2521
+ });
2495
2522
  };
2496
2523
  // The newest RAG index file the agent reported writing (task #136), and where it lives.
2497
2524
  // Empty until something is written; only rendered under --verbose.
@@ -2612,6 +2639,14 @@ function syncBarPinning() {
2612
2639
  * update), a standing fact about the licence (the gu token notice — yellow only when urgent), and
2613
2640
  * other agents. Dropped whole, lowest first, when they do not all fit.
2614
2641
  */
2642
+ /** The notice's text (sync — zoneNotice runs on every paint): "new prices updated 1 Oct 2026". */
2643
+ function priceUpdateText() {
2644
+ const d = new Date(priceUpdate?.publishedAt ?? "");
2645
+ if (Number.isNaN(d.getTime())) return "new prices updated";
2646
+ const m = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getUTCMonth()];
2647
+ return `new prices updated ${d.getUTCDate()} ${m} ${d.getUTCFullYear()}`;
2648
+ }
2649
+
2615
2650
  function zoneNotice(width) {
2616
2651
  const items = [];
2617
2652
  if (scrolledBack()) {
@@ -2622,6 +2657,7 @@ function zoneNotice(width) {
2622
2657
  const up = updateRow({ ...availableUpdates, width: 400 });
2623
2658
  if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
2624
2659
  }
2660
+ if (priceUpdate) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
2625
2661
  if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2626
2662
  const agents = agentsIndicator(otherAgents);
2627
2663
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
@@ -2704,6 +2740,7 @@ function frozenZone() {
2704
2740
  const sep = status.length + (notice ? 1 : 0);
2705
2741
  updateRowTarget = notice?.spans.update ?? null;
2706
2742
  agentsRowTarget = notice?.spans.agents ?? null;
2743
+ pricesRowTarget = notice?.spans.prices ?? null;
2707
2744
  jumpSpan = notice?.spans.jump ?? null;
2708
2745
  return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2709
2746
  }
@@ -2724,11 +2761,39 @@ function alignOutputToLineStart() {
2724
2761
  function paintBar() {
2725
2762
  if (!barPinned()) return;
2726
2763
  const zone = frozenZone();
2727
- // The zone grew or shrank (the --verbose RAG row appearing) → re-cut the region before
2728
- // painting, or its top rows would land inside the scrolling area.
2729
- const regrew = zone.rows.length !== barReserved;
2764
+ // The zone grew or shrank (a picker, the notice row, the status block, a wrapped input) →
2765
+ // re-cut the region before painting, or its top rows would land inside the scrolling area.
2766
+ const prev = barReserved;
2767
+ const regrew = zone.rows.length !== prev;
2768
+ if (regrew) { const v = viewportSlice(screenRows(), 0, viewportHeight()).rows; __dbg("MODEL AT REGROW", v.map((r) => r.replace(/\x1b\[[0-9;]*m/g, "").slice(0, 50))); }
2769
+ if (regrew) __dbg("ANY regrow", { prev, now: zone.rows.length, scrolledBack: scrolledBack(), rows: displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length, pad: padRows, vp: viewportRows() });
2730
2770
  if (regrew) {
2771
+ // #321: MOVE THE SCREEN LIKE A TERMINAL, never repaint it from the transcript — a repaint put
2772
+ // lines the terminal had already scrolled away back on screen, and the next output scrolled
2773
+ // them into the scrollback AGAIN (the banner and the update notice repeated after an update;
2774
+ // "auto-test off" doubled). See zoneResizePlan.
2775
+ const live = prev > 0 && !scrolledBack();
2776
+ let plan = null;
2777
+ let rows = 0;
2778
+ if (live) {
2779
+ rows = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length;
2780
+ plan = zoneResizePlan({ contentRows: rows, pad: currentPad(rows), oldHeight: viewportRows() - prev, newHeight: viewportRows() - zone.rows.length });
2781
+ // The rows the zone takes that still hold text: scroll them up and out — real newlines at
2782
+ // the OLD region's bottom, so the terminal files them in its scrollback once — and move the
2783
+ // saved output position up with the text it belongs to.
2784
+ if (plan.scrollUp > 0) {
2785
+ ui(`\x1b[${viewportRows() - prev};1H` + "\n".repeat(plan.scrollUp) + OUT_RESTORE + `\x1b[${plan.scrollUp}A` + OUT_SAVE);
2786
+ }
2787
+ }
2731
2788
  ui(scrollRegionSeq(viewportRows(), zone.rows.length, { preserveCaret: false }));
2789
+ if (live && plan.clearRows > 0) {
2790
+ // The rows the zone handed back still show its old rows — blank them, and remember them.
2791
+ let clear = "";
2792
+ for (let i = 0; i < plan.clearRows; i++) clear += `\x1b[${viewportRows() - prev + 1 + i};1H\x1b[2K`;
2793
+ ui(clear);
2794
+ }
2795
+ if (live) setPad(plan.pad, rows);
2796
+ __dbg("regrow", { prev, now: zone.rows.length, rows, plan, live });
2732
2797
  barReserved = zone.rows.length;
2733
2798
  }
2734
2799
  const top = viewportRows() - zone.rows.length + 1;
@@ -2748,19 +2813,17 @@ function paintBar() {
2748
2813
  // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2749
2814
  const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2750
2815
  jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2751
- updateRowAbs = updateRowTarget || agentsRowTarget ? noticeAbs : 0;
2816
+ updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
2752
2817
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2753
2818
  // is parked on the input line right after, so there is nothing to preserve here.
2754
2819
  ui(
2755
2820
  paintFrozenRowsSeq(zone.rows, viewportRows(), { preserveCaret: false }) +
2756
2821
  parkCaretSeq(zone.caretRow, zone.caretCol)
2757
2822
  );
2758
- // THE ZONE CHANGED HEIGHT — the line wrapped onto a new row, or unwrapped off one. Re-cutting
2759
- // the region does not move what is already on screen: growing freezes a row that still holds
2760
- // transcript text (and we have just painted over it), shrinking hands back a row that is
2761
- // blank. Repaint the view from the transcript so neither shows. Recurses exactly once —
2762
- // paintLiveTail ends in paintBar, by which point the count matches and this is skipped.
2763
- if (regrew && !scrolledBack()) paintLiveTail();
2823
+ // THE FIRST PIN only: before it, the screen is whatever setup printed, and one repaint puts the
2824
+ // view in gu's hands. Every later height change moved the screen above (#321) — a repaint here
2825
+ // is what filled the scrollback with copies. Recurses at most once (paintLiveTail → paintBar).
2826
+ if (regrew && !scrolledBack() && prev === 0) paintLiveTail();
2764
2827
  }
2765
2828
  /** Bytes that put the caret back on text row `row` (0-based) at 1-based `col`. */
2766
2829
  const parkCaretSeq = (row, col) => `\x1b[${inputRowAbs + Math.max(0, row | 0)};${col}H`;
@@ -2911,6 +2974,8 @@ let updateRowTarget = null;
2911
2974
  let updateRowAbs = 0;
2912
2975
  // …and the agents indicator, which shares that row when no update is pending (task #195).
2913
2976
  let agentsRowTarget = null;
2977
+ // …and "new prices updated <date>" (#323).
2978
+ let pricesRowTarget = null;
2914
2979
  // A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
2915
2980
  // bracketed-paste and mouse-reporting teardowns.
2916
2981
  process.on("exit", () => {
@@ -2927,11 +2992,25 @@ process.on("exit", () => {
2927
2992
  // the same space displayRows produces, so the highlight stays on the text it was drawn over
2928
2993
  // when the view scrolls rather than on whatever moved under that screen row.
2929
2994
  let liveSelection = null;
2930
- const screenRows = () =>
2931
- applySelectionHighlight(
2932
- displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1),
2933
- liveSelection
2934
- );
2995
+ /**
2996
+ * BLANK ROWS BELOW THE CONTENT at the live tail (#321): when the frozen zone shrinks, the rows it
2997
+ * hands back stay blank (nothing older is repainted into the view — those lines are already in the
2998
+ * terminal's scrollback), and new output fills them first. Kept as a count + the transcript's row
2999
+ * count when it was set, so the per-write path costs nothing: the pad left is what output since has
3000
+ * not consumed.
3001
+ */
3002
+ let padRows = 0;
3003
+ let padAnchor = 0;
3004
+ const currentPad = (rowCount) => (padRows > 0 ? Math.max(0, padRows - Math.max(0, rowCount - padAnchor)) : 0);
3005
+ function setPad(n, rowCount) {
3006
+ padRows = Math.max(0, n | 0);
3007
+ padAnchor = rowCount;
3008
+ }
3009
+ const screenRows = () => {
3010
+ const rows = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1);
3011
+ const pad = currentPad(rows.length);
3012
+ return applySelectionHighlight(pad ? [...rows, ...Array(pad).fill("")] : rows, liveSelection);
3013
+ };
2935
3014
  /**
2936
3015
  * Abandon any drag in progress and take its highlight off screen (task #177).
2937
3016
  *
@@ -3769,6 +3848,9 @@ function stopPendingStatus() {
3769
3848
  // Already-printed scrollback (bullets, answers, wrapBlock output) is deliberately NOT touched:
3770
3849
  // it belongs to the terminal now, and the terminal reflows it.
3771
3850
  function onWindowResize() {
3851
+ // #321: the blank rows below the content were counted in rows of the OLD width; the resize
3852
+ // repaint lays the view out afresh.
3853
+ padRows = 0;
3772
3854
  // Whatever owns the screen, readline's next refresh must not walk up a stale row count —
3773
3855
  // at the prompt this is the fix itself; elsewhere it stops a later rl.prompt() from erasing
3774
3856
  // rows above the input.
@@ -3981,6 +4063,9 @@ if (process.stdin.isTTY) {
3981
4063
  // re-rendered the slash overlay (the line still starts with "/" from before the paste) and
3982
4064
  // re-walked the paste store. Same for a mouse report's characters, which are not keys.
3983
4065
  if (pasteBuffer !== null || inMouseKeyWindow()) return;
4066
+ // #247: the restored question was erased (Backspace, Ctrl+U, a picker taking the line) — it
4067
+ // is let go, exactly as with Ctrl+C, so the next message does not REPLACE it in history.
4068
+ if (prefillFor && (listPickerActive || restoredQuestionLetGo(rl.line, key))) prefillFor = null;
3984
4069
  // An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
3985
4070
  if (listPickerActive) {
3986
4071
  onListKey(key);
@@ -6823,17 +6908,6 @@ function withTerminalHandedOver(run) {
6823
6908
  }
6824
6909
  }
6825
6910
 
6826
- /**
6827
- * /save-preset <name> — THE PRESET IN AN EDITOR (#312). Opens $VISUAL / $EDITOR / nano / vi
6828
- * (Notepad on Windows) on a temp file filled in with the preset being edited or the current
6829
- * setup; save and quit stores it, quit without a change cancels. A wrong value is named and the
6830
- * editor reopens with the person's OWN text, never the template again.
6831
- *
6832
- * /save-preset <name> <json> — the one-line form (#6), kept for scripts and for pasting the
6833
- * command /load-presets copies. Both validate everything before a byte is written. A preset SAVES
6834
- * the API key by design (unlike /save-settings) — stored 0600 in the home folder, marked
6835
- * "gu": "preset" so the two commands never offer each other's files.
6836
- */
6837
6911
  /**
6838
6912
  * THIS ACCOUNT'S WHOLE SETUP as a version-2 preset (#320): every coding server and its models, the
6839
6913
  * default coder, the agent, the embedder — and each server's API key, read through the local API the
@@ -6876,129 +6950,163 @@ async function fetchKeyForBackend(backendId) {
6876
6950
  }
6877
6951
  }
6878
6952
 
6953
+ /**
6954
+ * /save-preset [name] (#322) — PASTE A PRESET AND NAME IT. Opens an empty editor (the // header says
6955
+ * how to save in THAT editor); the person pastes the JSON another computer's /open-presets showed,
6956
+ * saves and quits. Checked in full, then stored 0600 under the name — NOTHING is applied (that is
6957
+ * /use-presets). `/save-preset <name> {json}` is the one-line form, for scripts.
6958
+ */
6879
6959
  async function runSavePresetCommand(line) {
6880
6960
  const pr = await import("./presets-transfer.mjs");
6881
- const home = homedir();
6961
+ const ed = await import("./preset-editor.mjs");
6882
6962
  console.log("");
6883
- const usage = "usage: /save-preset <name> (saves your whole setup) · /save-preset <name> --edit (in your editor) · /save-preset <name> {json}";
6884
6963
  const raw = line.trim().slice("/save-preset".length).trim();
6885
- const wantsEdit = /(^|\s)--edit(?=\s|$)/.test(raw);
6886
- let rest = raw.replace(/(^|\s)--edit(?=\s|$)/, " ").trim();
6887
- if (!rest) {
6888
- const typed = String((await ask("preset name: ")) ?? "").trim();
6889
- if (!typed) {
6890
- console.log(GUTTER + dim("cancelled — nothing saved.") + "\n");
6964
+ const space = raw.search(/\s/);
6965
+ const given = space === -1 ? raw : raw.slice(0, space);
6966
+ const json = space === -1 ? "" : raw.slice(space + 1).trim();
6967
+ if (given && !pr.isPresetName(given)) {
6968
+ console.log(GUTTER + `"${given}" is not a preset name — one word, no slashes. usage: /save-preset [name]` + "\n");
6969
+ return;
6970
+ }
6971
+ try {
6972
+ let parsed = null;
6973
+ if (json) {
6974
+ parsed = ed.parsePastedPreset(json);
6975
+ if (!parsed.ok) {
6976
+ console.log(GUTTER + `Preset not saved — ${parsed.problem}` + "\n");
6977
+ return;
6978
+ }
6979
+ } else {
6980
+ parsed = await pastePresetInEditor(ed);
6981
+ if (!parsed) {
6982
+ console.log(GUTTER + dim("Not saved.") + "\n");
6983
+ return;
6984
+ }
6985
+ }
6986
+ const name = given || (await askPresetName(pr, parsed.name || ""));
6987
+ if (!name) {
6988
+ console.log(GUTTER + dim("Not saved.") + "\n");
6891
6989
  return;
6892
6990
  }
6893
- rest = typed;
6894
- }
6895
- const space = rest.search(/\s/);
6896
- const name = space === -1 ? rest : rest.slice(0, space);
6897
- const json = space === -1 ? "" : rest.slice(space + 1).trim();
6898
- if (!pr.isPresetName(name)) {
6899
- console.log(GUTTER + `"${name}" is not a preset name — one word, no slashes. ` + usage);
6900
- console.log("");
6901
- return;
6991
+ const file = pr.presetFilePath(name, homedir());
6992
+ if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it?`, false))) {
6993
+ console.log(GUTTER + dim("Not saved.") + "\n");
6994
+ return;
6995
+ }
6996
+ writePresetFile(pr, file, name, parsed);
6997
+ console.log(GUTTER + dim(`/use-presets → ${name} applies it.`) + "\n");
6998
+ } catch (e) {
6999
+ if (wasPromptCancelled(e)) {
7000
+ console.log(GUTTER + dim("Not saved.") + "\n");
7001
+ return;
7002
+ }
7003
+ throw e;
6902
7004
  }
6903
- const file = pr.presetFilePath(name, home);
6904
- const store = (preset) => {
6905
- writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
6906
- console.log(GUTTER + `Preset "${name}" saved to ${file}`);
6907
- console.log(GUTTER + dim("server + api key + agent, coding and embedding models — the key is IN this file, keep it private."));
6908
- console.log("");
6909
- };
6910
- // A WHOLE-SETUP preset (#320): written as given (dated, named), then summarised — keys masked.
6911
- const storeFull = (doc) => {
6912
- const out = { ...doc, gu: "preset", version: pr.FULL_PRESET_VERSION, name, savedAt: new Date().toISOString(), savedOn: hostname(),
6913
- note: "The whole setup: every coding server and its models, the default coder, the agent and the embedder — and each server's API key. This file CONTAINS live API keys." };
7005
+ }
7006
+
7007
+ /** Store a checked preset (`parsed` from parsePastedPreset/parseEdited) — 0600, named, dated. */
7008
+ function writePresetFile(pr, file, name, parsed) {
7009
+ if (parsed.full) {
7010
+ const { note: _n, savedAt: _a, savedOn: _o, ...body } = parsed.doc;
7011
+ const out = { ...body, gu: "preset", version: pr.FULL_PRESET_VERSION, name, savedAt: new Date().toISOString(), savedOn: hostname() };
6914
7012
  writeFileSync(file, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
6915
7013
  const v = pr.validatePresetFile(JSON.stringify(out));
6916
- console.log(GUTTER + `Preset "${name}" saved to ${file} — your whole setup:`);
7014
+ console.log(GUTTER + `Preset "${name}" saved to ${file}:`);
6917
7015
  if (v.ok && v.full) for (const l of pr.fullPresetSummary(v.value, v.codingDefault)) console.log(GUTTER + dim(l));
6918
- console.log(GUTTER + dim("It CONTAINS your API keys — keep it private. /open-presets → v to view it, c to copy it for another machine."));
6919
- console.log("");
6920
- };
6921
- const ed = await import("./preset-editor.mjs");
6922
- if (json) {
6923
- const checked = ed.parseEdited(json);
6924
- if (!checked.ok) {
6925
- console.log(GUTTER + `Preset not saved — ${checked.problem}`);
6926
- console.log("");
6927
- return;
6928
- }
6929
- if (checked.full) storeFull(checked.doc);
6930
- else store(checked.preset);
6931
- return;
7016
+ } else {
7017
+ writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset: parsed.preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
7018
+ console.log(GUTTER + `Preset "${name}" saved to ${file}`);
6932
7019
  }
7020
+ console.log(GUTTER + dim("It CONTAINS API keys — the file is readable only by you."));
7021
+ }
6933
7022
 
6934
- // THE WHOLE SETUP, NOW (#320): every server, its models and key, the defaults — saved without an
6935
- // editor, because "as it is now" is what a person saving their setup means. --edit opens it first.
6936
- if (!wantsEdit) {
6937
- if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it with the setup you have now?`, false))) {
6938
- console.log(GUTTER + dim("Not saved.") + "\n");
6939
- return;
6940
- }
6941
- const doc = await withPendingStatus("reading your servers, models and keys", () => buildFullPresetNow(name));
7023
+ /**
7024
+ * /open-presets [name] (#322).
7025
+ * · no name — YOUR SETUP NOW (every server, model, key, the default coder, agent, embedder),
7026
+ * READ-ONLY in the editor, to copy to another computer. Then it offers the clipboard too (OSC 52
7027
+ * reaches the person's own machine over SSH): a long setup scrolls past one screen of nano, and
7028
+ * selecting across a scroll with the mouse does not work.
7029
+ * · a name — that preset in the editor; a saved change is checked in full first and a broken one is
7030
+ * never stored ("Edit again?" reopens the person's own text).
7031
+ */
7032
+ async function runOpenPresetsCommand(line) {
7033
+ const pr = await import("./presets-transfer.mjs");
7034
+ const ed = await import("./preset-editor.mjs");
7035
+ console.log("");
7036
+ const name = line.trim().slice("/open-presets".length).trim();
7037
+ if (!name) {
7038
+ const doc = await withPendingStatus("reading your servers, models and keys", () => buildFullPresetNow("my-setup"));
6942
7039
  if (!doc) {
6943
- console.log(GUTTER + red(`couldn't read your settings from ${apiBase} — nothing was saved.`) + "\n");
7040
+ console.log(GUTTER + red(`couldn't read your settings from ${apiBase}.`) + "\n");
6944
7041
  return;
6945
7042
  }
6946
- storeFull(doc);
7043
+ const text = ed.currentSetupText(doc);
7044
+ const editor = ed.pickEditor({ readOnly: true });
7045
+ if (editor && process.stdin.isTTY) {
7046
+ console.log(GUTTER + dim(`Opening ${basename(editor.cmd)} read-only (${editor.saveHint}).`));
7047
+ const r = withTerminalHandedOver(() => ed.editText(text, { editor, fileName: "gu-setup.json" }));
7048
+ if (!r.ok) console.log(GUTTER + yellow(`couldn't show it — ${r.why}.`));
7049
+ } else {
7050
+ console.log(GUTTER + yellow(editor ? "an editor needs a terminal." : ed.NO_EDITOR_HINT + "."));
7051
+ }
7052
+ const v = pr.validatePresetFile(JSON.stringify(doc));
7053
+ if (v.ok && v.full) for (const l of pr.fullPresetSummary(v.value, v.codingDefault)) console.log(GUTTER + dim(l));
7054
+ if (await askYesNo("Copy it to the clipboard (keys included)?", true)) {
7055
+ const res = await writeClipboard(text.split("\n").filter((l) => !l.startsWith("//")).join("\n"));
7056
+ console.log(GUTTER + (res.ok ? accent("✓ copied — on the other computer: /save-preset, paste, save and quit.") : yellow(`couldn't copy (${res.why}).`)));
7057
+ }
7058
+ console.log("");
7059
+ return;
7060
+ }
7061
+ const file = pr.presetFilePath(name, homedir());
7062
+ if (!file) return; // a cancel word
7063
+ if (!existsSync(file)) {
7064
+ console.log(GUTTER + `no preset called "${name}" — /use-presets lists them.` + "\n");
7065
+ return;
7066
+ }
7067
+ let doc = null;
7068
+ try {
7069
+ doc = JSON.parse(readFileSync(file, "utf8"));
7070
+ } catch {
7071
+ /* named below */
7072
+ }
7073
+ if (doc?.gu !== "preset") {
7074
+ console.log(GUTTER + `${file} is not a gu preset.` + "\n");
6947
7075
  return;
6948
7076
  }
6949
-
6950
- // THE EDITOR. Filled in from the preset being edited, else from the whole setup this account has now.
6951
7077
  const editor = ed.pickEditor();
6952
7078
  if (!editor || !process.stdin.isTTY) {
6953
- console.log(GUTTER + (editor ? "an editor needs a terminal — " : "no editor found (set $EDITOR, e.g. export EDITOR=nano) — ") + usage);
6954
- console.log("");
7079
+ console.log(GUTTER + yellow(editor ? "an editor needs a terminal." : ed.NO_EDITOR_HINT + ".") + "\n");
6955
7080
  return;
6956
7081
  }
6957
- let start = null;
6958
- if (existsSync(file)) {
6959
- try {
6960
- const doc = JSON.parse(readFileSync(file, "utf8"));
6961
- if (doc?.gu === "preset") start = doc;
6962
- } catch {
6963
- /* unreadable — start from the current setup instead */
6964
- }
6965
- }
6966
- if (!start) start = (await buildFullPresetNow(name)) ?? ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
6967
- const initial = ed.editorText(name, start);
6968
- console.log(GUTTER + dim(`${start.gu ? "editing" : "new"} preset "${name}" in ${basename(editor.cmd)} — save and quit to store it, quit without changes to cancel.`));
7082
+ const initial = ed.editorText(name, doc);
7083
+ console.log(GUTTER + dim(`Opening "${name}" in ${basename(editor.cmd)} — save a change, or just quit.`));
6969
7084
  let text = initial;
6970
7085
  for (;;) {
6971
7086
  const r = withTerminalHandedOver(() => ed.editText(text, { editor, fileName: `${name}.json` }));
6972
7087
  if (!r.ok) {
6973
- console.log(GUTTER + `Preset not saved — ${r.why}.`);
6974
- console.log("");
7088
+ console.log(GUTTER + `Not changed — ${r.why}.` + "\n");
6975
7089
  return;
6976
7090
  }
6977
7091
  if (r.text === initial) {
6978
- console.log(GUTTER + dim("Not saved — nothing changed.") + "\n");
7092
+ console.log(GUTTER + dim("Not changed.") + "\n");
6979
7093
  return;
6980
7094
  }
6981
7095
  const parsed = ed.parseEdited(r.text);
6982
7096
  if (parsed.ok) {
6983
- if (parsed.full) storeFull(parsed.doc);
6984
- else store(parsed.preset);
7097
+ writePresetFile(pr, file, name, parsed);
7098
+ console.log("");
6985
7099
  return;
6986
7100
  }
6987
- console.log(GUTTER + yellow(`Not saved yet — ${parsed.problem}`));
7101
+ console.log(GUTTER + yellow(`Not saved — ${parsed.problem}`));
6988
7102
  if (!(await askYesNo("Edit again?", true))) {
6989
- console.log(GUTTER + dim("Not saved.") + "\n");
7103
+ console.log(GUTTER + dim("Not changed.") + "\n");
6990
7104
  return;
6991
7105
  }
6992
- text = r.text; // their text, with the mistake in it — not the template again
7106
+ text = r.text; // their text, with the mistake in it — not the saved preset again
6993
7107
  }
6994
7108
  }
6995
7109
 
6996
- /**
6997
- * /load-presets — list the preset files /save-preset wrote (newest first, the arrow-key picker)
6998
- * and apply the chosen one: the server + both models land as one coding-backend row, the preset's
6999
- * key is saved under the preset's name and made that row's default, and the embedder is set. The
7000
- * user reconfirms before anything changes, like /load-settings.
7001
- */
7002
7110
  /**
7003
7111
  * After Ctrl+C stopped THIS terminal's turn (task #302): say what it left behind, and give the
7004
7112
  * question back.
@@ -7109,55 +7217,27 @@ async function runUndoCommand(line) {
7109
7217
  }
7110
7218
 
7111
7219
  /**
7112
- * #317: a preset the person ALREADY HAS, brought in by pasting — its JSON, a whole preset file, or
7113
- * the `/save-preset <name> {…}` command copied from another computer (#312). A multi-line paste
7114
- * arrives as "[Pasted text #N +M lines]" and is expanded before it is read, so the key is never
7115
- * echoed. A mistake is named; Enter then opens it IN THE EDITOR with the pasted text (not a blank
7116
- * template), and Enter on nothing opens the editor on the current setup. Esc cancels.
7117
- * Saved 0600 like /save-preset. → the file, or "" when cancelled.
7220
+ * The pasting half of /save-preset (#317, #322): an EMPTY editor with the // header (the person's
7221
+ * terminal editor over SSH — nano on a fresh Ubuntu server), or, with no editor or no terminal, a
7222
+ * one-line prompt. → the parsed preset, or null when cancelled.
7118
7223
  */
7119
- async function presetFromPaste(pr, home) {
7120
- const ed = await import("./preset-editor.mjs");
7224
+ async function pastePresetInEditor(ed) {
7121
7225
  const editor = ed.pickEditor();
7122
- let parsed = null;
7123
- try {
7124
- // AN EDITOR FIRST (#322): a preset is many lines — on an Ubuntu server over SSH, nano is the
7125
- // natural place to paste it. The one-line prompt is only the fallback with no editor/terminal.
7126
- if (editor && process.stdin.isTTY) {
7127
- // One short line (#322): the save keys are in the file's own header, where they are needed.
7128
- console.log(GUTTER + dim(`Opening ${basename(editor.cmd)} — paste your preset there, then save and quit.`));
7129
- parsed = await fixPresetInEditor(ed, ed.pasteTemplate(editor.saveHint), { editor });
7130
- if (!parsed) return "";
7131
- } else {
7132
- if (!editor) console.log(GUTTER + yellow(ed.NO_EDITOR_HINT) + dim(" — or paste it here, on one line."));
7133
- console.log(GUTTER + "Paste a preset you already have — its JSON, a preset file, or a /save-preset command.");
7134
- console.log(GUTTER + dim("Esc cancels."));
7135
- while (!parsed) {
7136
- const text = String((await ask("preset: ")) ?? "");
7137
- if (!text.trim()) return "";
7138
- const p = ed.parsePastedPreset(text);
7139
- if (p.ok) {
7140
- parsed = p;
7141
- break;
7142
- }
7143
- console.log(GUTTER + yellow(`Not a preset yet — ${p.problem}`));
7144
- console.log(GUTTER + dim("Paste it again, or Esc to cancel."));
7145
- }
7146
- }
7147
- const name = await askPresetName(pr, parsed.name || "preset");
7148
- if (!name) return "";
7149
- const file = pr.presetFilePath(name, home);
7150
- if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it?`, false))) return "";
7151
- // A whole-setup preset (#320) is kept as it is — every server, key and default — named and dated.
7152
- const out = parsed.full
7153
- ? { ...parsed.doc, gu: "preset", version: pr.FULL_PRESET_VERSION, name, savedAt: new Date().toISOString(), savedOn: hostname() }
7154
- : pr.buildPreset({ name, preset: parsed.preset, host: hostname() });
7155
- writeFileSync(file, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
7156
- console.log(GUTTER + `Preset "${name}" saved to ${file}`);
7157
- return file;
7158
- } catch (e) {
7159
- if (wasPromptCancelled(e)) return "";
7160
- throw e;
7226
+ if (editor && process.stdin.isTTY) {
7227
+ // One short line: the save keys are in the file's own header, where they are needed.
7228
+ console.log(GUTTER + dim(`Opening ${basename(editor.cmd)} — paste your preset there, then save and quit.`));
7229
+ return fixPresetInEditor(ed, ed.pasteTemplate(editor.saveHint), { editor });
7230
+ }
7231
+ if (!editor) console.log(GUTTER + yellow(ed.NO_EDITOR_HINT) + dim(" — or paste it here, on one line."));
7232
+ console.log(GUTTER + "Paste the preset — its JSON, a preset file, or a /save-preset line.");
7233
+ console.log(GUTTER + dim("Esc cancels."));
7234
+ for (;;) {
7235
+ const text = String((await ask("preset: ")) ?? "");
7236
+ if (!text.trim()) return null;
7237
+ const p = ed.parsePastedPreset(text);
7238
+ if (p.ok) return p;
7239
+ console.log(GUTTER + yellow(`Not a preset yet — ${p.problem}`));
7240
+ console.log(GUTTER + dim("Paste it again, or Esc to cancel."));
7161
7241
  }
7162
7242
  }
7163
7243
 
@@ -7197,98 +7277,43 @@ async function askPresetName(pr, dflt) {
7197
7277
  }
7198
7278
  }
7199
7279
 
7200
- async function runLoadPresetsCommand(line) {
7280
+ /**
7281
+ * /use-presets [name] (#322) — the saved presets, newest first: Enter APPLIES the chosen one (the plan
7282
+ * with keys masked, a backup of the current setup, then "apply? [y/N]" — preset-apply.mjs), o opens it
7283
+ * in the editor (/open-presets <name>). Presets come in through /save-preset.
7284
+ */
7285
+ async function runUsePresetsCommand(line) {
7201
7286
  const pr = await import("./presets-transfer.mjs");
7202
7287
  const home = homedir();
7203
7288
  console.log("");
7204
7289
  let file = "";
7205
- // /open-presets (#322), or its old name /load-presets — the printed undo lines of older backups use it.
7206
- const typed = line.trim().replace(/^\/(open|load)-presets\b/, "").trim();
7290
+ const typed = line.trim().slice("/use-presets".length).trim();
7207
7291
  if (typed) {
7208
7292
  file = pr.presetFilePath(typed, home);
7209
7293
  if (!file) return; // a cancel word
7210
7294
  if (!existsSync(file)) {
7211
- console.log(GUTTER + `no preset file at ${file}`);
7212
- console.log("");
7295
+ console.log(GUTTER + `no preset called "${typed}" — /use-presets lists them.` + "\n");
7213
7296
  return;
7214
7297
  }
7215
7298
  } else {
7216
- // THE PICKER DOES MORE THAN APPLY (#312): v shows the whole preset (key masked) with a
7217
- // clickable "⧉ copy", c / j copy it as the /save-preset command or as JSON (key included), e
7218
- // opens it in the editor. Only Enter applies. Each of the others ENDS the command: the mouse
7219
- // is gu's only at the ">> " prompt (never while a picker is up), so a "⧉ copy" printed under a
7220
- // picker that stayed open could not be clicked until it closed.
7221
7299
  const ed = await import("./preset-editor.mjs");
7222
7300
  const found = pr.presetFilesIn(listJsonFiles(home));
7223
- // NO DEAD END (#317: "I need a place to input a preset I have already"): with nothing saved,
7224
- // the command asks for one right here; with presets saved, the picker's LAST row does (last, so
7225
- // Enter still applies the newest preset, as it always has).
7226
7301
  if (found.length === 0) {
7227
7302
  console.log(GUTTER + "No presets saved yet.");
7228
- file = await presetFromPaste(pr, home);
7229
- if (!file) {
7230
- console.log(GUTTER + "load cancelled.");
7231
- console.log("");
7232
- return;
7233
- }
7234
- } else {
7235
- console.log(dim(` Presets in ${home === homedir() ? "~" : home} (${ed.PICKER_HINT}):`));
7236
- const PASTE_ROW = "+ Paste a preset I already have…";
7237
- const pick = await pickFromList([...found, PASTE_ROW], 0, { keys: ed.PICKER_KEYS });
7238
- if (!pick || pick.index < 0) {
7239
- console.log(GUTTER + "load cancelled.");
7240
- console.log("");
7241
- return;
7242
- }
7243
- if (pick.index === found.length) {
7244
- file = await presetFromPaste(pr, home);
7245
- if (!file) {
7246
- console.log(GUTTER + "load cancelled.");
7247
- console.log("");
7248
- return;
7249
- }
7250
- pick.action = "select"; // a pasted preset goes on to be applied, whatever key opened the row
7251
- } else {
7252
- file = pr.presetFilePath(found[pick.index], home);
7253
- }
7254
- if (pick.action === "edit") {
7255
- await runSavePresetCommand(`/save-preset ${found[pick.index]}`);
7256
- return;
7257
- }
7258
- if (pick.action !== "select") {
7259
- const shown = pr.validatePresetFile(readFileSync(file, "utf8"));
7260
- if (!shown.ok) {
7261
- console.log(GUTTER + `Can't show it — ${shown.problem}`);
7262
- console.log("");
7263
- return;
7264
- }
7265
- const pname = shown.name || found[pick.index];
7266
- // A whole-setup preset (#320) is shown and copied AS SAVED — every server, every key.
7267
- const body = shown.full ? JSON.parse(readFileSync(file, "utf8")) : shown.preset;
7268
- const command = ed.presetCopyCommand(pname, body);
7269
- if (pick.action === "view") {
7270
- const block = ed.presetBlock(pname, body, { width: process.stdout.columns || 80, gutter: GUTTER });
7271
- const [top, ...restRows] = block.rows;
7272
- const cut = block.button.from;
7273
- console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
7274
- for (const r of restRows) console.log(r);
7275
- registerCopyBlock(block.rows.length, command, 0, { button: block.button, note: "✓ copied — includes the API key" });
7276
- console.log(GUTTER + dim("click ⧉ copy for a /save-preset command to paste anywhere — it includes the real key."));
7277
- console.log(GUTTER + dim(`/open-presets ${pname} applies it.`));
7278
- console.log("");
7279
- return;
7280
- }
7281
- const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(body) : command);
7282
- console.log(
7283
- GUTTER +
7284
- (res.ok
7285
- ? accent(`✓ copied ${pick.action === "json" ? "the JSON" : "the /save-preset command"} for "${pname}" — includes the API key.`)
7286
- : yellow(`couldn't copy (${res.why}) — the preset is in ${file}.`))
7287
- );
7288
- console.log("");
7289
- return;
7290
- }
7303
+ console.log(GUTTER + dim("/save-preset pastes one in — copy it from another computer's /open-presets.") + "\n");
7304
+ return;
7305
+ }
7306
+ console.log(dim(` Presets (${ed.PICKER_HINT}):`));
7307
+ const pick = await pickFromList(found, 0, { keys: ed.PICKER_KEYS });
7308
+ if (!pick || pick.index < 0) {
7309
+ console.log(GUTTER + dim("nothing changed.") + "\n");
7310
+ return;
7311
+ }
7312
+ if (pick.action === "open") {
7313
+ await runOpenPresetsCommand(`/open-presets ${found[pick.index]}`);
7314
+ return;
7291
7315
  }
7316
+ file = pr.presetFilePath(found[pick.index], home);
7292
7317
  }
7293
7318
  const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
7294
7319
  if (checked.ok && checked.full) {
@@ -7531,6 +7556,195 @@ async function fetchWorkerSettings() {
7531
7556
  return null; // offline — callers fall back to showing what they know
7532
7557
  }
7533
7558
  }
7559
+
7560
+ // ---------------------------------------------------------------------------------------------
7561
+ // WHAT A TURN COST (#323): prices from the account's own Mongo (seeded once from the table gu
7562
+ // ships), the turn's cached share from the provider when it reports one — else 90% of the later
7563
+ // steps assumed — and the result on the token row ("· ~$0.334") and in /price.
7564
+ // ---------------------------------------------------------------------------------------------
7565
+ let priceRows = null; // null = not loaded yet; [] = none (and seeding failed)
7566
+ let priceVersion = "";
7567
+ let pricePublishedAt = ""; // when gu published the tables this account last applied ("" = none)
7568
+ let lastTurnCost = null; // { usd, estimated, cachedTokens, input, model, host }
7569
+ let sessionCostUsd = 0;
7570
+ let sessionCostEstimated = false;
7571
+ let sessionCostTurns = 0;
7572
+
7573
+ async function priceApi(method, body) {
7574
+ try {
7575
+ const r = await fetch(`${apiBase}/api/worker/model-prices`, {
7576
+ method,
7577
+ headers: { "X-Worker-Key": workerKey, ...(body ? { "Content-Type": "application/json" } : {}) },
7578
+ ...(body ? { body: JSON.stringify(body) } : {}),
7579
+ signal: AbortSignal.timeout(8000),
7580
+ });
7581
+ const j = await r.json().catch(() => null);
7582
+ return r.ok ? j : { error: j?.error || `HTTP ${r.status}` };
7583
+ } catch (e) {
7584
+ return { error: e?.message ?? String(e) };
7585
+ }
7586
+ }
7587
+
7588
+ /**
7589
+ * The account's prices. An EMPTY table is seeded once with the one gu ships (an upsert — running it
7590
+ * twice changes nothing). An API without the route (an older local API) → [] and no cost shown.
7591
+ */
7592
+ async function loadPrices({ force = false } = {}) {
7593
+ if (priceRows && !force) return priceRows;
7594
+ const mp = await import("./model-prices.mjs");
7595
+ let got = await priceApi("GET");
7596
+ if (got && !got.error && Array.isArray(got.rows) && got.rows.length === 0) {
7597
+ const seeded = await priceApi("PUT", { version: mp.PRICE_TABLE.version, rows: mp.PRICE_TABLE.prices, removes: [] });
7598
+ if (!seeded?.error) got = await priceApi("GET");
7599
+ }
7600
+ priceRows = got && !got.error && Array.isArray(got.rows) ? got.rows : [];
7601
+ priceVersion = typeof got?.version === "string" ? got.version : "";
7602
+ pricePublishedAt = typeof got?.publishedAt === "string" ? got.publishedAt : "";
7603
+ return priceRows;
7604
+ }
7605
+
7606
+ /** The coder model id in use — the folder's /model pick ("<kind>::<model>") or the account default. */
7607
+ function activeCodingModelId() {
7608
+ const m = String(sessionCodingModel || "");
7609
+ return m.includes("::") ? m.slice(m.indexOf("::") + 2) : m || defaultCodingModel;
7610
+ }
7611
+
7612
+ /** After a turn: what its prompt tokens cost, added to the session total. Never throws. */
7613
+ async function noteTurnCost(inputTokens, breakdown, coder = null) {
7614
+ try {
7615
+ lastTurnCost = null;
7616
+ if (!(inputTokens > 0)) return;
7617
+ const mp = await import("./model-prices.mjs");
7618
+ const rows = await loadPrices();
7619
+ const url = coder?.url ?? activeCodingUrl();
7620
+ const model = coder?.model ?? activeCodingModelId();
7621
+ const price = mp.findPrice(rows, url, model);
7622
+ const cache = readTurnCache(breakdown) ?? { cached: 0, cacheReported: false, firstIn: 0 };
7623
+ const c = mp.turnCost({ input: inputTokens, ...cache, price });
7624
+ if (!c) {
7625
+ lastTurnCost = { usd: null, model, host: mp.hostOf(url) };
7626
+ return;
7627
+ }
7628
+ lastTurnCost = { ...c, input: inputTokens, cacheReported: cache.cacheReported, model, host: mp.hostOf(url) };
7629
+ sessionCostUsd += c.usd;
7630
+ sessionCostEstimated ||= c.estimated;
7631
+ sessionCostTurns++;
7632
+ if (barPinned()) paintBar();
7633
+ } catch {
7634
+ /* a cost is never worth a broken prompt */
7635
+ }
7636
+ }
7637
+
7638
+ /**
7639
+ * /prices (#323) — the price table and the cache table this account's estimates use, the coder in
7640
+ * use marked; what the last turn and this session cost; and, when gu has published newer tables,
7641
+ * that they are waiting. `/prices update` applies them — the same as clicking the notice.
7642
+ */
7643
+ async function runPricesCommand(line = "/prices") {
7644
+ const mp = await import("./model-prices.mjs");
7645
+ console.log("");
7646
+ if (line.trim().slice("/prices".length).trim() === "update") {
7647
+ await runPricesUpdate();
7648
+ return;
7649
+ }
7650
+ const rows = await withPendingStatus("reading prices", () => loadPrices({ force: true }));
7651
+ if (!rows.length) {
7652
+ console.log(GUTTER + yellow("no prices — the local API is older than this gu, or unreachable.") + "\n");
7653
+ return;
7654
+ }
7655
+ const width = process.stdout.columns || 80;
7656
+ const url = activeCodingUrl();
7657
+ const model = activeCodingModelId();
7658
+ const paint = (l) => (l.startsWith("▸") ? accent(l) : /^[a-z0-9.-]+\.[a-z]+(:\d+)?( |$)/i.test(l) ? l : dim(l));
7659
+ console.log(GUTTER + "Prices");
7660
+ for (const l of mp.priceTableLines(rows, { width, current: { url, model }, version: priceVersion })) console.log(GUTTER + paint(l));
7661
+ console.log("");
7662
+ console.log(GUTTER + "Cache" + dim(" — the share of each step's prompt assumed cached, used only when a provider does not report its own count"));
7663
+ for (const l of mp.cacheShareLines({ yours: rows, width, current: { url, model } })) console.log(GUTTER + paint(l));
7664
+ console.log("");
7665
+ console.log(GUTTER + dim(pricePublishedAt ? `from gu's tables published ${new Date(pricePublishedAt).toLocaleString()}` : "gu's built-in tables (none published applied yet)"));
7666
+ if (priceUpdate) console.log(GUTTER + accent(`${mp.priceUpdateLabel(priceUpdate.publishedAt)} — click it below, or /prices update`));
7667
+ if (!mp.findPrice(rows, url, model)) {
7668
+ console.log(GUTTER + yellow(`no price for ${model || "the coder"} at ${mp.hostOf(url) || "its server"} — no cost is shown for its turns.`));
7669
+ }
7670
+ if (lastTurnCost?.usd != null) {
7671
+ const share = lastTurnCost.input ? Math.round((100 * lastTurnCost.cachedTokens) / lastTurnCost.input) : 0;
7672
+ console.log(GUTTER + `last turn: ${mp.formatUsd(lastTurnCost.usd, { estimated: lastTurnCost.estimated })}` +
7673
+ dim(` — ${lastTurnCost.input.toLocaleString("en-US")} prompt tokens, ${share}% cached ${lastTurnCost.cacheReported ? "(reported by the provider)" : "(assumed)"}`));
7674
+ }
7675
+ if (sessionCostTurns) console.log(GUTTER + `this session: ${mp.formatUsd(sessionCostUsd, { estimated: sessionCostEstimated })}` + dim(` over ${sessionCostTurns} turn${sessionCostTurns === 1 ? "" : "s"} · input only — output is not counted yet`));
7676
+ console.log("");
7677
+ }
7678
+
7679
+ // ---------------------------------------------------------------------------------------------
7680
+ // "NEW PRICES UPDATED <date>" (#323): the hosted API's newest published tables, checked at start
7681
+ // and hourly beside the version check; when newer than what this account applied, a clickable item
7682
+ // on the notice row. A click (or /prices update) writes both tables into the user's own Mongo.
7683
+ // ---------------------------------------------------------------------------------------------
7684
+ let priceUpdate = null; // { publishedAt, tables } while newer tables wait
7685
+ let priceUpdateBusy = false;
7686
+
7687
+ async function fetchPublishedTables() {
7688
+ const { DEFAULT_API_BASE } = await import("./device-login.mjs");
7689
+ const base = (String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE).replace(/\/+$/, "");
7690
+ try {
7691
+ const r = await fetch(`${base}/api/get-price-and-cache-table`, { signal: AbortSignal.timeout(8000) });
7692
+ if (!r.ok) return null;
7693
+ const j = await r.json().catch(() => null);
7694
+ return j && (j.prices || j.cacheShares) ? j : null;
7695
+ } catch {
7696
+ return null;
7697
+ }
7698
+ }
7699
+
7700
+ /** Are newer tables published? Fails silently to "no" — like the version check. */
7701
+ async function refreshPriceUpdate() {
7702
+ try {
7703
+ const mp = await import("./model-prices.mjs");
7704
+ const tables = await fetchPublishedTables();
7705
+ if (!tables) return;
7706
+ await loadPrices({ force: true });
7707
+ const was = Boolean(priceUpdate);
7708
+ priceUpdate = mp.hasNewerPublished(pricePublishedAt, tables.publishedAt) ? { publishedAt: tables.publishedAt, tables } : null;
7709
+ if (Boolean(priceUpdate) !== was && barPinned()) paintBar();
7710
+ } catch {
7711
+ /* a price notice is never worth an error */
7712
+ }
7713
+ }
7714
+
7715
+ /** Apply gu's newest published tables to this account's Mongo — the notice's click. */
7716
+ async function runPricesUpdate() {
7717
+ if (priceUpdateBusy) return;
7718
+ priceUpdateBusy = true;
7719
+ try {
7720
+ const mp = await import("./model-prices.mjs");
7721
+ const tables = (await fetchPublishedTables()) ?? priceUpdate?.tables ?? null;
7722
+ if (!tables) {
7723
+ console.log(GUTTER + yellow("couldn't reach gu's published prices — try again later.") + "\n");
7724
+ return;
7725
+ }
7726
+ const current = await loadPrices({ force: true });
7727
+ const m = mp.mergePublishedTables(current, tables);
7728
+ if (!m.ok) {
7729
+ console.log(GUTTER + yellow(`prices not updated — ${m.problem}`) + "\n");
7730
+ return;
7731
+ }
7732
+ const res = await priceApi("PUT", { version: m.version, publishedAt: m.publishedAt, rows: m.rows, removes: [] });
7733
+ if (res?.error) {
7734
+ console.log(GUTTER + red(`couldn't save the prices — ${res.error}. Nothing changed.`) + "\n");
7735
+ return;
7736
+ }
7737
+ await loadPrices({ force: true });
7738
+ priceUpdate = null;
7739
+ const d = m.diff;
7740
+ console.log(GUTTER + accent(`✓ ${mp.priceUpdateLabel(m.publishedAt).replace(/^new prices updated/, "prices updated to gu's tables of")}`) +
7741
+ dim(` — ${d.added.length} new · ${d.changed.length} changed · ${d.unchanged} unchanged. /prices shows them.`) + "\n");
7742
+ if (barPinned()) paintBar();
7743
+ } finally {
7744
+ priceUpdateBusy = false;
7745
+ }
7746
+ }
7747
+
7534
7748
  /**
7535
7749
  * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
7536
7750
  *
@@ -9513,9 +9727,12 @@ let listAllowDelete = false;
9513
9727
  // happened rather than encoding it into the index and hoping every caller decodes it.
9514
9728
  let listDeleteRequested = false;
9515
9729
  // Single letters that finish a single-select picker with an ACTION on the highlighted row, e.g.
9516
- // /load-presets' { v: "view", c: "copy" } (#312). Null unless a caller asks, like ctrl+d above.
9730
+ // /use-presets' { o: "open" } (#312, #322). Null unless a caller asks, like ctrl+d above.
9517
9731
  let listKeys = null;
9518
9732
  let listKeyAction = "";
9733
+ /** #321: the screen under a picker when it opened (content rows, pad, region height), and its tallest draw. */
9734
+ let listOpenedOver = null;
9735
+ let listMaxRows = 0;
9519
9736
  function drawList(redraw) {
9520
9737
  // Gutter (task #128 Phase 2): inset each row after its line-clear. Clipped to width-1 like
9521
9738
  // every other in-place block: a long item (a model id on a narrow terminal) that wrapped
@@ -9549,6 +9766,15 @@ function drawList(redraw) {
9549
9766
  if (listNote) rows.push(clipVisible(GUTTER + yellow(listNote), width - 1));
9550
9767
  // On a redraw the caret sits one row BELOW the block (each draw ends with "\n").
9551
9768
  const up = redraw ? physicalRows(listRowsDrawn, width) : 0;
9769
+ // #321: what the screen held under the picker when it opened, and how far it reaches — so the
9770
+ // wipe can account for anything it scrolled into the scrollback (blockClosedPad).
9771
+ if (!redraw && barPinned() && !scrolledBack()) {
9772
+ const content = displayRows(transcript.lines(), width - 1).length;
9773
+ listOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
9774
+ __dbg("picker open", listOpenedOver, { lastLines: transcript.lines().slice(-3) });
9775
+ listMaxRows = 0;
9776
+ }
9777
+ listMaxRows = Math.max(listMaxRows, physicalRows(rows, width));
9552
9778
  regionUi(
9553
9779
  (up ? `\x1b[${up}A` : "") +
9554
9780
  rows.map((r) => "\x1b[K" + r).join("\n") +
@@ -9563,6 +9789,15 @@ function finishList(index) {
9563
9789
  const width = Math.max(20, process.stdout.columns || 80);
9564
9790
  const up = physicalRows(listRowsDrawn, width); // ESC[0A would still move ONE row — guard it
9565
9791
  regionUi((up ? `\x1b[${up}A` : "") + "\x1b[J"); // wipe the list block
9792
+ // #321: the rows the picker scrolled away are in the scrollback; the content now sits that much
9793
+ // higher with blank rows under it — remember them, so nothing repaints those lines back on screen.
9794
+ if (listOpenedOver && barPinned() && !scrolledBack() && listOpenedOver.height === viewportHeight()) {
9795
+ setPad(blockClosedPad({ contentRows: listOpenedOver.content, pad: listOpenedOver.pad, height: listOpenedOver.height, blockRows: listMaxRows }), listOpenedOver.content);
9796
+ { const v = viewportSlice(screenRows(), 0, viewportHeight()).rows; __dbg("MODEL VIEW", v.map((r) => r.replace(/\x1b\[[0-9;]*m/g, "").slice(0, 60))); }
9797
+ __dbg("picker closed", { ...listOpenedOver, blockRows: listMaxRows, newPad: padRows, nowRows: displayRows(transcript.lines(), width - 1).length });
9798
+ }
9799
+ listOpenedOver = null;
9800
+ listMaxRows = 0;
9566
9801
  listRowsDrawn = [];
9567
9802
  listTop = 0;
9568
9803
  const r = listResolve;
@@ -12329,6 +12564,9 @@ async function main() {
12329
12564
  // reason gu refuses to exit.
12330
12565
  void refreshAvailableUpdates();
12331
12566
  setInterval(() => void refreshAvailableUpdates(), UPDATE_CHECK_MS).unref();
12567
+ // #323: gu's newest published price + cache tables — same cadence, same silent failure.
12568
+ void refreshPriceUpdate();
12569
+ setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
12332
12570
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
12333
12571
  startupTokenRefresh = refreshCliToken();
12334
12572
  setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
@@ -12457,13 +12695,13 @@ async function main() {
12457
12695
  }
12458
12696
 
12459
12697
  // #301: setup's "Load a saved preset" — the picker opens now that the terminal exists. The whole
12460
- // /load-presets flow (the list, the plan, the confirmation, the key) is that command's own.
12698
+ // /use-presets flow (the list, the plan, the confirmation, the key) is that command's own.
12461
12699
  if (openPresetsAtStart) {
12462
12700
  openPresetsAtStart = false;
12463
12701
  try {
12464
- await runLoadPresetsCommand("/open-presets");
12702
+ await runUsePresetsCommand("/use-presets");
12465
12703
  } catch (e) {
12466
- console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /open-presets)`));
12704
+ console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /use-presets)`));
12467
12705
  }
12468
12706
  }
12469
12707
 
@@ -12483,6 +12721,10 @@ async function main() {
12483
12721
  announceCliUpdate(); // deferred if a 6h refresh landed mid-turn; harmless once shown
12484
12722
  announceScaffold(); // the startup scaffold line, if it arrived while a turn was running
12485
12723
  rl.prompt();
12724
+ // #302, found live: prompt() puts the cursor at column 0, and a stopped turn's question is
12725
+ // already in the line — so what was typed went IN FRONT of it and Ctrl+U erased nothing.
12726
+ // The restored question is edited from its end, like a line just typed.
12727
+ if (prefillFor) rl.cursor = rl.line.length;
12486
12728
  paintBar();
12487
12729
  if (!barPinned()) probeCaretRow(); // fallback path still needs the caret's absolute row
12488
12730
  // Absorb blank Enter-presses SILENTLY — they never reach the agent. Lines typed while
@@ -12678,9 +12920,12 @@ async function main() {
12678
12920
  await runSavePresetCommand(line);
12679
12921
  continue;
12680
12922
  }
12681
- // #322: /open-presets, and its old name /load-presets (older backups print it in their undo line)
12682
- if (line === "/open-presets" || line.startsWith("/open-presets ") || line === "/load-presets" || line.startsWith("/load-presets ")) {
12683
- await runLoadPresetsCommand(line);
12923
+ if (line === "/open-presets" || line.startsWith("/open-presets ")) {
12924
+ await runOpenPresetsCommand(line);
12925
+ continue;
12926
+ }
12927
+ if (line === "/use-presets" || line.startsWith("/use-presets ")) {
12928
+ await runUsePresetsCommand(line);
12684
12929
  continue;
12685
12930
  }
12686
12931
  if (line === "/save-settings" || line.startsWith("/save-settings ")) {
@@ -12774,6 +13019,10 @@ async function main() {
12774
13019
  );
12775
13020
  continue;
12776
13021
  }
13022
+ if (line === "/prices" || line.startsWith("/prices ")) {
13023
+ await runPricesCommand(line);
13024
+ continue;
13025
+ }
12777
13026
  if (line === "/tokens") {
12778
13027
  // Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
12779
13028
  // this answers "and what displaced it", which is the part that tells you whether RAG is
@@ -12961,6 +13210,9 @@ async function main() {
12961
13210
  await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
12962
13211
  try {
12963
13212
  const turnStartMs = Date.now();
13213
+ // #323: priced by the coder this turn STARTS on — a /model switch afterwards must not put
13214
+ // another model's price on it.
13215
+ const turnCoder = { url: activeCodingUrl(), model: activeCodingModelId() };
12964
13216
  if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
12965
13217
  const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
12966
13218
  // NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
@@ -12983,6 +13235,8 @@ async function main() {
12983
13235
  lastTurnInputTokens = inputTokens ?? 0;
12984
13236
  lastTurnOutputTokens = turnOutputTokens ?? 0;
12985
13237
  lastTurnTotalOutput = outputTokens ?? 0;
13238
+ // #323: what this turn's prompt tokens cost — on the token row and in /price.
13239
+ void noteTurnCost(lastTurnInputTokens, lastPromptBreakdown, turnCoder);
12986
13240
  // A turn that FAILED carries the __TURN_FAILED__:: sentinel: the worker still delivers
12987
13241
  // the text (both UIs need to show it), but it is a failure notice, not a reply. Print
12988
13242
  // it as one and DROP it — persisting it would re-send "⚠️ your account is suspended" to