@tiens.nguyen/gu-cli 1.0.768 → 1.0.770

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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * THE WAY OUT OF THE MODEL QUESTIONS, and the key question that follows a typed server (#301).
3
+ *
4
+ * Reported: at "Agent + embedding server [https://ollama1.gomarsic.cc]:" there was no visible way
5
+ * to NOT choose. Enter takes the default server; a decline existed ("n", "no", "skip", "-") but no
6
+ * prompt said so, and one hint even claimed "press Enter with no address to skip", which was the
7
+ * opposite of what Enter does. Some people want no model setup at all and to load a saved preset.
8
+ *
9
+ * The decisions, agreed with the user (2026-09-29):
10
+ * · ASK ONCE, UP FRONT — guided / load a preset / skip. Enter = guided, so today's flow is one
11
+ * keypress away and unchanged.
12
+ * · TWO WAYS OUT, NOT FIVE — "s" skips a step, Esc skips all model setup. The old words still
13
+ * work, silently.
14
+ * · FULL HINT ONCE, THEN SHORT — a hint under every prompt is noise by the fourth.
15
+ * · A KEY ONLY WHEN ONE MAY BE NEEDED — a LAN Ollama box never needs one, a public https
16
+ * address may, and a server that answers 401/403 certainly does.
17
+ * · CONFIRM THE KEY WITHOUT SHOWING IT — a masked prompt gives no sign it took anything.
18
+ *
19
+ * PURE. Everything here is strings in, strings out, so the whole table is a test.
20
+ */
21
+ import { hostLocality } from "./coding-provider-setup.mjs";
22
+
23
+ /**
24
+ * What an Esc-aware prompt resolves to when Esc is pressed. A string no one can type (it starts
25
+ * with NUL), so it can travel through every `ask` that returns strings without a new type.
26
+ */
27
+ export const SKIP_ALL = "\u0000skip-all";
28
+
29
+ /** Esc, as readline reports it: name "escape" (meta:true on most terminals — accept either). */
30
+ export function isEscapeKey(key) {
31
+ return Boolean(key) && (key.name === "escape" || (key.meta && key.sequence === "\x1b"));
32
+ }
33
+
34
+ /**
35
+ * One answer at a model prompt → what it means.
36
+ * { kind: "all" } Esc: skip all model setup
37
+ * { kind: "skip" } "s" (or the older n / no / skip / -): skip this step
38
+ * { kind: "answer", text } anything else, including "" (= the default)
39
+ */
40
+ export function readStepAnswer(raw) {
41
+ if (raw === SKIP_ALL) return { kind: "all" };
42
+ const text = String(raw ?? "").trim();
43
+ if (/^(s|n|no|skip|-)$/i.test(text)) return { kind: "skip" };
44
+ return { kind: "answer", text };
45
+ }
46
+
47
+ export const FULL_HINT = "Enter = use this · s = skip this step · Esc = skip all model setup";
48
+ export const SHORT_HINT = "s skip · esc skip all";
49
+
50
+ /** A hinter: the full hint the first time it is asked for, the short one after. */
51
+ export function createHinter() {
52
+ let shown = false;
53
+ return () => {
54
+ if (shown) return SHORT_HINT;
55
+ shown = true;
56
+ return FULL_HINT;
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Should typing THIS address be followed by "API key for <host>"?
62
+ *
63
+ * Only for an address that was TYPED (Enter on the default is today's path and stays one key),
64
+ * and only when the host is public: a LAN box (192.168.x, 10.x, *.local, a bare hostname,
65
+ * localhost) is an Ollama that takes no key, and asking there is a question with one answer.
66
+ * A server that answers 401/403 is asked regardless — see needsKeyAfterStatus.
67
+ */
68
+ export function shouldAskKey({ typed, host }) {
69
+ if (!String(typed ?? "").trim()) return false;
70
+ return hostLocality(host) === "public";
71
+ }
72
+
73
+ /** The server refused to list its models without credentials. */
74
+ export function needsKeyAfterStatus(status) {
75
+ return status === 401 || status === 403;
76
+ }
77
+
78
+ /** The last 4 characters, in the bar's own style — enough to tell keys apart, not to use one. */
79
+ export function keyTail(key) {
80
+ const k = String(key ?? "").trim();
81
+ return k.length > 4 ? `····${k.slice(-4)}` : "····";
82
+ }
83
+
84
+ /** The line printed after the masked prompt, so the person knows whether it took anything. */
85
+ export function keyConfirmation(host, key) {
86
+ return String(key ?? "").trim()
87
+ ? `✓ key saved for ${host} (${keyTail(key)})`
88
+ : `no key for ${host}`;
89
+ }
90
+
91
+ /**
92
+ * The up-front question. `presets` = how many saved presets there are: with none, option 2 still
93
+ * shows (so the feature is discoverable) but says how to make one instead of offering nothing.
94
+ */
95
+ export function modelSetupMenu({ presets = 0 } = {}) {
96
+ return [
97
+ "How do you want to set up the models?",
98
+ " 1 Guided — pick the servers and models (Enter)",
99
+ presets > 0
100
+ ? ` 2 Load a saved preset (${presets} saved)`
101
+ : " 2 Load a saved preset (none saved yet — /save-preset makes one)",
102
+ " 3 Skip for now — set them later with /model",
103
+ ];
104
+ }
105
+
106
+ /** An answer to the up-front question → "guided" | "preset" | "skip", or null to ask again. */
107
+ export function parseModelSetupChoice(raw) {
108
+ if (raw === SKIP_ALL) return "skip";
109
+ const t = String(raw ?? "").trim().toLowerCase();
110
+ if (t === "" || t === "1" || t === "guided" || t === "g") return "guided";
111
+ if (t === "2" || t === "preset" || t === "p") return "preset";
112
+ if (t === "3" || t === "s" || t === "skip" || t === "n" || t === "no" || t === "-") return "skip";
113
+ return null;
114
+ }
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
- const typed = (await ask(` Agent + embedding server [${displayUrl(defaultUrl)}]: `)).trim();
661
- if (isDecline(typed)) break;
662
- const tried = await loadFirstThatAnswers(typed || defaultUrl, fetchTags, log);
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(typed || defaultUrl, probeStatus, log, describeOpenAiAgent);
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
- log(" (or press Enter with no address to skip — you can set it later with /model)");
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
- const typed = (await ask(` Coding-model server [${displayUrl(coderDefault)}]: `)).trim();
694
- if (isDecline(typed)) break;
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(typed || coderDefault);
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 loadFirstThatAnswers(typed || coderDefault, fetchTags, log);
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(typed || coderDefault, probeStatus, log, describeOpenAiCoder);
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
- const answer = await ask(` agent [${agentDefault}]: `);
745
- chat = parseMenuChoice(answer, cands, agentDefault) ?? "";
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
- const answer = await ask(` code [${roles.code}]: `);
760
- code = parseMenuChoice(answer, coder.models, roles.code) ?? "";
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
- extras = parseMultiChoice(await ask(" also: "), rest);
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
- const models = await fetchTags(tagsUrlFor(normalized));
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
- return { failed: [`could not reach ${candidates[0].host}: ${networkReason(last)}`,
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.768",
3
+ "version": "1.0.770",
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",
@@ -50,6 +50,10 @@
50
50
  "gu-cli.mjs",
51
51
  "gu-repl.mjs",
52
52
  "step-tokens.mjs",
53
+ "model-setup-ui.mjs",
54
+ "setup-prompts.mjs",
55
+ "turn-stop.mjs",
56
+ "workspace-revert.mjs",
53
57
  "terminal-hover.mjs",
54
58
  "terminal-layout.mjs",
55
59
  "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
+ }
@@ -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
- clipVisible(gutter + mode, w - 1),
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.
package/turn-stop.mjs ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * AFTER Ctrl+C STOPS A TURN — the question goes back in the input, and what the turn changed is
3
+ * named (task #302). Pure helpers; gu-repl.mjs does the drawing.
4
+ *
5
+ * Asked for: "for a couple of seconds the thinking is still in process and the user thinks again
6
+ * and wants to change the question" — as Claude Code does. The screen keeps what the turn printed
7
+ * (the record of what it did); files are NOT reverted by themselves (part of the work may be
8
+ * wanted, and a command that ran cannot be undone) — /undo offers that as a choice.
9
+ */
10
+
11
+ const PASTE_TOKEN_RE = /\[Pasted text #\d+(?: \+\d+ lines)?\]/g;
12
+ const IMAGE_TOKEN_RE = /\[Image #\d+\]/g;
13
+
14
+ /**
15
+ * What was TYPED, captured at the moment the line is submitted — before the pasted blocks and the
16
+ * images are expanded for the model and their stores are cleared. Only the entries whose
17
+ * placeholder is actually in the line are kept.
18
+ */
19
+ export function snapshotInput(raw, { pastes = new Map(), images = new Map() } = {}) {
20
+ const text = String(raw ?? "");
21
+ const keep = (re, store) => {
22
+ const present = new Set(text.match(re) ?? []);
23
+ return [...store].filter(([token]) => present.has(token));
24
+ };
25
+ return { raw: text, pastes: keep(PASTE_TOKEN_RE, pastes), images: keep(IMAGE_TOKEN_RE, images) };
26
+ }
27
+
28
+ /**
29
+ * Put a snapshot back: its placeholders' payloads return to the stores, and the typed text is
30
+ * returned for the input line. Nothing is renumbered — the text is shown exactly as it was.
31
+ */
32
+ export function restoreInput(snap, { pasteStore, imageStore } = {}) {
33
+ if (!snap) return "";
34
+ for (const [token, text] of snap.pastes ?? []) pasteStore?.set(token, text);
35
+ for (const [token, path] of snap.images ?? []) imageStore?.restore?.(token, path);
36
+ return String(snap.raw ?? "");
37
+ }
38
+
39
+ /**
40
+ * The restored question was EDITED and sent: that is a change of mind, so the abandoned version
41
+ * must not stay in history beside the new one (the model would take both as asks). Only when the
42
+ * last entry IS that stopped question; returns whether it replaced it.
43
+ *
44
+ * Sending it UNCHANGED is not handled here — the existing pending-question path re-runs it. And a
45
+ * question cleared and replaced by something else keeps its "still stands" tag (task #247).
46
+ */
47
+ export function replaceStoppedQuestion(history, oldTask, newTask) {
48
+ const last = Array.isArray(history) ? history[history.length - 1] : null;
49
+ const was = String(oldTask ?? "").trim();
50
+ const now = String(newTask ?? "").trim();
51
+ if (!last || last.role !== "user" || !last.status || !was || !now || was === now) return false;
52
+ if (String(last.content ?? "").trim() !== was) return false;
53
+ last.content = now;
54
+ delete last.status;
55
+ return true;
56
+ }
57
+
58
+ /** Short relative names for the stop note: the last two path parts. */
59
+ function shortPath(p) {
60
+ const parts = String(p ?? "").split(/[\\/]/).filter(Boolean);
61
+ return parts.slice(-2).join("/") || String(p ?? "");
62
+ }
63
+
64
+ /**
65
+ * The lines under a stopped turn. `files` = the paths the turn changed (from its run's manifest),
66
+ * `command` = the command it was in the middle of, if any.
67
+ */
68
+ export function stopNoteLines({ files = [], command = "", restored = true } = {}) {
69
+ const out = [];
70
+ if (command) out.push(`stopped while running \`${command}\` — it may be incomplete; run it again if you need it`);
71
+ const uniq = [...new Set(files)];
72
+ if (uniq.length) {
73
+ const shown = uniq.slice(0, 3).map(shortPath).join(", ") + (uniq.length > 3 ? ` +${uniq.length - 3} more` : "");
74
+ out.push(`this turn changed ${uniq.length} file${uniq.length === 1 ? "" : "s"}: ${shown} — /undo puts ${uniq.length === 1 ? "it" : "them"} back`);
75
+ }
76
+ if (restored) out.push("your question is back in the input — edit it and press Enter, or Ctrl+C to clear it");
77
+ return out;
78
+ }