@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/client-expert-run.mjs +2 -2
- package/gu-cli.mjs +9 -3
- package/gu-repl.mjs +504 -250
- package/model-prices.mjs +484 -0
- package/package.json +2 -1
- package/preset-apply.mjs +2 -2
- package/preset-editor.mjs +90 -12
- package/presets-transfer.mjs +5 -5
- package/step-tokens.mjs +45 -0
- package/terminal-layout.mjs +14 -0
- package/terminal-viewport.mjs +65 -0
- package/turn-stop.mjs +17 -0
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 /
|
|
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
|
|
885
|
-
{ name: "/open-presets",
|
|
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"
|
|
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
|
-
|
|
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
|
|
2728
|
-
// painting, or its top rows would land inside the scrolling area.
|
|
2729
|
-
const
|
|
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
|
|
2759
|
-
//
|
|
2760
|
-
//
|
|
2761
|
-
|
|
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
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
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
|
|
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
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
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
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
console.log(GUTTER +
|
|
6900
|
-
|
|
6901
|
-
|
|
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
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
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}
|
|
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
|
-
|
|
6919
|
-
|
|
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
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
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}
|
|
7040
|
+
console.log(GUTTER + red(`couldn't read your settings from ${apiBase}.`) + "\n");
|
|
6944
7041
|
return;
|
|
6945
7042
|
}
|
|
6946
|
-
|
|
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
|
|
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
|
-
|
|
6958
|
-
|
|
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 + `
|
|
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
|
|
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
|
-
|
|
6984
|
-
|
|
7097
|
+
writePresetFile(pr, file, name, parsed);
|
|
7098
|
+
console.log("");
|
|
6985
7099
|
return;
|
|
6986
7100
|
}
|
|
6987
|
-
console.log(GUTTER + yellow(`Not saved
|
|
7101
|
+
console.log(GUTTER + yellow(`Not saved — ${parsed.problem}`));
|
|
6988
7102
|
if (!(await askYesNo("Edit again?", true))) {
|
|
6989
|
-
console.log(GUTTER + dim("Not
|
|
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
|
|
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
|
-
*
|
|
7113
|
-
*
|
|
7114
|
-
*
|
|
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
|
|
7120
|
-
const ed = await import("./preset-editor.mjs");
|
|
7224
|
+
async function pastePresetInEditor(ed) {
|
|
7121
7225
|
const editor = ed.pickEditor();
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
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
|
-
// /
|
|
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
|
-
// /
|
|
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
|
|
12702
|
+
await runUsePresetsCommand("/use-presets");
|
|
12465
12703
|
} catch (e) {
|
|
12466
|
-
console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /
|
|
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
|
-
|
|
12682
|
-
|
|
12683
|
-
|
|
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
|