@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.
@@ -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
+ }
@@ -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
- /** Rows the TRANSCRIPT actually gets: the scrolling area, less any live block under it. */
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
- if (barPinned()) ui(scrollRegionSeq(viewportRows(), rows, { preserveCaret: false }));
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
- paintBar();
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 = prev > 0 && !scrolledBack();
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
- // THE FIRST PIN only: before it, the screen is whatever setup printed, and one repaint puts the
2824
- // view in gu's hands. Every later height change moved the screen above (#321) — a repaint here
2825
- // is what filled the scrollback with copies. Recurses at most once (paintLiveTail → paintBar).
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
- /** One block's rows, painted: dim normally, full-bright while the pointer is on it. */
3267
- const paintThoughtRows = (block, hovered) => {
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: block.collapsed,
3301
+ collapsed,
3271
3302
  lines: THOUGHT_COLLAPSED_LINES,
3272
3303
  });
3273
- return rows.map((r) => GUTTER + (hovered ? hoverThought(r) : dim(r)));
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 view = viewportSlice(rows, scrollOffset, viewportHeight());
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 next = thoughtBlockRows(block.prose, {
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
- const height = transcriptRows();
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
- const lastRow = Math.max(1, view.rows.length);
3647
- const lastCol = Math.min(width, visibleLen(view.rows[view.rows.length - 1] ?? "") + 1);
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
- const st = classifyBullet(line.trim());
11401
- const bRows = wrapBlock(line.trim(), GUTTER, process.stdout.columns || 80);
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
- consume(text.slice(shownChars));
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
- if (bits.length) statusRows.push(dim(bits.join(" · ") + " (/help · /mcp)"));
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("This folder's .gonext/settings.json has hooks that will not run until you approve them.") +
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 };