@tiens.nguyen/gu-cli 1.0.783 → 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.
package/gu-repl.mjs CHANGED
@@ -24,8 +24,8 @@
24
24
  * Commands: /exit /model /new /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
- import { readStepTokens } from "./step-tokens.mjs";
28
- import { snapshotInput, restoreInput, replaceStoppedQuestion, stopNoteLines } from "./turn-stop.mjs";
27
+ import { readStepTokens, readTurnCache } from "./step-tokens.mjs";
28
+ import { snapshotInput, restoreInput, replaceStoppedQuestion, restoredQuestionLetGo, stopNoteLines } from "./turn-stop.mjs";
29
29
  import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync, mkdirSync } from "node:fs";
30
30
  import { spawn } from "node:child_process";
31
31
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
@@ -66,6 +66,7 @@ import {
66
66
  formatThinkTime,
67
67
  plainAnswer,
68
68
  summarizeError,
69
+ tokenRowWithPrice,
69
70
  visibleLen,
70
71
  createProbeReplyReader,
71
72
  agentPickerItems,
@@ -125,12 +126,13 @@ import {
125
126
  rankSlashCommands,
126
127
  slashMenuWindow,
127
128
  } from "./terminal-layout.mjs";
128
- import { completedFlush } from "./completed-flush.mjs";
129
+ import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
129
130
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
130
131
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
131
132
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
132
133
  import { unapprovedProjectServers } from "./mcp-command.mjs";
133
134
  import { readHooks } from "./hooks-config.mjs";
135
+ import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.mjs";
134
136
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
135
137
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
136
138
  import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
@@ -179,6 +181,8 @@ import {
179
181
  viewportSlice,
180
182
  paintViewportSeq,
181
183
  jumpToBottomRow,
184
+ zoneResizePlan,
185
+ blockClosedPad,
182
186
  } from "./terminal-viewport.mjs";
183
187
 
184
188
  const DIR = dirname(fileURLToPath(import.meta.url));
@@ -886,11 +890,13 @@ const COMMANDS = [
886
890
  { name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
887
891
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
888
892
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
893
+ { name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
889
894
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
890
895
  // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
891
896
  { name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
892
897
  { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
893
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)" },
894
900
  { name: "/help", desc: "list these commands" },
895
901
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
896
902
  ];
@@ -1450,6 +1456,11 @@ function applyClick({ row, col }) {
1450
1456
  void runUpdate();
1451
1457
  return;
1452
1458
  }
1459
+ if (pricesRowTarget && row === updateRowAbs &&
1460
+ col - 1 >= pricesRowTarget.from && col - 1 < pricesRowTarget.to) {
1461
+ void runPricesUpdate();
1462
+ return;
1463
+ }
1453
1464
  if (agentsRowTarget && row === updateRowAbs &&
1454
1465
  col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
1455
1466
  void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
@@ -1561,7 +1572,8 @@ const pasteStore = new Map(); // token -> the real text
1561
1572
  // model, so a turn stopped with Ctrl+C can give the question back for editing.
1562
1573
  let lastSubmittedInput = null;
1563
1574
  // Task #302: set while the input holds a stopped turn's question; the next line sent from it
1564
- // REPLACES that question in history when it was edited. Cleared by "never mind" (Ctrl+C).
1575
+ // REPLACES that question in history when it was edited. Cleared by "never mind": Ctrl+C, or the
1576
+ // line emptied any other way (restoredQuestionLetGo) — what is typed next is a new message.
1565
1577
  let prefillFor = null;
1566
1578
  // Task #302: the backup run of the last stopped turn — what /undo reverts by default.
1567
1579
  let lastStoppedRun = null;
@@ -2454,6 +2466,12 @@ const identityLineText = () =>
2454
2466
  { orange, cyan: accent, dim, red },
2455
2467
  );
2456
2468
 
2469
+ /** "$0.334" / "~$0.334" (same rules as model-prices.mjs formatUsd, kept sync for the bar). */
2470
+ const fmtCost = (c) => {
2471
+ const v = Math.max(0, c.usd);
2472
+ return `${c.estimated ? "~" : ""}$${v >= 1 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v > 0 ? v.toFixed(4) : "0"}`;
2473
+ };
2474
+
2457
2475
  const tokenLineText = () => {
2458
2476
  // TWO different numbers, each labelled, because they are routinely off by 6×:
2459
2477
  // ctx = the NEWEST request — what the model is holding right now, the one to compare
@@ -2468,7 +2486,7 @@ const tokenLineText = () => {
2468
2486
  // a terminal — it was inline here, unasserted, and wrong for months. Both figures are TRUE
2469
2487
  // measurements: nothing is netted against a floor any more (see that function's comment).
2470
2488
  const { ctx, turn: sent } = tokenRowFigures({ ctxTrue, turnTrue: lastTurnInputTokens });
2471
- return (
2489
+ const head = (
2472
2490
  // IDENTITY IS NOT ON THIS ROW ANY MORE — it has its own (identityLineText), because five
2473
2491
  // measurements plus a coder and a folder name was ~110 columns and the clip falls on the
2474
2492
  // RIGHT, so the totals were the part being cut on any normal window.
@@ -2488,11 +2506,18 @@ const tokenLineText = () => {
2488
2506
  // `ctx` and `sent`, which do drive decisions, need more.
2489
2507
  ragSegmentText() +
2490
2508
  dim(" · ") +
2491
- accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`) +
2492
- dim(" · ") +
2493
- dim("total ") +
2494
- accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`)
2509
+ accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`)
2495
2510
  );
2511
+ const total = dim(" · ") + dim("total ") + accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`);
2512
+ // #323: what the last turn's prompt cost, LAST on the row after a "|" — "~" when the cached share
2513
+ // was assumed; nothing when the model has no price (never a misleading "| $0"). It survives a
2514
+ // narrow window: "total ↓" is dropped before it is (tokenRowWithPrice).
2515
+ return tokenRowWithPrice({
2516
+ withTotal: head + total,
2517
+ withoutTotal: head,
2518
+ price: lastTurnCost?.usd != null ? dim(" | ") + accent(fmtCost(lastTurnCost)) : "",
2519
+ room: (process.stdout.columns || 80) - 1 - visibleLen(GUTTER),
2520
+ });
2496
2521
  };
2497
2522
  // The newest RAG index file the agent reported writing (task #136), and where it lives.
2498
2523
  // Empty until something is written; only rendered under --verbose.
@@ -2572,9 +2597,15 @@ const viewportHeight = () => Math.max(1, viewportRows() - barReserved);
2572
2597
  * Set by the active turn (it owns `statusShown`/`shownRows`), null between turns.
2573
2598
  */
2574
2599
  let liveOverlayRows = null;
2575
- /** 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
+ */
2576
2607
  const transcriptRows = () =>
2577
- Math.max(1, viewportHeight() - (liveOverlayRows ? liveOverlayRows() : 0));
2608
+ Math.max(1, viewportHeight() - (liveOverlayRows ? liveOverlayRows() : 0) - livePad());
2578
2609
  // Rows the frozen zone occupies: the zone's head (the separator and the blank under it — see
2579
2610
  // INPUT_HEAD_ROWS) + the input line, then the bar's own rows (which grow by one under
2580
2611
  // --verbose). Everything that reserves, measures or paints the zone MUST use this — reserving
@@ -2590,10 +2621,23 @@ function pinBar() {
2590
2621
  const rows = frozenRowCount();
2591
2622
  // Re-cutting keeps the OUTPUT position that is already in the save slot (preserveCaret:false);
2592
2623
  // paintBar() below puts the caret back on the input line.
2593
- if (barPinned()) ui(scrollRegionSeq(viewportRows(), rows, { preserveCaret: false }));
2624
+ const first = !barPinned();
2625
+ if (!first) ui(scrollRegionSeq(viewportRows(), rows, { preserveCaret: false }));
2594
2626
  else ui(reserveRowsSeq(viewportRows(), rows) + OUT_SAVE); // …and remember where output goes
2595
2627
  barReserved = rows;
2596
- 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
2597
2641
  }
2598
2642
  /** Give the rows back and clear them — every exit path must reach this. */
2599
2643
  function unpinBar() {
@@ -2613,6 +2657,14 @@ function syncBarPinning() {
2613
2657
  * update), a standing fact about the licence (the gu token notice — yellow only when urgent), and
2614
2658
  * other agents. Dropped whole, lowest first, when they do not all fit.
2615
2659
  */
2660
+ /** The notice's text (sync — zoneNotice runs on every paint): "new prices updated 1 Oct 2026". */
2661
+ function priceUpdateText() {
2662
+ const d = new Date(priceUpdate?.publishedAt ?? "");
2663
+ if (Number.isNaN(d.getTime())) return "new prices updated";
2664
+ const m = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getUTCMonth()];
2665
+ return `new prices updated ${d.getUTCDate()} ${m} ${d.getUTCFullYear()}`;
2666
+ }
2667
+
2616
2668
  function zoneNotice(width) {
2617
2669
  const items = [];
2618
2670
  if (scrolledBack()) {
@@ -2623,6 +2675,7 @@ function zoneNotice(width) {
2623
2675
  const up = updateRow({ ...availableUpdates, width: 400 });
2624
2676
  if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
2625
2677
  }
2678
+ if (priceUpdate) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
2626
2679
  if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2627
2680
  const agents = agentsIndicator(otherAgents);
2628
2681
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
@@ -2705,6 +2758,7 @@ function frozenZone() {
2705
2758
  const sep = status.length + (notice ? 1 : 0);
2706
2759
  updateRowTarget = notice?.spans.update ?? null;
2707
2760
  agentsRowTarget = notice?.spans.agents ?? null;
2761
+ pricesRowTarget = notice?.spans.prices ?? null;
2708
2762
  jumpSpan = notice?.spans.jump ?? null;
2709
2763
  return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2710
2764
  }
@@ -2725,11 +2779,36 @@ function alignOutputToLineStart() {
2725
2779
  function paintBar() {
2726
2780
  if (!barPinned()) return;
2727
2781
  const zone = frozenZone();
2728
- // The zone grew or shrank (the --verbose RAG row appearing) → re-cut the region before
2729
- // painting, or its top rows would land inside the scrolling area.
2730
- const regrew = zone.rows.length !== barReserved;
2782
+ // The zone grew or shrank (a picker, the notice row, the status block, a wrapped input) →
2783
+ // re-cut the region before painting, or its top rows would land inside the scrolling area.
2784
+ const prev = barReserved;
2785
+ const regrew = zone.rows.length !== prev;
2731
2786
  if (regrew) {
2787
+ // #321: MOVE THE SCREEN LIKE A TERMINAL, never repaint it from the transcript — a repaint put
2788
+ // lines the terminal had already scrolled away back on screen, and the next output scrolled
2789
+ // them into the scrollback AGAIN (the banner and the update notice repeated after an update;
2790
+ // "auto-test off" doubled). See zoneResizePlan.
2791
+ const live = !scrolledBack();
2792
+ let plan = null;
2793
+ let rows = 0;
2794
+ if (live) {
2795
+ rows = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length;
2796
+ plan = zoneResizePlan({ contentRows: rows, pad: currentPad(rows), oldHeight: viewportRows() - prev, newHeight: viewportRows() - zone.rows.length });
2797
+ // The rows the zone takes that still hold text: scroll them up and out — real newlines at
2798
+ // the OLD region's bottom, so the terminal files them in its scrollback once — and move the
2799
+ // saved output position up with the text it belongs to.
2800
+ if (plan.scrollUp > 0) {
2801
+ ui(`\x1b[${viewportRows() - prev};1H` + "\n".repeat(plan.scrollUp) + OUT_RESTORE + `\x1b[${plan.scrollUp}A` + OUT_SAVE);
2802
+ }
2803
+ }
2732
2804
  ui(scrollRegionSeq(viewportRows(), zone.rows.length, { preserveCaret: false }));
2805
+ if (live && plan.clearRows > 0) {
2806
+ // The rows the zone handed back still show its old rows — blank them, and remember them.
2807
+ let clear = "";
2808
+ for (let i = 0; i < plan.clearRows; i++) clear += `\x1b[${viewportRows() - prev + 1 + i};1H\x1b[2K`;
2809
+ ui(clear);
2810
+ }
2811
+ if (live) setPad(plan.pad, rows);
2733
2812
  barReserved = zone.rows.length;
2734
2813
  }
2735
2814
  const top = viewportRows() - zone.rows.length + 1;
@@ -2749,19 +2828,16 @@ function paintBar() {
2749
2828
  // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2750
2829
  const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2751
2830
  jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2752
- updateRowAbs = updateRowTarget || agentsRowTarget ? noticeAbs : 0;
2831
+ updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
2753
2832
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2754
2833
  // is parked on the input line right after, so there is nothing to preserve here.
2755
2834
  ui(
2756
2835
  paintFrozenRowsSeq(zone.rows, viewportRows(), { preserveCaret: false }) +
2757
2836
  parkCaretSeq(zone.caretRow, zone.caretCol)
2758
2837
  );
2759
- // THE ZONE CHANGED HEIGHT — the line wrapped onto a new row, or unwrapped off one. Re-cutting
2760
- // the region does not move what is already on screen: growing freezes a row that still holds
2761
- // transcript text (and we have just painted over it), shrinking hands back a row that is
2762
- // blank. Repaint the view from the transcript so neither shows. Recurses exactly once —
2763
- // paintLiveTail ends in paintBar, by which point the count matches and this is skipped.
2764
- if (regrew && !scrolledBack()) 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.
2765
2841
  }
2766
2842
  /** Bytes that put the caret back on text row `row` (0-based) at 1-based `col`. */
2767
2843
  const parkCaretSeq = (row, col) => `\x1b[${inputRowAbs + Math.max(0, row | 0)};${col}H`;
@@ -2912,6 +2988,8 @@ let updateRowTarget = null;
2912
2988
  let updateRowAbs = 0;
2913
2989
  // …and the agents indicator, which shares that row when no update is pending (task #195).
2914
2990
  let agentsRowTarget = null;
2991
+ // …and "new prices updated <date>" (#323).
2992
+ let pricesRowTarget = null;
2915
2993
  // A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
2916
2994
  // bracketed-paste and mouse-reporting teardowns.
2917
2995
  process.on("exit", () => {
@@ -2928,11 +3006,28 @@ process.on("exit", () => {
2928
3006
  // the same space displayRows produces, so the highlight stays on the text it was drawn over
2929
3007
  // when the view scrolls rather than on whatever moved under that screen row.
2930
3008
  let liveSelection = null;
2931
- const screenRows = () =>
2932
- applySelectionHighlight(
2933
- displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1),
2934
- liveSelection
2935
- );
3009
+ /**
3010
+ * BLANK ROWS BELOW THE CONTENT at the live tail (#321): when the frozen zone shrinks, the rows it
3011
+ * hands back stay blank (nothing older is repainted into the view — those lines are already in the
3012
+ * terminal's scrollback), and new output fills them first. Kept as a count + the transcript's row
3013
+ * count when it was set, so the per-write path costs nothing: the pad left is what output since has
3014
+ * not consumed.
3015
+ */
3016
+ let padRows = 0;
3017
+ let padAnchor = 0;
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;
3022
+ function setPad(n, rowCount) {
3023
+ padRows = Math.max(0, n | 0);
3024
+ padAnchor = rowCount;
3025
+ }
3026
+ const screenRows = () => {
3027
+ const rows = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1);
3028
+ const pad = currentPad(rows.length);
3029
+ return applySelectionHighlight(pad ? [...rows, ...Array(pad).fill("")] : rows, liveSelection);
3030
+ };
2936
3031
  /**
2937
3032
  * Abandon any drag in progress and take its highlight off screen (task #177).
2938
3033
  *
@@ -3342,8 +3437,10 @@ let copyBadgeTimer = null;
3342
3437
  /** The transcript row currently under the viewport row `row`, as it should look. */
3343
3438
  function viewportRowText(row) {
3344
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.
3345
3441
  const rows = displayRows(transcript.lines(), width - 1);
3346
- const view = viewportSlice(rows, scrollOffset, viewportHeight());
3442
+ const pad = currentPad(rows.length);
3443
+ const view = viewportSlice(pad ? [...rows, ...Array(pad).fill("")] : rows, scrollOffset, viewportHeight());
3347
3444
  const line = view.rows[row - 1];
3348
3445
  return typeof line === "string" ? line : "";
3349
3446
  }
@@ -3554,7 +3651,9 @@ function paintLiveTail() {
3554
3651
  // painted row down by the block's height so a selection highlight lands N rows away from the
3555
3652
  // text it belongs to. Between turns the block is gone, transcriptRows() === viewportHeight(),
3556
3653
  // and this is the behaviour it always had.
3557
- 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;
3558
3657
  const view = viewportSlice(screenRows(), 0, height);
3559
3658
  ui(paintViewportSeq(view.rows, height, width, clipVisible, { preserveCaret: false }));
3560
3659
  // Park the caret at the end of the last printed line AND SAVE IT: the tail we just repainted
@@ -3565,8 +3664,9 @@ function paintLiveTail() {
3565
3664
  // that slot with the caret parked on the INPUT LINE. The next line printed after any scroll
3566
3665
  // round trip was then written at the prompt's column inside the frozen zone — on screen, a
3567
3666
  // question indented three columns to the right of every other line.
3568
- const lastRow = Math.max(1, view.rows.length);
3569
- 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);
3570
3670
  ui(`\x1b[${lastRow};${lastCol}H` + OUT_SAVE);
3571
3671
  paintBar();
3572
3672
  }
@@ -3770,6 +3870,9 @@ function stopPendingStatus() {
3770
3870
  // Already-printed scrollback (bullets, answers, wrapBlock output) is deliberately NOT touched:
3771
3871
  // it belongs to the terminal now, and the terminal reflows it.
3772
3872
  function onWindowResize() {
3873
+ // #321: the blank rows below the content were counted in rows of the OLD width; the resize
3874
+ // repaint lays the view out afresh.
3875
+ padRows = 0;
3773
3876
  // Whatever owns the screen, readline's next refresh must not walk up a stale row count —
3774
3877
  // at the prompt this is the fix itself; elsewhere it stops a later rl.prompt() from erasing
3775
3878
  // rows above the input.
@@ -3982,6 +4085,9 @@ if (process.stdin.isTTY) {
3982
4085
  // re-rendered the slash overlay (the line still starts with "/" from before the paste) and
3983
4086
  // re-walked the paste store. Same for a mouse report's characters, which are not keys.
3984
4087
  if (pasteBuffer !== null || inMouseKeyWindow()) return;
4088
+ // #247: the restored question was erased (Backspace, Ctrl+U, a picker taking the line) — it
4089
+ // is let go, exactly as with Ctrl+C, so the next message does not REPLACE it in history.
4090
+ if (prefillFor && (listPickerActive || restoredQuestionLetGo(rl.line, key))) prefillFor = null;
3985
4091
  // An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
3986
4092
  if (listPickerActive) {
3987
4093
  onListKey(key);
@@ -7075,6 +7181,82 @@ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stopped
7075
7181
  if (barPinned()) paintBar();
7076
7182
  }
7077
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
+
7078
7260
  /**
7079
7261
  * /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
7080
7262
  * run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
@@ -7472,6 +7654,195 @@ async function fetchWorkerSettings() {
7472
7654
  return null; // offline — callers fall back to showing what they know
7473
7655
  }
7474
7656
  }
7657
+
7658
+ // ---------------------------------------------------------------------------------------------
7659
+ // WHAT A TURN COST (#323): prices from the account's own Mongo (seeded once from the table gu
7660
+ // ships), the turn's cached share from the provider when it reports one — else 90% of the later
7661
+ // steps assumed — and the result on the token row ("· ~$0.334") and in /price.
7662
+ // ---------------------------------------------------------------------------------------------
7663
+ let priceRows = null; // null = not loaded yet; [] = none (and seeding failed)
7664
+ let priceVersion = "";
7665
+ let pricePublishedAt = ""; // when gu published the tables this account last applied ("" = none)
7666
+ let lastTurnCost = null; // { usd, estimated, cachedTokens, input, model, host }
7667
+ let sessionCostUsd = 0;
7668
+ let sessionCostEstimated = false;
7669
+ let sessionCostTurns = 0;
7670
+
7671
+ async function priceApi(method, body) {
7672
+ try {
7673
+ const r = await fetch(`${apiBase}/api/worker/model-prices`, {
7674
+ method,
7675
+ headers: { "X-Worker-Key": workerKey, ...(body ? { "Content-Type": "application/json" } : {}) },
7676
+ ...(body ? { body: JSON.stringify(body) } : {}),
7677
+ signal: AbortSignal.timeout(8000),
7678
+ });
7679
+ const j = await r.json().catch(() => null);
7680
+ return r.ok ? j : { error: j?.error || `HTTP ${r.status}` };
7681
+ } catch (e) {
7682
+ return { error: e?.message ?? String(e) };
7683
+ }
7684
+ }
7685
+
7686
+ /**
7687
+ * The account's prices. An EMPTY table is seeded once with the one gu ships (an upsert — running it
7688
+ * twice changes nothing). An API without the route (an older local API) → [] and no cost shown.
7689
+ */
7690
+ async function loadPrices({ force = false } = {}) {
7691
+ if (priceRows && !force) return priceRows;
7692
+ const mp = await import("./model-prices.mjs");
7693
+ let got = await priceApi("GET");
7694
+ if (got && !got.error && Array.isArray(got.rows) && got.rows.length === 0) {
7695
+ const seeded = await priceApi("PUT", { version: mp.PRICE_TABLE.version, rows: mp.PRICE_TABLE.prices, removes: [] });
7696
+ if (!seeded?.error) got = await priceApi("GET");
7697
+ }
7698
+ priceRows = got && !got.error && Array.isArray(got.rows) ? got.rows : [];
7699
+ priceVersion = typeof got?.version === "string" ? got.version : "";
7700
+ pricePublishedAt = typeof got?.publishedAt === "string" ? got.publishedAt : "";
7701
+ return priceRows;
7702
+ }
7703
+
7704
+ /** The coder model id in use — the folder's /model pick ("<kind>::<model>") or the account default. */
7705
+ function activeCodingModelId() {
7706
+ const m = String(sessionCodingModel || "");
7707
+ return m.includes("::") ? m.slice(m.indexOf("::") + 2) : m || defaultCodingModel;
7708
+ }
7709
+
7710
+ /** After a turn: what its prompt tokens cost, added to the session total. Never throws. */
7711
+ async function noteTurnCost(inputTokens, breakdown, coder = null) {
7712
+ try {
7713
+ lastTurnCost = null;
7714
+ if (!(inputTokens > 0)) return;
7715
+ const mp = await import("./model-prices.mjs");
7716
+ const rows = await loadPrices();
7717
+ const url = coder?.url ?? activeCodingUrl();
7718
+ const model = coder?.model ?? activeCodingModelId();
7719
+ const price = mp.findPrice(rows, url, model);
7720
+ const cache = readTurnCache(breakdown) ?? { cached: 0, cacheReported: false, firstIn: 0 };
7721
+ const c = mp.turnCost({ input: inputTokens, ...cache, price });
7722
+ if (!c) {
7723
+ lastTurnCost = { usd: null, model, host: mp.hostOf(url) };
7724
+ return;
7725
+ }
7726
+ lastTurnCost = { ...c, input: inputTokens, cacheReported: cache.cacheReported, model, host: mp.hostOf(url) };
7727
+ sessionCostUsd += c.usd;
7728
+ sessionCostEstimated ||= c.estimated;
7729
+ sessionCostTurns++;
7730
+ if (barPinned()) paintBar();
7731
+ } catch {
7732
+ /* a cost is never worth a broken prompt */
7733
+ }
7734
+ }
7735
+
7736
+ /**
7737
+ * /prices (#323) — the price table and the cache table this account's estimates use, the coder in
7738
+ * use marked; what the last turn and this session cost; and, when gu has published newer tables,
7739
+ * that they are waiting. `/prices update` applies them — the same as clicking the notice.
7740
+ */
7741
+ async function runPricesCommand(line = "/prices") {
7742
+ const mp = await import("./model-prices.mjs");
7743
+ console.log("");
7744
+ if (line.trim().slice("/prices".length).trim() === "update") {
7745
+ await runPricesUpdate();
7746
+ return;
7747
+ }
7748
+ const rows = await withPendingStatus("reading prices", () => loadPrices({ force: true }));
7749
+ if (!rows.length) {
7750
+ console.log(GUTTER + yellow("no prices — the local API is older than this gu, or unreachable.") + "\n");
7751
+ return;
7752
+ }
7753
+ const width = process.stdout.columns || 80;
7754
+ const url = activeCodingUrl();
7755
+ const model = activeCodingModelId();
7756
+ const paint = (l) => (l.startsWith("▸") ? accent(l) : /^[a-z0-9.-]+\.[a-z]+(:\d+)?( |$)/i.test(l) ? l : dim(l));
7757
+ console.log(GUTTER + "Prices");
7758
+ for (const l of mp.priceTableLines(rows, { width, current: { url, model }, version: priceVersion })) console.log(GUTTER + paint(l));
7759
+ console.log("");
7760
+ console.log(GUTTER + "Cache" + dim(" — the share of each step's prompt assumed cached, used only when a provider does not report its own count"));
7761
+ for (const l of mp.cacheShareLines({ yours: rows, width, current: { url, model } })) console.log(GUTTER + paint(l));
7762
+ console.log("");
7763
+ console.log(GUTTER + dim(pricePublishedAt ? `from gu's tables published ${new Date(pricePublishedAt).toLocaleString()}` : "gu's built-in tables (none published applied yet)"));
7764
+ if (priceUpdate) console.log(GUTTER + accent(`${mp.priceUpdateLabel(priceUpdate.publishedAt)} — click it below, or /prices update`));
7765
+ if (!mp.findPrice(rows, url, model)) {
7766
+ console.log(GUTTER + yellow(`no price for ${model || "the coder"} at ${mp.hostOf(url) || "its server"} — no cost is shown for its turns.`));
7767
+ }
7768
+ if (lastTurnCost?.usd != null) {
7769
+ const share = lastTurnCost.input ? Math.round((100 * lastTurnCost.cachedTokens) / lastTurnCost.input) : 0;
7770
+ console.log(GUTTER + `last turn: ${mp.formatUsd(lastTurnCost.usd, { estimated: lastTurnCost.estimated })}` +
7771
+ dim(` — ${lastTurnCost.input.toLocaleString("en-US")} prompt tokens, ${share}% cached ${lastTurnCost.cacheReported ? "(reported by the provider)" : "(assumed)"}`));
7772
+ }
7773
+ if (sessionCostTurns) console.log(GUTTER + `this session: ${mp.formatUsd(sessionCostUsd, { estimated: sessionCostEstimated })}` + dim(` over ${sessionCostTurns} turn${sessionCostTurns === 1 ? "" : "s"} · input only — output is not counted yet`));
7774
+ console.log("");
7775
+ }
7776
+
7777
+ // ---------------------------------------------------------------------------------------------
7778
+ // "NEW PRICES UPDATED <date>" (#323): the hosted API's newest published tables, checked at start
7779
+ // and hourly beside the version check; when newer than what this account applied, a clickable item
7780
+ // on the notice row. A click (or /prices update) writes both tables into the user's own Mongo.
7781
+ // ---------------------------------------------------------------------------------------------
7782
+ let priceUpdate = null; // { publishedAt, tables } while newer tables wait
7783
+ let priceUpdateBusy = false;
7784
+
7785
+ async function fetchPublishedTables() {
7786
+ const { DEFAULT_API_BASE } = await import("./device-login.mjs");
7787
+ const base = (String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE).replace(/\/+$/, "");
7788
+ try {
7789
+ const r = await fetch(`${base}/api/get-price-and-cache-table`, { signal: AbortSignal.timeout(8000) });
7790
+ if (!r.ok) return null;
7791
+ const j = await r.json().catch(() => null);
7792
+ return j && (j.prices || j.cacheShares) ? j : null;
7793
+ } catch {
7794
+ return null;
7795
+ }
7796
+ }
7797
+
7798
+ /** Are newer tables published? Fails silently to "no" — like the version check. */
7799
+ async function refreshPriceUpdate() {
7800
+ try {
7801
+ const mp = await import("./model-prices.mjs");
7802
+ const tables = await fetchPublishedTables();
7803
+ if (!tables) return;
7804
+ await loadPrices({ force: true });
7805
+ const was = Boolean(priceUpdate);
7806
+ priceUpdate = mp.hasNewerPublished(pricePublishedAt, tables.publishedAt) ? { publishedAt: tables.publishedAt, tables } : null;
7807
+ if (Boolean(priceUpdate) !== was && barPinned()) paintBar();
7808
+ } catch {
7809
+ /* a price notice is never worth an error */
7810
+ }
7811
+ }
7812
+
7813
+ /** Apply gu's newest published tables to this account's Mongo — the notice's click. */
7814
+ async function runPricesUpdate() {
7815
+ if (priceUpdateBusy) return;
7816
+ priceUpdateBusy = true;
7817
+ try {
7818
+ const mp = await import("./model-prices.mjs");
7819
+ const tables = (await fetchPublishedTables()) ?? priceUpdate?.tables ?? null;
7820
+ if (!tables) {
7821
+ console.log(GUTTER + yellow("couldn't reach gu's published prices — try again later.") + "\n");
7822
+ return;
7823
+ }
7824
+ const current = await loadPrices({ force: true });
7825
+ const m = mp.mergePublishedTables(current, tables);
7826
+ if (!m.ok) {
7827
+ console.log(GUTTER + yellow(`prices not updated — ${m.problem}`) + "\n");
7828
+ return;
7829
+ }
7830
+ const res = await priceApi("PUT", { version: m.version, publishedAt: m.publishedAt, rows: m.rows, removes: [] });
7831
+ if (res?.error) {
7832
+ console.log(GUTTER + red(`couldn't save the prices — ${res.error}. Nothing changed.`) + "\n");
7833
+ return;
7834
+ }
7835
+ await loadPrices({ force: true });
7836
+ priceUpdate = null;
7837
+ const d = m.diff;
7838
+ console.log(GUTTER + accent(`✓ ${mp.priceUpdateLabel(m.publishedAt).replace(/^new prices updated/, "prices updated to gu's tables of")}`) +
7839
+ dim(` — ${d.added.length} new · ${d.changed.length} changed · ${d.unchanged} unchanged. /prices shows them.`) + "\n");
7840
+ if (barPinned()) paintBar();
7841
+ } finally {
7842
+ priceUpdateBusy = false;
7843
+ }
7844
+ }
7845
+
7475
7846
  /**
7476
7847
  * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
7477
7848
  *
@@ -9236,6 +9607,8 @@ let forgetActiveStatus = null;
9236
9607
  // Ctrl+T reveal the full Thought from the KEYBOARD, so the feature doesn't depend on the mouse
9237
9608
  // capture that would block scrolling (see hoverMouse).
9238
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;
9239
9612
  function drawApprovalOptions(redraw) {
9240
9613
  // Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
9241
9614
  // step up 2 rows, clear+rewrite both, landing back where we started.
@@ -9245,11 +9618,24 @@ function drawApprovalOptions(redraw) {
9245
9618
  // each and the \x1b[2A count stays exact — which is why a resize can simply redraw here.
9246
9619
  const yes = approvalSel === 0 ? accent("▸ Yes") : dim(" Yes");
9247
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
+ }
9248
9625
  regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + GUTTER + no + "\n");
9249
9626
  }
9250
9627
  function finishApproval(allow) {
9251
9628
  if (!approvalActive) return;
9252
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
+ }
9253
9639
  // Settle the picker: overwrite the two option lines with a single result line.
9254
9640
  process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
9255
9641
  const r = approvalResolve;
@@ -9457,6 +9843,9 @@ let listDeleteRequested = false;
9457
9843
  // /use-presets' { o: "open" } (#312, #322). Null unless a caller asks, like ctrl+d above.
9458
9844
  let listKeys = null;
9459
9845
  let listKeyAction = "";
9846
+ /** #321: the screen under a picker when it opened (content rows, pad, region height), and its tallest draw. */
9847
+ let listOpenedOver = null;
9848
+ let listMaxRows = 0;
9460
9849
  function drawList(redraw) {
9461
9850
  // Gutter (task #128 Phase 2): inset each row after its line-clear. Clipped to width-1 like
9462
9851
  // every other in-place block: a long item (a model id on a narrow terminal) that wrapped
@@ -9490,6 +9879,14 @@ function drawList(redraw) {
9490
9879
  if (listNote) rows.push(clipVisible(GUTTER + yellow(listNote), width - 1));
9491
9880
  // On a redraw the caret sits one row BELOW the block (each draw ends with "\n").
9492
9881
  const up = redraw ? physicalRows(listRowsDrawn, width) : 0;
9882
+ // #321: what the screen held under the picker when it opened, and how far it reaches — so the
9883
+ // wipe can account for anything it scrolled into the scrollback (blockClosedPad).
9884
+ if (!redraw && barPinned() && !scrolledBack()) {
9885
+ const content = displayRows(transcript.lines(), width - 1).length;
9886
+ listOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
9887
+ listMaxRows = 0;
9888
+ }
9889
+ listMaxRows = Math.max(listMaxRows, physicalRows(rows, width));
9493
9890
  regionUi(
9494
9891
  (up ? `\x1b[${up}A` : "") +
9495
9892
  rows.map((r) => "\x1b[K" + r).join("\n") +
@@ -9504,6 +9901,13 @@ function finishList(index) {
9504
9901
  const width = Math.max(20, process.stdout.columns || 80);
9505
9902
  const up = physicalRows(listRowsDrawn, width); // ESC[0A would still move ONE row — guard it
9506
9903
  regionUi((up ? `\x1b[${up}A` : "") + "\x1b[J"); // wipe the list block
9904
+ // #321: the rows the picker scrolled away are in the scrollback; the content now sits that much
9905
+ // higher with blank rows under it — remember them, so nothing repaints those lines back on screen.
9906
+ if (listOpenedOver && barPinned() && !scrolledBack() && listOpenedOver.height === viewportHeight()) {
9907
+ setPad(blockClosedPad({ contentRows: listOpenedOver.content, pad: listOpenedOver.pad, height: listOpenedOver.height, blockRows: listMaxRows }), listOpenedOver.content);
9908
+ }
9909
+ listOpenedOver = null;
9910
+ listMaxRows = 0;
9507
9911
  listRowsDrawn = [];
9508
9912
  listTop = 0;
9509
9913
  const r = listResolve;
@@ -10383,6 +10787,8 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
10383
10787
  // completed resultText really EXTENDS what we streamed (an old worker replaces it
10384
10788
  // with the shorter bare answer, which must not be sliced by shownChars).
10385
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: "" };
10386
10792
 
10387
10793
  // --- Live in-progress ("blinking") bullet -------------------------------------------
10388
10794
  // The agent's raw Thought/code stream is SUPPRESSED (see the ~~~-fence handling in
@@ -11366,7 +11772,15 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11366
11772
  if (seenRaw.length < 4096) {
11367
11773
  seenRaw = (seenRaw + text.slice(shownChars)).slice(0, 4096);
11368
11774
  }
11369
- 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
+ }
11370
11784
  shownChars = text.length;
11371
11785
  }
11372
11786
  // Interactive command-approval gate: the agent paused on a risky command. Show the
@@ -11940,7 +12354,11 @@ async function main() {
11940
12354
  const nSkills = currentSkills().length;
11941
12355
  const nMcp = listMcpServers({ cwd: process.cwd() }).servers.filter((x) => x.runnable).length;
11942
12356
  const bits = [nSkills ? `${nSkills} skill${nSkills === 1 ? "" : "s"}` : "", nMcp ? `${nMcp} MCP server${nMcp === 1 ? "" : "s"}` : ""].filter(Boolean);
11943
- 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
+ }
11944
12362
  } catch {
11945
12363
  /* a broken file is reported below; the banner is not the place to fail */
11946
12364
  }
@@ -11957,7 +12375,7 @@ async function main() {
11957
12375
  for (const w of hk.warnings) console.log(GUTTER + yellow(w));
11958
12376
  if (hk.projectPending) {
11959
12377
  console.log(
11960
- 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.`) +
11961
12379
  "\n" + GUTTER + dim(" /hooks shows the commands · /hooks approve")
11962
12380
  );
11963
12381
  }
@@ -12270,6 +12688,9 @@ async function main() {
12270
12688
  // reason gu refuses to exit.
12271
12689
  void refreshAvailableUpdates();
12272
12690
  setInterval(() => void refreshAvailableUpdates(), UPDATE_CHECK_MS).unref();
12691
+ // #323: gu's newest published price + cache tables — same cadence, same silent failure.
12692
+ void refreshPriceUpdate();
12693
+ setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
12273
12694
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
12274
12695
  startupTokenRefresh = refreshCliToken();
12275
12696
  setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
@@ -12424,6 +12845,10 @@ async function main() {
12424
12845
  announceCliUpdate(); // deferred if a 6h refresh landed mid-turn; harmless once shown
12425
12846
  announceScaffold(); // the startup scaffold line, if it arrived while a turn was running
12426
12847
  rl.prompt();
12848
+ // #302, found live: prompt() puts the cursor at column 0, and a stopped turn's question is
12849
+ // already in the line — so what was typed went IN FRONT of it and Ctrl+U erased nothing.
12850
+ // The restored question is edited from its end, like a line just typed.
12851
+ if (prefillFor) rl.cursor = rl.line.length;
12427
12852
  paintBar();
12428
12853
  if (!barPinned()) probeCaretRow(); // fallback path still needs the caret's absolute row
12429
12854
  // Absorb blank Enter-presses SILENTLY — they never reach the agent. Lines typed while
@@ -12589,6 +13014,10 @@ async function main() {
12589
13014
  await runMcpSlashCommand(line);
12590
13015
  continue;
12591
13016
  }
13017
+ if (line === "/switch-skill" || line.startsWith("/switch-skill ")) {
13018
+ await runSwitchSkillCommand(line);
13019
+ continue;
13020
+ }
12592
13021
  if (line === "/hooks" || line.startsWith("/hooks ")) {
12593
13022
  const { runHooksCommand } = await import("./hooks-command.mjs");
12594
13023
  const style = { ok: accent, warn: yellow, dim };
@@ -12718,6 +13147,10 @@ async function main() {
12718
13147
  );
12719
13148
  continue;
12720
13149
  }
13150
+ if (line === "/prices" || line.startsWith("/prices ")) {
13151
+ await runPricesCommand(line);
13152
+ continue;
13153
+ }
12721
13154
  if (line === "/tokens") {
12722
13155
  // Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
12723
13156
  // this answers "and what displaced it", which is the part that tells you whether RAG is
@@ -12905,6 +13338,9 @@ async function main() {
12905
13338
  await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
12906
13339
  try {
12907
13340
  const turnStartMs = Date.now();
13341
+ // #323: priced by the coder this turn STARTS on — a /model switch afterwards must not put
13342
+ // another model's price on it.
13343
+ const turnCoder = { url: activeCodingUrl(), model: activeCodingModelId() };
12908
13344
  if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
12909
13345
  const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
12910
13346
  // NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
@@ -12927,6 +13363,8 @@ async function main() {
12927
13363
  lastTurnInputTokens = inputTokens ?? 0;
12928
13364
  lastTurnOutputTokens = turnOutputTokens ?? 0;
12929
13365
  lastTurnTotalOutput = outputTokens ?? 0;
13366
+ // #323: what this turn's prompt tokens cost — on the token row and in /price.
13367
+ void noteTurnCost(lastTurnInputTokens, lastPromptBreakdown, turnCoder);
12930
13368
  // A turn that FAILED carries the __TURN_FAILED__:: sentinel: the worker still delivers
12931
13369
  // the text (both UIs need to show it), but it is a failure notice, not a reply. Print
12932
13370
  // it as one and DROP it — persisting it would re-send "⚠️ your account is suspended" to