@tiens.nguyen/gu-cli 1.0.788 → 1.0.789
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/gu-repl.mjs +104 -25
- package/package.json +1 -1
- package/terminal-layout.mjs +112 -2
package/gu-repl.mjs
CHANGED
|
@@ -65,6 +65,9 @@ import {
|
|
|
65
65
|
runningDetailRows,
|
|
66
66
|
runningCommandLabel,
|
|
67
67
|
runningCommandRows,
|
|
68
|
+
dropRestatement,
|
|
69
|
+
isRestatement,
|
|
70
|
+
liveThoughtText,
|
|
68
71
|
formatThinkTime,
|
|
69
72
|
plainAnswer,
|
|
70
73
|
summarizeError,
|
|
@@ -3316,7 +3319,33 @@ const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
|
|
|
3316
3319
|
collapsed,
|
|
3317
3320
|
lines: THOUGHT_COLLAPSED_LINES,
|
|
3318
3321
|
});
|
|
3319
|
-
return rows.map((r) =>
|
|
3322
|
+
return (block.lead ? [""] : []).concat(rows.map((r) => THOUGHT_GUTTER + paint(r)));
|
|
3323
|
+
};
|
|
3324
|
+
|
|
3325
|
+
/**
|
|
3326
|
+
* A THOUGHT HAS ITS OWN MARK AND ITS OWN ROOM (task #334).
|
|
3327
|
+
*
|
|
3328
|
+
* Reported with a screenshot: "● listed files · read file 7×" and, flush under it in the same
|
|
3329
|
+
* grey at the same indent, "The user wants me to use the .env file…" — which read as the rest of
|
|
3330
|
+
* the bullet. Two things were missing, both since #326 made the look-ups ONE row that stays where
|
|
3331
|
+
* it is while thoughts print under it (before that a blank or the question always sat above):
|
|
3332
|
+
*
|
|
3333
|
+
* THE MARK — a dim rule down the margin, on every row of the block, so two or twenty rows read
|
|
3334
|
+
* as one thing and never as an action ("●" means something was DONE). It takes the gutter's own
|
|
3335
|
+
* three cells, so no row gets wider and nothing that measures rows changes.
|
|
3336
|
+
*
|
|
3337
|
+
* THE BLANK ROW — owned by the BLOCK (`lead`), not written beside it. The open group's row is
|
|
3338
|
+
* rewritten in place and everything under it is found by row arithmetic that expects the blocks
|
|
3339
|
+
* to touch (stepGroupStillOpen, the merge below, the shift when a block opens); a blank that
|
|
3340
|
+
* belonged to nobody would break each of those. As the block's first row it moves, repaints
|
|
3341
|
+
* and is replaced with the block.
|
|
3342
|
+
*/
|
|
3343
|
+
const THOUGHT_GUTTER = " " + dim("│") + " ";
|
|
3344
|
+
|
|
3345
|
+
/** Is the last finished line of the transcript empty (so a block printed now needs no blank)? */
|
|
3346
|
+
const transcriptEndsBlank = () => {
|
|
3347
|
+
const lines = transcript.lines();
|
|
3348
|
+
return lines.length < 2 || stripAnsiEscapes(lines[lines.length - 2] ?? "").trim() === "";
|
|
3320
3349
|
};
|
|
3321
3350
|
|
|
3322
3351
|
/** Width available to a thought row: the screen, less the gutter it hangs in. */
|
|
@@ -3332,7 +3361,9 @@ const thoughtWidth = () =>
|
|
|
3332
3361
|
*/
|
|
3333
3362
|
function printThoughtBlock(raw, before) {
|
|
3334
3363
|
if (!sessionShowThought) return false;
|
|
3335
|
-
|
|
3364
|
+
// The question said back ("The user wants me to…") is dropped from the front (#333): it sits
|
|
3365
|
+
// directly under the question, and it was all the two collapsed rows had room for.
|
|
3366
|
+
const prose = dropRestatement(thoughtProse(raw), turnQuestionText);
|
|
3336
3367
|
if (!prose) return false; // a bare tool label or pure code — not what anyone means by a thought
|
|
3337
3368
|
const rows = thoughtBlockRows(prose, {
|
|
3338
3369
|
width: thoughtWidth(),
|
|
@@ -3359,12 +3390,7 @@ function printThoughtBlock(raw, before) {
|
|
|
3359
3390
|
const prev = thoughtBlocks[thoughtBlocks.length - 1];
|
|
3360
3391
|
if (prev && !prev.kind && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
|
|
3361
3392
|
const merged = prev.prose.replace(/\s+$/, "") + " " + prose;
|
|
3362
|
-
const
|
|
3363
|
-
width: thoughtWidth(),
|
|
3364
|
-
collapsed: true,
|
|
3365
|
-
lines: THOUGHT_COLLAPSED_LINES,
|
|
3366
|
-
});
|
|
3367
|
-
const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + dim(r)));
|
|
3393
|
+
const newEnd = transcript.replaceRange(prev.from, prev.to, paintThoughtRows({ ...prev, prose: merged }, false));
|
|
3368
3394
|
if (newEnd !== null) {
|
|
3369
3395
|
prev.prose = merged;
|
|
3370
3396
|
prev.to = newEnd;
|
|
@@ -3377,11 +3403,16 @@ function printThoughtBlock(raw, before) {
|
|
|
3377
3403
|
}
|
|
3378
3404
|
// Scrolled out of the buffer — fall through and print a new block rather than lose it.
|
|
3379
3405
|
}
|
|
3380
|
-
|
|
3406
|
+
// Its own blank row when what is above is not one — see THOUGHT_GUTTER.
|
|
3407
|
+
const block = { from: 0, to: 0, prose, collapsed: true, lead: !transcriptEndsBlank() };
|
|
3408
|
+
const painted = paintThoughtRows(block, false);
|
|
3409
|
+
process.stdout.write(painted.join("\n") + "\n");
|
|
3381
3410
|
// Record where it landed. Measured AFTER the write so a trim during it cannot skew the
|
|
3382
3411
|
// range: the last completed line is length-2 (the final entry is the still-open line).
|
|
3383
3412
|
const endAbs = transcript.dropped() + transcript.length() - 2;
|
|
3384
|
-
|
|
3413
|
+
block.from = endAbs - painted.length + 1;
|
|
3414
|
+
block.to = endAbs;
|
|
3415
|
+
thoughtBlocks.push(block);
|
|
3385
3416
|
if (thoughtBlocks.length > 200) thoughtBlocks = thoughtBlocks.slice(-200);
|
|
3386
3417
|
return true;
|
|
3387
3418
|
}
|
|
@@ -10765,7 +10796,25 @@ async function askTokenAtStart() {
|
|
|
10765
10796
|
/** The token refresh started at launch — askTokenAtStart waits for it (see there). */
|
|
10766
10797
|
let startupTokenRefresh = null;
|
|
10767
10798
|
|
|
10799
|
+
/**
|
|
10800
|
+
* The fuller live thought (what the status row is driven from once it is longer than the row),
|
|
10801
|
+
* held to the same rule as the short one (#333): from its first real sentence, without the
|
|
10802
|
+
* question or gu's own instructions said back — "" while that is all there is so far.
|
|
10803
|
+
*/
|
|
10804
|
+
const liveThoughtClean = (text) => {
|
|
10805
|
+
const kept = dropRestatement(text, turnQuestionText);
|
|
10806
|
+
return isRestatement(kept, turnQuestionText) ? "" : liveThoughtText(kept);
|
|
10807
|
+
};
|
|
10808
|
+
|
|
10809
|
+
// The question the running turn is answering — what a thought is compared with so that the
|
|
10810
|
+
// question said back is not printed under the question (#333). Display only.
|
|
10811
|
+
let turnQuestionText = "";
|
|
10812
|
+
|
|
10768
10813
|
async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
|
|
10814
|
+
{
|
|
10815
|
+
const asked = [...(Array.isArray(history) ? history : [])].reverse().find((m) => m?.role === "user");
|
|
10816
|
+
turnQuestionText = typeof asked?.content === "string" ? asked.content : "";
|
|
10817
|
+
}
|
|
10769
10818
|
// #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
|
|
10770
10819
|
// startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
|
|
10771
10820
|
if (licenceBlock) {
|
|
@@ -11008,6 +11057,28 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11008
11057
|
// became of it. A no-code retry notice discards it (the model restates the plan on the retry);
|
|
11009
11058
|
// anything else prints it. Always null under --verbose, which shows everything as it comes.
|
|
11010
11059
|
let heldThought = null;
|
|
11060
|
+
// A THOUGHT INTERRUPTED BY THE HEARTBEAT IS STILL ONE THOUGHT (#333). The worker's
|
|
11061
|
+
// "…almost completed thinking… (180s)" is a step, and a step closes the stream fence — in the
|
|
11062
|
+
// MIDDLE of the model's sentence. The fence then reopens and the rest of the sentence arrived
|
|
11063
|
+
// as a new thought: the live line read `one, call use_skill(name) FIRST…` and
|
|
11064
|
+
// `` `, `bootstrap-api-service.mjs` — add… `` (both from stored streams), and the history got
|
|
11065
|
+
// the thought in two halves. So a closing fence is only PARKED here: if nothing but blanks and
|
|
11066
|
+
// heartbeats follow and the fence opens again, the thought carries on where it stopped;
|
|
11067
|
+
// anything else finishes it exactly as before.
|
|
11068
|
+
let pausedFence = null; // { step, fence } while a fence is closed but not yet finished
|
|
11069
|
+
const finishPausedFence = () => {
|
|
11070
|
+
if (!pausedFence) return;
|
|
11071
|
+
const step = pausedFence.step;
|
|
11072
|
+
pausedFence = null;
|
|
11073
|
+
// The step's model output is complete. Print its reasoning HERE, before the tools
|
|
11074
|
+
// report what they did, so the transcript reads in the order it happened: the model
|
|
11075
|
+
// says what it intends, then the actions it took appear under it.
|
|
11076
|
+
if (!REPL_VERBOSE && step.trim() && !stepHasCode(step)) {
|
|
11077
|
+
heldThought = step; // #45: decided by the next line — see releaseHeldThought
|
|
11078
|
+
} else if (printThoughtBlock(step, onContent)) {
|
|
11079
|
+
lastWasBlank = false;
|
|
11080
|
+
}
|
|
11081
|
+
};
|
|
11011
11082
|
const releaseHeldThought = () => {
|
|
11012
11083
|
if (heldThought === null) return;
|
|
11013
11084
|
const raw = heldThought;
|
|
@@ -11551,6 +11622,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11551
11622
|
almostDone = /almost/i.test(line);
|
|
11552
11623
|
continue;
|
|
11553
11624
|
}
|
|
11625
|
+
// A parked fence (see pausedFence) is finished by the first real line that is not the
|
|
11626
|
+
// fence opening again.
|
|
11627
|
+
if (pausedFence && line.trim() !== "" && !isFenceLine(line)) finishPausedFence();
|
|
11554
11628
|
// #45: retry notices and context-window bookkeeping are the agent's own machinery. Shown
|
|
11555
11629
|
// under --verbose only; otherwise swallowed, together with the no-code false start the
|
|
11556
11630
|
// retry notice is about.
|
|
@@ -11606,17 +11680,19 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11606
11680
|
if (isFenceLine(line)) {
|
|
11607
11681
|
inStreamFence = !inStreamFence;
|
|
11608
11682
|
if (inStreamFence) {
|
|
11609
|
-
|
|
11610
|
-
|
|
11611
|
-
|
|
11612
|
-
|
|
11613
|
-
|
|
11614
|
-
|
|
11615
|
-
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
11683
|
+
if (pausedFence) {
|
|
11684
|
+
// …the same thought, carrying on. The fence close put a line break where the model
|
|
11685
|
+
// had none ("…When the TASK matches" / " one, call…"), so the two halves are joined
|
|
11686
|
+
// on the line they were cut in.
|
|
11687
|
+
stepThought = pausedFence.step.replace(/\n$/, "");
|
|
11688
|
+
fenceThought = pausedFence.fence.replace(/\n$/, "");
|
|
11689
|
+
pausedFence = null;
|
|
11690
|
+
} else {
|
|
11691
|
+
fenceThought = "";
|
|
11692
|
+
stepThought = "";
|
|
11619
11693
|
}
|
|
11694
|
+
} else {
|
|
11695
|
+
pausedFence = { step: stepThought, fence: fenceThought };
|
|
11620
11696
|
stepThought = "";
|
|
11621
11697
|
}
|
|
11622
11698
|
continue;
|
|
@@ -11636,11 +11712,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11636
11712
|
// Accumulate a bounded head of the stream (enough to see the Thought prose OR the
|
|
11637
11713
|
// tool call), capped so a 16k-char runaway can't grow the buffer. normalizeThought
|
|
11638
11714
|
// yields a clean label — never raw code/CSS.
|
|
11639
|
-
|
|
11715
|
+
// (1200, not 400, since #333: the line is now taken from past the question said back,
|
|
11716
|
+
// and a restatement alone can run to a few hundred characters)
|
|
11717
|
+
if (fenceThought.length < 1200) {
|
|
11640
11718
|
fenceThought += line + "\n";
|
|
11641
|
-
const th = normalizeThought(fenceThought);
|
|
11719
|
+
const th = normalizeThought(fenceThought, { question: turnQuestionText });
|
|
11642
11720
|
if (th) liveThought = th;
|
|
11643
|
-
fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
|
|
11721
|
+
fullLiveThought = liveThoughtClean(thoughtFull(fenceThought)); // #96: keep the fuller thought for expand
|
|
11644
11722
|
}
|
|
11645
11723
|
continue;
|
|
11646
11724
|
}
|
|
@@ -11852,9 +11930,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11852
11930
|
// live clause from the partial too, so the blinking line updates mid-sentence instead
|
|
11853
11931
|
// of only once the line finally breaks (task #64).
|
|
11854
11932
|
if (inStreamFence && fenceThought.length < 400) {
|
|
11855
|
-
const th = normalizeThought(fenceThought + carry);
|
|
11933
|
+
const th = normalizeThought(fenceThought + carry, { question: turnQuestionText });
|
|
11856
11934
|
if (th) liveThought = th;
|
|
11857
|
-
fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
|
|
11935
|
+
fullLiveThought = liveThoughtClean(thoughtFull(fenceThought + carry)); // #96: fuller thought for expand
|
|
11858
11936
|
}
|
|
11859
11937
|
// A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
|
|
11860
11938
|
// a newline), so tokens are actively flowing — keep the ticker asleep. EXCEPT a buffered
|
|
@@ -12055,6 +12133,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12055
12133
|
lastWasBlank = false;
|
|
12056
12134
|
}
|
|
12057
12135
|
}
|
|
12136
|
+
finishPausedFence(); // #333: a fence closed and nothing followed — it is finished now
|
|
12058
12137
|
releaseHeldThought(); // #45: nothing followed it, so it was not a false start
|
|
12059
12138
|
clearStatus();
|
|
12060
12139
|
// Task #104: expose this turn's code-model INPUT + the CUMULATIVE output-token
|
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.789",
|
|
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",
|
package/terminal-layout.mjs
CHANGED
|
@@ -2434,11 +2434,104 @@ export function firstToolArg(codePart) {
|
|
|
2434
2434
|
return { name: m[1], arg: String(value).trim() };
|
|
2435
2435
|
}
|
|
2436
2436
|
|
|
2437
|
+
/**
|
|
2438
|
+
* WHERE A THOUGHT LINE REALLY STARTS (task #333).
|
|
2439
|
+
*
|
|
2440
|
+
* The live line once read `one, call use_skill(name) FIRST, read what it returns, and follow it".
|
|
2441
|
+
* I'll…` for 83 seconds, and later `` `, `bootstrap-api-service.mjs` — add … `` — both the tail of
|
|
2442
|
+
* a sentence whose head had been cut away. The main cause is fixed where it happened (a heartbeat
|
|
2443
|
+
* splitting the thought, see the fence handling in gu-repl.mjs); this is the guard for every other
|
|
2444
|
+
* way a line can arrive headless (a poll boundary, a buffer cap): a line that opens with closing
|
|
2445
|
+
* punctuation, or with a lower-case word, is the middle of something. Start at the next sentence
|
|
2446
|
+
* instead — and when there is none, say nothing ("" keeps whatever the line showed before).
|
|
2447
|
+
*/
|
|
2448
|
+
const _SENTENCE_END = /[.!?]["'”’`)\]]*\s+(?=["'“‘`(\[]?[A-Z0-9])/g;
|
|
2449
|
+
|
|
2450
|
+
export function thoughtLineStart(line) {
|
|
2451
|
+
const s = String(line ?? "").trim();
|
|
2452
|
+
if (!s) return "";
|
|
2453
|
+
const headless =
|
|
2454
|
+
/^[`'"”’)\]}]*\s*[,.;:!?)\]}]/.test(s) || // "`, `bootstrap…", ", and then", ") so"
|
|
2455
|
+
/^[a-z]/.test(s); // "one, call use_skill…" — a sentence starts with a capital
|
|
2456
|
+
if (!headless) return s;
|
|
2457
|
+
_SENTENCE_END.lastIndex = 0;
|
|
2458
|
+
const m = _SENTENCE_END.exec(s);
|
|
2459
|
+
return m ? s.slice(m.index + m[0].length).trim() : "";
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/**
|
|
2463
|
+
* gu's OWN INSTRUCTIONS, recited (task #333). A model weighing its prompt quotes it — "Rules say
|
|
2464
|
+
* call use_skill FIRST when the task matches" — and the status line then shows gu's scaffolding as
|
|
2465
|
+
* if it were news about the user's task. Matched by the fixed phrases of the prompt blocks the
|
|
2466
|
+
* worker writes (gu_skills.prompt_block, the workspace directives); a sentence carrying one is
|
|
2467
|
+
* not shown on the live line.
|
|
2468
|
+
*/
|
|
2469
|
+
const _PROMPT_ECHO = /use_skill\(name\)|call use_skill\b[^.]*\bFIRST\b|When the TASK matches|WORK LIKE A SENIOR DEVELOPER|packaged instructions for particular kinds of work/i;
|
|
2470
|
+
|
|
2471
|
+
/** Split prose into sentences, keeping each one's own punctuation. */
|
|
2472
|
+
function _sentences(text) {
|
|
2473
|
+
const s = String(text ?? "").trim();
|
|
2474
|
+
const out = [];
|
|
2475
|
+
let from = 0;
|
|
2476
|
+
_SENTENCE_END.lastIndex = 0;
|
|
2477
|
+
let m;
|
|
2478
|
+
while ((m = _SENTENCE_END.exec(s))) {
|
|
2479
|
+
out.push(s.slice(from, m.index + m[0].length).trim());
|
|
2480
|
+
from = m.index + m[0].length;
|
|
2481
|
+
}
|
|
2482
|
+
if (from < s.length) out.push(s.slice(from).trim());
|
|
2483
|
+
return out.filter(Boolean);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/** The line to show live: from its first real sentence, past any recital of gu's own prompt. */
|
|
2487
|
+
export function liveThoughtText(line) {
|
|
2488
|
+
const sentences = _sentences(thoughtLineStart(line));
|
|
2489
|
+
const at = sentences.findIndex((x) => !_PROMPT_ECHO.test(x));
|
|
2490
|
+
return at < 0 ? "" : sentences.slice(at).join(" ");
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/**
|
|
2494
|
+
* THE QUESTION, SAID BACK (task #333). A thought that opens "The user wants me to use the .env
|
|
2495
|
+
* file and add a template file." sits directly under ">> currently we are not using the .env
|
|
2496
|
+
* file, please use that and also add a template file" — the same thing twice, and on the two
|
|
2497
|
+
* collapsed rows that are all most people read. Measured on this machine's stored turns: 169 of
|
|
2498
|
+
* 296 first thoughts (57%) open this way.
|
|
2499
|
+
*
|
|
2500
|
+
* Leading sentences that restate are dropped; what the model said NEXT — what it will look at,
|
|
2501
|
+
* what it found — becomes the block's first row. Two tests, either is enough: the sentence is
|
|
2502
|
+
* shaped like a restatement ("The user wants/says/asks…", "Let me understand the task"), or most
|
|
2503
|
+
* of its words are the question's own. Only from the START, and never the whole thought: when
|
|
2504
|
+
* every sentence restates, the last one is kept, so a thought is shortened, not lost.
|
|
2505
|
+
* Display only — what the model wrote, and what is sent back to it, are untouched.
|
|
2506
|
+
*/
|
|
2507
|
+
const _RESTATE_SHAPE = /^(?:(?:ok(?:ay)?|so|alright|right|now|first)[,.]?\s+)*(?:let me (?:first\s+)?(?:understand|re-?read|restate|parse|break down|think about)\b[^.]*\b(?:task|request|question|ask)|the user(?:'s (?:request|question|task|message))?\s+(?:wants?|says?|said|is asking|asks?|asked|would like|needs?|is|has asked|requests?)|the (?:task|request|question)(?:\s+is)?\s*[:,]|(?:the )?user (?:request|query|message)\s*[:,]|the (?:system prompt|instructions?|rules?|skill)\s+(?:says?|tells?|states?|wants?|requires?)|(?:rules?|instructions?) say\b)/i;
|
|
2508
|
+
|
|
2509
|
+
const _words = (t) => String(t ?? "").toLowerCase().match(/[a-z0-9_.-]{3,}/g) ?? [];
|
|
2510
|
+
|
|
2511
|
+
/** Is this ONE sentence the question (or gu's instructions) said back? */
|
|
2512
|
+
export function isRestatement(sentence, question = "") {
|
|
2513
|
+
if (_RESTATE_SHAPE.test(sentence)) return true;
|
|
2514
|
+
const asked = new Set(_words(question));
|
|
2515
|
+
if (asked.size < 4) return false;
|
|
2516
|
+
const w = _words(sentence);
|
|
2517
|
+
if (w.length < 4) return false;
|
|
2518
|
+
return w.filter((x) => asked.has(x)).length / w.length >= 0.7;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
export function dropRestatement(prose, question = "") {
|
|
2522
|
+
const sentences = _sentences(prose);
|
|
2523
|
+
if (sentences.length < 2) return String(prose ?? "").trim();
|
|
2524
|
+
const restates = (sentence) => isRestatement(sentence, question);
|
|
2525
|
+
let at = 0;
|
|
2526
|
+
while (at < sentences.length - 1 && restates(sentences[at])) at++;
|
|
2527
|
+
return sentences.slice(at).join(" ");
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2437
2530
|
/**
|
|
2438
2531
|
* Raw in-fence stream → a short, meaningful status: a real Thought clause if the model wrote
|
|
2439
2532
|
* one, else a label naming the tool it is calling, else "" (the caller shows "Thinking…").
|
|
2440
2533
|
*/
|
|
2441
|
-
export function normalizeThought(buf) {
|
|
2534
|
+
export function normalizeThought(buf, { question = null } = {}) {
|
|
2442
2535
|
const t = String(buf || "");
|
|
2443
2536
|
const ci = t.search(/<code[\s>]/i);
|
|
2444
2537
|
const head = ci >= 0 ? t.slice(0, ci) : t;
|
|
@@ -2455,7 +2548,24 @@ export function normalizeThought(buf) {
|
|
|
2455
2548
|
.map((x) => x.trim())
|
|
2456
2549
|
.find(Boolean);
|
|
2457
2550
|
const proseIsClause = !!prose && prose.length >= 6 && prose.trim().split(/\s+/).length >= 2;
|
|
2458
|
-
if (proseIsClause && !_thoughtLooksCodey(prose))
|
|
2551
|
+
if (proseIsClause && !_thoughtLooksCodey(prose)) {
|
|
2552
|
+
// …from where a sentence starts, and never gu's own prompt said back (#333). A line with
|
|
2553
|
+
// nothing left falls through to the tool label / "", like any other non-thought.
|
|
2554
|
+
//
|
|
2555
|
+
// WITH THE QUESTION KNOWN (the terminal passes it), the line is taken from the whole head
|
|
2556
|
+
// rather than its first line, minus the question said back: "The user wants me to read
|
|
2557
|
+
// package.json…" sat on the status row for the whole wait while the next sentence — what
|
|
2558
|
+
// the model was actually about to do — was already there. While all there is so far IS the
|
|
2559
|
+
// restatement, nothing is shown: the row keeps "Thinking" for the second that takes.
|
|
2560
|
+
let text = prose;
|
|
2561
|
+
if (question !== null) {
|
|
2562
|
+
const whole = head.replace(/^\s*Thought:\s*/i, "").replace(/\s+/g, " ").trim();
|
|
2563
|
+
text = dropRestatement(whole, question);
|
|
2564
|
+
if (isRestatement(text, question)) text = "";
|
|
2565
|
+
}
|
|
2566
|
+
const shown = liveThoughtText(text);
|
|
2567
|
+
if (shown.length >= 6 && shown.split(/\s+/).length >= 2) return shown.slice(0, 100);
|
|
2568
|
+
}
|
|
2459
2569
|
// 2) otherwise name the tool being called (from the code part).
|
|
2460
2570
|
const codePart = ci >= 0 ? t.slice(ci) : t;
|
|
2461
2571
|
const call = firstToolArg(codePart);
|