@tiens.nguyen/gu-cli 1.0.784 → 1.0.785

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,13 @@ 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";
132
130
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
133
131
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
134
132
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
135
133
  import { unapprovedProjectServers } from "./mcp-command.mjs";
136
134
  import { readHooks } from "./hooks-config.mjs";
135
+ import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.mjs";
137
136
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
138
137
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
139
138
  import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
@@ -897,6 +896,7 @@ const COMMANDS = [
897
896
  { name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
898
897
  { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
899
898
  { 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" },
899
+ { 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
900
  { name: "/help", desc: "list these commands" },
901
901
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
902
902
  ];
@@ -1004,7 +1004,6 @@ const OUT_RESTORE = "\x1b8";
1004
1004
  const OUT_SAVE = "\x1b7";
1005
1005
  /** Draw in-place UI that lives in the SCROLLING area (status block, pickers, menus). */
1006
1006
  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
1007
  if (!barPinned()) {
1009
1008
  ui(seq);
1010
1009
  return;
@@ -2598,9 +2597,15 @@ const viewportHeight = () => Math.max(1, viewportRows() - barReserved);
2598
2597
  * Set by the active turn (it owns `statusShown`/`shownRows`), null between turns.
2599
2598
  */
2600
2599
  let liveOverlayRows = null;
2601
- /** Rows the TRANSCRIPT actually gets: the scrolling area, less any live block under it. */
2600
+ /**
2601
+ * Rows the TRANSCRIPT actually gets: the scrolling area, less any live block under it — and less
2602
+ * the blank rows a closed picker or a shrunk zone left below the content (#321's pad, #324).
2603
+ * Those rows hold nothing, exactly like the live block's: every screen-row → line mapping that
2604
+ * forgot them was off by that many rows. Reported as the question echo landing on the line above
2605
+ * it: the slash menu and the hover put rows BACK from a view that sat `pad` rows too low.
2606
+ */
2602
2607
  const transcriptRows = () =>
2603
- Math.max(1, viewportHeight() - (liveOverlayRows ? liveOverlayRows() : 0));
2608
+ Math.max(1, viewportHeight() - (liveOverlayRows ? liveOverlayRows() : 0) - livePad());
2604
2609
  // Rows the frozen zone occupies: the zone's head (the separator and the blank under it — see
2605
2610
  // INPUT_HEAD_ROWS) + the input line, then the bar's own rows (which grow by one under
2606
2611
  // --verbose). Everything that reserves, measures or paints the zone MUST use this — reserving
@@ -2616,10 +2621,23 @@ function pinBar() {
2616
2621
  const rows = frozenRowCount();
2617
2622
  // Re-cutting keeps the OUTPUT position that is already in the save slot (preserveCaret:false);
2618
2623
  // paintBar() below puts the caret back on the input line.
2619
- if (barPinned()) ui(scrollRegionSeq(viewportRows(), rows, { preserveCaret: false }));
2624
+ const first = !barPinned();
2625
+ if (!first) ui(scrollRegionSeq(viewportRows(), rows, { preserveCaret: false }));
2620
2626
  else ui(reserveRowsSeq(viewportRows(), rows) + OUT_SAVE); // …and remember where output goes
2621
2627
  barReserved = rows;
2622
- paintBar();
2628
+ if (!first) {
2629
+ paintBar();
2630
+ return;
2631
+ }
2632
+ // THE FIRST PIN TAKES THE SCREEN INTO gu's HANDS (#321, #324): one repaint from the transcript,
2633
+ // which also puts the OUTPUT POSITION at the end of the content. reserveRowsSeq leaves it on
2634
+ // the region's LAST row — right when the screen is full, but after a short banner the content
2635
+ // ends near the top, and every later height change is planned from where the content ends
2636
+ // (zoneResizePlan). Left at the bottom, the two disagreed: the region was cut above the saved
2637
+ // position and the next line — the question's echo — was written rows away from its place, on
2638
+ // top of the line printed before it. No pad survives a repaint: the screen IS the model now.
2639
+ padRows = 0;
2640
+ paintLiveTail(); // ends in paintBar
2623
2641
  }
2624
2642
  /** Give the rows back and clear them — every exit path must reach this. */
2625
2643
  function unpinBar() {
@@ -2765,14 +2783,12 @@ function paintBar() {
2765
2783
  // re-cut the region before painting, or its top rows would land inside the scrolling area.
2766
2784
  const prev = barReserved;
2767
2785
  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
2786
  if (regrew) {
2771
2787
  // #321: MOVE THE SCREEN LIKE A TERMINAL, never repaint it from the transcript — a repaint put
2772
2788
  // lines the terminal had already scrolled away back on screen, and the next output scrolled
2773
2789
  // them into the scrollback AGAIN (the banner and the update notice repeated after an update;
2774
2790
  // "auto-test off" doubled). See zoneResizePlan.
2775
- const live = prev > 0 && !scrolledBack();
2791
+ const live = !scrolledBack();
2776
2792
  let plan = null;
2777
2793
  let rows = 0;
2778
2794
  if (live) {
@@ -2793,7 +2809,6 @@ function paintBar() {
2793
2809
  ui(clear);
2794
2810
  }
2795
2811
  if (live) setPad(plan.pad, rows);
2796
- __dbg("regrow", { prev, now: zone.rows.length, rows, plan, live });
2797
2812
  barReserved = zone.rows.length;
2798
2813
  }
2799
2814
  const top = viewportRows() - zone.rows.length + 1;
@@ -2820,10 +2835,9 @@ function paintBar() {
2820
2835
  paintFrozenRowsSeq(zone.rows, viewportRows(), { preserveCaret: false }) +
2821
2836
  parkCaretSeq(zone.caretRow, zone.caretCol)
2822
2837
  );
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();
2838
+ // NO REPAINT HERE: a height change moved the screen above (#321) — repainting it from the
2839
+ // transcript is what filled the scrollback with copies. The one repaint is pinBar's, on the
2840
+ // first pin.
2827
2841
  }
2828
2842
  /** Bytes that put the caret back on text row `row` (0-based) at 1-based `col`. */
2829
2843
  const parkCaretSeq = (row, col) => `\x1b[${inputRowAbs + Math.max(0, row | 0)};${col}H`;
@@ -3002,6 +3016,9 @@ let liveSelection = null;
3002
3016
  let padRows = 0;
3003
3017
  let padAnchor = 0;
3004
3018
  const currentPad = (rowCount) => (padRows > 0 ? Math.max(0, padRows - Math.max(0, rowCount - padAnchor)) : 0);
3019
+ /** The pad right now — free when there is none, which is nearly always. */
3020
+ const livePad = () =>
3021
+ padRows > 0 ? currentPad(displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length) : 0;
3005
3022
  function setPad(n, rowCount) {
3006
3023
  padRows = Math.max(0, n | 0);
3007
3024
  padAnchor = rowCount;
@@ -3420,8 +3437,10 @@ let copyBadgeTimer = null;
3420
3437
  /** The transcript row currently under the viewport row `row`, as it should look. */
3421
3438
  function viewportRowText(row) {
3422
3439
  const width = Math.max(20, process.stdout.columns || 80);
3440
+ // The view as it is ON SCREEN — pad rows included (#324), or every row comes back `pad` too low.
3423
3441
  const rows = displayRows(transcript.lines(), width - 1);
3424
- const view = viewportSlice(rows, scrollOffset, viewportHeight());
3442
+ const pad = currentPad(rows.length);
3443
+ const view = viewportSlice(pad ? [...rows, ...Array(pad).fill("")] : rows, scrollOffset, viewportHeight());
3425
3444
  const line = view.rows[row - 1];
3426
3445
  return typeof line === "string" ? line : "";
3427
3446
  }
@@ -3632,7 +3651,9 @@ function paintLiveTail() {
3632
3651
  // painted row down by the block's height so a selection highlight lands N rows away from the
3633
3652
  // text it belongs to. Between turns the block is gone, transcriptRows() === viewportHeight(),
3634
3653
  // and this is the behaviour it always had.
3635
- const height = transcriptRows();
3654
+ // The pad rows are part of the screen (screenRows has them) but not of the transcript's rows.
3655
+ const pad = livePad();
3656
+ const height = transcriptRows() + pad;
3636
3657
  const view = viewportSlice(screenRows(), 0, height);
3637
3658
  ui(paintViewportSeq(view.rows, height, width, clipVisible, { preserveCaret: false }));
3638
3659
  // Park the caret at the end of the last printed line AND SAVE IT: the tail we just repainted
@@ -3643,8 +3664,9 @@ function paintLiveTail() {
3643
3664
  // that slot with the caret parked on the INPUT LINE. The next line printed after any scroll
3644
3665
  // round trip was then written at the prompt's column inside the frozen zone — on screen, a
3645
3666
  // 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);
3667
+ // …the last CONTENT row: output continues above the pad, which it then fills (#324).
3668
+ const lastRow = Math.max(1, view.rows.length - pad);
3669
+ const lastCol = Math.min(width, visibleLen(view.rows[lastRow - 1] ?? "") + 1);
3648
3670
  ui(`\x1b[${lastRow};${lastCol}H` + OUT_SAVE);
3649
3671
  paintBar();
3650
3672
  }
@@ -7159,6 +7181,82 @@ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stopped
7159
7181
  if (barPinned()) paintBar();
7160
7182
  }
7161
7183
 
7184
+ /**
7185
+ * /switch-skill — whose skills, MCP servers and hooks THIS FOLDER uses (task #325): Claude Code's,
7186
+ * read where Claude keeps them (~/.claude, ./.claude, ~/.claude.json), or gu's own (~/.gonext,
7187
+ * ./.gonext). One choice for all three — a skill names its MCP server's tools and the hooks feed
7188
+ * the same database, so they only make sense together. Stored in the folder's session file, which
7189
+ * the worker reads for every turn (skill-source.mjs): it holds from the next question, no restart.
7190
+ */
7191
+ async function runSwitchSkillCommand(line) {
7192
+ const cwd = resolve(process.cwd());
7193
+ const current = readSkillSource(cwd);
7194
+ const arg = (line.trim().split(/\s+/)[1] ?? "").toLowerCase();
7195
+ const where = (src) => (src === "claude" ? "~/.claude, ./.claude, ~/.claude.json" : "~/.gonext, ./.gonext");
7196
+ let pick = "";
7197
+ console.log("");
7198
+ if (arg) {
7199
+ pick = /^claude/.test(arg) ? "claude" : arg === "gu" ? "gu" : "";
7200
+ if (!pick) {
7201
+ console.log(GUTTER + yellow(`"${arg}" is not one of them — /switch-skill claude or /switch-skill gu\n`));
7202
+ return;
7203
+ }
7204
+ } else {
7205
+ const options = ["claude", "gu"];
7206
+ console.log(dim(` Whose skills, MCP servers and hooks this folder uses (${PICK_HINT}):`));
7207
+ const idx = await pickFromList(
7208
+ options.map((o) => sourceLabel(o) + dim(` · ${where(o)}`) + (o === current ? dim(" · in use") : "")),
7209
+ Math.max(0, options.indexOf(current))
7210
+ );
7211
+ if (idx < 0) {
7212
+ console.log(dim(" (cancelled)\n"));
7213
+ paintBar();
7214
+ return;
7215
+ }
7216
+ pick = options[idx];
7217
+ }
7218
+ try {
7219
+ await updateSessionFile(cwd, (raw) => {
7220
+ raw[SKILL_SOURCE_KEY] = pick;
7221
+ raw.updatedAt = new Date().toISOString();
7222
+ });
7223
+ } catch (err) {
7224
+ console.log(GUTTER + red(`couldn't save the choice — ${err.message}. Nothing changed.\n`));
7225
+ return;
7226
+ }
7227
+ _skillScan = { at: 0, skills: [] }; // the "/" menu and Tab follow at once
7228
+ const say = (l) => console.log(GUTTER + l);
7229
+ say(accent(`✓ ${pick === current ? "this folder uses" : "this folder now uses"} ${sourceLabel(pick)}`));
7230
+ say(dim(` skills, MCP servers and hooks are read from ${where(pick)}`));
7231
+ // What was found there — and what was not, said rather than shown as an empty list.
7232
+ try {
7233
+ const sk = discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES });
7234
+ const mcp = listMcpServers({ cwd: process.cwd() });
7235
+ const hk = readHooks({ cwd: process.cwd() });
7236
+ const nHooks = (h) => Object.values(h).reduce((a, groups) => a + groups.reduce((b, g) => b + g.hooks.length, 0), 0);
7237
+ const count = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
7238
+ const running = mcp.servers.filter((x) => x.runnable).length;
7239
+ 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")}`));
7240
+ // Nothing there at all is a normal state, not a fault: say so, and that gu needs none of it.
7241
+ if (!sk.skills.length && !mcp.servers.length && !nHooks(hk.user) && !nHooks(hk.project) && !hk.notes.length) {
7242
+ say(dim(" Nothing is set up there yet — gu works the same without any of them."));
7243
+ }
7244
+ for (const w of [...sk.warnings, ...mcp.warnings, ...hk.warnings]) say(yellow(" " + w));
7245
+ const unsupported = mcp.servers.filter((x) => x.error);
7246
+ if (unsupported.length) say(yellow(` ${count(unsupported.length, "MCP server")} cannot run here: ${unsupported.map((x) => `${x.name} (${x.error})`).join("; ")}`));
7247
+ const waiting = mcp.servers.filter((x) => x.scope === "project" && !x.approved && !x.error);
7248
+ if (waiting.length) say(yellow(` ${count(waiting.length, "project MCP server")} waiting for your approval: ${waiting.map((x) => x.name).join(", ")} — /mcp approve <name>`));
7249
+ if (hk.projectPending) say(yellow(` this folder's ${hk.projectFile.replace(/^\.\//, "")} has hooks that will not run until you approve them — /hooks approve`));
7250
+ 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`));
7251
+ 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."));
7252
+ } catch (err) {
7253
+ say(yellow(` (could not read it back: ${err.message})`));
7254
+ }
7255
+ say(dim(` It holds from your next question. ${pick === "claude" ? "/switch-skill gu" : "/switch-skill claude"} switches back.`));
7256
+ console.log("");
7257
+ if (barPinned()) paintBar();
7258
+ }
7259
+
7162
7260
  /**
7163
7261
  * /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
7164
7262
  * run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
@@ -9509,6 +9607,8 @@ let forgetActiveStatus = null;
9509
9607
  // Ctrl+T reveal the full Thought from the KEYBOARD, so the feature doesn't depend on the mouse
9510
9608
  // capture that would block scrolling (see hoverMouse).
9511
9609
  let toggleActiveThought = null;
9610
+ /** #324: the screen under the Yes/No picker when it opened — the list picker's bookkeeping (#321). */
9611
+ let approvalOpenedOver = null;
9512
9612
  function drawApprovalOptions(redraw) {
9513
9613
  // Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
9514
9614
  // step up 2 rows, clear+rewrite both, landing back where we started.
@@ -9518,11 +9618,24 @@ function drawApprovalOptions(redraw) {
9518
9618
  // each and the \x1b[2A count stays exact — which is why a resize can simply redraw here.
9519
9619
  const yes = approvalSel === 0 ? accent("▸ Yes") : dim(" Yes");
9520
9620
  const no = approvalSel === 1 ? accent("▸ No") : dim(" No");
9621
+ if (!redraw && barPinned() && !scrolledBack()) {
9622
+ const content = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length;
9623
+ approvalOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
9624
+ }
9521
9625
  regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + GUTTER + no + "\n");
9522
9626
  }
9523
9627
  function finishApproval(allow) {
9524
9628
  if (!approvalActive) return;
9525
9629
  approvalActive = false;
9630
+ // #324: on a full screen the two option rows SCROLLED the conversation up by two, and the one
9631
+ // result line below takes only one of them back — the content now ends a row above the region's
9632
+ // bottom. Unrecorded, every row put back from the transcript afterwards (the slash menu, a
9633
+ // hover) was drawn one row low, over its neighbour. Same account as a closed list picker.
9634
+ const over = approvalOpenedOver;
9635
+ approvalOpenedOver = null;
9636
+ if (over && barPinned() && !scrolledBack() && over.height === viewportHeight()) {
9637
+ setPad(blockClosedPad({ contentRows: over.content, pad: over.pad, height: over.height, blockRows: 2 }), over.content);
9638
+ }
9526
9639
  // Settle the picker: overwrite the two option lines with a single result line.
9527
9640
  process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
9528
9641
  const r = approvalResolve;
@@ -9771,7 +9884,6 @@ function drawList(redraw) {
9771
9884
  if (!redraw && barPinned() && !scrolledBack()) {
9772
9885
  const content = displayRows(transcript.lines(), width - 1).length;
9773
9886
  listOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
9774
- __dbg("picker open", listOpenedOver, { lastLines: transcript.lines().slice(-3) });
9775
9887
  listMaxRows = 0;
9776
9888
  }
9777
9889
  listMaxRows = Math.max(listMaxRows, physicalRows(rows, width));
@@ -9793,8 +9905,6 @@ function finishList(index) {
9793
9905
  // higher with blank rows under it — remember them, so nothing repaints those lines back on screen.
9794
9906
  if (listOpenedOver && barPinned() && !scrolledBack() && listOpenedOver.height === viewportHeight()) {
9795
9907
  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
9908
  }
9799
9909
  listOpenedOver = null;
9800
9910
  listMaxRows = 0;
@@ -10677,6 +10787,8 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
10677
10787
  // completed resultText really EXTENDS what we streamed (an old worker replaces it
10678
10788
  // with the shorter bare answer, which must not be sliced by shownChars).
10679
10789
  let seenRaw = "";
10790
+ // #326: where the live stream is relative to the <think> fence, carried from poll to poll.
10791
+ let liveSplit = { inThink: false, held: "" };
10680
10792
 
10681
10793
  // --- Live in-progress ("blinking") bullet -------------------------------------------
10682
10794
  // The agent's raw Thought/code stream is SUPPRESSED (see the ~~~-fence handling in
@@ -11660,7 +11772,15 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11660
11772
  if (seenRaw.length < 4096) {
11661
11773
  seenRaw = (seenRaw + text.slice(shownChars)).slice(0, 4096);
11662
11774
  }
11663
- consume(text.slice(shownChars));
11775
+ // #326: only the turn's WORK is shown live — the step lines and thoughts inside
11776
+ // <think>. What follows the fence is the ANSWER, which the caller prints once from the
11777
+ // completed text; fed to the step printer it came out as "●" bullets and then again
11778
+ // under "recap:". A plain chat reply is the exception: its live stream IS its output.
11779
+ const live = splitLiveStream(text.slice(shownChars), liveSplit);
11780
+ liveSplit = live.state;
11781
+ for (const part of live.parts) {
11782
+ if (part.inside || plainReplyFlow) consume(part.text);
11783
+ }
11664
11784
  shownChars = text.length;
11665
11785
  }
11666
11786
  // Interactive command-approval gate: the agent paused on a risky command. Show the
@@ -12234,7 +12354,11 @@ async function main() {
12234
12354
  const nSkills = currentSkills().length;
12235
12355
  const nMcp = listMcpServers({ cwd: process.cwd() }).servers.filter((x) => x.runnable).length;
12236
12356
  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)"));
12357
+ // #325: say whose they are when this folder uses Claude Code's — even when there are none.
12358
+ const claudes = readSkillSource(process.cwd()) === "claude";
12359
+ if (bits.length || claudes) {
12360
+ statusRows.push(dim((claudes ? "Claude's: " : "") + (bits.join(" · ") || "no skills or MCP servers") + (claudes ? " (/switch-skill · /mcp)" : " (/help · /mcp)")));
12361
+ }
12238
12362
  } catch {
12239
12363
  /* a broken file is reported below; the banner is not the place to fail */
12240
12364
  }
@@ -12251,7 +12375,7 @@ async function main() {
12251
12375
  for (const w of hk.warnings) console.log(GUTTER + yellow(w));
12252
12376
  if (hk.projectPending) {
12253
12377
  console.log(
12254
- GUTTER + yellow("This folder's .gonext/settings.json has hooks that will not run until you approve them.") +
12378
+ GUTTER + yellow(`This folder's ${hk.projectFile.replace(/^\.\//, "")} has hooks that will not run until you approve them.`) +
12255
12379
  "\n" + GUTTER + dim(" /hooks shows the commands · /hooks approve")
12256
12380
  );
12257
12381
  }
@@ -12890,6 +13014,10 @@ async function main() {
12890
13014
  await runMcpSlashCommand(line);
12891
13015
  continue;
12892
13016
  }
13017
+ if (line === "/switch-skill" || line.startsWith("/switch-skill ")) {
13018
+ await runSwitchSkillCommand(line);
13019
+ continue;
13020
+ }
12893
13021
  if (line === "/hooks" || line.startsWith("/hooks ")) {
12894
13022
  const { runHooksCommand } = await import("./hooks-command.mjs");
12895
13023
  const style = { ok: accent, warn: yellow, dim };
package/gu_hooks.py CHANGED
@@ -17,6 +17,18 @@ A hook that fails any other way, or runs past its timeout, is a warning — neve
17
17
 
18
18
  WHAT A HOOK SEES: its own env on a short whitelist of this process's (the worker's env carries gu's
19
19
  credentials), plus GU_PROJECT_DIR and GU_HOOK_EVENT. It runs in the project folder.
20
+
21
+ CLAUDE CODE'S HOOKS (task #325). A folder that chose "Claude's skills" hands over hooks read from
22
+ Claude Code's settings; their groups carry dialect "claude". Those commands were written for Claude
23
+ Code, so gu speaks Claude's vocabulary TO THEM and to nobody else:
24
+ the event SessionStart / UserPromptSubmit / PostToolUse / Stop, as "hook_event_name"
25
+ the tool Edit, Write, Read, Grep, Glob, Bash — for the matcher AND in the input, with
26
+ Claude's argument names (file_path, not path)
27
+ the fields prompt (question), tool_response (tool_result), last_assistant_message (answer)
28
+ the answer {"hookSpecificOutput": {"additionalContext": ...}} is read like gu's own shape
29
+ Measured with a real Claude memory hook: given gu's names it never ran; given Claude's event names
30
+ but gu's tool names and fields it ran and recorded nothing; given all of the above it recorded the
31
+ lookups and edits (with line ranges) and gave the next session its memory back.
20
32
  """
21
33
  from __future__ import annotations
22
34
 
@@ -36,6 +48,62 @@ _ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TM
36
48
  _PLAIN_MATCHER = re.compile(r"^[A-Za-z0-9_\-| ,]*$")
37
49
 
38
50
 
51
+ # ---- Claude Code's vocabulary (#325) -----------------------------------------------------------
52
+
53
+ CLAUDE_EVENT = {"onStart": "SessionStart", "onAsk": "UserPromptSubmit",
54
+ "onToolDone": "PostToolUse", "onTurnEnd": "Stop"}
55
+
56
+
57
+ def claude_tool(name: str, args: Any) -> tuple[str, dict]:
58
+ """gu's tool call as Claude Code would have named it. Unknown tools (mcp__server__tool, which
59
+ both spell the same way) pass through unchanged."""
60
+ a = dict(args) if isinstance(args, dict) else {}
61
+ path = a.get("path")
62
+ if name == "edit_file":
63
+ return "Edit", {"file_path": path, "old_string": a.get("old_string", ""),
64
+ "new_string": a.get("new_string", "")}
65
+ if name == "edit_lines":
66
+ # No old text to give: the new text is what a hook looks for in the file.
67
+ return "Edit", {"file_path": path, "old_string": "", "new_string": a.get("new_content", ""),
68
+ "start_line": a.get("start_line"), "end_line": a.get("end_line")}
69
+ if name == "create_file":
70
+ return "Write", {"file_path": path, "content": a.get("content", "")}
71
+ if name in ("read_file_lines", "read_text_file"):
72
+ out = {"file_path": path}
73
+ if a.get("start_line"):
74
+ out["offset"] = a.get("start_line")
75
+ return "Read", out
76
+ if name == "grep_repo":
77
+ return "Grep", {"pattern": a.get("pattern", ""), "path": path or "", "glob": a.get("glob", "")}
78
+ if name == "symbol_context":
79
+ return "Grep", {"pattern": a.get("name", ""), "path": path or ""}
80
+ if name == "list_dir":
81
+ return "Glob", {"pattern": (str(path or ".").rstrip("/\\") or ".") + "/*", "path": path or ""}
82
+ if name == "run_command":
83
+ return "Bash", {"command": a.get("command", "")}
84
+ return name, a
85
+
86
+
87
+ def claude_payload(event: str, payload: dict) -> dict:
88
+ """One gu event's payload with Claude Code's field names."""
89
+ out = dict(payload)
90
+ if event == "onStart":
91
+ out.setdefault("source", "startup")
92
+ elif event == "onAsk":
93
+ out["prompt"] = out.pop("question", "")
94
+ elif event == "onToolDone":
95
+ out["tool_name"], out["tool_input"] = claude_tool(str(out.get("tool_name", "")), out.get("tool_input"))
96
+ out["tool_response"] = out.pop("tool_result", "")
97
+ elif event == "onTurnEnd":
98
+ out["last_assistant_message"] = out.pop("answer", "")
99
+ out["stop_hook_active"] = False
100
+ return out
101
+
102
+
103
+ def is_claude(group: Any) -> bool:
104
+ return isinstance(group, dict) and group.get("dialect") == "claude"
105
+
106
+
39
107
  def matches(matcher: str | None, value: str) -> bool:
40
108
  """Empty or "*" = everything. Letters, digits, _ - and | = an exact list. Anything else = a regex."""
41
109
  m = (matcher or "").strip()
@@ -76,11 +144,24 @@ class HookRunner:
76
144
  def matcher_union(self, event: str) -> list[str]:
77
145
  return [g.get("matcher", "") for g in self.hooks.get(event) or [] if isinstance(g, dict)]
78
146
 
147
+ def wants_tool(self, name: str) -> bool:
148
+ """Does any onToolDone group name this tool — in gu's name for gu's hooks, in Claude Code's
149
+ for Claude's (a matcher "Edit|Write" means edit_file and create_file)."""
150
+ for g in self.hooks.get("onToolDone") or []:
151
+ # A group with nothing to run wraps no tool, whatever its matcher says.
152
+ if not isinstance(g, dict) or not any(isinstance(h, dict) and h.get("command") for h in g.get("hooks") or []):
153
+ continue
154
+ value = claude_tool(name, {})[0] if is_claude(g) else name
155
+ if matches(g.get("matcher"), value):
156
+ return True
157
+ return False
158
+
79
159
  def _env(self, event: str, extra: dict | None) -> dict:
80
160
  env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
81
161
  env.setdefault("PYTHONIOENCODING", "utf-8")
82
162
  env["GU_PROJECT_DIR"] = self.cwd or ""
83
163
  env["GU_HOOK_EVENT"] = event
164
+ env["CLAUDE_PROJECT_DIR"] = self.cwd or "" # what a hook written for Claude Code reads
84
165
  for k, v in (extra or {}).items():
85
166
  env[str(k)] = str(v)
86
167
  return env
@@ -91,13 +172,27 @@ class HookRunner:
91
172
  return res
92
173
  body = {"event": event, "session_id": self.session_id, "cwd": self.cwd or "", **payload}
93
174
  data = json.dumps(body, ensure_ascii=False, default=str)
175
+ claude_data = claude_match = None # built once, and only if a Claude hook is there
94
176
  for group in self.hooks.get(event) or []:
95
- if not isinstance(group, dict) or not matches(group.get("matcher"), match_value):
177
+ if not isinstance(group, dict):
178
+ continue
179
+ if is_claude(group):
180
+ if claude_data is None:
181
+ cbody = claude_payload(event, payload)
182
+ claude_match = str(cbody.get("tool_name", "")) if event == "onToolDone" else match_value
183
+ claude_data = json.dumps({"hook_event_name": CLAUDE_EVENT[event],
184
+ "session_id": self.session_id, "cwd": self.cwd or "",
185
+ "permission_mode": "default", **cbody},
186
+ ensure_ascii=False, default=str)
187
+ value, stdin = claude_match, claude_data
188
+ else:
189
+ value, stdin = match_value, data
190
+ if not matches(group.get("matcher"), value):
96
191
  continue
97
192
  for h in group.get("hooks") or []:
98
193
  if not isinstance(h, dict) or (h.get("type") or "command") != "command" or not h.get("command"):
99
194
  continue
100
- self._one(event, h, data, res)
195
+ self._one(event, h, stdin, res)
101
196
  return res
102
197
 
103
198
  def _one(self, event: str, h: dict, data: str, res: HookResult) -> None:
@@ -143,6 +238,10 @@ class HookRunner:
143
238
  res.blocked = str(obj.get("reason") or err or f"the {event} hook {label} said no")
144
239
  return
145
240
  ctx = obj.get("additionalContext")
241
+ if not isinstance(ctx, str):
242
+ # Claude Code's shape: {"hookSpecificOutput": {"hookEventName", "additionalContext"}}
243
+ inner = obj.get("hookSpecificOutput")
244
+ ctx = inner.get("additionalContext") if isinstance(inner, dict) else None
146
245
  if isinstance(ctx, str) and ctx.strip():
147
246
  res.context.append(ctx)
148
247
 
@@ -185,11 +284,10 @@ def wrap_tools(tools: list, runner: HookRunner, clip: int = 4000) -> int:
185
284
  context is appended to the tool's result; a block (exit 2) is appended as a message."""
186
285
  if not runner.has("onToolDone"):
187
286
  return 0
188
- matchers = runner.matcher_union("onToolDone")
189
287
  n = 0
190
288
  for t in tools:
191
289
  name = getattr(t, "name", "")
192
- if not name or name == "final_answer" or not any(matches(m, name) for m in matchers):
290
+ if not name or name == "final_answer" or not runner.wants_tool(name):
193
291
  continue
194
292
  orig = t.forward
195
293
  order = list((getattr(t, "inputs", None) or {}).keys())