@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.
- package/client-expert-run.mjs +41 -3
- package/coding-provider-setup.mjs +6 -0
- package/gonext_agent_chat.py +62 -0
- package/gu-repl.mjs +96 -16
- package/model-setup-ui.mjs +28 -0
- package/package.json +1 -1
package/client-expert-run.mjs
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
6531
|
-
|
|
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
|
|
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" +
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
package/model-setup-ui.mjs
CHANGED
|
@@ -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.
|
|
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",
|