@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/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.767",
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
+ }
@@ -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
+ }
@@ -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.