@tiens.nguyen/gu-cli 1.0.767 → 1.0.769
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-api.mjs +89 -8
- package/client-expert-run.mjs +104 -12
- package/gonext_agent_chat.py +6 -0
- package/gu-cli.mjs +13 -1
- package/gu-repl.mjs +138 -22
- package/model-setup-ui.mjs +114 -0
- package/ollama-setup.mjs +128 -23
- package/package.json +4 -1
- package/setup-prompts.mjs +82 -0
- package/step-tokens.mjs +55 -0
- package/terminal-layout.mjs +31 -1
package/ollama-setup.mjs
CHANGED
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
import { isLikelyEmbedder } from "./agent-model-command.mjs";
|
|
22
22
|
import { EMBED_CHOICES } from "./embed-model-command.mjs";
|
|
23
23
|
import { isCancelWord } from "./coding-provider-setup.mjs";
|
|
24
|
+
import {
|
|
25
|
+
SKIP_ALL,
|
|
26
|
+
createHinter,
|
|
27
|
+
keyConfirmation,
|
|
28
|
+
needsKeyAfterStatus,
|
|
29
|
+
readStepAnswer,
|
|
30
|
+
shouldAskKey,
|
|
31
|
+
} from "./model-setup-ui.mjs";
|
|
24
32
|
|
|
25
33
|
/** Ollama's native port, used when someone types a bare host or an IP with no port. */
|
|
26
34
|
export const OLLAMA_DEFAULT_PORT = 11434;
|
|
@@ -649,21 +657,83 @@ export async function askOllamaSetup(
|
|
|
649
657
|
// Injected so the tests can drive it; defaults to a real request with a short deadline,
|
|
650
658
|
// because this runs while someone is waiting at a prompt.
|
|
651
659
|
probeStatus = defaultProbeStatus,
|
|
660
|
+
// #301: the MASKED prompt for an API key. Without one no key is ever asked — a secret is
|
|
661
|
+
// never read through a prompt that echoes it.
|
|
662
|
+
askSecret = null,
|
|
663
|
+
// #301: the hint under each model prompt — full the first time, short after.
|
|
664
|
+
hint = createHinter(),
|
|
652
665
|
} = {},
|
|
653
666
|
) {
|
|
667
|
+
// #301: every key typed for a server, by its HOSTNAME. "" = asked, left empty. Not by URL: the
|
|
668
|
+
// key is asked before listing, for the first reading of what was typed, and a different reading
|
|
669
|
+
// (another port or scheme) may be the one that answers.
|
|
670
|
+
const keys = new Map();
|
|
671
|
+
const SKIPPED = { skipped: true, keys: [] };
|
|
672
|
+
// #301: a step SKIPPED ON PURPOSE, as against a server that never answered. Nothing chosen
|
|
673
|
+
// because of a skip means "leave the models unset"; nothing chosen because nothing answered
|
|
674
|
+
// stays null, and the caller falls back to the shared box as it always has.
|
|
675
|
+
let declined = false;
|
|
676
|
+
// Dim, like the other guidance lines under a question; one line, above the prompt it explains.
|
|
677
|
+
const say = (h) => log(` \x1b[2m${h}\x1b[0m`);
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The key question for a TYPED address, before its models are listed (so a server that needs
|
|
681
|
+
* one can list them). Returns the key, "" for none, or SKIP_ALL.
|
|
682
|
+
*/
|
|
683
|
+
const askKeyFor = async (host) => {
|
|
684
|
+
if (keys.has(host)) return keys.get(host); // same server for both roles → asked once
|
|
685
|
+
if (typeof askSecret !== "function") return "";
|
|
686
|
+
say(hint());
|
|
687
|
+
const raw = await askSecret(` API key for ${host} (Enter for none): `);
|
|
688
|
+
if (raw === SKIP_ALL) return SKIP_ALL;
|
|
689
|
+
const key = String(raw ?? "").trim();
|
|
690
|
+
keys.set(host, key);
|
|
691
|
+
log(` ${keyConfirmation(host, key)}`);
|
|
692
|
+
return key;
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* List the models of what was typed (or the default), asking for a key when the address is
|
|
697
|
+
* public or the server refuses without one. { ok, normalized, models } | { failed } | SKIP_ALL.
|
|
698
|
+
*/
|
|
699
|
+
const loadServer = async (typed, fallbackUrl, reuse) => {
|
|
700
|
+
const raw = typed || fallbackUrl;
|
|
701
|
+
const first = urlCandidates(raw)[0];
|
|
702
|
+
if (reuse && first && reuse.normalized.url === first.url) return reuse;
|
|
703
|
+
let key = "";
|
|
704
|
+
if (first && shouldAskKey({ typed, host: first.host })) {
|
|
705
|
+
key = await askKeyFor(first.host);
|
|
706
|
+
if (key === SKIP_ALL) return SKIP_ALL;
|
|
707
|
+
}
|
|
708
|
+
let tried = await loadFirstThatAnswers(raw, fetchTags, log, key);
|
|
709
|
+
// A server that refused for want of credentials: ask now, whatever the address looked like,
|
|
710
|
+
// and list again with the key. Once — a second refusal is a wrong key, reported as such.
|
|
711
|
+
if (tried?.failed && needsKeyAfterStatus(tried.status) && !key && first) {
|
|
712
|
+
log(` ${first.host} needs an API key to list its models.`);
|
|
713
|
+
keys.delete(first.host);
|
|
714
|
+
key = await askKeyFor(first.host);
|
|
715
|
+
if (key === SKIP_ALL) return SKIP_ALL;
|
|
716
|
+
if (key) tried = await loadFirstThatAnswers(raw, fetchTags, log, key);
|
|
717
|
+
}
|
|
718
|
+
return tried;
|
|
719
|
+
};
|
|
720
|
+
|
|
654
721
|
// ---- SERVER 1: the agent model and the embedder -------------------------------------
|
|
655
722
|
// These two are REQUIRED and not a free choice (see REQUIRED_AGENT_MODEL). A box without
|
|
656
723
|
// them is "not ready" rather than "wrong": it may still be a perfectly good coder host, which
|
|
657
724
|
// is why this refusal does not end the flow.
|
|
658
725
|
let agent = null;
|
|
659
726
|
for (let attempt = 0; attempt < attempts && !agent; attempt++) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
727
|
+
say(hint());
|
|
728
|
+
const a = readStepAnswer(await ask(` Agent + embedding server [${displayUrl(defaultUrl)}]: `));
|
|
729
|
+
if (a.kind === "all") return SKIPPED;
|
|
730
|
+
if (a.kind === "skip") { declined = true; break; }
|
|
731
|
+
const tried = await loadServer(a.text, defaultUrl, null);
|
|
732
|
+
if (tried === SKIP_ALL) return SKIPPED;
|
|
663
733
|
if (!tried || tried.failed) {
|
|
664
734
|
// One explanation, the best one available: the provider message when we recognise the
|
|
665
735
|
// address, otherwise the reachability advice we were going to give anyway.
|
|
666
|
-
const explained = await explainIfOpenAi(
|
|
736
|
+
const explained = await explainIfOpenAi(a.text || defaultUrl, probeStatus, log, describeOpenAiAgent);
|
|
667
737
|
if (!explained) for (const l of tried?.failed ?? []) log(` ${l}`);
|
|
668
738
|
continue;
|
|
669
739
|
}
|
|
@@ -676,7 +746,9 @@ export async function askOllamaSetup(
|
|
|
676
746
|
const need = checkRequired(models);
|
|
677
747
|
if (!need.ok) {
|
|
678
748
|
for (const l of describeNotReady(normalized.host, need.missing)) log(` ${l}`);
|
|
679
|
-
|
|
749
|
+
// #301: this used to tell people to press Enter to skip — but Enter takes the DEFAULT
|
|
750
|
+
// server. "s" is the skip.
|
|
751
|
+
log(" (or type s to skip — you can set it later with /model)");
|
|
680
752
|
continue;
|
|
681
753
|
}
|
|
682
754
|
log(` ✓ ${normalized.host} has the ${REQUIRED_AGENT_PREFIX} family and the embedder`);
|
|
@@ -690,18 +762,21 @@ export async function askOllamaSetup(
|
|
|
690
762
|
const coderDefault = agent?.normalized?.url ?? defaultUrl;
|
|
691
763
|
let coder = null;
|
|
692
764
|
for (let attempt = 0; attempt < attempts && !coder; attempt++) {
|
|
693
|
-
|
|
694
|
-
|
|
765
|
+
say(hint());
|
|
766
|
+
const a = readStepAnswer(await ask(` Coding-model server [${displayUrl(coderDefault)}]: `));
|
|
767
|
+
if (a.kind === "all") return SKIPPED;
|
|
768
|
+
if (a.kind === "skip") { declined = true; break; }
|
|
695
769
|
// Same box as the agent server → reuse the list we already have rather than fetching twice.
|
|
696
|
-
const same = normalizeOllamaUrl(
|
|
770
|
+
const same = normalizeOllamaUrl(a.text || coderDefault);
|
|
697
771
|
const tried =
|
|
698
772
|
agent && same && same.url === agent.normalized.url
|
|
699
773
|
? { normalized: same, models: agent.models }
|
|
700
|
-
: await
|
|
774
|
+
: await loadServer(a.text, coderDefault, agent);
|
|
775
|
+
if (tried === SKIP_ALL) return SKIPPED;
|
|
701
776
|
if (!tried || tried.failed) {
|
|
702
777
|
// One explanation, the best one available: the provider message when we recognise the
|
|
703
778
|
// address, otherwise the reachability advice we were going to give anyway.
|
|
704
|
-
const explained = await explainIfOpenAi(
|
|
779
|
+
const explained = await explainIfOpenAi(a.text || coderDefault, probeStatus, log, describeOpenAiCoder);
|
|
705
780
|
if (!explained) for (const l of tried?.failed ?? []) log(` ${l}`);
|
|
706
781
|
continue;
|
|
707
782
|
}
|
|
@@ -718,7 +793,7 @@ export async function askOllamaSetup(
|
|
|
718
793
|
// A real answer, not a failure. The machine finishes setup unconfigured, the bar shows the
|
|
719
794
|
// roles as unset, and the first question says so instead of hanging — which is the entire
|
|
720
795
|
// reason declining is allowed to be this easy.
|
|
721
|
-
if (!agent && !coder) return null;
|
|
796
|
+
if (!agent && !coder) return declined ? SKIPPED : null;
|
|
722
797
|
|
|
723
798
|
// ---- PICK THE MODELS -----------------------------------------------------------------
|
|
724
799
|
// THE AGENT IS CHOSEN, not assumed (user, 2026-09-06). The gate above proved the family is
|
|
@@ -729,7 +804,6 @@ export async function askOllamaSetup(
|
|
|
729
804
|
// Seed the embedder the box ACTUALLY has: the shipped default when present, else whichever
|
|
730
805
|
// accepted embedder passed the gate — a qwen3-embedding-only box must not be told it now
|
|
731
806
|
// embeds with a model it never had.
|
|
732
|
-
const embed = agent ? defaultEmbedModel(agent.models) || REQUIRED_EMBED_MODEL : null;
|
|
733
807
|
let chat = "";
|
|
734
808
|
if (agent) {
|
|
735
809
|
const cands = agentCandidates(agent.models);
|
|
@@ -741,13 +815,18 @@ export async function askOllamaSetup(
|
|
|
741
815
|
log(" AGENT model — routes every turn and writes the plain replies");
|
|
742
816
|
for (const row of renderModelMenu(cands, { defaultModel: agentDefault, formatSize })) log(row);
|
|
743
817
|
for (let t = 0; t < attempts && !chat; t++) {
|
|
744
|
-
|
|
745
|
-
|
|
818
|
+
say(hint());
|
|
819
|
+
const a = readStepAnswer(await ask(` agent [${agentDefault}]: `));
|
|
820
|
+
if (a.kind === "all") return SKIPPED;
|
|
821
|
+
// #301: "s" here skips the AGENT role — its server was chosen, its model is not.
|
|
822
|
+
if (a.kind === "skip") { agent = null; break; }
|
|
823
|
+
chat = parseMenuChoice(a.text, cands, agentDefault) ?? "";
|
|
746
824
|
if (!chat) log(` no model called that — type its number, or Enter for ${agentDefault}`);
|
|
747
825
|
}
|
|
748
|
-
chat = chat || agentDefault;
|
|
826
|
+
if (agent) chat = chat || agentDefault;
|
|
749
827
|
}
|
|
750
828
|
}
|
|
829
|
+
const embed = agent ? defaultEmbedModel(agent.models) || REQUIRED_EMBED_MODEL : null;
|
|
751
830
|
let code = "";
|
|
752
831
|
let extras = [];
|
|
753
832
|
if (coder) {
|
|
@@ -756,20 +835,37 @@ export async function askOllamaSetup(
|
|
|
756
835
|
log(" CODING model — writes the code; worth waiting for");
|
|
757
836
|
for (const row of renderModelMenu(coder.models, { defaultModel: roles.code, formatSize })) log(row);
|
|
758
837
|
for (let t = 0; t < attempts && !code; t++) {
|
|
759
|
-
|
|
760
|
-
|
|
838
|
+
say(hint());
|
|
839
|
+
const a = readStepAnswer(await ask(` code [${roles.code}]: `));
|
|
840
|
+
if (a.kind === "all") return SKIPPED;
|
|
841
|
+
if (a.kind === "skip") { coder = null; break; }
|
|
842
|
+
code = parseMenuChoice(a.text, coder.models, roles.code) ?? "";
|
|
761
843
|
if (!code) log(` no model called that — type its number, or Enter for ${roles.code}`);
|
|
762
844
|
}
|
|
763
|
-
code = code || roles.code;
|
|
845
|
+
if (coder) code = code || roles.code;
|
|
764
846
|
|
|
765
|
-
const rest = coder.models.filter((m) => !isLikelyEmbedder(m) && m.model !== code);
|
|
847
|
+
const rest = coder ? coder.models.filter((m) => !isLikelyEmbedder(m) && m.model !== code) : [];
|
|
766
848
|
if (rest.length) {
|
|
767
849
|
log("");
|
|
768
850
|
log(" Also offer in /model? (numbers separated by commas, or Enter for none)");
|
|
769
851
|
for (const row of renderModelMenu(rest, { formatSize })) log(row);
|
|
770
|
-
|
|
852
|
+
say(hint());
|
|
853
|
+
const a = readStepAnswer(await ask(" also: "));
|
|
854
|
+
if (a.kind === "all") return SKIPPED;
|
|
855
|
+
extras = a.kind === "skip" ? [] : parseMultiChoice(a.text, rest);
|
|
771
856
|
}
|
|
772
857
|
}
|
|
858
|
+
// Both roles skipped at the model menus: as much a decision as skipping both servers.
|
|
859
|
+
if (!agent && !coder) return SKIPPED;
|
|
860
|
+
|
|
861
|
+
// #301: the keys typed, only for the servers actually used, never the empty ones — each under
|
|
862
|
+
// the URL that ANSWERED, which is the one the settings rows are written with.
|
|
863
|
+
const used = [agent?.normalized, coder?.normalized].filter(Boolean);
|
|
864
|
+
const keyList = [];
|
|
865
|
+
for (const [host, key] of keys) {
|
|
866
|
+
const n = key && used.find((u) => u.host === host);
|
|
867
|
+
if (n && !keyList.some((k) => k.url === n.url)) keyList.push({ url: n.url, key });
|
|
868
|
+
}
|
|
773
869
|
|
|
774
870
|
// HALF-CONFIGURED IS A REAL STATE and must be representable. Someone with an agent box but no
|
|
775
871
|
// coder yet, or a coder but no agent box, has made progress worth keeping — forcing both or
|
|
@@ -786,6 +882,7 @@ export async function askOllamaSetup(
|
|
|
786
882
|
...(embed ? { embed } : {}),
|
|
787
883
|
altCoders: (extras ?? []).filter((n) => n !== code),
|
|
788
884
|
ready: Boolean(agent && coder),
|
|
885
|
+
...(keyList.length ? { keys: keyList } : {}),
|
|
789
886
|
};
|
|
790
887
|
}
|
|
791
888
|
|
|
@@ -848,7 +945,7 @@ async function explainIfOpenAi(raw, probeStatus, log, describe) {
|
|
|
848
945
|
return true;
|
|
849
946
|
}
|
|
850
947
|
|
|
851
|
-
async function loadFirstThatAnswers(raw, fetchTags, log) {
|
|
948
|
+
async function loadFirstThatAnswers(raw, fetchTags, log, apiKey = "") {
|
|
852
949
|
const candidates = urlCandidates(raw);
|
|
853
950
|
if (!candidates.length) {
|
|
854
951
|
return { failed: ["that is not a server address — try 192.168.1.40, ollama.local, or a full URL"] };
|
|
@@ -856,7 +953,10 @@ async function loadFirstThatAnswers(raw, fetchTags, log) {
|
|
|
856
953
|
let last = null;
|
|
857
954
|
for (const normalized of candidates) {
|
|
858
955
|
try {
|
|
859
|
-
|
|
956
|
+
// #301: the key goes along only when there is one — a keyless call stays byte-identical.
|
|
957
|
+
const models = apiKey
|
|
958
|
+
? await fetchTags(tagsUrlFor(normalized), { apiKey })
|
|
959
|
+
: await fetchTags(tagsUrlFor(normalized));
|
|
860
960
|
// Say WHICH reading answered, but only when we had to guess — otherwise it is noise
|
|
861
961
|
// repeating what the user just typed.
|
|
862
962
|
if (candidates.length > 1) log(` using ${displayUrl(normalized.url)}`);
|
|
@@ -869,6 +969,11 @@ async function loadFirstThatAnswers(raw, fetchTags, log) {
|
|
|
869
969
|
// NOT PRINTED HERE ANY MORE. "check that Ollama is running there" is exactly the wrong thing
|
|
870
970
|
// to say about a correct address for a hosted provider, and the caller is the only one that
|
|
871
971
|
// knows whether it found a better explanation. It prints one message or the other, never both.
|
|
872
|
-
|
|
972
|
+
// #301: an HTTP status (401/403 = needs a key) travels with the failure so the caller can ask.
|
|
973
|
+
const status = Number(last?.status ?? last?.httpStatus) || 0;
|
|
974
|
+
if (needsKeyAfterStatus(status)) {
|
|
975
|
+
return { status, failed: [`${candidates[0].host} refused the request (HTTP ${status}) — ${apiKey ? "that key was not accepted" : "it needs an API key"}.`] };
|
|
976
|
+
}
|
|
977
|
+
return { status, failed: [`could not reach ${candidates[0].host}: ${networkReason(last)}`,
|
|
873
978
|
"check the address, or that Ollama is running there and listening on the network."] };
|
|
874
979
|
}
|
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.769",
|
|
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",
|
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
"accent.mjs",
|
|
50
50
|
"gu-cli.mjs",
|
|
51
51
|
"gu-repl.mjs",
|
|
52
|
+
"step-tokens.mjs",
|
|
53
|
+
"model-setup-ui.mjs",
|
|
54
|
+
"setup-prompts.mjs",
|
|
52
55
|
"terminal-hover.mjs",
|
|
53
56
|
"terminal-layout.mjs",
|
|
54
57
|
"terminal-viewport.mjs",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FIRST-RUN PROMPTS THAT PLAIN rl.question CANNOT BE (#301, #300).
|
|
3
|
+
*
|
|
4
|
+
* First-run setup runs before the REPL exists, on its own readline (gu-repl.mjs `rl0`), and every
|
|
5
|
+
* question there was `rl0.question` — which echoes. Two things need more than that:
|
|
6
|
+
*
|
|
7
|
+
* · ESC. The model questions offer "Esc = skip all model setup". Esc submits nothing in readline,
|
|
8
|
+
* so the pending question is cancelled through its AbortSignal and resolved with SKIP_ALL.
|
|
9
|
+
* · A SECRET. An API key, or the MongoDB password (#300), must never reach the screen or the
|
|
10
|
+
* scrollback. Readline's echo and line refresh are muted for the question, one "•" per
|
|
11
|
+
* character is drawn instead (the only proof a keystroke registered), and the answer is taken
|
|
12
|
+
* back out of readline's history — which keeps every submitted line, so the secret would
|
|
13
|
+
* otherwise come back in plain text on the next press of Up.
|
|
14
|
+
*
|
|
15
|
+
* The REPL has its own askSecret with the same guarantees (task #127/#191); this is the version for
|
|
16
|
+
* the one stretch of the program that runs before it.
|
|
17
|
+
*/
|
|
18
|
+
import { SKIP_ALL, isEscapeKey } from "./model-setup-ui.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One question on `rl`. Options: `secret` (mask + keep out of history), `esc` (Esc → SKIP_ALL).
|
|
22
|
+
* `style` decorates the label (the accent colour). Resolves with the answer string, or SKIP_ALL.
|
|
23
|
+
*/
|
|
24
|
+
export function askOn(rl, q, { secret = false, esc = false, style = (s) => s, input = process.stdin, output = process.stdout } = {}) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const shown = style(q);
|
|
27
|
+
const ac = new AbortController();
|
|
28
|
+
const origWrite = rl._writeToOutput;
|
|
29
|
+
const origRefresh = rl._refreshLine;
|
|
30
|
+
let done = false;
|
|
31
|
+
|
|
32
|
+
const finish = (value) => {
|
|
33
|
+
if (done) return;
|
|
34
|
+
done = true;
|
|
35
|
+
input.removeListener?.("keypress", onKey);
|
|
36
|
+
if (secret) {
|
|
37
|
+
rl._writeToOutput = origWrite;
|
|
38
|
+
rl._refreshLine = origRefresh;
|
|
39
|
+
if (Array.isArray(rl.history) && value && rl.history[0] === value) rl.history.shift();
|
|
40
|
+
rl.historyIndex = -1;
|
|
41
|
+
}
|
|
42
|
+
// A muted Enter printed no newline, and an Esc left the caret on the prompt's row.
|
|
43
|
+
if (secret || value === SKIP_ALL) output.write("\n");
|
|
44
|
+
resolve(value);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Runs AFTER readline's own keypress handler (registered when rl was created), so rl.line is
|
|
48
|
+
// already the updated text when the bullets are drawn.
|
|
49
|
+
function onKey(_s, key) {
|
|
50
|
+
if (done) return;
|
|
51
|
+
if (esc && isEscapeKey(key)) {
|
|
52
|
+
ac.abort();
|
|
53
|
+
// The cancelled question leaves what was typed in rl.line; the next question must not
|
|
54
|
+
// start with it.
|
|
55
|
+
rl.line = "";
|
|
56
|
+
rl.cursor = 0;
|
|
57
|
+
finish(SKIP_ALL);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (secret && output.isTTY) {
|
|
61
|
+
output.write(`\r\x1b[2K${shown}${"•".repeat(String(rl.line ?? "").length)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
input.on?.("keypress", onKey);
|
|
66
|
+
// question() draws the label synchronously — mute only AFTER, or the label is muted too.
|
|
67
|
+
rl.question(shown, { signal: ac.signal }, (a) => finish(String(a ?? "")));
|
|
68
|
+
if (secret) {
|
|
69
|
+
rl._writeToOutput = () => {};
|
|
70
|
+
rl._refreshLine = () => {};
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The three readers setup takes: plain, Esc-aware (model questions), masked (keys, passwords). */
|
|
76
|
+
export function setupPrompts(rl, { style = (s) => s, input = process.stdin, output = process.stdout } = {}) {
|
|
77
|
+
return {
|
|
78
|
+
ask: (q) => askOn(rl, q, { style, input, output }),
|
|
79
|
+
askStep: (q) => askOn(rl, q, { esc: true, style, input, output }),
|
|
80
|
+
askSecret: (q) => askOn(rl, q, { secret: true, esc: true, style, input, output }),
|
|
81
|
+
};
|
|
82
|
+
}
|
package/step-tokens.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THIS STEP'S TOKENS, for the thinking line (task #298).
|
|
3
|
+
*
|
|
4
|
+
* The thinking line showed "· ↑ 478k · ↓ 4.3k" — the turn's running totals, which are exactly the
|
|
5
|
+
* bar's "sent 478k" and "↓ out 4.3k" one row below. Same numbers twice. What the line can say that
|
|
6
|
+
* the bar cannot is what the CURRENT model call is costing: its own prompt (↑) and its own output
|
|
7
|
+
* (↓), starting from zero at each new step.
|
|
8
|
+
*
|
|
9
|
+
* Python already knows both per call (`stepInput`, `stepOutput`) and tags them with `step`. The API
|
|
10
|
+
* only stores job fields it knows, but it passes agentPromptBreakdown through as-is — so the worker
|
|
11
|
+
* rides these three figures on that object (liveStep / liveStepIn / liveStepOut) and no API release
|
|
12
|
+
* is needed. Everything that reads the breakdown does so by named bucket, so extra keys are inert.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The state before any step of a turn. */
|
|
16
|
+
export const NO_STEP = Object.freeze({ step: 0, stepIn: 0, stepOut: 0 });
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fold one python "tokens" event into the current step's figures.
|
|
20
|
+
*
|
|
21
|
+
* A new `step` number starts from zero: its output is not known until the call returns, so the old
|
|
22
|
+
* step's ↓ must not linger beside the new step's ↑. Input can arrive twice for one step — the
|
|
23
|
+
* estimate at send time, then the server's exact count if higher — and the larger one wins.
|
|
24
|
+
*/
|
|
25
|
+
export function foldStepTokens(prev, event) {
|
|
26
|
+
let { step, stepIn, stepOut } = prev ?? NO_STEP;
|
|
27
|
+
if (!event || typeof event !== "object") return { step, stepIn, stepOut };
|
|
28
|
+
if (Number.isFinite(event.step) && event.step !== step) {
|
|
29
|
+
step = event.step;
|
|
30
|
+
stepIn = 0;
|
|
31
|
+
stepOut = 0;
|
|
32
|
+
}
|
|
33
|
+
if (Number.isFinite(event.stepInput) && event.stepInput > stepIn) stepIn = event.stepInput;
|
|
34
|
+
if (Number.isFinite(event.stepOutput) && event.stepOutput >= 0) stepOut = event.stepOutput;
|
|
35
|
+
return { step, stepIn, stepOut };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The breakdown the worker PATCHes, carrying the step's figures. */
|
|
39
|
+
export function withStepTokens(breakdown, s) {
|
|
40
|
+
if (!breakdown || typeof breakdown !== "object" || !s || !s.step) return breakdown;
|
|
41
|
+
return { ...breakdown, liveStep: s.step, liveStepIn: s.stepIn, liveStepOut: s.stepOut };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the terminal reads back from a polled breakdown: this step's figures, or null when the
|
|
46
|
+
* breakdown carries none (an older worker, or no step reported yet) — then the line shows nothing
|
|
47
|
+
* rather than a total dressed up as a step.
|
|
48
|
+
*/
|
|
49
|
+
export function readStepTokens(breakdown) {
|
|
50
|
+
if (!breakdown || typeof breakdown !== "object") return null;
|
|
51
|
+
const step = Number(breakdown.liveStep);
|
|
52
|
+
if (!Number.isFinite(step) || step <= 0) return null;
|
|
53
|
+
const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
|
|
54
|
+
return { step, stepIn: n(breakdown.liveStepIn), stepOut: n(breakdown.liveStepOut) };
|
|
55
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -468,6 +468,27 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
|
468
468
|
* `identity` is optional so callers that have nothing to put there (and the tests that predate
|
|
469
469
|
* it) keep producing the same rows as before.
|
|
470
470
|
*/
|
|
471
|
+
/**
|
|
472
|
+
* HOW TO PASTE, for this platform (issue #67). Two different things share the word: TEXT is pasted
|
|
473
|
+
* by the terminal app (gu only sees the result), an IMAGE by gu's own keys (#284).
|
|
474
|
+
*
|
|
475
|
+
* macOS text ⌘V image ctrl+v — a terminal's ⌘V sends nothing for an image
|
|
476
|
+
* Windows text ctrl+v image alt+v — Windows Terminal keeps ctrl+v for itself
|
|
477
|
+
* Linux text ctrl+shift+v image ctrl+v
|
|
478
|
+
*
|
|
479
|
+
* The image half only when the coder can take one (a Claude coder); offering a key that then
|
|
480
|
+
* answers "images need a Claude coder" would be a hint that leads to a warning.
|
|
481
|
+
*/
|
|
482
|
+
export function pasteHint({ platform = process.platform, images = false } = {}) {
|
|
483
|
+
const text = platform === "darwin" ? "⌘V" : platform === "win32" ? "ctrl+v" : "ctrl+shift+v";
|
|
484
|
+
if (!images) return `${text} to paste`;
|
|
485
|
+
const image = platform === "win32" ? "alt+v" : "ctrl+v";
|
|
486
|
+
return `${text} paste · ${image} image`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** At least this many spaces between the mode text and the hint, or the hint is left out. */
|
|
490
|
+
const HINT_MIN_GAP = 3;
|
|
491
|
+
|
|
471
492
|
export function buildFooterRows({
|
|
472
493
|
width,
|
|
473
494
|
gutter = " ",
|
|
@@ -475,16 +496,25 @@ export function buildFooterRows({
|
|
|
475
496
|
identity = "",
|
|
476
497
|
token = "",
|
|
477
498
|
mode = "",
|
|
499
|
+
// Right-aligned on the mode row (issue #67), dim. DROPPED WHOLE when it does not fit: every
|
|
500
|
+
// footer row must stay one physical row (#109/#132/#138), and half a key name is worse than none.
|
|
501
|
+
modeHint = "",
|
|
478
502
|
notice = "",
|
|
479
503
|
missing = "",
|
|
480
504
|
}) {
|
|
481
505
|
const w = Math.max(20, width || 80);
|
|
506
|
+
const modeLeft = gutter + mode;
|
|
507
|
+
const hintGap = modeHint ? w - 1 - visibleLen(modeLeft) - visibleLen(modeHint) : -1;
|
|
508
|
+
const modeRow =
|
|
509
|
+
hintGap >= HINT_MIN_GAP
|
|
510
|
+
? modeLeft + " ".repeat(hintGap) + dim(modeHint)
|
|
511
|
+
: clipVisible(modeLeft, w - 1);
|
|
482
512
|
return [
|
|
483
513
|
dim("─".repeat(w)),
|
|
484
514
|
...(rag ? [clipVisible(gutter + rag, w - 1)] : []),
|
|
485
515
|
...(identity ? [clipVisible(gutter + identity, w - 1)] : []),
|
|
486
516
|
clipVisible(gutter + token, w - 1),
|
|
487
|
-
|
|
517
|
+
modeRow,
|
|
488
518
|
// A passing notice (the image-paste warning, #54) gets its OWN row, under the mode row — it
|
|
489
519
|
// used to be appended to that row and was clipped at the right edge ("…pick one with /mo").
|
|
490
520
|
// Present only while it shows; paintBar re-cuts the frozen zone when the count changes.
|