@tiens.nguyen/gu-cli 1.0.816 → 1.0.817

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.
@@ -40,7 +40,15 @@ import {
40
40
  LOCAL_MONGO_URI,
41
41
  } from "./client-expert-api.mjs";
42
42
  import { askOllamaSetup, localOllamaDefault } from "./ollama-setup.mjs";
43
- import { SKIP_ALL, createHinter, modelSetupMenu, parseModelSetupChoice } from "./model-setup-ui.mjs";
43
+ import {
44
+ SKIP_ALL,
45
+ createHinter,
46
+ modelSetupMenu,
47
+ parseModelSetupChoice,
48
+ providerChoiceMenu,
49
+ parseProviderChoice,
50
+ } from "./model-setup-ui.mjs";
51
+ import { providerPresets } from "./coding-provider-setup.mjs";
44
52
  import { presetFilesIn } from "./presets-transfer.mjs";
45
53
  import { readdirSync, statSync, readFileSync } from "node:fs";
46
54
  import { parseOllamaTags, formatSize } from "./agent-model-command.mjs";
@@ -1324,6 +1332,33 @@ export async function runClientExpertSetup({
1324
1332
  chosenModels = { skipped: true, keys: [], loadPreset: path === "preset" };
1325
1333
  throw new SkipModelQuestion();
1326
1334
  }
1335
+ // WHICH PROVIDER (#132 follow-up): Ollama is still the Enter default, but a hosted provider can be
1336
+ // picked from the preset list or typed. That pick is HANDED to gu's own add-provider flow, which
1337
+ // asks the key and then the model once gu starts — the terminal that flow needs does not exist yet.
1338
+ const provPresets = providerPresets("coding");
1339
+ console.log("");
1340
+ const [ptitle, ...prows] = providerChoiceMenu(provPresets);
1341
+ console.log(" " + bold(ptitle));
1342
+ for (const r of prows) console.log(dim(" " + r));
1343
+ let provider = null;
1344
+ for (let t = 0; t < 3 && !provider; t++) {
1345
+ provider = parseProviderChoice(await askStep(" choose [1]: "), provPresets);
1346
+ }
1347
+ provider = provider ?? { kind: "ollama" };
1348
+ if (provider.kind === "skip") {
1349
+ chosenModels = { skipped: true, keys: [], loadPreset: false };
1350
+ throw new SkipModelQuestion();
1351
+ }
1352
+ if (provider.kind === "preset" || provider.kind === "url") {
1353
+ chosenModels = {
1354
+ skipped: true,
1355
+ keys: [],
1356
+ addProvider: provider.kind === "preset"
1357
+ ? { name: provider.preset.name, url: provider.preset.url, dialect: provider.preset.dialect ?? "openai" }
1358
+ : { name: "", url: "", dialect: "openai" },
1359
+ };
1360
+ throw new SkipModelQuestion();
1361
+ }
1327
1362
  console.log("");
1328
1363
  console.log(" " + bold("Which Ollama server should this machine use?"));
1329
1364
  // THIS MACHINE FIRST, when it has one. Client Expert is the mode whose whole promise is
@@ -1371,6 +1406,7 @@ export async function runClientExpertSetup({
1371
1406
  // #301: skipped on purpose (up front, "s" at both servers, or Esc) → no model field is written.
1372
1407
  const skipModels = Boolean(chosenModels?.skipped);
1373
1408
  const loadPreset = Boolean(chosenModels?.loadPreset);
1409
+ const addProvider = chosenModels?.addProvider ?? null;
1374
1410
 
1375
1411
  try {
1376
1412
  const written = await seedSettings({
@@ -1391,7 +1427,9 @@ export async function runClientExpertSetup({
1391
1427
  }
1392
1428
  if (skipModels) {
1393
1429
  console.log("");
1394
- console.log(" " + cyan("✓ Model setup skipped") + dim(loadPreset
1430
+ console.log(" " + cyan("✓ Model setup skipped") + dim(addProvider
1431
+ ? ` — ${addProvider.name || "the provider list"}: its key and model are asked as soon as gu starts`
1432
+ : loadPreset
1395
1433
  ? " — the preset picker opens as soon as gu starts"
1396
1434
  : " — set them any time with /model (or /use-presets)"));
1397
1435
  } else if (Object.keys(written).length === 0) {
@@ -1443,5 +1481,5 @@ export async function runClientExpertSetup({
1443
1481
  );
1444
1482
  // #301: the caller opens /use-presets once the terminal exists — the picker, the plan and the
1445
1483
  // confirmation are that command's, not rebuilt here.
1446
- return { complete: true, stoppedAt: null, ...(loadPreset ? { loadPreset: true } : {}) };
1484
+ return { complete: true, stoppedAt: null, ...(loadPreset ? { loadPreset: true } : {}), ...(addProvider ? { addProvider } : {}) };
1447
1485
  }
@@ -260,6 +260,12 @@ export function providerPresets(role = "coding") {
260
260
  { name: "Claude API (Anthropic)", url: CLAUDE_API_URL, roles: ["coding", "agent"] },
261
261
  { name: "OpenAI", url: DEFAULT_PROVIDER_URL, roles: ["coding", "agent", "embed"] },
262
262
  { name: "Kimi (Moonshot)", url: "https://api.moonshot.ai/v1", roles: ["coding", "agent"] },
263
+ // #132. Ollama's cloud speaks Ollama's own API (/api/tags), so the preset names its dialect.
264
+ { name: "Ollama cloud", url: "https://ollama.com", dialect: "ollama", roles: ["coding", "agent"] },
265
+ // Qwen's two plans take DIFFERENT keys (Token Plan sk-sp-..., pay-as-you-go sk-ws-...), so the
266
+ // plan is in the name. Both are OpenAI-compatible and already carry their /compatible-mode/v1 path.
267
+ { name: "Qwen Token Plan (OpenAI-compatible)", url: "https://token-plan.maas.qwencloudapi.com/compatible-mode/v1", roles: ["coding", "agent"] },
268
+ { name: "Qwen pay-as-you-go (OpenAI-compatible)", url: "https://maas.qwencloudapi.com/compatible-mode/v1", roles: ["coding", "agent"] },
263
269
  ];
264
270
  return all.filter((p) => p.roles.includes(role));
265
271
  }
@@ -4574,6 +4574,8 @@ def _strip_tool_tags(text: str) -> str:
4574
4574
  """Remove the internal hint tags we append to tool output (e.g. '[SUCCESS …]',
4575
4575
  '[NOTE: …]', 'Note: This URL failed …') so they never leak into the user reply."""
4576
4576
  out = []
4577
+ # DeepSeek tool_calls/invoke markup must never reach the user as the answer (#133).
4578
+ text = _strip_toolcall_markup(text)
4577
4579
  for ln in (text or "").splitlines():
4578
4580
  s = ln.strip()
4579
4581
  if s.startswith("[SUCCESS") or s.startswith("[NOTE:") or s.startswith("Note: This URL failed"):
@@ -4905,6 +4907,62 @@ def _dsml_to_code_block(text):
4905
4907
  return (prose + "\n" if prose else "") + "<code>\n" + body + "\n</code>"
4906
4908
 
4907
4909
 
4910
+ # DeepSeek plain tool-call markup (issue #133). Besides the DSML form above, DeepSeek-family
4911
+ # models answer with <tool_calls><invoke name="X"><parameter name="k" string="true">v</parameter>
4912
+ # </invoke></tool_calls>. That matched nothing, so it reached the user as the FINAL answer (an
4913
+ # unexecuted run_command and no recap). Read it as a code block so the call runs, and strip any
4914
+ # leftover from the final text so it is never shown.
4915
+ _TC_INVOKE = re.compile(r"<invoke\s+name\s*=\s*[\x22\x27]([A-Za-z_]\w*)[\x22\x27]\s*>([\s\S]*?)(?:</invoke\s*>|(?=<invoke\b)|$)")
4916
+ _TC_PARAM = re.compile(r"<parameter\s+name\s*=\s*[\x22\x27]([A-Za-z_]\w*)[\x22\x27]"
4917
+ r"(?:\s+string\s*=\s*[\x22\x27](true|false)[\x22\x27])?\s*>([\s\S]*?)(?:</parameter\s*>|(?=<parameter\b)|$)")
4918
+ _TC_BLOCK = re.compile(r"<\s*tool_calls\s*>[\s\S]*?(?:<\s*/\s*tool_calls\s*>|$)", re.I)
4919
+ _TC_STRAY = re.compile(r"</?\s*tool_calls\s*>|</?\s*invoke\b[^<>]*>|</?\s*parameter\b[^<>]*>", re.I)
4920
+ _TC_JSON_WORDS = {"true": "True", "false": "False", "null": "None"}
4921
+
4922
+
4923
+ def _strip_toolcall_markup(text):
4924
+ """Remove tool_calls/invoke/parameter markup from text that is shown to the user."""
4925
+ if not text or ("<tool_calls" not in text and "<invoke" not in text and "<parameter" not in text):
4926
+ return text
4927
+ return _TC_STRAY.sub("", _TC_BLOCK.sub("", text)).strip()
4928
+
4929
+
4930
+ def _toolcalls_to_code_block(text):
4931
+ """Rewrite tool_calls/invoke/parameter markup into a code block that calls the tools, or
4932
+ return the text unchanged (only when there is no code tag already and the result COMPILES)."""
4933
+ if not text or "<invoke" not in text or re.search(r"<\s*/?\s*code\b", text, re.I):
4934
+ return text
4935
+ calls = []
4936
+ for m in _TC_INVOKE.finditer(text):
4937
+ args = []
4938
+ for pm in _TC_PARAM.finditer(m.group(2)):
4939
+ val = pm.group(3).strip("\n")
4940
+ lit = repr(val)
4941
+ if pm.group(2) == "false":
4942
+ if val in _TC_JSON_WORDS:
4943
+ lit = _TC_JSON_WORDS[val]
4944
+ else:
4945
+ try:
4946
+ json.loads(val)
4947
+ lit = val
4948
+ except Exception: # noqa: BLE001
4949
+ lit = repr(val)
4950
+ args.append(pm.group(1) + "=" + lit)
4951
+ calls.append("print(" + m.group(1) + "(" + ", ".join(args) + "))")
4952
+ if not calls:
4953
+ return text
4954
+ body = "\n".join(calls)
4955
+ try:
4956
+ compile(body, "<gu-code>", "exec")
4957
+ except Exception: # noqa: BLE001
4958
+ return text
4959
+ first = text.find("<tool_calls")
4960
+ if first < 0:
4961
+ first = text.find("<invoke")
4962
+ prose = text[:first].rstrip()
4963
+ return (prose + "\n" if prose else "") + "<code>\n" + body + "\n</code>"
4964
+
4965
+
4908
4966
  def _code_block_readings(text):
4909
4967
  """Every plausible reading of where the code block is, CHEAPEST FIRST.
4910
4968
 
@@ -4969,6 +5027,10 @@ def _canonicalise_code_reply(text):
4969
5027
  if _dsml != text:
4970
5028
  _log("DSML tool-call markup converted to a code block (#119)")
4971
5029
  return _dsml
5030
+ _tc = _toolcalls_to_code_block(text)
5031
+ if _tc != text:
5032
+ _log("tool_calls/invoke markup converted to a code block (#133)")
5033
+ return _tc
4972
5034
  if not text or not _CODE_ANY_TAGGISH.search(text):
4973
5035
  return text
4974
5036
  fallback = None
package/gu-repl.mjs CHANGED
@@ -676,6 +676,8 @@ let clientExpertRanThisProcess = false;
676
676
  // Declared HERE, above the first-run block: that block runs at module top level, before any
677
677
  // declaration further down exists.
678
678
  let openPresetsAtStart = false;
679
+ // #132 follow-up: a hosted provider picked in first-run setup; its key and model are asked once the terminal exists.
680
+ let addProviderAtStart = null;
679
681
 
680
682
  if (!workerKey || !apiBase) {
681
683
  if (process.stdin.isTTY && process.stdout.isTTY) {
@@ -800,6 +802,7 @@ if (!workerKey || !apiBase) {
800
802
  const sp = setupPrompts(rl0, { style: accent });
801
803
  const setup = await runClientExpertSetup({ ask: ask0, askStep: sp.askStep, askSecret: sp.askSecret });
802
804
  if (setup.loadPreset) openPresetsAtStart = true;
805
+ if (setup.addProvider) addProviderAtStart = setup.addProvider;
803
806
  // STOP when setup cannot go further.
804
807
  //
805
808
  // This used to fall through to the hosted sign-in, on the theory that "this machine
@@ -6527,8 +6530,24 @@ async function changeProviderModel(ps, rows, p) {
6527
6530
  /** Point a provider at a different URL, keeping its id so saved picks still resolve. */
6528
6531
  async function changeProviderUrl(ps, rows, p) {
6529
6532
  const md = await import("./model-doctor.mjs");
6530
- console.log("\n" + GUTTER + dim(`Enter keeps ${p.url}`));
6531
- const typed = (await ask(" provider URL: ")).trim();
6533
+ // THE SAME PRESET LIST AS ADDING (#132), OpenAI-compatible ones only: an Ollama row has another
6534
+ // kind, and this flow keeps the row's id and kind. "type a URL…" is the default row, so Enter
6535
+ // still gets the old free-text question.
6536
+ const urlPresets = ps.providerPresets("coding").filter((x) => (x.dialect ?? "openai") === "openai");
6537
+ const urlRows = [...urlPresets.map((x) => `${x.name}${dim(` · ${x.url}`)}`), "type a URL…"];
6538
+ console.log("\n" + GUTTER + dim(`Which provider? (${PICK_HINT})`));
6539
+ const upi = await pickFromList(urlRows, urlRows.length - 1);
6540
+ if (upi < 0) {
6541
+ console.log(GUTTER + dim("(nothing changed)\n"));
6542
+ return;
6543
+ }
6544
+ let typed;
6545
+ if (upi < urlPresets.length) {
6546
+ typed = urlPresets[upi].url;
6547
+ } else {
6548
+ console.log(GUTTER + dim(`Enter keeps ${p.url}`));
6549
+ typed = (await ask(" provider URL: ")).trim();
6550
+ }
6532
6551
  if (!typed || typed === p.url) {
6533
6552
  console.log(GUTTER + dim("(nothing changed)\n"));
6534
6553
  return;
@@ -6682,13 +6701,14 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
6682
6701
  // without Claude, which serves no embeddings).
6683
6702
  const presets = ps.providerPresets(role);
6684
6703
  const rowsShown = [...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`), "another URL…"];
6704
+ // A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
6685
6705
  console.log(dim(` Which provider? (${PICK_HINT})`));
6686
6706
  const pi = await pickFromList(rowsShown, 0);
6687
6707
  if (pi < 0) {
6688
6708
  console.log(dim(" (nothing saved)\n"));
6689
6709
  return;
6690
6710
  }
6691
- if (pi < presets.length) target = as.serverTarget("openai", presets[pi].url);
6711
+ if (pi < presets.length) target = as.serverTarget(presets[pi].dialect ?? "openai", presets[pi].url);
6692
6712
  }
6693
6713
  if (!target && !presetUrl) {
6694
6714
  const dflt = as.defaultServerUrl(role, dialect, settings, existing);
@@ -10238,6 +10258,16 @@ let forgetActiveStatus = null;
10238
10258
  let toggleActiveThought = null;
10239
10259
  /** #324: the screen under the Yes/No picker when it opened — the list picker's bookkeeping (#321). */
10240
10260
  let approvalOpenedOver = null;
10261
+ // #135: deadline (epoch ms) of the auto-"No" for the Yes/No prompt and for a timed list picker, 0 = none.
10262
+ // The remaining time is drawn next to the option that will be chosen, and ticks once a second.
10263
+ let approvalDeadline = 0;
10264
+ let listDeadline = 0;
10265
+ let listTimeoutIndex = -1;
10266
+ /** "2:49" above a minute, "45s" below; never negative. */
10267
+ function countdownLabel(msLeft) {
10268
+ const s = Math.max(0, Math.ceil(msLeft / 1000));
10269
+ return s >= 60 ? Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0") : s + "s";
10270
+ }
10241
10271
  function drawApprovalOptions(redraw) {
10242
10272
  // Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
10243
10273
  // step up 2 rows, clear+rewrite both, landing back where we started.
@@ -10246,16 +10276,19 @@ function drawApprovalOptions(redraw) {
10246
10276
  // Both rows are a handful of columns wide, so they survive any resize on ONE physical row
10247
10277
  // each and the \x1b[2A count stays exact — which is why a resize can simply redraw here.
10248
10278
  const yes = approvalSel === 0 ? accent("▸ Yes") : dim(" Yes");
10249
- const no = approvalSel === 1 ? accent("▸ No") : dim(" No");
10279
+ const tail = approvalDeadline ? " (auto-No in " + countdownLabel(approvalDeadline - Date.now()) + ")" : "";
10280
+ const no = approvalSel === 1 ? accent("▸ No" + tail) : dim(" No" + tail);
10281
+ const noRow = clipVisible(GUTTER + no, Math.max(20, process.stdout.columns || 80) - 1);
10250
10282
  if (!redraw && barPinned() && !scrolledBack()) {
10251
10283
  const content = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length;
10252
10284
  approvalOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
10253
10285
  }
10254
- regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + GUTTER + no + "\n");
10286
+ regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + noRow + "\n");
10255
10287
  }
10256
- function finishApproval(allow) {
10288
+ function finishApproval(allow, timedOut = false) {
10257
10289
  if (!approvalActive) return;
10258
10290
  approvalActive = false;
10291
+ approvalDeadline = 0;
10259
10292
  // #324: on a full screen the two option rows SCROLLED the conversation up by two, and the one
10260
10293
  // result line below takes only one of them back — the content now ends a row above the region's
10261
10294
  // bottom. Unrecorded, every row put back from the transcript afterwards (the slash menu, a
@@ -10266,7 +10299,7 @@ function finishApproval(allow) {
10266
10299
  setPad(blockClosedPad({ contentRows: over.content, pad: over.pad, height: over.height, blockRows: 2 }), over.content);
10267
10300
  }
10268
10301
  // Settle the picker: overwrite the two option lines with a single result line.
10269
- process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
10302
+ process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red(timedOut ? "▸ No — no answer in time, skipped" : "▸ No — skipped")) + "\n");
10270
10303
  const r = approvalResolve;
10271
10304
  approvalResolve = null;
10272
10305
  if (r) r(!!allow);
@@ -10423,11 +10456,17 @@ async function approvalPrompt(command) {
10423
10456
  approvalSel = 0;
10424
10457
  approvalResolve = resolve;
10425
10458
  approvalActive = true;
10459
+ approvalDeadline = Date.now() + 170000;
10426
10460
  drawApprovalOptions(false);
10427
- const t = setTimeout(() => finishApproval(false), 170000);
10461
+ const t = setTimeout(() => finishApproval(false, true), 170000);
10462
+ // #135: redraw the two option rows once a second so the countdown on "No" ticks down.
10463
+ const tick = setInterval(() => {
10464
+ if (approvalActive) drawApprovalOptions(true);
10465
+ }, 1000);
10428
10466
  const orig = resolve;
10429
10467
  approvalResolve = (v) => {
10430
10468
  clearTimeout(t);
10469
+ clearInterval(tick);
10431
10470
  orig(v);
10432
10471
  };
10433
10472
  });
@@ -10492,7 +10531,8 @@ function drawList(redraw) {
10492
10531
  // and "this is where you are" — and collapsing them into one marker is what makes a
10493
10532
  // multi-select feel like it forgot what you chose.
10494
10533
  const box = listMulti ? (listChecked.has(i) ? "[x] " : "[ ] ") : "";
10495
- const body = box + listItems[i];
10534
+ const left = listDeadline && i === listTimeoutIndex ? " (auto in " + countdownLabel(listDeadline - Date.now()) + ")" : "";
10535
+ const body = box + listItems[i] + left;
10496
10536
  return clipVisible(GUTTER + (i === listSel ? accent("▸ " + body) : dim(" " + body)), width - 1);
10497
10537
  };
10498
10538
  const marker = (text) => clipVisible(GUTTER + dim(text), width - 1);
@@ -10681,6 +10721,12 @@ function pickFromList(items, initialSel = 0, opts = {}) {
10681
10721
  listSel = Math.max(0, Math.min(initialSel, items.length - 1));
10682
10722
  listResolve = resolve;
10683
10723
  listPickerActive = true;
10724
+ listDeadline = 0;
10725
+ listTimeoutIndex = -1;
10726
+ if (opts && opts.timeoutMs > 0 && (opts.timeoutIndex ?? -1) >= 0) {
10727
+ listDeadline = Date.now() + opts.timeoutMs;
10728
+ listTimeoutIndex = opts.timeoutIndex;
10729
+ }
10684
10730
  drawList(false);
10685
10731
  // Optional auto-resolve after opts.timeoutMs (task #81 time-budget prompt): if the
10686
10732
  // user doesn't choose in time, finish with opts.timeoutIndex (default -1). Wrap
@@ -10690,9 +10736,18 @@ function pickFromList(items, initialSel = 0, opts = {}) {
10690
10736
  () => finishList(opts.timeoutIndex ?? -1),
10691
10737
  opts.timeoutMs
10692
10738
  );
10739
+ // #135: tick the countdown on the row that will be auto-chosen.
10740
+ const tick = listDeadline
10741
+ ? setInterval(() => {
10742
+ if (listPickerActive) drawList(true);
10743
+ }, 1000)
10744
+ : null;
10693
10745
  const orig = listResolve;
10694
10746
  listResolve = (v) => {
10695
10747
  clearTimeout(to);
10748
+ if (tick) clearInterval(tick);
10749
+ listDeadline = 0;
10750
+ listTimeoutIndex = -1;
10696
10751
  orig(v);
10697
10752
  };
10698
10753
  }
@@ -10999,6 +11054,8 @@ const answerFrom = (s) =>
10999
11054
 
11000
11055
  // Word-wrap a plain (ANSI-free) string into at most `maxLines` lines of width ≤ `width`,
11001
11056
  // appending an ellipsis to the last line when text was dropped (task #96 expand view).
11057
+ const THOUGHT_PREVIEW_LINES = 3; // #134: dim lines always shown under the cut purple thought
11058
+ const THOUGHT_OPEN_LINES = 8; // …and after a click / Ctrl+T
11002
11059
  const wrapThought = (s, width, maxLines) => {
11003
11060
  const words = String(s || "").split(/\s+/).filter(Boolean);
11004
11061
  const lines = [];
@@ -11020,7 +11077,13 @@ const wrapThought = (s, width, maxLines) => {
11020
11077
  if (truncated && lines.length) {
11021
11078
  const last = lines[maxLines - 1] ?? lines[lines.length - 1];
11022
11079
  const room = Math.max(1, width - 1);
11023
- lines[lines.length - 1] = (last.length > room ? last.slice(0, room) : last).replace(/\s+$/, "") + "…";
11080
+ let kept = last;
11081
+ if (last.length > room) {
11082
+ // Cut at a word boundary (#134) so the "…" never lands inside a half word like "lim…".
11083
+ const sp = last.lastIndexOf(" ", room);
11084
+ kept = sp >= Math.floor(room * 0.5) ? last.slice(0, sp) : last.slice(0, room);
11085
+ }
11086
+ lines[lines.length - 1] = kept.replace(/\s+$/, "") + "…";
11024
11087
  }
11025
11088
  return lines;
11026
11089
  };
@@ -11702,12 +11765,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11702
11765
  // to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
11703
11766
  // "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
11704
11767
  // the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
11705
- const clickable = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
11706
- // #350: the click and Ctrl+T ask the SAME question the highlight does. They used to accept
11707
- // any thought at all, so a line that was already whole took the click and redrew for nothing.
11708
- liveExpandable = clickable;
11768
+ const split = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
11709
11769
  let label, rest;
11710
- if (clickable) {
11770
+ if (split) {
11711
11771
  // Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
11712
11772
  // space at/before max-1 (unless that snaps back too far into the line — then hard-cut).
11713
11773
  // Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
@@ -11727,7 +11787,15 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11727
11787
  }
11728
11788
  // When clicked, unfold the remainder as up to 4 wrapped GREY lines BETWEEN the thought and
11729
11789
  // the playful-word line.
11730
- const expLines = thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
11790
+ // #134: the rest of a cut thought is ALWAYS visible as THOUGHT_PREVIEW_LINES dim lines under
11791
+ // the purple one (ending in "…" when more was dropped); a click / Ctrl+T opens it up to
11792
+ // THOUGHT_OPEN_LINES. #350: the click and Ctrl+T ask the SAME question the highlight does —
11793
+ // they only count when opening actually shows more rows than the preview already does.
11794
+ const previewLines = rest ? wrapThought(rest, max, THOUGHT_PREVIEW_LINES) : [];
11795
+ const openLines = rest ? wrapThought(rest, max, THOUGHT_OPEN_LINES) : [];
11796
+ const clickable = split && previewLines.join("\n") !== openLines.join("\n");
11797
+ liveExpandable = clickable;
11798
+ const expLines = thoughtExpanded ? openLines : previewLines;
11731
11799
  // WHAT IS RUNNING, under the label (task #177). Capped at two rows and clipped, because
11732
11800
  // this block is redrawn by walking the caret over PHYSICAL rows — a wrapped row here is
11733
11801
  // the #109/#138 failure mode.
@@ -12863,6 +12931,7 @@ async function main() {
12863
12931
  if (setup?.stoppedAt === "node") process.exit(1); // GitHub #126, as above
12864
12932
  if (setup?.complete) reloadWorkerEnv();
12865
12933
  if (setup?.loadPreset) openPresetsAtStart = true;
12934
+ if (setup?.addProvider) addProviderAtStart = setup.addProvider;
12866
12935
  }
12867
12936
  } catch {
12868
12937
  /* never block the REPL on the mode question — the banner still shows what is configured */
@@ -13717,6 +13786,17 @@ async function main() {
13717
13786
  console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /use-presets)`));
13718
13787
  }
13719
13788
  }
13789
+ // #132 follow-up: the provider chosen in first-run setup — the URL (or the preset list when none was
13790
+ // chosen), then the key, then the model, all by the same flow as "add another provider".
13791
+ if (addProviderAtStart) {
13792
+ const pick = addProviderAtStart;
13793
+ addProviderAtStart = null;
13794
+ try {
13795
+ await addServer({ role: "coding", dialect: pick.dialect || "openai", ...(pick.url ? { url: pick.url } : {}) });
13796
+ } catch (e) {
13797
+ console.log(GUTTER + dim(`(could not add the provider: ${e?.message ?? e} — run /model → add another provider)`));
13798
+ }
13799
+ }
13720
13800
 
13721
13801
  for (;;) {
13722
13802
  // #307: a held question whose turn ended without printing anything (and was not a quick
@@ -103,6 +103,34 @@ export function modelSetupMenu({ presets = 0 } = {}) {
103
103
  ];
104
104
  }
105
105
 
106
+ /**
107
+ * The provider question inside "Guided" (#132 follow-up): Ollama stays the Enter default, then every
108
+ * preset (OpenAI, Claude, Kimi, Qwen...), then "another URL". `presets` = providerPresets().
109
+ */
110
+ export function providerChoiceMenu(presets = []) {
111
+ return [
112
+ "Which provider do you want to use?",
113
+ " 1 Ollama server — this machine or your own (Enter)",
114
+ ...presets.map((p, i) => ` ${i + 2} ${p.name} · ${p.url}`),
115
+ ` ${presets.length + 2} Another provider — type its URL`,
116
+ ];
117
+ }
118
+
119
+ /** An answer to providerChoiceMenu → {kind:"ollama"|"preset"|"url"|"skip", preset?}, or null to ask again. */
120
+ export function parseProviderChoice(raw, presets = []) {
121
+ if (raw === SKIP_ALL) return { kind: "skip" };
122
+ const t = String(raw ?? "").trim().toLowerCase();
123
+ if (t === "" || t === "1" || t === "ollama") return { kind: "ollama" };
124
+ if (t === "s" || t === "skip" || t === "n" || t === "no" || t === "-") return { kind: "skip" };
125
+ if (t === "u" || t === "url") return { kind: "url" };
126
+ if (/^\d+$/.test(t)) {
127
+ const n = Number(t);
128
+ if (n >= 2 && n <= presets.length + 1) return { kind: "preset", preset: presets[n - 2] };
129
+ if (n === presets.length + 2) return { kind: "url" };
130
+ }
131
+ return null;
132
+ }
133
+
106
134
  /** An answer to the up-front question → "guided" | "preset" | "skip", or null to ask again. */
107
135
  export function parseModelSetupChoice(raw) {
108
136
  if (raw === SKIP_ALL) return "skip";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.816",
3
+ "version": "1.0.817",
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",