@tiens.nguyen/gu-cli 1.0.784 → 1.0.786
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/completed-flush.mjs +47 -0
- package/gonext_agent_chat.py +1 -1
- package/gu-repl.mjs +252 -39
- package/gu_hooks.py +102 -4
- package/gu_skills.py +64 -4
- package/hooks-command.mjs +14 -9
- package/hooks-config.mjs +130 -33
- package/mcp-command.mjs +11 -5
- package/mcp-config.mjs +99 -11
- package/package.json +4 -2
- package/skill-source.mjs +60 -0
- package/skills.mjs +24 -12
- package/step-group.mjs +140 -0
- package/terminal-layout.mjs +16 -2
package/completed-flush.mjs
CHANGED
|
@@ -103,3 +103,50 @@ export function completedFlush({
|
|
|
103
103
|
const steps = tail.slice(0, close);
|
|
104
104
|
return steps ? { kind: "steps", text: steps } : { kind: "none", text: "" };
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* WHILE THE JOB IS STILL RUNNING: which of the newly arrived text is the turn's WORK (step lines
|
|
109
|
+
* and thoughts, inside <think>…</think>) and which is the ANSWER (outside it). Task #326.
|
|
110
|
+
*
|
|
111
|
+
* An agent turn's stream is `<think>steps…</think>answer`. The answer is the caller's to print —
|
|
112
|
+
* once, wrapped, from the completed resultText. completedFlush has always stopped at </think> for
|
|
113
|
+
* that reason, but the RUNNING poll did not: it fed everything to the step printer. So whenever
|
|
114
|
+
* the answer's chunk was polled before the job flipped to "completed" (any answer long enough to
|
|
115
|
+
* be posted on its own), every paragraph of it was printed as a "●" step bullet — and then the
|
|
116
|
+
* whole answer again under "recap:". Reported as the answer appearing twice; short answers escaped
|
|
117
|
+
* only because their chunk and the completion arrived in the same poll.
|
|
118
|
+
*
|
|
119
|
+
* `state` is carried from poll to poll: { inThink, held }. `held` is the start of a tag cut in two
|
|
120
|
+
* by a poll boundary ("</thi" + "nk>"), kept back until the rest arrives.
|
|
121
|
+
* → { parts: [{ inside, text }], state } — the tags themselves are dropped.
|
|
122
|
+
*/
|
|
123
|
+
export function splitLiveStream(fresh, state = {}) {
|
|
124
|
+
let text = String(state.held ?? "") + String(fresh ?? "");
|
|
125
|
+
let inThink = Boolean(state.inThink);
|
|
126
|
+
const parts = [];
|
|
127
|
+
const push = (inside, t) => {
|
|
128
|
+
if (!t) return;
|
|
129
|
+
const last = parts[parts.length - 1];
|
|
130
|
+
if (last && last.inside === inside) last.text += t;
|
|
131
|
+
else parts.push({ inside, text: t });
|
|
132
|
+
};
|
|
133
|
+
// Hold back a tail that could be the beginning of a tag.
|
|
134
|
+
let held = "";
|
|
135
|
+
const lt = text.lastIndexOf("<");
|
|
136
|
+
if (lt >= 0 && lt > text.length - 8) {
|
|
137
|
+
const tail = text.slice(lt).toLowerCase();
|
|
138
|
+
if ("<think>".startsWith(tail) || "</think>".startsWith(tail)) {
|
|
139
|
+
held = text.slice(lt);
|
|
140
|
+
text = text.slice(0, lt);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const re = /<(\/?)think>/gi;
|
|
144
|
+
let at = 0;
|
|
145
|
+
for (let m; (m = re.exec(text)); ) {
|
|
146
|
+
push(inThink, text.slice(at, m.index));
|
|
147
|
+
inThink = m[1] !== "/";
|
|
148
|
+
at = m.index + m[0].length;
|
|
149
|
+
}
|
|
150
|
+
push(inThink, text.slice(at));
|
|
151
|
+
return { parts, state: { inThink, held } };
|
|
152
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -7782,7 +7782,7 @@ def run_agent_chat(cfg):
|
|
|
7782
7782
|
_skill_call = _skills_mod.invocation(task_text, _skills)
|
|
7783
7783
|
if _skill_call:
|
|
7784
7784
|
_sk, _sk_body, _sk_req = _skill_call
|
|
7785
|
-
task_text = (_skills_mod.render(_sk, _sk_body) + "\n\nThe user's request: "
|
|
7785
|
+
task_text = (_skills_mod.render(_sk, _sk_body, _sk_req) + "\n\nThe user's request: "
|
|
7786
7786
|
+ (_sk_req or "(nothing more — do what the skill describes)"))
|
|
7787
7787
|
_log(f"skill invoked: {_sk['name']}")
|
|
7788
7788
|
|
package/gu-repl.mjs
CHANGED
|
@@ -23,8 +23,6 @@
|
|
|
23
23
|
* Requires the worker daemon to be running (it claims the job).
|
|
24
24
|
* Commands: /exit /model /new /help Ctrl-C stops following the current run.
|
|
25
25
|
*/
|
|
26
|
-
import { appendFileSync as __afs } from "node:fs";
|
|
27
|
-
const __dbg = (...a) => { try { __afs("/private/tmp/claude-501/-Users-joseph-Projects-gonext/bf6ac2f6-435f-4150-a01e-cea31d8ff4d2/scratchpad/r321/dbg.log", a.map((x) => JSON.stringify(x)).join(" ") + "\n"); } catch {} };
|
|
28
26
|
import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
|
|
29
27
|
import { readStepTokens, readTurnCache } from "./step-tokens.mjs";
|
|
30
28
|
import { snapshotInput, restoreInput, replaceStoppedQuestion, restoredQuestionLetGo, stopNoteLines } from "./turn-stop.mjs";
|
|
@@ -128,12 +126,14 @@ import {
|
|
|
128
126
|
rankSlashCommands,
|
|
129
127
|
slashMenuWindow,
|
|
130
128
|
} from "./terminal-layout.mjs";
|
|
131
|
-
import { completedFlush } from "./completed-flush.mjs";
|
|
129
|
+
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
130
|
+
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
132
131
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
133
132
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
134
133
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
135
134
|
import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
136
135
|
import { readHooks } from "./hooks-config.mjs";
|
|
136
|
+
import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.mjs";
|
|
137
137
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
138
138
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
139
139
|
import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
|
|
@@ -897,6 +897,7 @@ const COMMANDS = [
|
|
|
897
897
|
{ name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
|
|
898
898
|
{ name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
|
|
899
899
|
{ name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
|
|
900
|
+
{ name: "/switch-skill", usage: "/switch-skill [claude|gu]", desc: "whose skills, MCP servers and hooks this folder uses — Claude Code's (.claude) or gu's own (.gonext)" },
|
|
900
901
|
{ name: "/help", desc: "list these commands" },
|
|
901
902
|
{ name: "/exit", aliases: ["/quit"], desc: "quit" },
|
|
902
903
|
];
|
|
@@ -1004,7 +1005,6 @@ const OUT_RESTORE = "\x1b8";
|
|
|
1004
1005
|
const OUT_SAVE = "\x1b7";
|
|
1005
1006
|
/** Draw in-place UI that lives in the SCROLLING area (status block, pickers, menus). */
|
|
1006
1007
|
const regionUi = (seq) => {
|
|
1007
|
-
if (/\n/.test(seq)) __dbg("regionUi with newline", { nl: (seq.match(/\n/g) || []).length, head: seq.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").slice(0, 60), at: new Error().stack.split("\n").slice(2, 5).map((x) => x.trim().slice(0, 80)) });
|
|
1008
1008
|
if (!barPinned()) {
|
|
1009
1009
|
ui(seq);
|
|
1010
1010
|
return;
|
|
@@ -2598,9 +2598,15 @@ const viewportHeight = () => Math.max(1, viewportRows() - barReserved);
|
|
|
2598
2598
|
* Set by the active turn (it owns `statusShown`/`shownRows`), null between turns.
|
|
2599
2599
|
*/
|
|
2600
2600
|
let liveOverlayRows = null;
|
|
2601
|
-
/**
|
|
2601
|
+
/**
|
|
2602
|
+
* Rows the TRANSCRIPT actually gets: the scrolling area, less any live block under it — and less
|
|
2603
|
+
* the blank rows a closed picker or a shrunk zone left below the content (#321's pad, #324).
|
|
2604
|
+
* Those rows hold nothing, exactly like the live block's: every screen-row → line mapping that
|
|
2605
|
+
* forgot them was off by that many rows. Reported as the question echo landing on the line above
|
|
2606
|
+
* it: the slash menu and the hover put rows BACK from a view that sat `pad` rows too low.
|
|
2607
|
+
*/
|
|
2602
2608
|
const transcriptRows = () =>
|
|
2603
|
-
Math.max(1, viewportHeight() - (liveOverlayRows ? liveOverlayRows() : 0));
|
|
2609
|
+
Math.max(1, viewportHeight() - (liveOverlayRows ? liveOverlayRows() : 0) - livePad());
|
|
2604
2610
|
// Rows the frozen zone occupies: the zone's head (the separator and the blank under it — see
|
|
2605
2611
|
// INPUT_HEAD_ROWS) + the input line, then the bar's own rows (which grow by one under
|
|
2606
2612
|
// --verbose). Everything that reserves, measures or paints the zone MUST use this — reserving
|
|
@@ -2616,10 +2622,23 @@ function pinBar() {
|
|
|
2616
2622
|
const rows = frozenRowCount();
|
|
2617
2623
|
// Re-cutting keeps the OUTPUT position that is already in the save slot (preserveCaret:false);
|
|
2618
2624
|
// paintBar() below puts the caret back on the input line.
|
|
2619
|
-
|
|
2625
|
+
const first = !barPinned();
|
|
2626
|
+
if (!first) ui(scrollRegionSeq(viewportRows(), rows, { preserveCaret: false }));
|
|
2620
2627
|
else ui(reserveRowsSeq(viewportRows(), rows) + OUT_SAVE); // …and remember where output goes
|
|
2621
2628
|
barReserved = rows;
|
|
2622
|
-
|
|
2629
|
+
if (!first) {
|
|
2630
|
+
paintBar();
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
// THE FIRST PIN TAKES THE SCREEN INTO gu's HANDS (#321, #324): one repaint from the transcript,
|
|
2634
|
+
// which also puts the OUTPUT POSITION at the end of the content. reserveRowsSeq leaves it on
|
|
2635
|
+
// the region's LAST row — right when the screen is full, but after a short banner the content
|
|
2636
|
+
// ends near the top, and every later height change is planned from where the content ends
|
|
2637
|
+
// (zoneResizePlan). Left at the bottom, the two disagreed: the region was cut above the saved
|
|
2638
|
+
// position and the next line — the question's echo — was written rows away from its place, on
|
|
2639
|
+
// top of the line printed before it. No pad survives a repaint: the screen IS the model now.
|
|
2640
|
+
padRows = 0;
|
|
2641
|
+
paintLiveTail(); // ends in paintBar
|
|
2623
2642
|
}
|
|
2624
2643
|
/** Give the rows back and clear them — every exit path must reach this. */
|
|
2625
2644
|
function unpinBar() {
|
|
@@ -2765,14 +2784,12 @@ function paintBar() {
|
|
|
2765
2784
|
// re-cut the region before painting, or its top rows would land inside the scrolling area.
|
|
2766
2785
|
const prev = barReserved;
|
|
2767
2786
|
const regrew = zone.rows.length !== prev;
|
|
2768
|
-
if (regrew) { const v = viewportSlice(screenRows(), 0, viewportHeight()).rows; __dbg("MODEL AT REGROW", v.map((r) => r.replace(/\x1b\[[0-9;]*m/g, "").slice(0, 50))); }
|
|
2769
|
-
if (regrew) __dbg("ANY regrow", { prev, now: zone.rows.length, scrolledBack: scrolledBack(), rows: displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length, pad: padRows, vp: viewportRows() });
|
|
2770
2787
|
if (regrew) {
|
|
2771
2788
|
// #321: MOVE THE SCREEN LIKE A TERMINAL, never repaint it from the transcript — a repaint put
|
|
2772
2789
|
// lines the terminal had already scrolled away back on screen, and the next output scrolled
|
|
2773
2790
|
// them into the scrollback AGAIN (the banner and the update notice repeated after an update;
|
|
2774
2791
|
// "auto-test off" doubled). See zoneResizePlan.
|
|
2775
|
-
const live =
|
|
2792
|
+
const live = !scrolledBack();
|
|
2776
2793
|
let plan = null;
|
|
2777
2794
|
let rows = 0;
|
|
2778
2795
|
if (live) {
|
|
@@ -2793,7 +2810,6 @@ function paintBar() {
|
|
|
2793
2810
|
ui(clear);
|
|
2794
2811
|
}
|
|
2795
2812
|
if (live) setPad(plan.pad, rows);
|
|
2796
|
-
__dbg("regrow", { prev, now: zone.rows.length, rows, plan, live });
|
|
2797
2813
|
barReserved = zone.rows.length;
|
|
2798
2814
|
}
|
|
2799
2815
|
const top = viewportRows() - zone.rows.length + 1;
|
|
@@ -2820,10 +2836,9 @@ function paintBar() {
|
|
|
2820
2836
|
paintFrozenRowsSeq(zone.rows, viewportRows(), { preserveCaret: false }) +
|
|
2821
2837
|
parkCaretSeq(zone.caretRow, zone.caretCol)
|
|
2822
2838
|
);
|
|
2823
|
-
//
|
|
2824
|
-
//
|
|
2825
|
-
//
|
|
2826
|
-
if (regrew && !scrolledBack() && prev === 0) paintLiveTail();
|
|
2839
|
+
// NO REPAINT HERE: a height change moved the screen above (#321) — repainting it from the
|
|
2840
|
+
// transcript is what filled the scrollback with copies. The one repaint is pinBar's, on the
|
|
2841
|
+
// first pin.
|
|
2827
2842
|
}
|
|
2828
2843
|
/** Bytes that put the caret back on text row `row` (0-based) at 1-based `col`. */
|
|
2829
2844
|
const parkCaretSeq = (row, col) => `\x1b[${inputRowAbs + Math.max(0, row | 0)};${col}H`;
|
|
@@ -3002,6 +3017,9 @@ let liveSelection = null;
|
|
|
3002
3017
|
let padRows = 0;
|
|
3003
3018
|
let padAnchor = 0;
|
|
3004
3019
|
const currentPad = (rowCount) => (padRows > 0 ? Math.max(0, padRows - Math.max(0, rowCount - padAnchor)) : 0);
|
|
3020
|
+
/** The pad right now — free when there is none, which is nearly always. */
|
|
3021
|
+
const livePad = () =>
|
|
3022
|
+
padRows > 0 ? currentPad(displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length) : 0;
|
|
3005
3023
|
function setPad(n, rowCount) {
|
|
3006
3024
|
padRows = Math.max(0, n | 0);
|
|
3007
3025
|
padAnchor = rowCount;
|
|
@@ -3263,14 +3281,27 @@ const hoverCtl = createHoverController({
|
|
|
3263
3281
|
afterPaint: () => paintBar(),
|
|
3264
3282
|
});
|
|
3265
3283
|
|
|
3266
|
-
/**
|
|
3267
|
-
|
|
3284
|
+
/**
|
|
3285
|
+
* One block's rows, painted: dim normally, full-bright while the pointer is on it.
|
|
3286
|
+
*
|
|
3287
|
+
* TWO KINDS OF BLOCK share the registry, the hover and the click (#326): a THOUGHT (prose) and a
|
|
3288
|
+
* group of the turn's look-ups (`kind: "steps"`, see step-group.mjs) — "searched code 3× · read
|
|
3289
|
+
* lines 2×", one row until it is clicked open. They differ only in how their rows are made, so
|
|
3290
|
+
* that is the one place they are told apart.
|
|
3291
|
+
*/
|
|
3292
|
+
const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
|
|
3293
|
+
const paint = (r) => (hovered ? hoverThought(r) : dim(r));
|
|
3294
|
+
if (block.kind === "steps") {
|
|
3295
|
+
return stepGroupRows(block.items, { width: thoughtWidth(), collapsed }).map(
|
|
3296
|
+
(r) => (r.head ? markLine(dim(BULLET)) : GUTTER) + paint(r.text)
|
|
3297
|
+
);
|
|
3298
|
+
}
|
|
3268
3299
|
const rows = thoughtBlockRows(block.prose, {
|
|
3269
3300
|
width: thoughtWidth(),
|
|
3270
|
-
collapsed
|
|
3301
|
+
collapsed,
|
|
3271
3302
|
lines: THOUGHT_COLLAPSED_LINES,
|
|
3272
3303
|
});
|
|
3273
|
-
return rows.map((r) => GUTTER + (
|
|
3304
|
+
return rows.map((r) => GUTTER + paint(r));
|
|
3274
3305
|
};
|
|
3275
3306
|
|
|
3276
3307
|
/** Width available to a thought row: the screen, less the gutter it hangs in. */
|
|
@@ -3311,7 +3342,7 @@ function printThoughtBlock(raw, before) {
|
|
|
3311
3342
|
// between, so the two really are continuous — grow it rather than start a new one. Any
|
|
3312
3343
|
// bullet, blank or answer in between breaks contiguity, and a separate block is then right.
|
|
3313
3344
|
const prev = thoughtBlocks[thoughtBlocks.length - 1];
|
|
3314
|
-
if (prev && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
|
|
3345
|
+
if (prev && !prev.kind && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
|
|
3315
3346
|
const merged = prev.prose.replace(/\s+$/, "") + " " + prose;
|
|
3316
3347
|
const grown = thoughtBlockRows(merged, {
|
|
3317
3348
|
width: thoughtWidth(),
|
|
@@ -3420,8 +3451,10 @@ let copyBadgeTimer = null;
|
|
|
3420
3451
|
/** The transcript row currently under the viewport row `row`, as it should look. */
|
|
3421
3452
|
function viewportRowText(row) {
|
|
3422
3453
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
3454
|
+
// The view as it is ON SCREEN — pad rows included (#324), or every row comes back `pad` too low.
|
|
3423
3455
|
const rows = displayRows(transcript.lines(), width - 1);
|
|
3424
|
-
const
|
|
3456
|
+
const pad = currentPad(rows.length);
|
|
3457
|
+
const view = viewportSlice(pad ? [...rows, ...Array(pad).fill("")] : rows, scrollOffset, viewportHeight());
|
|
3425
3458
|
const line = view.rows[row - 1];
|
|
3426
3459
|
return typeof line === "string" ? line : "";
|
|
3427
3460
|
}
|
|
@@ -3588,12 +3621,7 @@ function hoverThoughtAtRow(row) {
|
|
|
3588
3621
|
|
|
3589
3622
|
/** Swap one recorded block for its other form and repaint from the transcript. */
|
|
3590
3623
|
function toggleThoughtBlock(block) {
|
|
3591
|
-
const
|
|
3592
|
-
width: thoughtWidth(),
|
|
3593
|
-
collapsed: !block.collapsed,
|
|
3594
|
-
lines: THOUGHT_COLLAPSED_LINES,
|
|
3595
|
-
});
|
|
3596
|
-
const painted = next.map((r) => GUTTER + dim(r));
|
|
3624
|
+
const painted = paintThoughtRows(block, false, !block.collapsed);
|
|
3597
3625
|
const newEnd = transcript.replaceRange(block.from, block.to, painted);
|
|
3598
3626
|
if (newEnd === null) {
|
|
3599
3627
|
// Scrolled out of the buffer — the block is gone, and so is the thing to toggle.
|
|
@@ -3632,7 +3660,9 @@ function paintLiveTail() {
|
|
|
3632
3660
|
// painted row down by the block's height so a selection highlight lands N rows away from the
|
|
3633
3661
|
// text it belongs to. Between turns the block is gone, transcriptRows() === viewportHeight(),
|
|
3634
3662
|
// and this is the behaviour it always had.
|
|
3635
|
-
|
|
3663
|
+
// The pad rows are part of the screen (screenRows has them) but not of the transcript's rows.
|
|
3664
|
+
const pad = livePad();
|
|
3665
|
+
const height = transcriptRows() + pad;
|
|
3636
3666
|
const view = viewportSlice(screenRows(), 0, height);
|
|
3637
3667
|
ui(paintViewportSeq(view.rows, height, width, clipVisible, { preserveCaret: false }));
|
|
3638
3668
|
// Park the caret at the end of the last printed line AND SAVE IT: the tail we just repainted
|
|
@@ -3643,8 +3673,9 @@ function paintLiveTail() {
|
|
|
3643
3673
|
// that slot with the caret parked on the INPUT LINE. The next line printed after any scroll
|
|
3644
3674
|
// round trip was then written at the prompt's column inside the frozen zone — on screen, a
|
|
3645
3675
|
// question indented three columns to the right of every other line.
|
|
3646
|
-
|
|
3647
|
-
const
|
|
3676
|
+
// …the last CONTENT row: output continues above the pad, which it then fills (#324).
|
|
3677
|
+
const lastRow = Math.max(1, view.rows.length - pad);
|
|
3678
|
+
const lastCol = Math.min(width, visibleLen(view.rows[lastRow - 1] ?? "") + 1);
|
|
3648
3679
|
ui(`\x1b[${lastRow};${lastCol}H` + OUT_SAVE);
|
|
3649
3680
|
paintBar();
|
|
3650
3681
|
}
|
|
@@ -7159,6 +7190,82 @@ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stopped
|
|
|
7159
7190
|
if (barPinned()) paintBar();
|
|
7160
7191
|
}
|
|
7161
7192
|
|
|
7193
|
+
/**
|
|
7194
|
+
* /switch-skill — whose skills, MCP servers and hooks THIS FOLDER uses (task #325): Claude Code's,
|
|
7195
|
+
* read where Claude keeps them (~/.claude, ./.claude, ~/.claude.json), or gu's own (~/.gonext,
|
|
7196
|
+
* ./.gonext). One choice for all three — a skill names its MCP server's tools and the hooks feed
|
|
7197
|
+
* the same database, so they only make sense together. Stored in the folder's session file, which
|
|
7198
|
+
* the worker reads for every turn (skill-source.mjs): it holds from the next question, no restart.
|
|
7199
|
+
*/
|
|
7200
|
+
async function runSwitchSkillCommand(line) {
|
|
7201
|
+
const cwd = resolve(process.cwd());
|
|
7202
|
+
const current = readSkillSource(cwd);
|
|
7203
|
+
const arg = (line.trim().split(/\s+/)[1] ?? "").toLowerCase();
|
|
7204
|
+
const where = (src) => (src === "claude" ? "~/.claude, ./.claude, ~/.claude.json" : "~/.gonext, ./.gonext");
|
|
7205
|
+
let pick = "";
|
|
7206
|
+
console.log("");
|
|
7207
|
+
if (arg) {
|
|
7208
|
+
pick = /^claude/.test(arg) ? "claude" : arg === "gu" ? "gu" : "";
|
|
7209
|
+
if (!pick) {
|
|
7210
|
+
console.log(GUTTER + yellow(`"${arg}" is not one of them — /switch-skill claude or /switch-skill gu\n`));
|
|
7211
|
+
return;
|
|
7212
|
+
}
|
|
7213
|
+
} else {
|
|
7214
|
+
const options = ["claude", "gu"];
|
|
7215
|
+
console.log(dim(` Whose skills, MCP servers and hooks this folder uses (${PICK_HINT}):`));
|
|
7216
|
+
const idx = await pickFromList(
|
|
7217
|
+
options.map((o) => sourceLabel(o) + dim(` · ${where(o)}`) + (o === current ? dim(" · in use") : "")),
|
|
7218
|
+
Math.max(0, options.indexOf(current))
|
|
7219
|
+
);
|
|
7220
|
+
if (idx < 0) {
|
|
7221
|
+
console.log(dim(" (cancelled)\n"));
|
|
7222
|
+
paintBar();
|
|
7223
|
+
return;
|
|
7224
|
+
}
|
|
7225
|
+
pick = options[idx];
|
|
7226
|
+
}
|
|
7227
|
+
try {
|
|
7228
|
+
await updateSessionFile(cwd, (raw) => {
|
|
7229
|
+
raw[SKILL_SOURCE_KEY] = pick;
|
|
7230
|
+
raw.updatedAt = new Date().toISOString();
|
|
7231
|
+
});
|
|
7232
|
+
} catch (err) {
|
|
7233
|
+
console.log(GUTTER + red(`couldn't save the choice — ${err.message}. Nothing changed.\n`));
|
|
7234
|
+
return;
|
|
7235
|
+
}
|
|
7236
|
+
_skillScan = { at: 0, skills: [] }; // the "/" menu and Tab follow at once
|
|
7237
|
+
const say = (l) => console.log(GUTTER + l);
|
|
7238
|
+
say(accent(`✓ ${pick === current ? "this folder uses" : "this folder now uses"} ${sourceLabel(pick)}`));
|
|
7239
|
+
say(dim(` skills, MCP servers and hooks are read from ${where(pick)}`));
|
|
7240
|
+
// What was found there — and what was not, said rather than shown as an empty list.
|
|
7241
|
+
try {
|
|
7242
|
+
const sk = discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES });
|
|
7243
|
+
const mcp = listMcpServers({ cwd: process.cwd() });
|
|
7244
|
+
const hk = readHooks({ cwd: process.cwd() });
|
|
7245
|
+
const nHooks = (h) => Object.values(h).reduce((a, groups) => a + groups.reduce((b, g) => b + g.hooks.length, 0), 0);
|
|
7246
|
+
const count = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
7247
|
+
const running = mcp.servers.filter((x) => x.runnable).length;
|
|
7248
|
+
say(dim(` ${count(sk.skills.length, "skill")}${sk.skills.length ? ` (${sk.skills.map((x) => "/" + x.name).join(", ")})` : ""} · ${count(running, "MCP server")} · ${count(nHooks(hk.user) + (hk.projectApproved ? nHooks(hk.project) : 0), "hook")}`));
|
|
7249
|
+
// Nothing there at all is a normal state, not a fault: say so, and that gu needs none of it.
|
|
7250
|
+
if (!sk.skills.length && !mcp.servers.length && !nHooks(hk.user) && !nHooks(hk.project) && !hk.notes.length) {
|
|
7251
|
+
say(dim(" Nothing is set up there yet — gu works the same without any of them."));
|
|
7252
|
+
}
|
|
7253
|
+
for (const w of [...sk.warnings, ...mcp.warnings, ...hk.warnings]) say(yellow(" " + w));
|
|
7254
|
+
const unsupported = mcp.servers.filter((x) => x.error);
|
|
7255
|
+
if (unsupported.length) say(yellow(` ${count(unsupported.length, "MCP server")} cannot run here: ${unsupported.map((x) => `${x.name} (${x.error})`).join("; ")}`));
|
|
7256
|
+
const waiting = mcp.servers.filter((x) => x.scope === "project" && !x.approved && !x.error);
|
|
7257
|
+
if (waiting.length) say(yellow(` ${count(waiting.length, "project MCP server")} waiting for your approval: ${waiting.map((x) => x.name).join(", ")} — /mcp approve <name>`));
|
|
7258
|
+
if (hk.projectPending) say(yellow(` this folder's ${hk.projectFile.replace(/^\.\//, "")} has hooks that will not run until you approve them — /hooks approve`));
|
|
7259
|
+
if (hk.notes.length) say(yellow(` ${count(hk.notes.length, "hook")} in Claude's settings ${hk.notes.length === 1 ? "is" : "are"} NOT run by gu — /hooks says which and why`));
|
|
7260
|
+
if (pick === "claude" && (nHooks(hk.user) || nHooks(hk.project))) say(dim(" Claude's hooks get Claude's event and tool names from gu, so they record here as they do there."));
|
|
7261
|
+
} catch (err) {
|
|
7262
|
+
say(yellow(` (could not read it back: ${err.message})`));
|
|
7263
|
+
}
|
|
7264
|
+
say(dim(` It holds from your next question. ${pick === "claude" ? "/switch-skill gu" : "/switch-skill claude"} switches back.`));
|
|
7265
|
+
console.log("");
|
|
7266
|
+
if (barPinned()) paintBar();
|
|
7267
|
+
}
|
|
7268
|
+
|
|
7162
7269
|
/**
|
|
7163
7270
|
* /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
|
|
7164
7271
|
* run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
|
|
@@ -9509,6 +9616,8 @@ let forgetActiveStatus = null;
|
|
|
9509
9616
|
// Ctrl+T reveal the full Thought from the KEYBOARD, so the feature doesn't depend on the mouse
|
|
9510
9617
|
// capture that would block scrolling (see hoverMouse).
|
|
9511
9618
|
let toggleActiveThought = null;
|
|
9619
|
+
/** #324: the screen under the Yes/No picker when it opened — the list picker's bookkeeping (#321). */
|
|
9620
|
+
let approvalOpenedOver = null;
|
|
9512
9621
|
function drawApprovalOptions(redraw) {
|
|
9513
9622
|
// Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
|
|
9514
9623
|
// step up 2 rows, clear+rewrite both, landing back where we started.
|
|
@@ -9518,11 +9627,24 @@ function drawApprovalOptions(redraw) {
|
|
|
9518
9627
|
// each and the \x1b[2A count stays exact — which is why a resize can simply redraw here.
|
|
9519
9628
|
const yes = approvalSel === 0 ? accent("▸ Yes") : dim(" Yes");
|
|
9520
9629
|
const no = approvalSel === 1 ? accent("▸ No") : dim(" No");
|
|
9630
|
+
if (!redraw && barPinned() && !scrolledBack()) {
|
|
9631
|
+
const content = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length;
|
|
9632
|
+
approvalOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
|
|
9633
|
+
}
|
|
9521
9634
|
regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + GUTTER + no + "\n");
|
|
9522
9635
|
}
|
|
9523
9636
|
function finishApproval(allow) {
|
|
9524
9637
|
if (!approvalActive) return;
|
|
9525
9638
|
approvalActive = false;
|
|
9639
|
+
// #324: on a full screen the two option rows SCROLLED the conversation up by two, and the one
|
|
9640
|
+
// result line below takes only one of them back — the content now ends a row above the region's
|
|
9641
|
+
// bottom. Unrecorded, every row put back from the transcript afterwards (the slash menu, a
|
|
9642
|
+
// hover) was drawn one row low, over its neighbour. Same account as a closed list picker.
|
|
9643
|
+
const over = approvalOpenedOver;
|
|
9644
|
+
approvalOpenedOver = null;
|
|
9645
|
+
if (over && barPinned() && !scrolledBack() && over.height === viewportHeight()) {
|
|
9646
|
+
setPad(blockClosedPad({ contentRows: over.content, pad: over.pad, height: over.height, blockRows: 2 }), over.content);
|
|
9647
|
+
}
|
|
9526
9648
|
// Settle the picker: overwrite the two option lines with a single result line.
|
|
9527
9649
|
process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
|
|
9528
9650
|
const r = approvalResolve;
|
|
@@ -9771,7 +9893,6 @@ function drawList(redraw) {
|
|
|
9771
9893
|
if (!redraw && barPinned() && !scrolledBack()) {
|
|
9772
9894
|
const content = displayRows(transcript.lines(), width - 1).length;
|
|
9773
9895
|
listOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
|
|
9774
|
-
__dbg("picker open", listOpenedOver, { lastLines: transcript.lines().slice(-3) });
|
|
9775
9896
|
listMaxRows = 0;
|
|
9776
9897
|
}
|
|
9777
9898
|
listMaxRows = Math.max(listMaxRows, physicalRows(rows, width));
|
|
@@ -9793,8 +9914,6 @@ function finishList(index) {
|
|
|
9793
9914
|
// higher with blank rows under it — remember them, so nothing repaints those lines back on screen.
|
|
9794
9915
|
if (listOpenedOver && barPinned() && !scrolledBack() && listOpenedOver.height === viewportHeight()) {
|
|
9795
9916
|
setPad(blockClosedPad({ contentRows: listOpenedOver.content, pad: listOpenedOver.pad, height: listOpenedOver.height, blockRows: listMaxRows }), listOpenedOver.content);
|
|
9796
|
-
{ const v = viewportSlice(screenRows(), 0, viewportHeight()).rows; __dbg("MODEL VIEW", v.map((r) => r.replace(/\x1b\[[0-9;]*m/g, "").slice(0, 60))); }
|
|
9797
|
-
__dbg("picker closed", { ...listOpenedOver, blockRows: listMaxRows, newPad: padRows, nowRows: displayRows(transcript.lines(), width - 1).length });
|
|
9798
9917
|
}
|
|
9799
9918
|
listOpenedOver = null;
|
|
9800
9919
|
listMaxRows = 0;
|
|
@@ -10677,6 +10796,8 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
10677
10796
|
// completed resultText really EXTENDS what we streamed (an old worker replaces it
|
|
10678
10797
|
// with the shorter bare answer, which must not be sliced by shownChars).
|
|
10679
10798
|
let seenRaw = "";
|
|
10799
|
+
// #326: where the live stream is relative to the <think> fence, carried from poll to poll.
|
|
10800
|
+
let liveSplit = { inThink: false, held: "" };
|
|
10680
10801
|
|
|
10681
10802
|
// --- Live in-progress ("blinking") bullet -------------------------------------------
|
|
10682
10803
|
// The agent's raw Thought/code stream is SUPPRESSED (see the ~~~-fence handling in
|
|
@@ -11151,6 +11272,67 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11151
11272
|
const openBullet = () => {
|
|
11152
11273
|
if (!lastWasBlank) process.stdout.write("\n");
|
|
11153
11274
|
};
|
|
11275
|
+
// THE TURN'S LOOK-UPS AS ONE LINE (#326, step-group.mjs). `stepGroup` is the group still OPEN:
|
|
11276
|
+
// the next look-up joins it and its row is rewritten where it sits. It stays open only while
|
|
11277
|
+
// nothing but thoughts has been printed under it — an edit card, a command's result, a failure,
|
|
11278
|
+
// an approval or the answer ends it, and the next look-up starts a new group below them.
|
|
11279
|
+
let stepGroup = null;
|
|
11280
|
+
const turnCwd = resolve(process.cwd());
|
|
11281
|
+
/** The last finished transcript line — where a block must end to still be the tail. */
|
|
11282
|
+
const transcriptTail = () => transcript.dropped() + transcript.length() - 2;
|
|
11283
|
+
/** Is the open group still followed by nothing but thought blocks? */
|
|
11284
|
+
const stepGroupStillOpen = () => {
|
|
11285
|
+
if (!stepGroup || !barPinned() || scrolledBack()) return false;
|
|
11286
|
+
let end = stepGroup.to;
|
|
11287
|
+
const at = thoughtBlocks.indexOf(stepGroup);
|
|
11288
|
+
if (at < 0) return false; // dropped from the registry (it is capped): no longer ours to rewrite
|
|
11289
|
+
for (let i = at + 1; i < thoughtBlocks.length; i++) {
|
|
11290
|
+
if (thoughtBlocks[i].from !== end + 1) return false;
|
|
11291
|
+
end = thoughtBlocks[i].to;
|
|
11292
|
+
}
|
|
11293
|
+
return end === transcriptTail();
|
|
11294
|
+
};
|
|
11295
|
+
const addLookupStep = (text) => {
|
|
11296
|
+
if (stepGroupStillOpen()) {
|
|
11297
|
+
stepGroup.items.push(text);
|
|
11298
|
+
const painted = paintThoughtRows(stepGroup, hoverCtl.hovered === stepGroup);
|
|
11299
|
+
const newEnd = transcript.replaceRange(stepGroup.from, stepGroup.to, painted);
|
|
11300
|
+
if (newEnd !== null) {
|
|
11301
|
+
const delta = newEnd - stepGroup.to;
|
|
11302
|
+
stepGroup.to = newEnd;
|
|
11303
|
+
if (delta === 0) {
|
|
11304
|
+
// Collapsed: one row before, one row after — rewrite that row and nothing else.
|
|
11305
|
+
ui(thoughtBlockRepaintSeq(stepGroup));
|
|
11306
|
+
paintBar();
|
|
11307
|
+
} else {
|
|
11308
|
+
// Open (the user clicked it mid-turn): it grew, and what is under it moved down.
|
|
11309
|
+
for (const b of thoughtBlocks) {
|
|
11310
|
+
if (b !== stepGroup && b.from > stepGroup.from) {
|
|
11311
|
+
b.from += delta;
|
|
11312
|
+
b.to += delta;
|
|
11313
|
+
}
|
|
11314
|
+
}
|
|
11315
|
+
hoverCtl.reset();
|
|
11316
|
+
paintLiveTail();
|
|
11317
|
+
}
|
|
11318
|
+
return;
|
|
11319
|
+
}
|
|
11320
|
+
stepGroup.items.pop(); // scrolled out of the buffer: it is history now — start a new one
|
|
11321
|
+
}
|
|
11322
|
+
stepGroup = null;
|
|
11323
|
+
const block = { kind: "steps", from: 0, to: 0, items: [text], collapsed: true };
|
|
11324
|
+
const painted = paintThoughtRows(block, false);
|
|
11325
|
+
openBullet();
|
|
11326
|
+
process.stdout.write(painted.join("\n") + "\n");
|
|
11327
|
+
block.to = transcriptTail();
|
|
11328
|
+
block.from = block.to - painted.length + 1;
|
|
11329
|
+
// Unpinned (a window too short for the zone, or piped): nothing can be rewritten in place, so
|
|
11330
|
+
// every look-up is its own line, as it always was — and there is no click to register for.
|
|
11331
|
+
if (!barPinned()) return;
|
|
11332
|
+
thoughtBlocks.push(block);
|
|
11333
|
+
if (thoughtBlocks.length > 200) thoughtBlocks = thoughtBlocks.slice(-200);
|
|
11334
|
+
stepGroup = block;
|
|
11335
|
+
};
|
|
11154
11336
|
// Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
|
|
11155
11337
|
// a long plain-reply answer stream character-by-character instead of waiting for a "\n"
|
|
11156
11338
|
// that might not arrive until the very end (most short replies have none at all).
|
|
@@ -11370,6 +11552,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11370
11552
|
}
|
|
11371
11553
|
// Skip the status-clear for a BUFFERED plain reply (STREAM_PLAIN_REPLY=false): nothing
|
|
11372
11554
|
// prints yet, so the thinking ticker should keep animating until completion.
|
|
11555
|
+
// #326: the turn's own machinery ("Starting 1 MCP server(s)…", "Composing answer…", the
|
|
11556
|
+
// retrieval tally the group line now carries) is live status, not history. --verbose keeps it.
|
|
11557
|
+
if (!plainReplyFlow && !REPL_VERBOSE && isScaffoldStep(line)) {
|
|
11558
|
+
lastContentAt = Date.now();
|
|
11559
|
+
continue;
|
|
11560
|
+
}
|
|
11373
11561
|
if (alreadyFlushed === 0 && !(plainReplyFlow && !STREAM_PLAIN_REPLY)) onContent();
|
|
11374
11562
|
if (plainReplyFlow) {
|
|
11375
11563
|
// Plain-reply content IS the answer. When STREAMING, print it live (default colour) and
|
|
@@ -11397,8 +11585,17 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11397
11585
|
// one thing not to miss), and a neutral/informational step keeps the dim ● . The marker
|
|
11398
11586
|
// hangs in the gutter (markLine); a long bullet wraps with every continuation row
|
|
11399
11587
|
// re-indented to the SAME gutter, so it shares one text column with the prompt/answer.
|
|
11400
|
-
|
|
11401
|
-
const
|
|
11588
|
+
// Paths under this folder are shown relative to it (#326): the folder is on the bar.
|
|
11589
|
+
const stepText = relativizePaths(line.trim(), turnCwd);
|
|
11590
|
+
// A LOOK-UP joins the turn's open group — one counted row, a click away from the lines
|
|
11591
|
+
// it stands for — instead of printing a line of its own.
|
|
11592
|
+
if (isGroupableStep(stepText, bulletKind(stepText))) {
|
|
11593
|
+
addLookupStep(stepText);
|
|
11594
|
+
lastWasBlank = false;
|
|
11595
|
+
continue;
|
|
11596
|
+
}
|
|
11597
|
+
const st = classifyBullet(stepText);
|
|
11598
|
+
const bRows = wrapBlock(stepText, GUTTER, process.stdout.columns || 80);
|
|
11402
11599
|
// One empty line above EVERY bullet now (see openBullet), which SUBSUMES the old
|
|
11403
11600
|
// special case that opened only the turn's final phase — opensAnswerPhase's blank is
|
|
11404
11601
|
// just one of these. Still idempotent, so it cannot double the seam the caller owns.
|
|
@@ -11660,7 +11857,15 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11660
11857
|
if (seenRaw.length < 4096) {
|
|
11661
11858
|
seenRaw = (seenRaw + text.slice(shownChars)).slice(0, 4096);
|
|
11662
11859
|
}
|
|
11663
|
-
|
|
11860
|
+
// #326: only the turn's WORK is shown live — the step lines and thoughts inside
|
|
11861
|
+
// <think>. What follows the fence is the ANSWER, which the caller prints once from the
|
|
11862
|
+
// completed text; fed to the step printer it came out as "●" bullets and then again
|
|
11863
|
+
// under "recap:". A plain chat reply is the exception: its live stream IS its output.
|
|
11864
|
+
const live = splitLiveStream(text.slice(shownChars), liveSplit);
|
|
11865
|
+
liveSplit = live.state;
|
|
11866
|
+
for (const part of live.parts) {
|
|
11867
|
+
if (part.inside || plainReplyFlow) consume(part.text);
|
|
11868
|
+
}
|
|
11664
11869
|
shownChars = text.length;
|
|
11665
11870
|
}
|
|
11666
11871
|
// Interactive command-approval gate: the agent paused on a risky command. Show the
|
|
@@ -12234,7 +12439,11 @@ async function main() {
|
|
|
12234
12439
|
const nSkills = currentSkills().length;
|
|
12235
12440
|
const nMcp = listMcpServers({ cwd: process.cwd() }).servers.filter((x) => x.runnable).length;
|
|
12236
12441
|
const bits = [nSkills ? `${nSkills} skill${nSkills === 1 ? "" : "s"}` : "", nMcp ? `${nMcp} MCP server${nMcp === 1 ? "" : "s"}` : ""].filter(Boolean);
|
|
12237
|
-
|
|
12442
|
+
// #325: say whose they are when this folder uses Claude Code's — even when there are none.
|
|
12443
|
+
const claudes = readSkillSource(process.cwd()) === "claude";
|
|
12444
|
+
if (bits.length || claudes) {
|
|
12445
|
+
statusRows.push(dim((claudes ? "Claude's: " : "") + (bits.join(" · ") || "no skills or MCP servers") + (claudes ? " (/switch-skill · /mcp)" : " (/help · /mcp)")));
|
|
12446
|
+
}
|
|
12238
12447
|
} catch {
|
|
12239
12448
|
/* a broken file is reported below; the banner is not the place to fail */
|
|
12240
12449
|
}
|
|
@@ -12251,7 +12460,7 @@ async function main() {
|
|
|
12251
12460
|
for (const w of hk.warnings) console.log(GUTTER + yellow(w));
|
|
12252
12461
|
if (hk.projectPending) {
|
|
12253
12462
|
console.log(
|
|
12254
|
-
GUTTER + yellow(
|
|
12463
|
+
GUTTER + yellow(`This folder's ${hk.projectFile.replace(/^\.\//, "")} has hooks that will not run until you approve them.`) +
|
|
12255
12464
|
"\n" + GUTTER + dim(" /hooks shows the commands · /hooks approve")
|
|
12256
12465
|
);
|
|
12257
12466
|
}
|
|
@@ -12890,6 +13099,10 @@ async function main() {
|
|
|
12890
13099
|
await runMcpSlashCommand(line);
|
|
12891
13100
|
continue;
|
|
12892
13101
|
}
|
|
13102
|
+
if (line === "/switch-skill" || line.startsWith("/switch-skill ")) {
|
|
13103
|
+
await runSwitchSkillCommand(line);
|
|
13104
|
+
continue;
|
|
13105
|
+
}
|
|
12893
13106
|
if (line === "/hooks" || line.startsWith("/hooks ")) {
|
|
12894
13107
|
const { runHooksCommand } = await import("./hooks-command.mjs");
|
|
12895
13108
|
const style = { ok: accent, warn: yellow, dim };
|