@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/preset-editor.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* user's own 0600 file; root would make it root-owned and ask for a password for nothing.
|
|
12
12
|
* · The temp file holds a live key: it is created 0600 inside a private 0700 directory and the
|
|
13
13
|
* directory is removed on every path out, a Ctrl+C included.
|
|
14
|
-
* · /
|
|
14
|
+
* · /use-presets could show a preset as one framed block — the key MASKED on screen (scrollback
|
|
15
15
|
* and logs must not keep it, #248) — and copy it with the real key: as the ready-to-paste
|
|
16
16
|
* `/save-preset <name> {…}` command (paste it into gu on another computer and it is there),
|
|
17
17
|
* or as plain JSON.
|
|
@@ -52,8 +52,9 @@ export function isHeadless({ env = process.env, platform = process.platform } =
|
|
|
52
52
|
* `saveHint` is what the header tells the person to press — editors differ, and a server's nano is
|
|
53
53
|
* often someone's first.
|
|
54
54
|
*/
|
|
55
|
-
export function pickEditor({ env = process.env, platform = process.platform, has = onPath } = {}) {
|
|
55
|
+
export function pickEditor({ env = process.env, platform = process.platform, has = onPath, readOnly = false } = {}) {
|
|
56
56
|
const headless = isHeadless({ env, platform });
|
|
57
|
+
const withHints = (ed) => editorHints(ed, { readOnly, platform });
|
|
57
58
|
const named = [env.VISUAL, env.EDITOR].map(splitCommand).filter((w) => w.length);
|
|
58
59
|
for (const words of named) {
|
|
59
60
|
const base = words[0].split(/[\\/]/).pop();
|
|
@@ -65,8 +66,30 @@ export function pickEditor({ env = process.env, platform = process.platform, has
|
|
|
65
66
|
return null;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The flags and the "how do I save" line for one editor.
|
|
71
|
+
*
|
|
72
|
+
* READ-ONLY (#322, /open-presets with no name — the current setup, to copy to another computer):
|
|
73
|
+
* nano -v (view mode), vim/vi -R. And for nano, SOFT-WRAP (-$): nano otherwise shows a long line cut
|
|
74
|
+
* off at the screen edge, and a key copied from an 80-column SSH terminal would come out SHORTER —
|
|
75
|
+
* a key that looks fine and is wrong. (Not macOS's /usr/bin/nano, which is pico and has no -$; the
|
|
76
|
+
* line breaks a soft-wrapped copy carries are taken out again by parseEdited.)
|
|
77
|
+
*/
|
|
78
|
+
function editorHints(ed, { readOnly = false, platform = process.platform } = {}) {
|
|
69
79
|
const base = ed.cmd.split(/[\\/]/).pop().replace(/\.exe$/i, "").toLowerCase();
|
|
80
|
+
if (readOnly) {
|
|
81
|
+
let args = ed.args;
|
|
82
|
+
if (base === "nano") {
|
|
83
|
+
if (!args.includes("-v") && !args.includes("--view")) args = ["-v", ...args];
|
|
84
|
+
if (platform !== "darwin" && !args.some((a) => a === "-$" || a === "--softwrap")) args = ["-$", ...args];
|
|
85
|
+
} else if (/^(vim?|nvim|gvim)$/.test(base) && !args.includes("-R")) args = ["-R", ...args];
|
|
86
|
+
const closeHint =
|
|
87
|
+
base === "nano" ? "nano: Ctrl+X to close"
|
|
88
|
+
: /^(vim?|nvim|gvim)$/.test(base) ? "vi: :q Enter to close"
|
|
89
|
+
: base === "notepad" ? "Notepad: close the window (nothing you change is kept)"
|
|
90
|
+
: "quit the editor (nothing you change is kept)";
|
|
91
|
+
return { ...ed, args, saveHint: closeHint, readOnly: true };
|
|
92
|
+
}
|
|
70
93
|
if (base === "nano" && !ed.args.some((a) => /^(-w|--nowrap|-b|--breaklonglines)$/.test(a))) ed = { ...ed, args: ["-w", ...ed.args] };
|
|
71
94
|
const saveHint =
|
|
72
95
|
base === "nano" ? "nano: Ctrl+O, Enter, Ctrl+X to save · Ctrl+X, N to cancel"
|
|
@@ -77,6 +100,47 @@ function withHints(ed) {
|
|
|
77
100
|
return { ...ed, saveHint };
|
|
78
101
|
}
|
|
79
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Take raw line breaks out of JSON STRINGS (never out of the structure between them). Only strings
|
|
105
|
+
* are touched, and a valid JSON string cannot contain a raw line break — so valid JSON is unchanged.
|
|
106
|
+
*/
|
|
107
|
+
export function joinBrokenStrings(src) {
|
|
108
|
+
let out = "";
|
|
109
|
+
let inStr = false;
|
|
110
|
+
for (let i = 0; i < src.length; i++) {
|
|
111
|
+
const c = src[i];
|
|
112
|
+
if (inStr) {
|
|
113
|
+
if (c === "\\") {
|
|
114
|
+
out += c + (src[i + 1] ?? "");
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (c === "\r" || c === "\n") continue;
|
|
119
|
+
if (c === '"') inStr = false;
|
|
120
|
+
} else if (c === '"') inStr = true;
|
|
121
|
+
out += c;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* /open-presets with no name (#322): THE CURRENT SETUP, read-only, to copy to another computer and
|
|
128
|
+
* paste into /save-preset there. The full keys are in it — that is the point — so the header says so.
|
|
129
|
+
*/
|
|
130
|
+
export function currentSetupText(doc) {
|
|
131
|
+
const { note: _n, savedAt: _a, savedOn: _o, ...body } = doc ?? {};
|
|
132
|
+
return (
|
|
133
|
+
[
|
|
134
|
+
"// Your gu setup NOW — read-only. Copy ALL of it (keys included) and on the",
|
|
135
|
+
"// other computer run /save-preset there, paste it, save and quit.",
|
|
136
|
+
"// This CONTAINS your API keys — paste it only somewhere private.",
|
|
137
|
+
].join("\n") +
|
|
138
|
+
"\n" +
|
|
139
|
+
JSON.stringify({ ...body, gu: "preset", version: FULL_PRESET_VERSION }, null, 2) +
|
|
140
|
+
"\n"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
80
144
|
/** What to say when a server has no editor at all. */
|
|
81
145
|
export const NO_EDITOR_HINT = "no editor found — install one (Ubuntu: sudo apt install nano) or set $EDITOR";
|
|
82
146
|
|
|
@@ -122,9 +186,11 @@ export function editorText(name, preset = {}) {
|
|
|
122
186
|
const { note: _n, savedAt: _a, savedOn: _o, ...body } = preset;
|
|
123
187
|
return (
|
|
124
188
|
[
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"//
|
|
189
|
+
// each line fits an 80-column SSH terminal (#322)
|
|
190
|
+
`// gu preset "${name}" — servers, models, API keys, coder, agent, embedder.`,
|
|
191
|
+
"// Edit anything, then save and quit to keep it (it is checked first).",
|
|
192
|
+
"// Quit without saving to leave it as it is. Lines starting // are ignored.",
|
|
193
|
+
"// This CONTAINS your API keys.",
|
|
128
194
|
].join("\n") +
|
|
129
195
|
"\n" +
|
|
130
196
|
JSON.stringify({ ...body, gu: "preset", version: FULL_PRESET_VERSION, name }, null, 2) +
|
|
@@ -221,11 +287,23 @@ export function jsonErrorIndex(src) {
|
|
|
221
287
|
* blanked (not removed) so a JSON error's line number is the line the person sees in the editor.
|
|
222
288
|
*/
|
|
223
289
|
export function parseEdited(text) {
|
|
224
|
-
|
|
290
|
+
let src = String(text ?? "").replace(/^[ \t]*\/\/.*$/gm, "");
|
|
225
291
|
if (!src.trim()) return { ok: false, empty: true, problem: "the file is empty." };
|
|
226
292
|
try {
|
|
227
293
|
JSON.parse(src);
|
|
228
294
|
} catch (e) {
|
|
295
|
+
// A copy from a soft-wrapping terminal or editor breaks a long line — a key — in two (#322). A
|
|
296
|
+
// JSON string can never hold a raw line break, so taking them out of strings changes nothing
|
|
297
|
+
// that was valid; it is only tried when the text does not parse as it is.
|
|
298
|
+
const joined = joinBrokenStrings(src);
|
|
299
|
+
if (joined !== src) {
|
|
300
|
+
try {
|
|
301
|
+
JSON.parse(joined);
|
|
302
|
+
return parseEdited(joined);
|
|
303
|
+
} catch {
|
|
304
|
+
/* still broken — report the ORIGINAL text's mistake, at the line the person sees */
|
|
305
|
+
}
|
|
306
|
+
}
|
|
229
307
|
// Node names the line only for SOME errors ("Expected ':' …"); a stray comma comes back as just a
|
|
230
308
|
// snippet. So when it does not say, find the spot ourselves — the person fixes a LINE in an editor.
|
|
231
309
|
let at = /line (\d+) column (\d+)/.exec(String(e?.message ?? ""));
|
|
@@ -301,7 +379,7 @@ export function presetCopyJson(preset) {
|
|
|
301
379
|
}
|
|
302
380
|
|
|
303
381
|
/**
|
|
304
|
-
* The framed block
|
|
382
|
+
* The framed block a preset view prints for "view": the key masked, "⧉ copy" in the top border.
|
|
305
383
|
* Every row fits `width - 1` so none wraps (a wrapped row breaks the click arithmetic, #109).
|
|
306
384
|
* → { rows, button: { from, to } } — the button's column span on row 0, 0-based, gutter included.
|
|
307
385
|
*/
|
|
@@ -325,10 +403,10 @@ export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
|
|
|
325
403
|
};
|
|
326
404
|
}
|
|
327
405
|
|
|
328
|
-
/** The keys the /
|
|
329
|
-
export const PICKER_KEYS = {
|
|
406
|
+
/** The keys the /use-presets picker takes, in the order its hint line names them. */
|
|
407
|
+
export const PICKER_KEYS = { o: "open" };
|
|
330
408
|
// Short enough for one 80-column row with " Presets in ~ (…):" around it (#322 — it wrapped).
|
|
331
|
-
export const PICKER_HINT = "Enter use
|
|
409
|
+
export const PICKER_HINT = "Enter use it · o open in the editor · Esc";
|
|
332
410
|
|
|
333
411
|
/**
|
|
334
412
|
* The file the editor opens for "paste a preset" (#322: "load preset doesn't give me any editor so
|
|
@@ -347,7 +425,7 @@ export function pasteTemplate(saveHint = "save, then quit the editor") {
|
|
|
347
425
|
}
|
|
348
426
|
|
|
349
427
|
/**
|
|
350
|
-
* A PRESET SOMEONE ALREADY HAS, pasted into /
|
|
428
|
+
* A PRESET SOMEONE ALREADY HAS, pasted into /save-preset (#317: "I need a place to input a preset
|
|
351
429
|
* I have already, which is in JSON format"). Three shapes are accepted, because those are the three
|
|
352
430
|
* things a person could be holding:
|
|
353
431
|
*
|
package/presets-transfer.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MODEL PRESETS — the files /save-preset writes and /
|
|
2
|
+
* MODEL PRESETS — the files /save-preset writes and /use-presets reads (#6).
|
|
3
3
|
*
|
|
4
4
|
* HOW A PRESET DIFFERS FROM A SETTINGS FILE. /save-settings asks nothing: it writes the whole
|
|
5
5
|
* account, names the keys, and refuses to carry a single secret. /save-preset is the opposite
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* preset's name, bound to that row, and made the row's DEFAULT key so the resolver finds it.
|
|
16
16
|
*
|
|
17
17
|
* THE MARKER. A preset file carries "gu": "preset" (not "settings") so /load-settings never
|
|
18
|
-
* offers it and /
|
|
18
|
+
* offers it and /use-presets never offers a settings file — two commands, two folders' worth of
|
|
19
19
|
* files, no cross-picking.
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -56,7 +56,7 @@ export function presetFilePath(typed, home) {
|
|
|
56
56
|
const withJson = (p) => (extname(basename(p)) ? p : `${p}.json`);
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* The preset files in the default folder, newest first — what /
|
|
59
|
+
* The preset files in the default folder, newest first — what /use-presets offers. Only files
|
|
60
60
|
* that ARE preset files (the "gu": "preset" marker). `entries` = [{ name, text, mtimeMs }], and
|
|
61
61
|
* each name is returned as just the preset name (the .json comes off — it is the name the user
|
|
62
62
|
* typed when saving).
|
|
@@ -198,7 +198,7 @@ export function presetToSettingsPatch(preset, current = {}) {
|
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
/** The lines /
|
|
201
|
+
/** The lines /use-presets prints before it asks — what applying the preset will do. */
|
|
202
202
|
export function presetPlanLines(name, preset, current = {}) {
|
|
203
203
|
const same = (a, b) => clean(a).toLowerCase() === clean(b).toLowerCase();
|
|
204
204
|
const lines = [];
|
|
@@ -409,7 +409,7 @@ export function maskedFullPreset(doc) {
|
|
|
409
409
|
return { ...doc, keys: (Array.isArray(doc?.keys) ? doc.keys : []).map((k) => ({ ...k, ...(k.key ? { key: mask(k.key) } : {}) })) };
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
-
/** The lines /
|
|
412
|
+
/** The lines /use-presets prints before applying a version-2 preset (keys masked). */
|
|
413
413
|
export function fullPresetSummary(v, codingDefault) {
|
|
414
414
|
const lines = [];
|
|
415
415
|
for (const b of v.backends) {
|
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);
|