@tiens.nguyen/gu-cli 1.0.784 → 1.0.786

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp-config.mjs CHANGED
@@ -18,13 +18,27 @@
18
18
  * tool is read-only (readOnlyHint) is NOT a rule: the MCP spec says clients must treat those hints
19
19
  * as untrusted, and a hostile server would simply claim it.
20
20
  *
21
+ * CLAUDE CODE'S SERVERS (#325). A folder that chose "Claude's skills" (/switch-skill) takes YOUR
22
+ * servers from Claude Code's own file instead of ~/.gonext/mcp.json:
23
+ * ~/.claude.json projects["<this folder>"].mcpServers (Claude's "local" scope — this folder only)
24
+ * ~/.claude.json mcpServers (Claude's "user" scope — every folder)
25
+ * The project's .mcp.json is the same file either way. Yours still win over the project's. gu's own
26
+ * bookkeeping — disabled, the allow/deny rules, which project servers you approved — stays in
27
+ * ~/.gonext/mcp.json whichever source is chosen: Claude Code's file has no place for it, and gu
28
+ * reads Claude's setup without Claude ever having to read gu's.
29
+ *
30
+ * ~/.claude.json IS CLAUDE CODE'S WHOLE STATE FILE, not a config file of ours. It is only written
31
+ * when you add or remove a server yourself, and then only its mcpServers entry changes: re-read,
32
+ * change one key, write back atomically with the file's own permissions.
33
+ *
21
34
  * ONE WRITER SHAPE. Both `gu mcp` and `/mcp` go through updateUserConfig, which re-reads the file,
22
35
  * changes one thing and writes it back atomically — keys it does not know are kept. Rebuilding the
23
36
  * object from what one side knows is how a settings screen once deleted the other side's entries.
24
37
  */
25
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
38
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
26
39
  import { homedir } from "node:os";
27
40
  import { dirname, join, resolve } from "node:path";
41
+ import { resolveSkillSource } from "./skill-source.mjs";
28
42
 
29
43
  export const SERVER_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
30
44
 
@@ -34,6 +48,13 @@ export function userConfigPath(home = homedir()) {
34
48
  export function projectConfigPath(cwd) {
35
49
  return join(cwd, ".mcp.json");
36
50
  }
51
+ /** Claude Code's state file — where its user- and local-scope MCP servers are (#325). */
52
+ export function claudeConfigPath(home = homedir()) {
53
+ return join(home, ".claude.json");
54
+ }
55
+ /** The file YOUR servers are in, for the source in use. */
56
+ export const userServersPath = (home = homedir(), source = "gu") =>
57
+ source === "claude" ? claudeConfigPath(home) : userConfigPath(home);
37
58
 
38
59
  function readJson(file) {
39
60
  let text;
@@ -67,6 +88,39 @@ export function readUserConfig(home = homedir()) {
67
88
 
68
89
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
69
90
 
91
+ /** Claude Code's servers for this folder → { local, user, error? }. A missing file is no servers. */
92
+ export function readClaudeServers({ cwd, home = homedir() } = {}) {
93
+ const { data, error } = readJson(claudeConfigPath(home));
94
+ const proj = cwd && isObj(data?.projects) ? data.projects[resolve(cwd)] : null;
95
+ return {
96
+ error,
97
+ local: isObj(proj?.mcpServers) ? proj.mcpServers : {},
98
+ user: isObj(data?.mcpServers) ? data.mcpServers : {},
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Change ~/.claude.json: re-read, edit in place, write back atomically, permissions kept. Refused
104
+ * when the file is not valid JSON — it is Claude Code's, and not ours to "repair".
105
+ */
106
+ export function updateClaudeConfig(mutate, home = homedir()) {
107
+ const file = claudeConfigPath(home);
108
+ const { data, error } = readJson(file);
109
+ if (error) throw new Error(`${file} is ${error} — fix it first (it is Claude Code's own file)`);
110
+ const raw = data ?? {};
111
+ mutate(raw);
112
+ let mode = 0o600;
113
+ try {
114
+ mode = statSync(file).mode & 0o777;
115
+ } catch {
116
+ /* a new file: private, as Claude Code makes it */
117
+ }
118
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
119
+ writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode });
120
+ renameSync(tmp, file);
121
+ return raw;
122
+ }
123
+
70
124
  /** Write ~/.gonext/mcp.json atomically: a crash mid-write leaves the old file, never half of one. */
71
125
  function writeUserRaw(raw, home) {
72
126
  const file = userConfigPath(home);
@@ -103,12 +157,17 @@ export function normalizeServer(entry) {
103
157
 
104
158
  /**
105
159
  * Every server this folder knows of, with its state.
106
- * → { servers: [{ name, scope, type, command, args, env, disabled, approved, runnable, error? }], warnings }
160
+ * `source` = whose servers ("gu" | "claude"); left out, it is the folder's own choice.
161
+ * scope: "user" (yours, every folder), "local" (Claude's: yours, this folder only), "project".
162
+ * → { servers: [{ name, scope, type, command, args, env, disabled, approved, runnable, error? }], warnings, source }
107
163
  */
108
- export function listServers({ cwd = process.cwd(), home = homedir() } = {}) {
164
+ export function listServers({ cwd = process.cwd(), home = homedir(), source } = {}) {
165
+ const src = resolveSkillSource({ source, cwd, home });
109
166
  const user = readUserConfig(home);
110
167
  const warnings = [];
111
- if (user.error) warnings.push(`${userConfigPath(home)} is ${user.error} — its servers are skipped`);
168
+ if (user.error) {
169
+ warnings.push(`${userConfigPath(home)} is ${user.error} — ${src === "claude" ? "its rules and approvals are skipped" : "its servers are skipped"}`);
170
+ }
112
171
  const servers = [];
113
172
  const seen = new Set();
114
173
  const add = (name, entry, scope, approved) => {
@@ -129,7 +188,14 @@ export function listServers({ cwd = process.cwd(), home = homedir() } = {}) {
129
188
  runnable: !n.error && !disabled && approved,
130
189
  });
131
190
  };
132
- for (const [name, entry] of Object.entries(user.mcpServers)) add(name, entry, "user", true);
191
+ if (src === "claude") {
192
+ const claude = readClaudeServers({ cwd, home });
193
+ if (claude.error) warnings.push(`${claudeConfigPath(home)} is ${claude.error} — its servers are skipped`);
194
+ for (const [name, entry] of Object.entries(claude.local)) add(name, entry, "local", true);
195
+ for (const [name, entry] of Object.entries(claude.user)) add(name, entry, "user", true);
196
+ } else {
197
+ for (const [name, entry] of Object.entries(user.mcpServers)) add(name, entry, "user", true);
198
+ }
133
199
  if (cwd) {
134
200
  const proj = readJson(projectConfigPath(cwd));
135
201
  if (proj.error) warnings.push(`${projectConfigPath(cwd)} is ${proj.error} — its servers are skipped`);
@@ -138,12 +204,12 @@ export function listServers({ cwd = process.cwd(), home = homedir() } = {}) {
138
204
  add(name, entry, "project", approvedHere.includes(name));
139
205
  }
140
206
  }
141
- return { servers, warnings };
207
+ return { servers, warnings, source: src };
142
208
  }
143
209
 
144
210
  /** What the worker hands the agent for one turn: the servers it may start, and the rules. */
145
- export function mcpForTurn({ cwd, home = homedir() } = {}) {
146
- const { servers } = listServers({ cwd, home });
211
+ export function mcpForTurn({ cwd, home = homedir(), source } = {}) {
212
+ const { servers } = listServers({ cwd, home, source });
147
213
  const user = readUserConfig(home);
148
214
  return {
149
215
  servers: servers
@@ -177,7 +243,7 @@ export function decide({ allow = [], deny = [] }, server, tool) {
177
243
 
178
244
  /* ---------------------------------------------------------------- changes -------------- */
179
245
 
180
- export function addServer({ name, command, args = [], env = {}, scope = "user", cwd, home = homedir() }) {
246
+ export function addServer({ name, command, args = [], env = {}, scope = "user", cwd, home = homedir(), source }) {
181
247
  if (!SERVER_NAME_RE.test(name ?? "")) throw new Error(`"${name}" is not a valid name — use letters, digits, _ or -`);
182
248
  if (!String(command ?? "").trim()) throw new Error("no command given");
183
249
  const entry = { type: "stdio", command: String(command), args: args.map(String), env };
@@ -196,6 +262,15 @@ export function addServer({ name, command, args = [], env = {}, scope = "user",
196
262
  approveProjectServer({ name, cwd, home });
197
263
  return { file };
198
264
  }
265
+ if (resolveSkillSource({ source, cwd, home }) === "claude") {
266
+ // Claude Code's user scope. Its entry, its file; gu's file only forgets a stale "disabled".
267
+ updateClaudeConfig((raw) => {
268
+ raw.mcpServers = isObj(raw.mcpServers) ? raw.mcpServers : {};
269
+ raw.mcpServers[name] = entry;
270
+ }, home);
271
+ if (readUserConfig(home).disabled.includes(name)) setDisabled({ name, disabled: false, home });
272
+ return { file: claudeConfigPath(home) };
273
+ }
199
274
  updateUserConfig((raw) => {
200
275
  raw.mcpServers = isObj(raw.mcpServers) ? raw.mcpServers : {};
201
276
  raw.mcpServers[name] = entry;
@@ -205,10 +280,23 @@ export function addServer({ name, command, args = [], env = {}, scope = "user",
205
280
  }
206
281
 
207
282
  /** Remove from YOUR file. A project's server cannot be removed from here — only not approved. */
208
- export function removeServer({ name, home = homedir() }) {
283
+ export function removeServer({ name, home = homedir(), cwd, source }) {
209
284
  let found = false;
285
+ const claude = resolveSkillSource({ source, cwd, home }) === "claude";
286
+ if (claude) {
287
+ // From Claude Code's file: this folder's own entry and the every-folder one.
288
+ const has = readClaudeServers({ cwd, home });
289
+ if (name in has.local || name in has.user) {
290
+ updateClaudeConfig((raw) => {
291
+ if (isObj(raw.mcpServers) && name in raw.mcpServers) delete raw.mcpServers[name];
292
+ const proj = cwd && isObj(raw.projects) ? raw.projects[resolve(cwd)] : null;
293
+ if (isObj(proj?.mcpServers) && name in proj.mcpServers) delete proj.mcpServers[name];
294
+ }, home);
295
+ found = true;
296
+ }
297
+ }
210
298
  updateUserConfig((raw) => {
211
- if (isObj(raw.mcpServers) && name in raw.mcpServers) {
299
+ if (!claude && isObj(raw.mcpServers) && name in raw.mcpServers) {
212
300
  delete raw.mcpServers[name];
213
301
  found = true;
214
302
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.784",
3
+ "version": "1.0.786",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "check:image": "node scripts/image-paste-check.mjs",
42
42
  "check:image-clipboard": "node scripts/image-paste-check.mjs --clipboard",
43
43
  "test:presets": "node --test tests/open-presets.test.mjs tests/preset-safety.test.mjs tests/preset-more.test.mjs tests/preset-apply.test.mjs tests/preset-full.test.mjs tests/presets-transfer.test.mjs tests/preset-editor.test.mjs tests/load-preset-paste.test.mjs",
44
- "test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
44
+ "test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_claude_hooks.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/switch-skill.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
45
45
  "capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
46
46
  "test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
47
47
  "test:bearer-e2e": "bash scripts/bearer-e2e.sh"
@@ -109,6 +109,8 @@
109
109
  "powershell-alias.mjs",
110
110
  "install-path.mjs",
111
111
  "skills.mjs",
112
+ "skill-source.mjs",
113
+ "step-group.mjs",
112
114
  "mcp-config.mjs",
113
115
  "mcp-command.mjs",
114
116
  "hooks-config.mjs",
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WHOSE SKILLS THIS FOLDER USES — gu's or Claude Code's (task #325, /switch-skill).
3
+ *
4
+ * "gu" ~/.gonext and <project>/.gonext (the default; a folder that never chose)
5
+ * "claude" ~/.claude and <project>/.claude Claude Code's own files, read where they are
6
+ *
7
+ * One choice covers skills, MCP servers and hooks, because they depend on each other: a skill names
8
+ * an MCP server's tools, and the hooks feed the same database that server reads. Switching one of
9
+ * the three alone would leave them talking about different things.
10
+ *
11
+ * ONE-WAY. gu reads Claude Code's setup; Claude Code is never expected to read gu's. So nothing of
12
+ * gu's own is written into Claude's files: which project servers and hooks you approved, and the
13
+ * allow rules, stay in ~/.gonext whichever source is chosen.
14
+ *
15
+ * PER FOLDER, IN THE FOLDER'S SESSION FILE. The terminal writes it; the worker — a different
16
+ * process, which builds each turn's skills, servers and hooks — reads the same file. Every reader
17
+ * in this package resolves the source through readSkillSource, so the banner and the turn cannot
18
+ * disagree about which setup is in use.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ export const SKILL_SOURCES = ["gu", "claude"];
26
+ export const DEFAULT_SKILL_SOURCE = "gu";
27
+ /** The session file's key. */
28
+ export const SKILL_SOURCE_KEY = "skillSource";
29
+
30
+ export const normalizeSkillSource = (v) => (v === "claude" ? "claude" : DEFAULT_SKILL_SOURCE);
31
+
32
+ /** The folder each source keeps its files in — under the home directory and under the project. */
33
+ export const sourceDirName = (source) => (normalizeSkillSource(source) === "claude" ? ".claude" : ".gonext");
34
+
35
+ /** What a picker and a status line call it. */
36
+ export const sourceLabel = (source) => (normalizeSkillSource(source) === "claude" ? "Claude's skills" : "gu's skills");
37
+
38
+ /** The terminal's per-folder session file — the same name gu-repl.mjs uses. */
39
+ export function sessionFileFor(cwd, home = homedir()) {
40
+ const hash = createHash("sha256").update(String(cwd)).digest("hex").slice(0, 32);
41
+ return join(home, ".gonext", "sessions", `${hash}.json`);
42
+ }
43
+
44
+ /**
45
+ * This folder's choice. No folder, no session file, a file that cannot be read, any other value:
46
+ * gu's — the behaviour before there was a choice, and never an error.
47
+ */
48
+ export function readSkillSource(cwd, home = homedir()) {
49
+ if (!cwd) return DEFAULT_SKILL_SOURCE;
50
+ try {
51
+ const raw = JSON.parse(readFileSync(sessionFileFor(cwd, home), "utf8"));
52
+ return normalizeSkillSource(raw?.[SKILL_SOURCE_KEY]);
53
+ } catch {
54
+ return DEFAULT_SKILL_SOURCE;
55
+ }
56
+ }
57
+
58
+ /** `source` when a caller names one, else the folder's own. */
59
+ export const resolveSkillSource = ({ source, cwd, home = homedir() } = {}) =>
60
+ source ? normalizeSkillSource(source) : readSkillSource(cwd, home);
package/skills.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
  * ~/.gonext/skills/<name>/SKILL.md yours, every folder
5
5
  * <project>/.gonext/skills/<name>/SKILL.md this project only
6
6
  *
7
+ * …or Claude Code's own, when the folder chose them with /switch-skill (#325, skill-source.mjs):
8
+ * ~/.claude/skills/<name>/SKILL.md and <project>/.claude/skills/<name>/SKILL.md
9
+ * The same file in the same layout — only the folder differs, so nothing is converted.
10
+ *
7
11
  * A SKILL.md is YAML frontmatter (name, description) and a body of instructions. Only the name and
8
12
  * description go into every prompt; the body is read when the agent calls use_skill(name) or the
9
13
  * user types /<name> — never pasted into every turn. Same split as Claude Code, and for the same
@@ -24,16 +28,17 @@
24
28
  import { readdirSync, readFileSync, statSync } from "node:fs";
25
29
  import { homedir } from "node:os";
26
30
  import { join } from "node:path";
31
+ import { resolveSkillSource, sourceDirName } from "./skill-source.mjs";
27
32
 
28
33
  export const SKILL_FILE = "SKILL.md";
29
34
  /** Claude Code's rule: lowercase letters, digits and hyphens, at most 64. It is also a slash command. */
30
35
  export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
31
36
 
32
- export function userSkillsDir(home = homedir()) {
33
- return join(home, ".gonext", "skills");
37
+ export function userSkillsDir(home = homedir(), source = "gu") {
38
+ return join(home, sourceDirName(source), "skills");
34
39
  }
35
- export function projectSkillsDir(cwd) {
36
- return join(cwd, ".gonext", "skills");
40
+ export function projectSkillsDir(cwd, source = "gu") {
41
+ return join(cwd, sourceDirName(source), "skills");
37
42
  }
38
43
 
39
44
  /** Strip one pair of matching quotes. */
@@ -138,13 +143,17 @@ function scanDir(dir, scope) {
138
143
  * the built-in wins, and the skill is still usable by the agent, just not as a slash command —
139
144
  * that is `slash: false`.
140
145
  *
141
- * → { skills: [{ name, description, path, dir, scope, slash }], warnings: string[] }
146
+ * `source` = whose skills ("gu" | "claude"); left out, it is the folder's own choice (/switch-skill).
147
+ * Only that source's skills are listed — the two are never mixed.
148
+ *
149
+ * → { skills: [{ name, description, path, dir, scope, slash, source }], warnings: string[], source }
142
150
  */
143
- export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved = [] } = {}) {
144
- const user = scanDir(userSkillsDir(home), "user");
151
+ export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved = [], source } = {}) {
152
+ const src = resolveSkillSource({ source, cwd, home });
153
+ const user = scanDir(userSkillsDir(home, src), "user");
145
154
  const project =
146
- cwd && projectSkillsDir(cwd) !== userSkillsDir(home)
147
- ? scanDir(projectSkillsDir(cwd), "project")
155
+ cwd && projectSkillsDir(cwd, src) !== userSkillsDir(home, src)
156
+ ? scanDir(projectSkillsDir(cwd, src), "project")
148
157
  : { skills: [], warnings: [] };
149
158
  const byName = new Map();
150
159
  for (const s of user.skills) byName.set(s.name, s);
@@ -152,17 +161,20 @@ export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved
152
161
  const taken = new Set(reserved.map((r) => r.replace(/^\//, "")));
153
162
  const skills = [...byName.values()]
154
163
  .sort((a, b) => a.name.localeCompare(b.name))
155
- .map((s) => ({ ...s, slash: !taken.has(s.name) }));
156
- return { skills, warnings: [...user.warnings, ...project.warnings] };
164
+ .map((s) => ({ ...s, slash: !taken.has(s.name), source: src }));
165
+ return { skills, warnings: [...user.warnings, ...project.warnings], source: src };
157
166
  }
158
167
 
159
168
  /** What the worker hands the agent for one turn: enough to list them and to read one. */
160
169
  export function skillsForTurn(opts) {
161
- return discoverSkills(opts).skills.map(({ name, description, path, dir }) => ({
170
+ // `source` goes along only for Claude's: the agent then reads the skill as one written for
171
+ // Claude Code's tools (gu_skills.py). gu's own keep the shape they always had.
172
+ return discoverSkills(opts).skills.map(({ name, description, path, dir, source }) => ({
162
173
  name,
163
174
  description,
164
175
  path,
165
176
  dir,
177
+ ...(source === "claude" ? { source } : {}),
166
178
  }));
167
179
  }
168
180
 
package/step-group.mjs ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * A TURN'S LOOK-UPS AS ONE LINE (task #326) — pure helpers; gu-repl.mjs does the drawing.
3
+ *
4
+ * Asked for after reading a Claude Code session beside a gu one. gu printed one "●" line per tool
5
+ * call — "Searching code → …", "Reading lines → …", again and again — so a turn that read six
6
+ * files and searched five times was thirty rows of scaffolding above a ten-row answer. Claude Code
7
+ * says the same thing in one line ("Searched for 2 patterns, read 6 files") and keeps the detail a
8
+ * click away. That is what a GROUP is: consecutive look-ups share one row, counted by kind, and a
9
+ * click opens it into the lines gu used to print.
10
+ *
11
+ * WHAT IS GROUPED: a neutral step of the shape "Label → detail" — the agent telling us what it
12
+ * looked at. NOT grouped, because each is something the user must be able to see at a glance:
13
+ * a failure (✗), a command's result (✓), an edit card, an approval, the answer.
14
+ *
15
+ * NOTHING HERE KNOWS A TOOL'S NAME. The kind is whatever stands before the arrow, so a tool added
16
+ * tomorrow is counted with no change here — the same rule the tool list itself follows (#37).
17
+ *
18
+ * SCAFFOLD LINES are the turn's machinery talking about itself ("Starting 1 MCP server(s)…",
19
+ * "Composing answer…", the retrieval tally the group line now carries): live status, not history.
20
+ * Still printed under --verbose.
21
+ */
22
+
23
+ const ARROW = " → ";
24
+
25
+ const SCAFFOLD = [/^Starting \d+ MCP server\(s\)…$/, /^Composing answer…$/, /^Found code by → /];
26
+
27
+ /** The turn's own machinery, not something it did for the user. */
28
+ export function isScaffoldStep(text) {
29
+ const t = String(text ?? "").trim();
30
+ return SCAFFOLD.some((re) => re.test(t));
31
+ }
32
+
33
+ /** "Searching code → seenNames (3 hits)" → "Searching code"; "" when the line has no such shape. */
34
+ export function stepLabel(text) {
35
+ const t = String(text ?? "").trim();
36
+ const at = t.indexOf(ARROW);
37
+ if (at <= 0 || at > 40) return ""; // a sentence that merely contains an arrow is not a step
38
+ const label = t.slice(0, at).trim();
39
+ // A label is a few words ("Indexing project for RAG" is the longest the agent writes) — more
40
+ // than four is prose that happens to hold an arrow.
41
+ if (label.split(/\s+/).length > 4) return "";
42
+ return /^[A-Za-z][A-Za-z0-9 _.\-/]*$/.test(label) ? label : "";
43
+ }
44
+
45
+ /** Does this step join a group? `kind` is bulletKind()'s: only neutral ("info") steps do. */
46
+ export function isGroupableStep(text, kind) {
47
+ return kind === "info" && !isScaffoldStep(text) && stepLabel(text) !== "";
48
+ }
49
+
50
+ // "Searching code" reads as work in progress; a finished group is said in the past. Only the first
51
+ // word changes, and only when it is one of these — anything else is shown as the agent wrote it.
52
+ const PAST = {
53
+ searching: "searched", reading: "read", listing: "listed", locating: "located", exploring: "explored",
54
+ calculating: "calculated", downloading: "downloaded", fetching: "fetched", checking: "checked",
55
+ calling: "called", indexing: "indexed", sending: "sent", deploying: "deployed",
56
+ };
57
+
58
+ function pastLabel(label) {
59
+ const [first, ...rest] = label.split(" ");
60
+ const past = PAST[first.toLowerCase()];
61
+ return past ? [past, ...rest].join(" ") : label;
62
+ }
63
+
64
+ /** "searched code 3× · read lines 2× · listed files" — kinds in the order they first happened. */
65
+ export function stepGroupSummary(items) {
66
+ const counts = new Map();
67
+ for (const it of items ?? []) {
68
+ const label = stepLabel(it) || String(it ?? "").trim();
69
+ counts.set(label, (counts.get(label) ?? 0) + 1);
70
+ }
71
+ return [...counts].map(([label, n]) => pastLabel(label) + (n > 1 ? ` ${n}×` : "")).join(" · ");
72
+ }
73
+
74
+ function wrapWords(text, width) {
75
+ const rows = [];
76
+ let cur = "";
77
+ for (const word of String(text).trim().split(/\s+/)) {
78
+ const next = cur ? cur + " " + word : word;
79
+ if (next.length <= width) cur = next;
80
+ else {
81
+ if (cur) rows.push(cur);
82
+ // a word longer than the row (a path) is cut into row-sized pieces, never dropped
83
+ let w = word;
84
+ while (w.length > width) {
85
+ rows.push(w.slice(0, width));
86
+ w = w.slice(width);
87
+ }
88
+ cur = w;
89
+ }
90
+ }
91
+ if (cur) rows.push(cur);
92
+ return rows;
93
+ }
94
+
95
+ const clip = (text, width) => (text.length <= width ? text : text.slice(0, Math.max(1, width - 1)) + "…");
96
+
97
+ const EXPAND_HINT = " (click to expand)";
98
+ const COLLAPSE_HINT = "(click to collapse)";
99
+
100
+ /**
101
+ * The rows of one group: plain text, no colour, no gutter — the caller owns both.
102
+ * → [{ text, head }]; `head` marks a row that starts an entry (it gets the "●").
103
+ *
104
+ * COLLAPSED IS ALWAYS EXACTLY ONE ROW, whatever the width. The group is updated in place as the
105
+ * turn goes on, and a row that is always one row can be rewritten without moving anything below
106
+ * it — the lesson of every repaint bug in this terminal (#109, #321, #324): rows must not wrap,
107
+ * and what is on screen must stay what gu thinks is on screen.
108
+ * one look-up → the look-up itself (cut with "…" if it is too long; a click shows it whole)
109
+ * several → the counted summary, with the hint when there is room for it
110
+ */
111
+ export function stepGroupRows(items, { width = 76, collapsed = true } = {}) {
112
+ const list = (items ?? []).map((x) => String(x ?? "").trim()).filter(Boolean);
113
+ const w = Math.max(20, width);
114
+ if (!list.length) return [];
115
+ if (collapsed) {
116
+ if (list.length === 1) return [{ text: clip(list[0], w), head: true }];
117
+ const summary = stepGroupSummary(list);
118
+ const withHint = summary + EXPAND_HINT;
119
+ return [{ text: withHint.length <= w ? withHint : clip(summary, w), head: true }];
120
+ }
121
+ const rows = [];
122
+ for (const item of list) wrapWords(item, w).forEach((text, i) => rows.push({ text, head: i === 0 }));
123
+ // Only a group that HAS a shorter form offers to go back to it.
124
+ if (list.length > 1 || rows.length > 1) rows.push({ text: COLLAPSE_HINT, head: false });
125
+ return rows;
126
+ }
127
+
128
+ /**
129
+ * Paths under the project folder, shown relative to it: "/Users/x/proj/src/a.ts:1-9" → "src/a.ts:1-9".
130
+ * Display only. The folder itself becomes "." so "Listing files → /Users/x/proj" still says where.
131
+ */
132
+ export function relativizePaths(text, cwd) {
133
+ const root = String(cwd ?? "").replace(/[\\/]+$/, "");
134
+ const src = String(text ?? "");
135
+ if (!root || root.length < 2 || !src.includes(root)) return src;
136
+ const esc = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
137
+ return src
138
+ .replace(new RegExp(esc + "[\\\\/]", "g"), "")
139
+ .replace(new RegExp(esc + "(?![\\w.-])", "g"), ".");
140
+ }
@@ -1038,6 +1038,10 @@ export function thoughtProse(buf) {
1038
1038
  // A leading pipe or quote survives some streams ("| Thought: …").
1039
1039
  .replace(/^\s*[|>\-]+\s*/, "")
1040
1040
  .replace(/^\s*Thought:\s*/i, "")
1041
+ // …and the same label in the MIDDLE, glued to the sentence before it ("…the app does.Thought:
1042
+ // I have all the file contents") — a model that reasons and then restates under the label
1043
+ // (seen live, #326). It is a label, not a word of the thought: a space takes its place.
1044
+ .replace(/(?<=\S)\s*\bThought:\s*/g, " ")
1041
1045
  .replace(/```[a-z]*\n?/gi, " ")
1042
1046
  .replace(/\s+/g, " ")
1043
1047
  .trim();
@@ -1735,7 +1739,12 @@ export function answerBlock({
1735
1739
  doneAt = Date.now(),
1736
1740
  inputLabel = "",
1737
1741
  } = {}) {
1738
- const rendered = render(worked ? `recap: ${answer}` : answer);
1742
+ // NO "recap:" LABEL (#326). It was added when the answer arrived under a long list of step
1743
+ // lines and needed saying what it was; the look-ups are one row now, so the answer is simply
1744
+ // the bright text under them — and a label on it read as if a recap FOLLOWED the answer.
1745
+ // `worked` is still accepted: callers pass it, and it is what a real recap line would gate on.
1746
+ void worked;
1747
+ const rendered = render(answer);
1739
1748
  const clock = formatClockTime(doneAt);
1740
1749
  // The stamp is DROPPED, not defaulted, when the time is unusable — "done Invalid Date" beside
1741
1750
  // a correct duration is worse than no stamp at all. The same for the input figure (#53): the
@@ -2619,6 +2628,11 @@ export function formatThinkTime(ms) {
2619
2628
  const _PICTOGRAPH =
2620
2629
  /[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}️‍]+[ \t]?/gu;
2621
2630
 
2631
+ // A KEYCAP ("1️⃣") is a digit + a variation selector + U+20E3, and only the selector is a
2632
+ // pictograph: stripping left "1⃣ What it is" — a digit wearing a combining box, which terminals
2633
+ // draw badly or over the next letter (seen live, #326). The model meant a numbered point: "1.".
2634
+ const _KEYCAP = /([0-9#*])\uFE0F?\u20E3/g;
2635
+
2622
2636
  /**
2623
2637
  * The answer as PLAIN TEXT: decorative emoji removed (task #178).
2624
2638
  *
@@ -2643,7 +2657,7 @@ export function plainAnswer(text) {
2643
2657
  : // …and within prose, odd indices are inline code spans.
2644
2658
  part
2645
2659
  .split(/(`[^`\n]*`)/g)
2646
- .map((seg, j) => (j % 2 ? seg : seg.replace(_PICTOGRAPH, "")))
2660
+ .map((seg, j) => (j % 2 ? seg : seg.replace(_KEYCAP, "$1.").replace(_PICTOGRAPH, "")))
2647
2661
  .join("")
2648
2662
  )
2649
2663
  .join("")