@tiens.nguyen/gu-cli 1.0.783 → 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-cli.mjs +9 -3
- package/gu-repl.mjs +333 -23
- package/model-prices.mjs +484 -0
- package/package.json +2 -1
- 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-cli.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { execFile as execFileCallback, spawn } from "node:child_process";
|
|
|
20
20
|
import { createHash, randomUUID } from "node:crypto";
|
|
21
21
|
import { writeFileSync, unlinkSync } from "node:fs";
|
|
22
22
|
import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
|
|
23
|
-
import { NO_STEP, foldStepTokens, withStepTokens } from "./step-tokens.mjs";
|
|
23
|
+
import { NO_STEP, foldStepTokens, withStepTokens, NO_CACHE, foldTurnCache, withTurnCache } from "./step-tokens.mjs";
|
|
24
24
|
import { agentStoppedLine, jobEnded, jobStatusFrom409, stopGracefully } from "./agent-stop.mjs";
|
|
25
25
|
import { ragJobConfig } from "./rag-job-config.mjs";
|
|
26
26
|
import { codingKeyFallbackLine, isOllamaCloudUrl } from "./coding-key-prompt.mjs";
|
|
@@ -4138,6 +4138,9 @@ async function runAgentChatJob(job) {
|
|
|
4138
4138
|
// Task #298: the CURRENT model call's own ↑ in / ↓ out, for the thinking line (the bar
|
|
4139
4139
|
// already shows the totals). Rides on the breakdown — see step-tokens.mjs.
|
|
4140
4140
|
let stepTokens = NO_STEP;
|
|
4141
|
+
// #323: the turn's cached prompt tokens (when the provider reports them) and its first step's
|
|
4142
|
+
// prompt — what the terminal needs to say what the turn COST. Rides on the breakdown too.
|
|
4143
|
+
let turnCache = NO_CACHE;
|
|
4141
4144
|
// Raw model-stream tokens (event.type === "stream") are Python code + `Thought:`
|
|
4142
4145
|
// prose + <code>/<|"|> fragments. The web Thought panel renders reasoning as
|
|
4143
4146
|
// markdown, which turns `#`-prefixed code comments into <h1> headings and
|
|
@@ -4305,17 +4308,20 @@ async function runAgentChatJob(job) {
|
|
|
4305
4308
|
if (breakdown) latestPromptBreakdown = breakdown;
|
|
4306
4309
|
const prevStep = stepTokens;
|
|
4307
4310
|
stepTokens = foldStepTokens(stepTokens, event);
|
|
4311
|
+
const prevCache = turnCache;
|
|
4312
|
+
turnCache = foldTurnCache(turnCache, event);
|
|
4313
|
+
const cacheChanged = turnCache.cached !== prevCache.cached || turnCache.firstIn !== prevCache.firstIn || turnCache.cacheReported !== prevCache.cacheReported;
|
|
4308
4314
|
const stepChanged =
|
|
4309
4315
|
stepTokens.step !== prevStep.step ||
|
|
4310
4316
|
stepTokens.stepIn !== prevStep.stepIn ||
|
|
4311
4317
|
stepTokens.stepOut !== prevStep.stepOut;
|
|
4312
|
-
if (latestPromptBreakdown) latestPromptBreakdown = withStepTokens(latestPromptBreakdown, stepTokens);
|
|
4318
|
+
if (latestPromptBreakdown) latestPromptBreakdown = withTurnCache(withStepTokens(latestPromptBreakdown, stepTokens), turnCache);
|
|
4313
4319
|
if (
|
|
4314
4320
|
nextTotal !== latestCodeTokens ||
|
|
4315
4321
|
nextMax !== latestMaxCodeTokens ||
|
|
4316
4322
|
nextOut !== latestCodeOutputTokens ||
|
|
4317
4323
|
breakdown ||
|
|
4318
|
-
(stepChanged && latestPromptBreakdown)
|
|
4324
|
+
((stepChanged || cacheChanged) && latestPromptBreakdown)
|
|
4319
4325
|
) {
|
|
4320
4326
|
latestCodeTokens = nextTotal;
|
|
4321
4327
|
latestMaxCodeTokens = nextMax;
|
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));
|
|
@@ -886,6 +891,7 @@ const COMMANDS = [
|
|
|
886
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" },
|
|
887
892
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
888
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" },
|
|
889
895
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
890
896
|
// for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
|
|
891
897
|
{ name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
|
|
@@ -998,6 +1004,7 @@ const OUT_RESTORE = "\x1b8";
|
|
|
998
1004
|
const OUT_SAVE = "\x1b7";
|
|
999
1005
|
/** Draw in-place UI that lives in the SCROLLING area (status block, pickers, menus). */
|
|
1000
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)) });
|
|
1001
1008
|
if (!barPinned()) {
|
|
1002
1009
|
ui(seq);
|
|
1003
1010
|
return;
|
|
@@ -1450,6 +1457,11 @@ function applyClick({ row, col }) {
|
|
|
1450
1457
|
void runUpdate();
|
|
1451
1458
|
return;
|
|
1452
1459
|
}
|
|
1460
|
+
if (pricesRowTarget && row === updateRowAbs &&
|
|
1461
|
+
col - 1 >= pricesRowTarget.from && col - 1 < pricesRowTarget.to) {
|
|
1462
|
+
void runPricesUpdate();
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1453
1465
|
if (agentsRowTarget && row === updateRowAbs &&
|
|
1454
1466
|
col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
|
|
1455
1467
|
void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
|
|
@@ -1561,7 +1573,8 @@ const pasteStore = new Map(); // token -> the real text
|
|
|
1561
1573
|
// model, so a turn stopped with Ctrl+C can give the question back for editing.
|
|
1562
1574
|
let lastSubmittedInput = null;
|
|
1563
1575
|
// Task #302: set while the input holds a stopped turn's question; the next line sent from it
|
|
1564
|
-
// 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.
|
|
1565
1578
|
let prefillFor = null;
|
|
1566
1579
|
// Task #302: the backup run of the last stopped turn — what /undo reverts by default.
|
|
1567
1580
|
let lastStoppedRun = null;
|
|
@@ -2454,6 +2467,12 @@ const identityLineText = () =>
|
|
|
2454
2467
|
{ orange, cyan: accent, dim, red },
|
|
2455
2468
|
);
|
|
2456
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
|
+
|
|
2457
2476
|
const tokenLineText = () => {
|
|
2458
2477
|
// TWO different numbers, each labelled, because they are routinely off by 6×:
|
|
2459
2478
|
// ctx = the NEWEST request — what the model is holding right now, the one to compare
|
|
@@ -2468,7 +2487,7 @@ const tokenLineText = () => {
|
|
|
2468
2487
|
// a terminal — it was inline here, unasserted, and wrong for months. Both figures are TRUE
|
|
2469
2488
|
// measurements: nothing is netted against a floor any more (see that function's comment).
|
|
2470
2489
|
const { ctx, turn: sent } = tokenRowFigures({ ctxTrue, turnTrue: lastTurnInputTokens });
|
|
2471
|
-
|
|
2490
|
+
const head = (
|
|
2472
2491
|
// IDENTITY IS NOT ON THIS ROW ANY MORE — it has its own (identityLineText), because five
|
|
2473
2492
|
// measurements plus a coder and a folder name was ~110 columns and the clip falls on the
|
|
2474
2493
|
// RIGHT, so the totals were the part being cut on any normal window.
|
|
@@ -2488,11 +2507,18 @@ const tokenLineText = () => {
|
|
|
2488
2507
|
// `ctx` and `sent`, which do drive decisions, need more.
|
|
2489
2508
|
ragSegmentText() +
|
|
2490
2509
|
dim(" · ") +
|
|
2491
|
-
accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`)
|
|
2492
|
-
dim(" · ") +
|
|
2493
|
-
dim("total ") +
|
|
2494
|
-
accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`)
|
|
2510
|
+
accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`)
|
|
2495
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
|
+
});
|
|
2496
2522
|
};
|
|
2497
2523
|
// The newest RAG index file the agent reported writing (task #136), and where it lives.
|
|
2498
2524
|
// Empty until something is written; only rendered under --verbose.
|
|
@@ -2613,6 +2639,14 @@ function syncBarPinning() {
|
|
|
2613
2639
|
* update), a standing fact about the licence (the gu token notice — yellow only when urgent), and
|
|
2614
2640
|
* other agents. Dropped whole, lowest first, when they do not all fit.
|
|
2615
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
|
+
|
|
2616
2650
|
function zoneNotice(width) {
|
|
2617
2651
|
const items = [];
|
|
2618
2652
|
if (scrolledBack()) {
|
|
@@ -2623,6 +2657,7 @@ function zoneNotice(width) {
|
|
|
2623
2657
|
const up = updateRow({ ...availableUpdates, width: 400 });
|
|
2624
2658
|
if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
|
|
2625
2659
|
}
|
|
2660
|
+
if (priceUpdate) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
|
|
2626
2661
|
if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
|
|
2627
2662
|
const agents = agentsIndicator(otherAgents);
|
|
2628
2663
|
if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
|
|
@@ -2705,6 +2740,7 @@ function frozenZone() {
|
|
|
2705
2740
|
const sep = status.length + (notice ? 1 : 0);
|
|
2706
2741
|
updateRowTarget = notice?.spans.update ?? null;
|
|
2707
2742
|
agentsRowTarget = notice?.spans.agents ?? null;
|
|
2743
|
+
pricesRowTarget = notice?.spans.prices ?? null;
|
|
2708
2744
|
jumpSpan = notice?.spans.jump ?? null;
|
|
2709
2745
|
return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
|
|
2710
2746
|
}
|
|
@@ -2725,11 +2761,39 @@ function alignOutputToLineStart() {
|
|
|
2725
2761
|
function paintBar() {
|
|
2726
2762
|
if (!barPinned()) return;
|
|
2727
2763
|
const zone = frozenZone();
|
|
2728
|
-
// The zone grew or shrank (the
|
|
2729
|
-
// painting, or its top rows would land inside the scrolling area.
|
|
2730
|
-
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() });
|
|
2731
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
|
+
}
|
|
2732
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 });
|
|
2733
2797
|
barReserved = zone.rows.length;
|
|
2734
2798
|
}
|
|
2735
2799
|
const top = viewportRows() - zone.rows.length + 1;
|
|
@@ -2749,19 +2813,17 @@ function paintBar() {
|
|
|
2749
2813
|
// THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
|
|
2750
2814
|
const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
|
|
2751
2815
|
jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
|
|
2752
|
-
updateRowAbs = updateRowTarget || agentsRowTarget ? noticeAbs : 0;
|
|
2816
|
+
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
|
|
2753
2817
|
// preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
|
|
2754
2818
|
// is parked on the input line right after, so there is nothing to preserve here.
|
|
2755
2819
|
ui(
|
|
2756
2820
|
paintFrozenRowsSeq(zone.rows, viewportRows(), { preserveCaret: false }) +
|
|
2757
2821
|
parkCaretSeq(zone.caretRow, zone.caretCol)
|
|
2758
2822
|
);
|
|
2759
|
-
// THE
|
|
2760
|
-
//
|
|
2761
|
-
//
|
|
2762
|
-
|
|
2763
|
-
// paintLiveTail ends in paintBar, by which point the count matches and this is skipped.
|
|
2764
|
-
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();
|
|
2765
2827
|
}
|
|
2766
2828
|
/** Bytes that put the caret back on text row `row` (0-based) at 1-based `col`. */
|
|
2767
2829
|
const parkCaretSeq = (row, col) => `\x1b[${inputRowAbs + Math.max(0, row | 0)};${col}H`;
|
|
@@ -2912,6 +2974,8 @@ let updateRowTarget = null;
|
|
|
2912
2974
|
let updateRowAbs = 0;
|
|
2913
2975
|
// …and the agents indicator, which shares that row when no update is pending (task #195).
|
|
2914
2976
|
let agentsRowTarget = null;
|
|
2977
|
+
// …and "new prices updated <date>" (#323).
|
|
2978
|
+
let pricesRowTarget = null;
|
|
2915
2979
|
// A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
|
|
2916
2980
|
// bracketed-paste and mouse-reporting teardowns.
|
|
2917
2981
|
process.on("exit", () => {
|
|
@@ -2928,11 +2992,25 @@ process.on("exit", () => {
|
|
|
2928
2992
|
// the same space displayRows produces, so the highlight stays on the text it was drawn over
|
|
2929
2993
|
// when the view scrolls rather than on whatever moved under that screen row.
|
|
2930
2994
|
let liveSelection = null;
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
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
|
+
};
|
|
2936
3014
|
/**
|
|
2937
3015
|
* Abandon any drag in progress and take its highlight off screen (task #177).
|
|
2938
3016
|
*
|
|
@@ -3770,6 +3848,9 @@ function stopPendingStatus() {
|
|
|
3770
3848
|
// Already-printed scrollback (bullets, answers, wrapBlock output) is deliberately NOT touched:
|
|
3771
3849
|
// it belongs to the terminal now, and the terminal reflows it.
|
|
3772
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;
|
|
3773
3854
|
// Whatever owns the screen, readline's next refresh must not walk up a stale row count —
|
|
3774
3855
|
// at the prompt this is the fix itself; elsewhere it stops a later rl.prompt() from erasing
|
|
3775
3856
|
// rows above the input.
|
|
@@ -3982,6 +4063,9 @@ if (process.stdin.isTTY) {
|
|
|
3982
4063
|
// re-rendered the slash overlay (the line still starts with "/" from before the paste) and
|
|
3983
4064
|
// re-walked the paste store. Same for a mouse report's characters, which are not keys.
|
|
3984
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;
|
|
3985
4069
|
// An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
|
|
3986
4070
|
if (listPickerActive) {
|
|
3987
4071
|
onListKey(key);
|
|
@@ -7472,6 +7556,195 @@ async function fetchWorkerSettings() {
|
|
|
7472
7556
|
return null; // offline — callers fall back to showing what they know
|
|
7473
7557
|
}
|
|
7474
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
|
+
|
|
7475
7748
|
/**
|
|
7476
7749
|
* `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
|
|
7477
7750
|
*
|
|
@@ -9457,6 +9730,9 @@ let listDeleteRequested = false;
|
|
|
9457
9730
|
// /use-presets' { o: "open" } (#312, #322). Null unless a caller asks, like ctrl+d above.
|
|
9458
9731
|
let listKeys = null;
|
|
9459
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;
|
|
9460
9736
|
function drawList(redraw) {
|
|
9461
9737
|
// Gutter (task #128 Phase 2): inset each row after its line-clear. Clipped to width-1 like
|
|
9462
9738
|
// every other in-place block: a long item (a model id on a narrow terminal) that wrapped
|
|
@@ -9490,6 +9766,15 @@ function drawList(redraw) {
|
|
|
9490
9766
|
if (listNote) rows.push(clipVisible(GUTTER + yellow(listNote), width - 1));
|
|
9491
9767
|
// On a redraw the caret sits one row BELOW the block (each draw ends with "\n").
|
|
9492
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));
|
|
9493
9778
|
regionUi(
|
|
9494
9779
|
(up ? `\x1b[${up}A` : "") +
|
|
9495
9780
|
rows.map((r) => "\x1b[K" + r).join("\n") +
|
|
@@ -9504,6 +9789,15 @@ function finishList(index) {
|
|
|
9504
9789
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
9505
9790
|
const up = physicalRows(listRowsDrawn, width); // ESC[0A would still move ONE row — guard it
|
|
9506
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;
|
|
9507
9801
|
listRowsDrawn = [];
|
|
9508
9802
|
listTop = 0;
|
|
9509
9803
|
const r = listResolve;
|
|
@@ -12270,6 +12564,9 @@ async function main() {
|
|
|
12270
12564
|
// reason gu refuses to exit.
|
|
12271
12565
|
void refreshAvailableUpdates();
|
|
12272
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();
|
|
12273
12570
|
// The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
|
|
12274
12571
|
startupTokenRefresh = refreshCliToken();
|
|
12275
12572
|
setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
|
|
@@ -12424,6 +12721,10 @@ async function main() {
|
|
|
12424
12721
|
announceCliUpdate(); // deferred if a 6h refresh landed mid-turn; harmless once shown
|
|
12425
12722
|
announceScaffold(); // the startup scaffold line, if it arrived while a turn was running
|
|
12426
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;
|
|
12427
12728
|
paintBar();
|
|
12428
12729
|
if (!barPinned()) probeCaretRow(); // fallback path still needs the caret's absolute row
|
|
12429
12730
|
// Absorb blank Enter-presses SILENTLY — they never reach the agent. Lines typed while
|
|
@@ -12718,6 +13019,10 @@ async function main() {
|
|
|
12718
13019
|
);
|
|
12719
13020
|
continue;
|
|
12720
13021
|
}
|
|
13022
|
+
if (line === "/prices" || line.startsWith("/prices ")) {
|
|
13023
|
+
await runPricesCommand(line);
|
|
13024
|
+
continue;
|
|
13025
|
+
}
|
|
12721
13026
|
if (line === "/tokens") {
|
|
12722
13027
|
// Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
|
|
12723
13028
|
// this answers "and what displaced it", which is the part that tells you whether RAG is
|
|
@@ -12905,6 +13210,9 @@ async function main() {
|
|
|
12905
13210
|
await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
|
|
12906
13211
|
try {
|
|
12907
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() };
|
|
12908
13216
|
if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
|
|
12909
13217
|
const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
|
|
12910
13218
|
// NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
|
|
@@ -12927,6 +13235,8 @@ async function main() {
|
|
|
12927
13235
|
lastTurnInputTokens = inputTokens ?? 0;
|
|
12928
13236
|
lastTurnOutputTokens = turnOutputTokens ?? 0;
|
|
12929
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);
|
|
12930
13240
|
// A turn that FAILED carries the __TURN_FAILED__:: sentinel: the worker still delivers
|
|
12931
13241
|
// the text (both UIs need to show it), but it is a failure notice, not a reply. Print
|
|
12932
13242
|
// it as one and DROP it — persisting it would re-send "⚠️ your account is suspended" to
|
package/model-prices.mjs
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MODEL PRICES AND WHAT A TURN COST (#323).
|
|
3
|
+
*
|
|
4
|
+
* The user: "estimate cost of USD after each turn" — from each provider's price per 1 million INPUT
|
|
5
|
+
* tokens (output ignored for now, but stored so turning it on needs no migration), with the cheaper
|
|
6
|
+
* CACHED-input price for the part of each step the provider served from its prompt cache.
|
|
7
|
+
*
|
|
8
|
+
* HOW MUCH WAS CACHED. The agent reads the provider's own count when it reports one (OpenAI
|
|
9
|
+
* usage.prompt_tokens_details.cached_tokens, Anthropic cache_read_input_tokens, Kimi cached_tokens
|
|
10
|
+
* — see _cached_tokens_from_usage in gonext_agent_chat.py) and the cost then uses it: "$0.04".
|
|
11
|
+
* When nothing is reported, gu ASSUMES 90% of every step after the first was cached — what the
|
|
12
|
+
* user sees on the Kimi and Claude consoles for gu's turns — and says so: "~$0.04".
|
|
13
|
+
*
|
|
14
|
+
* Prices live in the user's own Mongo (one row per server + model, via the local API); this file is
|
|
15
|
+
* pure — the table it ships (PRICE_TABLE) is what an empty Mongo is seeded with, and what the Deploy
|
|
16
|
+
* app's "Prices" button exports for the admin to publish.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PRICE_TABLE_VERSION = "1.0";
|
|
20
|
+
/** Of a step after the first, the share assumed served from the cache when the provider says nothing. */
|
|
21
|
+
export const ASSUMED_CACHED_SHARE = 0.9;
|
|
22
|
+
export const MAX_PRICE_ROWS = 500;
|
|
23
|
+
const MAX_PRICE = 1000; // USD per 1M tokens — anything above is a typo (per-token, per-1k…)
|
|
24
|
+
|
|
25
|
+
const ANTHROPIC = "https://platform.claude.com/docs/en/about-claude/pricing";
|
|
26
|
+
const OPENAI = "https://developers.openai.com/api/docs/pricing";
|
|
27
|
+
const KIMI = "https://platform.kimi.ai/docs/pricing/chat";
|
|
28
|
+
const OLLAMA = "https://ollama.com/pricing";
|
|
29
|
+
const CHECKED = "2026-10-01";
|
|
30
|
+
|
|
31
|
+
/** [host, model, input, cachedInput, output, cacheWrite?] — USD per 1M tokens. */
|
|
32
|
+
const ROWS = [
|
|
33
|
+
// Anthropic — cache write (5 minutes) is 1.25× input; gu does not price writes yet (noted per row).
|
|
34
|
+
["api.anthropic.com", "claude-fable-5-1", 10, 0.25, 50, 12.5],
|
|
35
|
+
["api.anthropic.com", "claude-opus-5-5", 4, 0.2, 20, 5],
|
|
36
|
+
["api.anthropic.com", "claude-opus-5", 5, 0.5, 25, 6.25],
|
|
37
|
+
["api.anthropic.com", "claude-opus-4-8", 5, 0.5, 25, 6.25],
|
|
38
|
+
["api.anthropic.com", "claude-sonnet-5-5", 2, 0.2, 10, 2.5],
|
|
39
|
+
["api.anthropic.com", "claude-sonnet-5", 2, 0.2, 10, 2.5],
|
|
40
|
+
["api.anthropic.com", "claude-sonnet-4-6", 3, 0.3, 15, 3.75],
|
|
41
|
+
["api.anthropic.com", "claude-sonnet-4-5", 3, 0.3, 15, 3.75],
|
|
42
|
+
["api.anthropic.com", "claude-haiku-4-5", 1, 0.1, 5, 1.25],
|
|
43
|
+
// OpenAI (standard tier)
|
|
44
|
+
["api.openai.com", "gpt-6-astra", 10, 1, 50],
|
|
45
|
+
["api.openai.com", "gpt-6.1-sol", 2, 0.1, 10],
|
|
46
|
+
["api.openai.com", "gpt-6-sol", 2, 0.2, 10],
|
|
47
|
+
["api.openai.com", "gpt-6-luna", 0.1, 0.01, 0.5],
|
|
48
|
+
["api.openai.com", "gpt-5.6-sol", 4, 0.4, 20],
|
|
49
|
+
["api.openai.com", "gpt-5.6-terra", 2, 0.2, 12],
|
|
50
|
+
["api.openai.com", "gpt-5.6-luna", 0.2, 0.02, 1.2],
|
|
51
|
+
["api.openai.com", "gpt-5.5", 5, 0.5, 30],
|
|
52
|
+
["api.openai.com", "gpt-5.4", 2.5, 0.25, 15],
|
|
53
|
+
["api.openai.com", "gpt-5.3-codex", 1.75, 0.175, 14],
|
|
54
|
+
["api.openai.com", "gpt-5.2", 1.75, 0.175, 14],
|
|
55
|
+
["api.openai.com", "gpt-5.1", 1.25, 0.125, 10],
|
|
56
|
+
["api.openai.com", "gpt-5", 1.25, 0.125, 10],
|
|
57
|
+
["api.openai.com", "gpt-4.1", 2, 0.5, 8],
|
|
58
|
+
["api.openai.com", "gpt-4o", 2.5, 1.25, 10],
|
|
59
|
+
// Moonshot / Kimi
|
|
60
|
+
["api.moonshot.ai", "kimi-k3", 3, 0.3, 15, 3], // cache write (5 min) = the input price
|
|
61
|
+
["api.moonshot.ai", "kimi-k2.7-code", 0.95, 0.19, 4],
|
|
62
|
+
["api.moonshot.ai", "kimi-k2.7-code-highspeed", 1.9, 0.38, 8],
|
|
63
|
+
["api.moonshot.ai", "kimi-k2.6", 0.95, 0.16, 4],
|
|
64
|
+
// Ollama cloud — pay-as-you-go credits (peak price; off-peak is about half)
|
|
65
|
+
["ollama.com", "kimi-k3", 3, 0.3, 15],
|
|
66
|
+
["ollama.com", "kimi-k2.7-code", 0.95, 0.19, 4],
|
|
67
|
+
["ollama.com", "kimi-k2.6", 0.95, 0.16, 4],
|
|
68
|
+
["ollama.com", "glm-5.3", 1.4, 0.26, 4.4],
|
|
69
|
+
["ollama.com", "glm-5.3-flash", 0.15, 0.03, 0.5],
|
|
70
|
+
["ollama.com", "glm-5.2", 1.4, 0.26, 4.4],
|
|
71
|
+
["ollama.com", "deepseek-v4-pro", 1.32, 0.044, 3.96],
|
|
72
|
+
["ollama.com", "deepseek-v4.1-flash", 0.3, 0.006, 1.2],
|
|
73
|
+
["ollama.com", "minimax-m3", 0.6, 0.12, 2.4],
|
|
74
|
+
["ollama.com", "minimax-m2.7", 0.3, 0.06, 1.2],
|
|
75
|
+
["ollama.com", "gpt-oss:120b", 0.15, 0.014, 0.6],
|
|
76
|
+
["ollama.com", "gpt-oss:20b", 0.07, 0.035, 0.3],
|
|
77
|
+
["ollama.com", "gemma4", 0.14, 0.05, 0.4],
|
|
78
|
+
["ollama.com", "mistral-large-3", 0.5, null, 1.5],
|
|
79
|
+
["ollama.com", "nemotron-3-nano", 0.06, null, 0.24],
|
|
80
|
+
["ollama.com", "nemotron-3-super", 0.015, 0.015, 0.6],
|
|
81
|
+
["ollama.com", "nemotron-3-ultra", 0.1, 0.1, 3],
|
|
82
|
+
];
|
|
83
|
+
const PROVIDER = { "api.anthropic.com": "Anthropic", "api.openai.com": "OpenAI", "api.moonshot.ai": "Moonshot (Kimi)", "ollama.com": "Ollama cloud" };
|
|
84
|
+
const SOURCE = { "api.anthropic.com": ANTHROPIC, "api.openai.com": OPENAI, "api.moonshot.ai": KIMI, "ollama.com": OLLAMA };
|
|
85
|
+
|
|
86
|
+
/** The table gu ships — what an empty Mongo is seeded with and what the Deploy app exports. */
|
|
87
|
+
export const PRICE_TABLE = Object.freeze({
|
|
88
|
+
gu: "prices",
|
|
89
|
+
version: PRICE_TABLE_VERSION,
|
|
90
|
+
publishedAt: CHECKED,
|
|
91
|
+
currency: "USD",
|
|
92
|
+
unit: "per 1M tokens",
|
|
93
|
+
note: "input = uncached prompt tokens; cachedInput = prompt tokens served from the provider's cache; output is stored but not yet used. Anthropic cache writes (cacheWrite) are not priced yet. Ollama cloud: peak prices — off-peak is about half.",
|
|
94
|
+
prices: ROWS.map(([host, model, input, cachedInput, output, cacheWrite]) => ({
|
|
95
|
+
provider: PROVIDER[host],
|
|
96
|
+
host,
|
|
97
|
+
model,
|
|
98
|
+
input,
|
|
99
|
+
cachedInput,
|
|
100
|
+
output,
|
|
101
|
+
...(cacheWrite != null ? { cacheWrite } : {}),
|
|
102
|
+
// Used only when the provider does not report its cached count: Kimi and OpenAI cache prompt
|
|
103
|
+
// prefixes automatically; Anthropic only when the request asks (gu's Claude path does); Ollama
|
|
104
|
+
// cloud lists cache prices. No cache price → no discount, so no share.
|
|
105
|
+
...(cachedInput != null ? { assumedCacheShare: ASSUMED_CACHED_SHARE } : {}),
|
|
106
|
+
source: SOURCE[host],
|
|
107
|
+
checkedAt: CHECKED,
|
|
108
|
+
})),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------------------------
|
|
112
|
+
// keys and lookup
|
|
113
|
+
// ---------------------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/** A server URL (or bare host) → its host: "https://api.moonshot.ai/v1/" → "api.moonshot.ai". */
|
|
116
|
+
export function hostOf(urlOrHost) {
|
|
117
|
+
const raw = String(urlOrHost ?? "").trim();
|
|
118
|
+
if (!raw) return "";
|
|
119
|
+
try {
|
|
120
|
+
return new URL(raw.includes("://") ? raw : `https://${raw}`).host.toLowerCase();
|
|
121
|
+
} catch {
|
|
122
|
+
return raw.replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One row's identity: host + model, lower-cased. */
|
|
127
|
+
export const priceKey = (host, model) => `${hostOf(host)}|${String(model ?? "").trim().toLowerCase()}`;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The price for a server + model, or null. Exact first; then without a dated suffix
|
|
131
|
+
* ("claude-haiku-4-5-20251001" → "claude-haiku-4-5"); then without an Ollama tag ("kimi-k3:cloud",
|
|
132
|
+
* "kimi-k3:latest" → "kimi-k3"). Never a different host — the same model costs differently there.
|
|
133
|
+
*/
|
|
134
|
+
export function findPrice(rows, url, model) {
|
|
135
|
+
const host = hostOf(url);
|
|
136
|
+
const m = String(model ?? "").trim().toLowerCase();
|
|
137
|
+
if (!host || !m || !Array.isArray(rows)) return null;
|
|
138
|
+
const byKey = new Map(rows.map((r) => [priceKey(r.host, r.model), r]));
|
|
139
|
+
const tries = [m, m.replace(/-\d{8}$/, ""), m.replace(/:(cloud|latest)$/, "")];
|
|
140
|
+
for (const t of tries) {
|
|
141
|
+
const r = byKey.get(`${host}|${t}`);
|
|
142
|
+
if (r) return r;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------------------------
|
|
148
|
+
// what a turn cost
|
|
149
|
+
// ---------------------------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The input cost of one turn.
|
|
153
|
+
* input prompt tokens sent, summed over every step of the turn
|
|
154
|
+
* cached prompt tokens the provider served from its cache (summed), when it reported them
|
|
155
|
+
* cacheReported whether the provider reported a cached count at all
|
|
156
|
+
* firstIn the first step's prompt tokens (nothing is cached on the first step of a turn)
|
|
157
|
+
* → { usd, estimated, cachedTokens } — or null when there is no price (never a misleading $0).
|
|
158
|
+
*/
|
|
159
|
+
export function turnCost({ input = 0, cached = 0, cacheReported = false, firstIn = 0, price, assumedShare } = {}) {
|
|
160
|
+
if (!price || !Number.isFinite(price.input)) return null;
|
|
161
|
+
// The row's own assumption wins (providers cache differently), else the 90% default.
|
|
162
|
+
const share = Number.isFinite(assumedShare) ? assumedShare
|
|
163
|
+
: Number.isFinite(price.assumedCacheShare) ? price.assumedCacheShare
|
|
164
|
+
: ASSUMED_CACHED_SHARE;
|
|
165
|
+
const sent = Math.max(0, Number(input) || 0);
|
|
166
|
+
let cachedTokens;
|
|
167
|
+
let estimated = false;
|
|
168
|
+
if (cacheReported) {
|
|
169
|
+
cachedTokens = Math.min(sent, Math.max(0, Number(cached) || 0));
|
|
170
|
+
} else {
|
|
171
|
+
cachedTokens = Math.round(Math.min(1, Math.max(0, share)) * Math.max(0, sent - Math.max(0, Number(firstIn) || 0)));
|
|
172
|
+
estimated = cachedTokens > 0;
|
|
173
|
+
}
|
|
174
|
+
const cachedRate = Number.isFinite(price.cachedInput) ? price.cachedInput : price.input; // no cache price → no discount
|
|
175
|
+
const usd = ((sent - cachedTokens) * price.input + cachedTokens * cachedRate) / 1e6;
|
|
176
|
+
return { usd, estimated, cachedTokens };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** "$1.23", "$0.123", "$0.0042"; "~" in front when the cached share was assumed. */
|
|
180
|
+
export function formatUsd(usd, { estimated = false } = {}) {
|
|
181
|
+
if (!Number.isFinite(usd)) return "";
|
|
182
|
+
const v = Math.max(0, usd);
|
|
183
|
+
const s = v >= 1 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v > 0 ? v.toFixed(4) : "0";
|
|
184
|
+
return `${estimated ? "~" : ""}$${s}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------------------------
|
|
188
|
+
// checking a price table (the published one, before it is applied)
|
|
189
|
+
// ---------------------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
const ROW_FIELDS = new Set(["provider", "host", "url", "model", "input", "cachedInput", "output", "cacheWrite", "assumedCacheShare", "source", "checkedAt", "remove"]);
|
|
192
|
+
const price = (v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_PRICE);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check a price table (the shape PRICE_TABLE has; a bare array of rows is accepted too).
|
|
196
|
+
* → { ok, version, rows, removes, warnings } | { ok: false, problem }.
|
|
197
|
+
* A row with "remove": true deletes that server + model — nothing is ever deleted otherwise.
|
|
198
|
+
*/
|
|
199
|
+
export function validatePriceTable(input) {
|
|
200
|
+
let doc = input;
|
|
201
|
+
if (typeof input === "string") {
|
|
202
|
+
const src = input.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
203
|
+
if (!src.trim()) return { ok: false, empty: true, problem: "nothing was pasted." };
|
|
204
|
+
try {
|
|
205
|
+
doc = JSON.parse(src);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return { ok: false, problem: `that is not valid JSON — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$/, "")}.` };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const list = Array.isArray(doc) ? doc : doc?.prices;
|
|
211
|
+
if (!Array.isArray(list)) return { ok: false, problem: 'expected { "prices": [ … ] } or a list of rows.' };
|
|
212
|
+
if (list.length === 0) return { ok: false, problem: "the table has no rows." };
|
|
213
|
+
if (list.length > MAX_PRICE_ROWS) return { ok: false, problem: `${list.length} rows — at most ${MAX_PRICE_ROWS}.` };
|
|
214
|
+
const version = !Array.isArray(doc) && doc.version != null ? String(doc.version).slice(0, 40) : "";
|
|
215
|
+
const rows = [];
|
|
216
|
+
const removes = [];
|
|
217
|
+
const warnings = [];
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
for (let i = 0; i < list.length; i++) {
|
|
220
|
+
const r = list[i];
|
|
221
|
+
const at = `row ${i + 1}`;
|
|
222
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) return { ok: false, problem: `${at} is not an object.` };
|
|
223
|
+
const host = hostOf(r.host ?? r.url);
|
|
224
|
+
const model = typeof r.model === "string" ? r.model.trim() : "";
|
|
225
|
+
if (!host) return { ok: false, problem: `${at} needs "host" (or "url") — the server it is priced on.` };
|
|
226
|
+
if (!model || model.length > 120) return { ok: false, problem: `${at} needs "model" (at most 120 characters).` };
|
|
227
|
+
const key = priceKey(host, model);
|
|
228
|
+
if (seen.has(key)) return { ok: false, problem: `${at}: ${host} ${model} appears twice.` };
|
|
229
|
+
seen.add(key);
|
|
230
|
+
const extra = Object.keys(r).filter((k) => !ROW_FIELDS.has(k));
|
|
231
|
+
if (extra.length) warnings.push(`${at} (${model}): ignored ${extra.map((k) => `"${k}"`).join(", ")}`);
|
|
232
|
+
if (r.remove === true) {
|
|
233
|
+
removes.push({ host, model });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!(typeof r.input === "number" && price(r.input))) {
|
|
237
|
+
return { ok: false, problem: `${at} (${model}): "input" must be a number of USD per 1M tokens, 0 to ${MAX_PRICE}.` };
|
|
238
|
+
}
|
|
239
|
+
for (const f of ["cachedInput", "output", "cacheWrite"]) {
|
|
240
|
+
if (r[f] !== undefined && !price(r[f])) return { ok: false, problem: `${at} (${model}): "${f}" must be a number 0 to ${MAX_PRICE}, or null.` };
|
|
241
|
+
}
|
|
242
|
+
if (r.assumedCacheShare !== undefined && !(typeof r.assumedCacheShare === "number" && r.assumedCacheShare >= 0 && r.assumedCacheShare <= 1)) {
|
|
243
|
+
return { ok: false, problem: `${at} (${model}): "assumedCacheShare" must be a number from 0 to 1 (0.9 = 90%).` };
|
|
244
|
+
}
|
|
245
|
+
if (typeof r.cachedInput === "number" && r.cachedInput > r.input) warnings.push(`${at} (${model}): cachedInput is MORE than input — check it`);
|
|
246
|
+
rows.push({
|
|
247
|
+
...(typeof r.provider === "string" && r.provider.trim() ? { provider: r.provider.trim().slice(0, 60) } : {}),
|
|
248
|
+
host,
|
|
249
|
+
model,
|
|
250
|
+
input: r.input,
|
|
251
|
+
cachedInput: r.cachedInput ?? null,
|
|
252
|
+
output: r.output ?? null,
|
|
253
|
+
...(typeof r.cacheWrite === "number" ? { cacheWrite: r.cacheWrite } : {}),
|
|
254
|
+
...(typeof r.assumedCacheShare === "number" ? { assumedCacheShare: r.assumedCacheShare } : {}),
|
|
255
|
+
...(typeof r.source === "string" && /^https?:\/\//.test(r.source) ? { source: r.source.slice(0, 300) } : {}),
|
|
256
|
+
...(typeof r.checkedAt === "string" && !Number.isNaN(Date.parse(r.checkedAt)) ? { checkedAt: r.checkedAt.slice(0, 30) } : {}),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, version, rows, removes, warnings };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const COMPARED = ["input", "cachedInput", "output", "cacheWrite", "assumedCacheShare", "provider"];
|
|
263
|
+
|
|
264
|
+
/** What applying `incoming` to `current` would do. → { added, changed:[{row, was, fields}], unchanged, removed, missingRemoves } */
|
|
265
|
+
export function diffPrices(current, incoming, removes = []) {
|
|
266
|
+
const now = new Map((current ?? []).map((r) => [priceKey(r.host, r.model), r]));
|
|
267
|
+
const added = [];
|
|
268
|
+
const changed = [];
|
|
269
|
+
let unchanged = 0;
|
|
270
|
+
for (const r of incoming) {
|
|
271
|
+
const was = now.get(priceKey(r.host, r.model));
|
|
272
|
+
if (!was) {
|
|
273
|
+
added.push(r);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const fields = COMPARED.filter((f) => (r[f] ?? null) !== (was[f] ?? null) && !(f === "provider" && r[f] == null));
|
|
277
|
+
if (fields.length) changed.push({ row: r, was, fields });
|
|
278
|
+
else unchanged++;
|
|
279
|
+
}
|
|
280
|
+
const removed = removes.filter((x) => now.has(priceKey(x.host, x.model)));
|
|
281
|
+
const missingRemoves = removes.filter((x) => !now.has(priceKey(x.host, x.model)));
|
|
282
|
+
return { added, changed, unchanged, removed, missingRemoves };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The diff as lines, for the "apply? [y/N]" question. */
|
|
286
|
+
export function diffLines(d) {
|
|
287
|
+
const p = (v) => (v == null ? "—" : `$${v}`);
|
|
288
|
+
const out = [];
|
|
289
|
+
for (const r of d.added) out.push(`+ ${r.host} ${r.model} in ${p(r.input)} · cached ${p(r.cachedInput)}`);
|
|
290
|
+
const pct = (v) => (v == null ? "—" : `${Math.round(v * 100)}%`);
|
|
291
|
+
const show = (f, v) => (f === "assumedCacheShare" ? pct(v) : p(v));
|
|
292
|
+
for (const c of d.changed) out.push(`~ ${c.row.host} ${c.row.model} ${c.fields.map((f) => (f === "provider" ? `provider → ${c.row.provider}` : `${f} ${show(f, c.was[f])} → ${show(f, c.row[f])}`)).join(", ")}`);
|
|
293
|
+
for (const r of d.removed) out.push(`- ${r.host} ${r.model} (removed)`);
|
|
294
|
+
out.push(`${d.added.length} new · ${d.changed.length} changed · ${d.unchanged} unchanged${d.removed.length ? ` · ${d.removed.length} removed` : ""}`);
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------------------------
|
|
299
|
+
// /price
|
|
300
|
+
// ---------------------------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The table for /price, grouped by server, newest-first nowhere — alphabetical, so a model is easy
|
|
304
|
+
* to find. `current` = { url, model } of the coder in use: its row is marked "▸".
|
|
305
|
+
* Columns: model · input · cached · output (USD per 1M). Fits `width` (80 on a server over SSH).
|
|
306
|
+
*/
|
|
307
|
+
export function priceTableLines(rows, { width = 80, current = null, version = "" } = {}) {
|
|
308
|
+
const cur = current ? findPrice(rows, current.url, current.model) : null;
|
|
309
|
+
const money = (v) => (v == null ? "—" : `$${Number(v) < 0.1 && v !== 0 ? Number(v).toString() : Number(v).toFixed(2)}`);
|
|
310
|
+
const numW = 9;
|
|
311
|
+
const modelW = Math.max(12, Math.min(34, width - 4 - 2 - numW * 3));
|
|
312
|
+
const cut = (s, w) => (s.length > w ? s.slice(0, w - 1) + "…" : s.padEnd(w));
|
|
313
|
+
const out = [`USD per 1M tokens${version ? ` · table v${version}` : ""}`];
|
|
314
|
+
out.push(` ${"model".padEnd(modelW)}${"input".padStart(numW)}${"cached".padStart(numW)}${"output".padStart(numW)}`);
|
|
315
|
+
const hosts = [...new Set(rows.map((r) => r.host))].sort();
|
|
316
|
+
for (const h of hosts) {
|
|
317
|
+
const group = rows.filter((r) => r.host === h).sort((a, b) => a.model.localeCompare(b.model));
|
|
318
|
+
out.push(`${h}${group[0]?.provider ? ` (${group[0].provider})` : ""}`);
|
|
319
|
+
for (const r of group) {
|
|
320
|
+
out.push(`${r === cur ? "▸ " : " "}${cut(r.model, modelW)}${money(r.input).padStart(numW)}${money(r.cachedInput).padStart(numW)}${money(r.output).padStart(numW)}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------------------------
|
|
327
|
+
// THE ASSUMED-CACHE-SHARE TABLE (#323 follow-up) — its own table so the admin can review and
|
|
328
|
+
// publish it on its own (Deploy app → "Cache shares" → Update to API), and users can read it with
|
|
329
|
+
// /prices. Made FROM the price table (the rows that have a cache price), so the two cannot
|
|
330
|
+
// disagree.
|
|
331
|
+
// ---------------------------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/** Why each provider's share is what it is — shown beside the number, so it can be reviewed. */
|
|
334
|
+
export const CACHE_SHARE_WHY = Object.freeze({
|
|
335
|
+
"api.moonshot.ai": "automatic prefix caching (Kimi reports cached_tokens)",
|
|
336
|
+
"api.openai.com": "automatic prefix caching for prompts ≥ 1024 tokens (reports cached_tokens)",
|
|
337
|
+
"api.anthropic.com": "cached only when the request asks — gu's Claude path does (reports cache reads)",
|
|
338
|
+
"ollama.com": "lists cache prices; whether it reports a count is not yet verified",
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
export const CACHE_SHARE_TABLE = Object.freeze({
|
|
342
|
+
gu: "cache-shares",
|
|
343
|
+
version: PRICE_TABLE_VERSION,
|
|
344
|
+
publishedAt: PRICE_TABLE.publishedAt,
|
|
345
|
+
note: "The share of a step's prompt (after a turn's first step) assumed served from the provider's cache, used ONLY when the provider does not report its own cached count. 0.9 = 90%.",
|
|
346
|
+
shares: PRICE_TABLE.prices
|
|
347
|
+
.filter((r) => r.cachedInput != null)
|
|
348
|
+
.map((r) => ({ provider: r.provider, host: r.host, model: r.model, assumedCacheShare: r.assumedCacheShare ?? ASSUMED_CACHED_SHARE, why: CACHE_SHARE_WHY[r.host] ?? "" })),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/** Check a cache-share table → { ok, version, shares } | { ok: false, problem }. */
|
|
352
|
+
export function validateCacheShareTable(input) {
|
|
353
|
+
let doc = input;
|
|
354
|
+
if (typeof input === "string") {
|
|
355
|
+
try {
|
|
356
|
+
doc = JSON.parse(input.replace(/^[ \t]*\/\/.*$/gm, ""));
|
|
357
|
+
} catch (e) {
|
|
358
|
+
return { ok: false, problem: `that is not valid JSON — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$/, "")}.` };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const list = Array.isArray(doc) ? doc : doc?.shares;
|
|
362
|
+
if (!Array.isArray(list) || list.length === 0) return { ok: false, problem: 'expected { "shares": [ … ] } with at least one row.' };
|
|
363
|
+
if (list.length > MAX_PRICE_ROWS) return { ok: false, problem: `${list.length} rows — at most ${MAX_PRICE_ROWS}.` };
|
|
364
|
+
const seen = new Set();
|
|
365
|
+
const shares = [];
|
|
366
|
+
for (const [i, r] of list.entries()) {
|
|
367
|
+
const at = `row ${i + 1}`;
|
|
368
|
+
const host = hostOf(r?.host ?? r?.url);
|
|
369
|
+
const model = typeof r?.model === "string" ? r.model.trim() : "";
|
|
370
|
+
if (!host || !model) return { ok: false, problem: `${at} needs "host" and "model".` };
|
|
371
|
+
if (!(typeof r.assumedCacheShare === "number" && r.assumedCacheShare >= 0 && r.assumedCacheShare <= 1)) {
|
|
372
|
+
return { ok: false, problem: `${at} (${model}): "assumedCacheShare" must be a number from 0 to 1 (0.9 = 90%).` };
|
|
373
|
+
}
|
|
374
|
+
const key = priceKey(host, model);
|
|
375
|
+
if (seen.has(key)) return { ok: false, problem: `${at}: ${host} ${model} appears twice.` };
|
|
376
|
+
seen.add(key);
|
|
377
|
+
shares.push({
|
|
378
|
+
...(typeof r.provider === "string" && r.provider.trim() ? { provider: r.provider.trim().slice(0, 60) } : {}),
|
|
379
|
+
host,
|
|
380
|
+
model,
|
|
381
|
+
assumedCacheShare: r.assumedCacheShare,
|
|
382
|
+
...(typeof r.why === "string" && r.why.trim() ? { why: r.why.trim().slice(0, 200) } : {}),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const version = !Array.isArray(doc) && doc.version != null ? String(doc.version).slice(0, 40) : "";
|
|
386
|
+
return { ok: true, version, shares };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The cache table in /prices: the share each model is assumed to cache — THIS ACCOUNT's (its price rows, what its
|
|
391
|
+
* estimates use) beside the one gu PUBLISHED (when the API has one), marking where they differ.
|
|
392
|
+
* Fits `width`; the coder in use is marked "▸".
|
|
393
|
+
*/
|
|
394
|
+
export function cacheShareLines({ yours = [], published = null, width = 80, current = null } = {}) {
|
|
395
|
+
const pub = new Map((published?.shares ?? []).map((s) => [priceKey(s.host, s.model), s.assumedCacheShare]));
|
|
396
|
+
const mine = (yours ?? []).filter((r) => r.cachedInput != null || pub.has(priceKey(r.host, r.model)));
|
|
397
|
+
const cur = current ? findPrice(mine, current.url, current.model) : null;
|
|
398
|
+
const pct = (v) => (Number.isFinite(v) ? `${Math.round(v * 100)}%` : "—");
|
|
399
|
+
const colW = 8;
|
|
400
|
+
const modelW = Math.max(12, Math.min(34, width - 4 - 2 - colW * 2 - 2));
|
|
401
|
+
const cut = (s, w) => (s.length > w ? s.slice(0, w - 1) + "…" : s.padEnd(w));
|
|
402
|
+
const out = [];
|
|
403
|
+
out.push(` ${"model".padEnd(modelW)}${"yours".padStart(colW)}${published ? "gu".padStart(colW) : ""}`);
|
|
404
|
+
const keys = new Set([...mine.map((r) => priceKey(r.host, r.model)), ...pub.keys()]);
|
|
405
|
+
const rows = [...keys].map((k) => {
|
|
406
|
+
const r = mine.find((x) => priceKey(x.host, x.model) === k);
|
|
407
|
+
const s = published?.shares?.find((x) => priceKey(x.host, x.model) === k);
|
|
408
|
+
return { host: r?.host ?? s.host, model: r?.model ?? s.model, yours: r ? (Number.isFinite(r.assumedCacheShare) ? r.assumedCacheShare : ASSUMED_CACHED_SHARE) : undefined, gu: pub.get(k), row: r };
|
|
409
|
+
});
|
|
410
|
+
for (const h of [...new Set(rows.map((r) => r.host))].sort()) {
|
|
411
|
+
out.push(h);
|
|
412
|
+
for (const r of rows.filter((x) => x.host === h).sort((a, b) => a.model.localeCompare(b.model))) {
|
|
413
|
+
const differs = published && r.gu !== undefined && r.yours !== undefined && Math.abs(r.gu - r.yours) > 1e-9;
|
|
414
|
+
out.push(`${r.row && r.row === cur ? "▸ " : " "}${cut(r.model, modelW)}${pct(r.yours).padStart(colW)}${published ? pct(r.gu).padStart(colW) : ""}${differs ? " *" : ""}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------------------------
|
|
421
|
+
// "NEW PRICES UPDATED <date>" (#323): gu checks the hosted API's GET /api/get-price-and-cache-table
|
|
422
|
+
// (the newest tables the admin published from the Deploy app); when they are newer than what this
|
|
423
|
+
// account last applied, the notice row offers them, and a click writes both into the user's Mongo.
|
|
424
|
+
// ---------------------------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
/** Is the published stamp newer than the one this account applied? ("" applied = never.) */
|
|
427
|
+
export function hasNewerPublished(appliedAt, publishedAt) {
|
|
428
|
+
const p = Date.parse(publishedAt ?? "");
|
|
429
|
+
if (!Number.isFinite(p)) return false;
|
|
430
|
+
const a = Date.parse(appliedAt ?? "");
|
|
431
|
+
return !Number.isFinite(a) || p > a;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The notice: "new prices updated 1 Oct 2026". */
|
|
435
|
+
export function priceUpdateLabel(publishedAt) {
|
|
436
|
+
const d = new Date(publishedAt);
|
|
437
|
+
if (Number.isNaN(d.getTime())) return "new prices updated";
|
|
438
|
+
const m = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getUTCMonth()];
|
|
439
|
+
return `new prices updated ${d.getUTCDate()} ${m} ${d.getUTCFullYear()}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The published tables onto this account's rows → what to write.
|
|
444
|
+
* · the published PRICE table's rows replace the same server + model here (others are kept —
|
|
445
|
+
* a self-hosted server priced by hand stays);
|
|
446
|
+
* · the published CACHE table's share is set on each matching row (published price rows and the
|
|
447
|
+
* account's own);
|
|
448
|
+
* · nothing is deleted.
|
|
449
|
+
* → { ok, rows: the rows that change (to PUT), diff, version, publishedAt } | { ok: false, problem }
|
|
450
|
+
*/
|
|
451
|
+
export function mergePublishedTables(current, cloud) {
|
|
452
|
+
const pub = cloud ?? {};
|
|
453
|
+
const priceRows = [];
|
|
454
|
+
if (pub.prices) {
|
|
455
|
+
const c = validatePriceTable(pub.prices);
|
|
456
|
+
if (!c.ok) return { ok: false, problem: `the published price table is not usable — ${c.problem}` };
|
|
457
|
+
priceRows.push(...c.rows);
|
|
458
|
+
}
|
|
459
|
+
let shares = [];
|
|
460
|
+
if (pub.cacheShares) {
|
|
461
|
+
const c = validateCacheShareTable(pub.cacheShares);
|
|
462
|
+
if (!c.ok) return { ok: false, problem: `the published cache table is not usable — ${c.problem}` };
|
|
463
|
+
shares = c.shares;
|
|
464
|
+
}
|
|
465
|
+
if (!priceRows.length && !shares.length) return { ok: false, problem: "nothing has been published yet." };
|
|
466
|
+
const share = new Map(shares.map((s) => [priceKey(s.host, s.model), s.assumedCacheShare]));
|
|
467
|
+
const byKey = new Map((current ?? []).map((r) => [priceKey(r.host, r.model), { ...r }]));
|
|
468
|
+
for (const r of priceRows) {
|
|
469
|
+
const was = byKey.get(priceKey(r.host, r.model));
|
|
470
|
+
// A published price row without its own share keeps the one this account has, until the cache
|
|
471
|
+
// table says otherwise.
|
|
472
|
+
byKey.set(priceKey(r.host, r.model), { ...r, ...(r.assumedCacheShare == null && was?.assumedCacheShare != null ? { assumedCacheShare: was.assumedCacheShare } : {}) });
|
|
473
|
+
}
|
|
474
|
+
for (const [k, row] of byKey) if (share.has(k) && row.cachedInput != null) row.assumedCacheShare = share.get(k);
|
|
475
|
+
const merged = [...byKey.values()];
|
|
476
|
+
const diff = diffPrices(current ?? [], merged);
|
|
477
|
+
return {
|
|
478
|
+
ok: true,
|
|
479
|
+
rows: [...diff.added, ...diff.changed.map((c) => c.row)],
|
|
480
|
+
diff,
|
|
481
|
+
version: pub.prices?.version || pub.cacheShares?.version || "",
|
|
482
|
+
publishedAt: pub.publishedAt || [pub.prices?.publishedAt, pub.cacheShares?.publishedAt].filter(Boolean).sort().at(-1) || "",
|
|
483
|
+
};
|
|
484
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.784",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"image-capable-coders.json",
|
|
70
70
|
"preset-editor.mjs",
|
|
71
71
|
"preset-apply.mjs",
|
|
72
|
+
"model-prices.mjs",
|
|
72
73
|
"device-login.mjs",
|
|
73
74
|
"daemon-control.mjs",
|
|
74
75
|
"log-policy.mjs",
|
package/step-tokens.mjs
CHANGED
|
@@ -53,3 +53,48 @@ export function readStepTokens(breakdown) {
|
|
|
53
53
|
const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
|
|
54
54
|
return { step, stepIn: n(breakdown.liveStepIn), stepOut: n(breakdown.liveStepOut) };
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------------------------
|
|
58
|
+
// WHAT THE TURN COST needs two more figures (#323), riding the same breakdown for the same reason:
|
|
59
|
+
// · liveCached / liveCacheReported — the prompt tokens the provider served from its cache, summed
|
|
60
|
+
// over the turn (python's `codeCached`, present ONLY when the provider reported a count);
|
|
61
|
+
// · liveFirstIn — the first step's prompt: nothing is cached on it, so when the provider reports
|
|
62
|
+
// nothing, gu assumes 90% of every LATER step was cached (model-prices.mjs).
|
|
63
|
+
// ---------------------------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** The turn's cache figures before any event. */
|
|
66
|
+
export const NO_CACHE = Object.freeze({ cached: 0, cacheReported: false, firstIn: 0, firstStep: 0 });
|
|
67
|
+
|
|
68
|
+
/** Fold one python "tokens" event into the turn's cache figures. */
|
|
69
|
+
export function foldTurnCache(prev, event) {
|
|
70
|
+
let { cached, cacheReported, firstIn, firstStep } = prev ?? NO_CACHE;
|
|
71
|
+
if (!event || typeof event !== "object") return { cached, cacheReported, firstIn, firstStep };
|
|
72
|
+
if (Number.isFinite(event.codeCached) && event.codeCached >= 0) {
|
|
73
|
+
cacheReported = true;
|
|
74
|
+
if (event.codeCached > cached) cached = event.codeCached;
|
|
75
|
+
}
|
|
76
|
+
const step = Number.isFinite(event.step) ? event.step : 0;
|
|
77
|
+
if (step > 0 && (firstStep === 0 || step < firstStep)) {
|
|
78
|
+
firstStep = step;
|
|
79
|
+
firstIn = 0;
|
|
80
|
+
}
|
|
81
|
+
if (step > 0 && step === firstStep && Number.isFinite(event.stepInput) && event.stepInput > firstIn) firstIn = event.stepInput;
|
|
82
|
+
return { cached, cacheReported, firstIn, firstStep };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The breakdown, carrying the turn's cache figures. */
|
|
86
|
+
export function withTurnCache(breakdown, c) {
|
|
87
|
+
if (!breakdown || typeof breakdown !== "object" || !c) return breakdown;
|
|
88
|
+
return {
|
|
89
|
+
...breakdown,
|
|
90
|
+
liveFirstIn: c.firstIn,
|
|
91
|
+
...(c.cacheReported ? { liveCached: c.cached, liveCacheReported: true } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What the terminal reads back: { cached, cacheReported, firstIn }, or null from an older worker. */
|
|
96
|
+
export function readTurnCache(breakdown) {
|
|
97
|
+
if (!breakdown || typeof breakdown !== "object" || !("liveFirstIn" in breakdown)) return null;
|
|
98
|
+
const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
|
|
99
|
+
return { cached: n(breakdown.liveCached), cacheReported: breakdown.liveCacheReported === true, firstIn: n(breakdown.liveFirstIn) };
|
|
100
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -2852,3 +2852,17 @@ export function noticeRow(items, { width = 80, gutter = " ", sep = " · " }
|
|
|
2852
2852
|
});
|
|
2853
2853
|
return { row, spans };
|
|
2854
2854
|
}
|
|
2855
|
+
|
|
2856
|
+
/**
|
|
2857
|
+
* THE TURN'S PRICE AT THE END OF THE TOKEN ROW (#323): "↑ sent 500k · ↓ out 4.3k · total ↓ 1.6M | ~$0.33".
|
|
2858
|
+
*
|
|
2859
|
+
* The row is clipped on the RIGHT at the window's width, and the price is the last thing on it — so
|
|
2860
|
+
* on a narrow window (80 columns over SSH) it would be the first thing lost. When the whole row does
|
|
2861
|
+
* not fit, the lifetime "total ↓" goes first (it changes no decision about this turn); the price
|
|
2862
|
+
* stays. `price` = the already-painted " | ~$0.33" segment, or "" when the model has no price.
|
|
2863
|
+
*/
|
|
2864
|
+
export function tokenRowWithPrice({ withTotal = "", withoutTotal = "", price = "", room = 80 } = {}) {
|
|
2865
|
+
if (!price) return withTotal;
|
|
2866
|
+
if (visibleLen(withTotal) + visibleLen(price) <= room) return withTotal + price;
|
|
2867
|
+
return withoutTotal + price;
|
|
2868
|
+
}
|
package/terminal-viewport.mjs
CHANGED
|
@@ -674,3 +674,68 @@ export function applySelectionHighlight(rows, sel, sgr = SELECTION_SGR) {
|
|
|
674
674
|
return highlightRow(row, from, to, sgr);
|
|
675
675
|
});
|
|
676
676
|
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* WHEN THE FROZEN ZONE CHANGES HEIGHT (#321) — move the screen the way a terminal would, instead of
|
|
680
|
+
* repainting it from the transcript.
|
|
681
|
+
*
|
|
682
|
+
* Reported: after an update, the banner and the update notice appeared again and again in the
|
|
683
|
+
* scrollback, and single lines ("auto-test off", "1 skill · 1 MCP server") were doubled. The cause
|
|
684
|
+
* was the repaint that followed every height change of the bottom zone (a picker, the notice row,
|
|
685
|
+
* the status block coming and going):
|
|
686
|
+
* · the zone SHRANK → the region grew → the repaint filled the new rows at the TOP with older
|
|
687
|
+
* transcript lines — lines the terminal had ALREADY scrolled into its scrollback. The next
|
|
688
|
+
* output scrolled them in again: one more copy per shrink;
|
|
689
|
+
* · the zone GREW → the repaint painted over the top rows, which never reached the scrollback.
|
|
690
|
+
*
|
|
691
|
+
* Now:
|
|
692
|
+
* · GROW by k: the k rows the zone takes come out of the blank rows below the content first; for
|
|
693
|
+
* the rest, the region is scrolled up (real newlines), so the top lines go into the scrollback
|
|
694
|
+
* ONCE, the normal way;
|
|
695
|
+
* · SHRINK by k: the content stays where it is and the k freed rows are cleared; they are
|
|
696
|
+
* remembered as blank rows below the content ("pad"), which new output fills first.
|
|
697
|
+
* gu's model of the screen is the transcript's rows plus `pad` blank rows, bottom-aligned once it
|
|
698
|
+
* fills the region and top-aligned before (viewportSlice), so it matches without a repaint.
|
|
699
|
+
*
|
|
700
|
+
* → { scrollUp, clearRows, pad }: lines to scroll the OLD region up by, rows to clear below the old
|
|
701
|
+
* region (the freed rows), and the new pad.
|
|
702
|
+
*/
|
|
703
|
+
export function zoneResizePlan({ contentRows = 0, pad = 0, oldHeight, newHeight }) {
|
|
704
|
+
const c = Math.max(0, contentRows | 0);
|
|
705
|
+
const p = Math.max(0, pad | 0);
|
|
706
|
+
const oldH = Math.max(1, oldHeight | 0);
|
|
707
|
+
const newH = Math.max(1, newHeight | 0);
|
|
708
|
+
const full = c + p >= oldH; // bottom-aligned (the region is full) vs top-aligned
|
|
709
|
+
if (newH >= oldH) {
|
|
710
|
+
const k = newH - oldH;
|
|
711
|
+
return { scrollUp: 0, clearRows: k, pad: full ? p + k : p };
|
|
712
|
+
}
|
|
713
|
+
const k = oldH - newH;
|
|
714
|
+
const blanks = full ? p : oldH - c; // rows at the bottom holding nothing
|
|
715
|
+
const fromBlank = Math.min(blanks, k);
|
|
716
|
+
const scrollUp = k - fromBlank;
|
|
717
|
+
if (scrollUp > 0) return { scrollUp, clearRows: 0, pad: 0 };
|
|
718
|
+
return { scrollUp: 0, clearRows: 0, pad: full ? p - fromBlank : Math.max(0, Math.min(p, newH - c)) };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* A BLOCK DRAWN BELOW THE CONTENT AND WIPED AGAIN — a picker (#321). Its rows are not transcript
|
|
723
|
+
* content, but drawing them can scroll the region: whatever scrolled away is in the terminal's
|
|
724
|
+
* scrollback now. Wiping the block leaves the content shifted up by that much with blank rows under
|
|
725
|
+
* it — recorded as pad, so no later repaint brings the scrolled-away lines back (each one would
|
|
726
|
+
* land in the scrollback a second time).
|
|
727
|
+
* contentRows the transcript's rows when the block was first drawn (its last is the cursor row)
|
|
728
|
+
* pad the blank rows below the content then (gu's pad)
|
|
729
|
+
* height the region's rows
|
|
730
|
+
* blockRows the most physical rows the block drew (it ends with a newline)
|
|
731
|
+
* → the pad after the wipe.
|
|
732
|
+
*/
|
|
733
|
+
export function blockClosedPad({ contentRows = 0, pad = 0, height, blockRows = 0 }) {
|
|
734
|
+
const c = Math.max(0, contentRows | 0);
|
|
735
|
+
const p = Math.max(0, pad | 0);
|
|
736
|
+
const h = Math.max(1, height | 0);
|
|
737
|
+
const full = c + p >= h;
|
|
738
|
+
const blanks = full ? p : h - c; // rows below the cursor row's region that hold nothing
|
|
739
|
+
const scrolled = Math.max(0, (blockRows | 0) - blanks);
|
|
740
|
+
return full || scrolled > 0 ? blanks + scrolled : p;
|
|
741
|
+
}
|
package/turn-stop.mjs
CHANGED
|
@@ -55,6 +55,23 @@ export function replaceStoppedQuestion(history, oldTask, newTask) {
|
|
|
55
55
|
return true;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Has the user LET GO of the restored question? (task #247 meets #302)
|
|
60
|
+
*
|
|
61
|
+
* The line going EMPTY is "never mind", however it was emptied — Ctrl+C, Backspace, Ctrl+U, a
|
|
62
|
+
* picker taking the line. Only Ctrl+C used to count, so erasing the question and typing "where
|
|
63
|
+
* were we?" was taken for an EDIT of it: the message replaced the stopped question in history and
|
|
64
|
+
* the model was asked where we were with nothing before it — the report #247 began with.
|
|
65
|
+
* What is typed after that is a new message; the stopped question stays, tagged "still stands".
|
|
66
|
+
*
|
|
67
|
+
* Enter is not an emptying: readline has already cleared the line it is submitting by the time
|
|
68
|
+
* the keypress is seen.
|
|
69
|
+
*/
|
|
70
|
+
export function restoredQuestionLetGo(line, key) {
|
|
71
|
+
if (key && (key.name === "return" || key.name === "enter")) return false;
|
|
72
|
+
return String(line ?? "") === "";
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
/** Short relative names for the stop note: the last two path parts. */
|
|
59
76
|
function shortPath(p) {
|
|
60
77
|
const parts = String(p ?? "").split(/[\\/]/).filter(Boolean);
|