@tiens.nguyen/gu-cli 1.0.838 → 1.0.840
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/browser-chrome.mjs +244 -0
- package/browser-command.mjs +158 -11
- package/gonext_agent_chat.py +24 -2
- package/gu-repl.mjs +200 -67
- package/gu_browser.py +47 -4
- package/package.json +2 -1
- package/step-group.mjs +17 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// gu's OWN Chrome window (task #369, GitHub #150) — the browser tool with nothing to install.
|
|
2
|
+
//
|
|
3
|
+
// Chrome lets no program turn on Developer mode or load an unpacked extension, so without the
|
|
4
|
+
// gu extension the bridge starts a Chrome of its own: a normal, visible window on a separate
|
|
5
|
+
// profile (~/.gonext/browser-profile), started with its DevTools port open, and drives it over the
|
|
6
|
+
// DevTools protocol — the same protocol the extension uses through chrome.debugger, through the
|
|
7
|
+
// same actions.js. Nothing in the user's own Chrome is touched; the agent asks the user before it
|
|
8
|
+
// is first used (gu_browser.py).
|
|
9
|
+
//
|
|
10
|
+
// The profile is gu's alone: the user's logins are not in it (sign in there once if a test needs
|
|
11
|
+
// it — it is kept). Chrome 136+ refuses a DevTools port on the default profile anyway.
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { WebSocket } from "undici";
|
|
17
|
+
import { enableDomains, forgetTab, onCdpEvent, runAction } from "./browser-extension/actions.js";
|
|
18
|
+
|
|
19
|
+
export const profileDir = () => process.env.GU_BROWSER_PROFILE || join(homedir(), ".gonext", "browser-profile");
|
|
20
|
+
|
|
21
|
+
/** The Chrome to start: GU_BROWSER_CHROME, else Chrome / Chromium / Edge / Brave where they install. */
|
|
22
|
+
export function findChrome(platform = process.platform, env = process.env, exists = existsSync) {
|
|
23
|
+
if (env.GU_BROWSER_CHROME) return exists(env.GU_BROWSER_CHROME) ? env.GU_BROWSER_CHROME : "";
|
|
24
|
+
let list = [];
|
|
25
|
+
if (platform === "darwin") {
|
|
26
|
+
list = ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"].map((n) => `/Applications/${n}.app/Contents/MacOS/${n}`);
|
|
27
|
+
} else if (platform === "win32") {
|
|
28
|
+
const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
|
|
29
|
+
list = roots.flatMap((r) => [join(r, "Google", "Chrome", "Application", "chrome.exe"), join(r, "Microsoft", "Edge", "Application", "msedge.exe"), join(r, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")]);
|
|
30
|
+
} else {
|
|
31
|
+
list = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"]
|
|
32
|
+
.flatMap((n) => (env.PATH || "/usr/bin:/usr/local/bin").split(":").map((d) => join(d, n)));
|
|
33
|
+
}
|
|
34
|
+
return list.find((p) => exists(p)) || "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** DevToolsActivePort: line 1 the port, line 2 the browser's WebSocket path. */
|
|
38
|
+
export function readActivePort(dir) {
|
|
39
|
+
try {
|
|
40
|
+
const [port, path] = readFileSync(join(dir, "DevToolsActivePort"), "utf8").trim().split("\n");
|
|
41
|
+
return Number(port) > 0 && path ? { port: Number(port), path: path.trim() } : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
48
|
+
|
|
49
|
+
export class OwnChrome {
|
|
50
|
+
constructor({ dir = profileDir(), chrome = findChrome(), headless = process.env.GU_BROWSER_HEADLESS === "1", log = () => {} } = {}) {
|
|
51
|
+
Object.assign(this, { dir, chrome, headless, log });
|
|
52
|
+
this.ws = null;
|
|
53
|
+
this.nextId = 1;
|
|
54
|
+
this.pending = new Map();
|
|
55
|
+
this.sessions = new Map(); // targetId → sessionId
|
|
56
|
+
this.targetOf = new Map(); // sessionId → targetId
|
|
57
|
+
this.current = null;
|
|
58
|
+
this.launched = false;
|
|
59
|
+
// Main frames between "started loading" and "stopped loading". During a navigation the OLD
|
|
60
|
+
// document still says readyState "complete", so that alone cannot tell a page has moved on.
|
|
61
|
+
this.loading = new Set();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get running() {
|
|
65
|
+
return Boolean(this.ws && this.ws.readyState === WebSocket.OPEN);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── the DevTools connection ──
|
|
69
|
+
send(method, params = {}, sessionId) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
if (!this.running) return reject(new Error("gu's Chrome window is not running"));
|
|
72
|
+
const id = this.nextId++;
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
this.pending.delete(id);
|
|
75
|
+
reject(new Error(`${method} timed out`));
|
|
76
|
+
}, 30000);
|
|
77
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
78
|
+
this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async connect(ap) {
|
|
83
|
+
const ws = new WebSocket(`ws://127.0.0.1:${ap.port}${ap.path}`);
|
|
84
|
+
await new Promise((resolve, reject) => {
|
|
85
|
+
ws.onopen = resolve;
|
|
86
|
+
ws.onerror = () => reject(new Error("cannot reach Chrome's DevTools port"));
|
|
87
|
+
});
|
|
88
|
+
this.ws = ws;
|
|
89
|
+
ws.onmessage = (ev) => this.onMessage(JSON.parse(String(ev.data)));
|
|
90
|
+
ws.onclose = () => {
|
|
91
|
+
if (this.ws !== ws) return;
|
|
92
|
+
this.ws = null; // the user closed the window: the next command starts it again
|
|
93
|
+
for (const p of this.pending.values()) {
|
|
94
|
+
clearTimeout(p.timer);
|
|
95
|
+
p.reject(new Error("gu's Chrome window was closed"));
|
|
96
|
+
}
|
|
97
|
+
this.pending.clear();
|
|
98
|
+
this.sessions.clear();
|
|
99
|
+
this.targetOf.clear();
|
|
100
|
+
this.current = null;
|
|
101
|
+
};
|
|
102
|
+
await this.send("Target.setDiscoverTargets", { discover: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onMessage(m) {
|
|
106
|
+
if (m.id !== undefined) {
|
|
107
|
+
const p = this.pending.get(m.id);
|
|
108
|
+
if (!p) return;
|
|
109
|
+
this.pending.delete(m.id);
|
|
110
|
+
clearTimeout(p.timer);
|
|
111
|
+
if (m.error) p.reject(new Error(m.error.message || "DevTools error"));
|
|
112
|
+
else p.resolve(m.result || {});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (m.method === "Target.targetCreated") {
|
|
116
|
+
// A page gu's tab opened (target=_blank, window.open) becomes the current one.
|
|
117
|
+
const t = m.params.targetInfo;
|
|
118
|
+
if (t.type === "page" && t.openerId && t.openerId === this.current) this.current = t.targetId;
|
|
119
|
+
} else if (m.method === "Target.targetDestroyed" || m.method === "Target.detachedFromTarget") {
|
|
120
|
+
const id = m.params.targetId || this.targetOf.get(m.params.sessionId);
|
|
121
|
+
if (id) {
|
|
122
|
+
const s = this.sessions.get(id);
|
|
123
|
+
this.sessions.delete(id);
|
|
124
|
+
if (s) this.targetOf.delete(s);
|
|
125
|
+
if (m.method === "Target.targetDestroyed") forgetTab(id);
|
|
126
|
+
if (this.current === id && m.method === "Target.targetDestroyed") this.current = null;
|
|
127
|
+
}
|
|
128
|
+
} else if (m.sessionId && this.targetOf.has(m.sessionId)) {
|
|
129
|
+
const id = this.targetOf.get(m.sessionId);
|
|
130
|
+
// A tab's main frame has the tab's own id.
|
|
131
|
+
if (m.method === "Page.frameStartedLoading" && m.params.frameId === id) this.loading.add(id);
|
|
132
|
+
else if (m.method === "Page.frameStoppedLoading" && m.params.frameId === id) this.loading.delete(id);
|
|
133
|
+
onCdpEvent(id, m.method, m.params || {}, this.cdp);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Connect to gu's Chrome, starting it if it is not running. */
|
|
138
|
+
async ensure() {
|
|
139
|
+
if (this.running) return;
|
|
140
|
+
let ap = readActivePort(this.dir);
|
|
141
|
+
if (ap) {
|
|
142
|
+
try {
|
|
143
|
+
await this.connect(ap); // still running from an earlier bridge
|
|
144
|
+
return;
|
|
145
|
+
} catch {
|
|
146
|
+
/* stale file */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!this.chrome) throw new Error("no Chrome, Chromium, Edge or Brave found on this computer (set GU_BROWSER_CHROME to one)");
|
|
150
|
+
mkdirSync(this.dir, { recursive: true });
|
|
151
|
+
rmSync(join(this.dir, "DevToolsActivePort"), { force: true });
|
|
152
|
+
const args = [`--user-data-dir=${this.dir}`, "--remote-debugging-port=0", "--no-first-run", "--no-default-browser-check", "--window-size=1280,900", ...(this.headless ? ["--headless=new"] : []), "about:blank"];
|
|
153
|
+
spawn(this.chrome, args, { detached: true, stdio: "ignore" }).unref();
|
|
154
|
+
this.log(`started gu's Chrome window (${this.chrome})`);
|
|
155
|
+
this.launched = true;
|
|
156
|
+
const end = Date.now() + 20000;
|
|
157
|
+
while (Date.now() < end) {
|
|
158
|
+
await sleep(150);
|
|
159
|
+
ap = readActivePort(this.dir);
|
|
160
|
+
if (!ap) continue;
|
|
161
|
+
try {
|
|
162
|
+
await this.connect(ap);
|
|
163
|
+
return;
|
|
164
|
+
} catch {
|
|
165
|
+
/* not listening yet */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new Error("gu's Chrome window did not start");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── the host actions.js runs on ──
|
|
172
|
+
cdp = (targetId, method, params = {}) => this.send(method, params, this.sessions.get(targetId));
|
|
173
|
+
|
|
174
|
+
async pages() {
|
|
175
|
+
const { targetInfos } = await this.send("Target.getTargets");
|
|
176
|
+
return targetInfos.filter((t) => t.type === "page" && !/^(devtools|chrome-extension):/.test(t.url));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
host() {
|
|
180
|
+
return {
|
|
181
|
+
// gu's own window is not an extension: no store rules, so the agent's js action runs here.
|
|
182
|
+
runsJs: true,
|
|
183
|
+
cdp: this.cdp,
|
|
184
|
+
attach: async (id) => {
|
|
185
|
+
if (this.sessions.has(id)) return;
|
|
186
|
+
const { sessionId } = await this.send("Target.attachToTarget", { targetId: id, flatten: true });
|
|
187
|
+
this.sessions.set(id, sessionId);
|
|
188
|
+
this.targetOf.set(sessionId, id);
|
|
189
|
+
await enableDomains(this.cdp, id);
|
|
190
|
+
},
|
|
191
|
+
open: async (url, newTab) => {
|
|
192
|
+
const pages = await this.pages();
|
|
193
|
+
const reuse = newTab ? null : pages.find((t) => t.targetId === this.current) || pages[0];
|
|
194
|
+
// A new tab starts blank and is navigated once attached, so its load events are seen.
|
|
195
|
+
const id = reuse ? reuse.targetId : (await this.send("Target.createTarget", { url: "about:blank" })).targetId;
|
|
196
|
+
await this.host().attach(id);
|
|
197
|
+
this.loading.add(id);
|
|
198
|
+
await this.cdp(id, "Page.navigate", { url });
|
|
199
|
+
this.current = id;
|
|
200
|
+
await this.send("Target.activateTarget", { targetId: id }).catch(() => {});
|
|
201
|
+
return id;
|
|
202
|
+
},
|
|
203
|
+
tabs: async () => (await this.pages()).map((t) => ({ id: t.targetId, title: t.title, url: t.url, current: t.targetId === this.current })),
|
|
204
|
+
switchTo: async (id) => {
|
|
205
|
+
if (!(await this.pages()).some((t) => t.targetId === id)) return false;
|
|
206
|
+
this.current = id;
|
|
207
|
+
await this.send("Target.activateTarget", { targetId: id }).catch(() => {});
|
|
208
|
+
return true;
|
|
209
|
+
},
|
|
210
|
+
current: async () => {
|
|
211
|
+
const pages = await this.pages();
|
|
212
|
+
const t = pages.find((p) => p.targetId === this.current) || pages[pages.length - 1];
|
|
213
|
+
if (!t) throw new Error('gu has no tab open yet — use action "open" with a url first');
|
|
214
|
+
this.current = t.targetId;
|
|
215
|
+
return t.targetId;
|
|
216
|
+
},
|
|
217
|
+
info: async (id) => {
|
|
218
|
+
const t = (await this.pages()).find((p) => p.targetId === id);
|
|
219
|
+
if (!t) return null;
|
|
220
|
+
let status = "loading";
|
|
221
|
+
if (this.sessions.has(id) && !this.loading.has(id)) {
|
|
222
|
+
const r = await Promise.race([this.cdp(id, "Runtime.evaluate", { expression: "document.readyState", returnByValue: true }).catch(() => null), sleep(2000)]);
|
|
223
|
+
if (r?.result?.value === "complete") status = "complete";
|
|
224
|
+
}
|
|
225
|
+
return { url: t.url, title: t.title, status };
|
|
226
|
+
},
|
|
227
|
+
activate: (id) => this.send("Target.activateTarget", { targetId: id }).catch(() => {}),
|
|
228
|
+
close: (id) => this.send("Target.closeTarget", { targetId: id }),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async run(cmd) {
|
|
233
|
+
await this.ensure();
|
|
234
|
+
return runAction(cmd, this.host());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Close gu's window (tests, and /browser cleanup). */
|
|
238
|
+
async quit() {
|
|
239
|
+
if (!this.running) return;
|
|
240
|
+
await this.send("Browser.close").catch(() => {});
|
|
241
|
+
this.ws?.close();
|
|
242
|
+
this.ws = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
package/browser-command.mjs
CHANGED
|
@@ -47,6 +47,34 @@ async function status(st, waitMs = 0) {
|
|
|
47
47
|
|
|
48
48
|
// → { running, connected, version }. Starts the bridge when it is not running, so an extension
|
|
49
49
|
// installed a moment from now has something to connect to.
|
|
50
|
+
/**
|
|
51
|
+
* The error a bridge that died on start left in its log, in one line — or "" (#381, GitHub #170).
|
|
52
|
+
* Same reading as gu_browser.bridge_error: gu 1.0.838 shipped without browser-chrome.mjs, and a
|
|
53
|
+
* bridge that cannot start was shown as "nothing to install" / "no Chrome".
|
|
54
|
+
*/
|
|
55
|
+
export function bridgeError(logText) {
|
|
56
|
+
let lines = String(logText || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
57
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
58
|
+
if (lines[i].includes("listening on 127.0.0.1")) {
|
|
59
|
+
lines = lines.slice(i + 1);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const hits = lines.filter((l) => /Cannot find (?:module|package)|ERR_[A-Z_]+|\b\w*Error\b:|cannot listen|EADDRINUSE|EACCES/.test(l));
|
|
64
|
+
if (!hits.length) return "";
|
|
65
|
+
const line = hits.find((l) => l.includes("Cannot find")) ?? hits[hits.length - 1];
|
|
66
|
+
return line.replace(/\s+/g, " ").slice(0, 240);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function bridgeLogTail(maxBytes = 16000) {
|
|
70
|
+
try {
|
|
71
|
+
const text = readFileSync(join(dataDir(), "browser-bridge.log"), "utf8");
|
|
72
|
+
return text.slice(-maxBytes);
|
|
73
|
+
} catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
|
|
51
79
|
let st = readState();
|
|
52
80
|
try {
|
|
@@ -57,10 +85,13 @@ export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
|
|
|
57
85
|
if (!start) return { running: false, connected: false };
|
|
58
86
|
mkdirSync(dataDir(), { recursive: true });
|
|
59
87
|
const log = openSync(join(dataDir(), "browser-bridge.log"), "a");
|
|
60
|
-
spawn(process.execPath, [join(HERE, "browser-bridge.mjs")], { detached: true, stdio: ["ignore", log, log] })
|
|
88
|
+
const child = spawn(process.execPath, [join(HERE, "browser-bridge.mjs")], { detached: true, stdio: ["ignore", log, log] });
|
|
89
|
+
let died = false;
|
|
90
|
+
child.once("exit", () => { died = true; });
|
|
91
|
+
child.unref();
|
|
61
92
|
closeSync(log);
|
|
62
93
|
const end = Date.now() + 5000;
|
|
63
|
-
while (Date.now() < end) {
|
|
94
|
+
while (Date.now() < end && !died) {
|
|
64
95
|
await new Promise((r) => setTimeout(r, 150));
|
|
65
96
|
st = readState();
|
|
66
97
|
if (!st) continue;
|
|
@@ -70,11 +101,14 @@ export async function bridgeStatus({ start = true, waitMs = 1500 } = {}) {
|
|
|
70
101
|
/* not listening yet */
|
|
71
102
|
}
|
|
72
103
|
}
|
|
73
|
-
return { running: false, connected: false };
|
|
104
|
+
return { running: false, connected: false, error: bridgeError(bridgeLogTail()) || "it did not start" };
|
|
74
105
|
}
|
|
75
106
|
|
|
76
107
|
// The Chrome Web Store page of the gu extension, once published (#149/#150). Empty until then.
|
|
77
|
-
|
|
108
|
+
// The published listing (#385, GitHub #175) — live since 2026-10-16, gu browser 1.0.4. The env var
|
|
109
|
+
// still overrides it (a staging listing, or "" to fall back to the developer steps).
|
|
110
|
+
export const PUBLISHED_STORE_URL = "https://chromewebstore.google.com/detail/gu-browser/ldfcjapmdbgdkdhlbdkbkiankneabagj";
|
|
111
|
+
export const STORE_URL = process.env.GU_BROWSER_STORE_URL ?? PUBLISHED_STORE_URL;
|
|
78
112
|
|
|
79
113
|
// "Always allow the browser in this folder" (#150) — the agent reads the same file
|
|
80
114
|
// (gu_browser.allowed_in) before it asks.
|
|
@@ -106,7 +140,16 @@ export function allowIn(folder, allow = true) {
|
|
|
106
140
|
writeFileSync(allowPath(), JSON.stringify({ folders }, null, 2));
|
|
107
141
|
}
|
|
108
142
|
|
|
109
|
-
export function browserReport({ connected, version, installType, refused = [] }, { allowed = false } = {}) {
|
|
143
|
+
export function browserReport({ connected, version, installType, refused = [], running = true, error = "" }, { allowed = false, storeUrl = STORE_URL, storeInstalled = false } = {}) {
|
|
144
|
+
// The bridge itself would not start (#170): that is gu's fault, and says so — not "nothing to install".
|
|
145
|
+
if (!running && error) {
|
|
146
|
+
return [
|
|
147
|
+
`⚠ gu's browser bridge did not start: ${error}`,
|
|
148
|
+
"⚠ This is a problem in gu, not in Chrome — no browser needs installing.",
|
|
149
|
+
"Update gu: npm install -g @tiens.nguyen/gu-cli@latest — then restart gu.",
|
|
150
|
+
"The bridge's log: ~/.gonext/browser-bridge.log",
|
|
151
|
+
];
|
|
152
|
+
}
|
|
110
153
|
// Two copies installed (the store one next to a developer one): the bridge kept one (#148).
|
|
111
154
|
const twoCopies = refused.length
|
|
112
155
|
? [`⚠ A second gu extension (${refused.map((r) => r.id).join(", ")}) tried to connect and was`,
|
|
@@ -130,9 +173,15 @@ export function browserReport({ connected, version, installType, refused = [] },
|
|
|
130
173
|
"Nothing to install. When the agent needs a browser it asks you first, then",
|
|
131
174
|
"opens gu's own Chrome window (a separate profile — not signed in to your",
|
|
132
175
|
"accounts) and works in it with a real mouse and keyboard.",
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
176
|
+
// The link on a row of its own (#385): the published one is longer than a row beside the text.
|
|
177
|
+
// #386: which step is missing — added but not connected, or not added at all.
|
|
178
|
+
...(storeUrl && storeInstalled
|
|
179
|
+
? ["The gu extension is added to Chrome but not connected: keep Chrome open (it",
|
|
180
|
+
"connects within 30 s) — /browser install waits for it."]
|
|
181
|
+
: storeUrl
|
|
182
|
+
? ["To let it use YOUR Chrome (your logins, a tab you share), add the gu",
|
|
183
|
+
"extension — /browser install guides you through it:", ` ${storeUrl}`]
|
|
184
|
+
: ["To let it use YOUR Chrome (your logins, a tab you share): /browser install"]),
|
|
136
185
|
here,
|
|
137
186
|
...twoCopies,
|
|
138
187
|
];
|
|
@@ -140,9 +189,10 @@ export function browserReport({ connected, version, installType, refused = [] },
|
|
|
140
189
|
|
|
141
190
|
// /browser install — the extension, for the user's own Chrome. The store page when there is one;
|
|
142
191
|
// until then the developer way (Developer mode → Load unpacked).
|
|
143
|
-
export function installSteps(folder, platform = process.platform) {
|
|
144
|
-
if (
|
|
145
|
-
return [
|
|
192
|
+
export function installSteps(folder, platform = process.platform, storeUrl = STORE_URL) {
|
|
193
|
+
if (storeUrl) {
|
|
194
|
+
return ["Add the gu extension from the Chrome Web Store — click \"Add to Chrome\":", ` ${storeUrl}`,
|
|
195
|
+
"Then type /browser — it should say connected."];
|
|
146
196
|
}
|
|
147
197
|
const pick = platform === "darwin"
|
|
148
198
|
? [" In the file dialog press ⌘⇧G, paste the folder (it is on your", " clipboard) and press Enter."]
|
|
@@ -171,6 +221,23 @@ export function openExtensionsPage(platform = process.platform) {
|
|
|
171
221
|
}
|
|
172
222
|
}
|
|
173
223
|
|
|
224
|
+
/** Open the store listing (#385): Chrome on macOS when it is there, else the default browser. */
|
|
225
|
+
export function openStorePage(url = STORE_URL, platform = process.platform) {
|
|
226
|
+
if (!url || process.env.GU_NO_OPEN_APPS) return false;
|
|
227
|
+
try {
|
|
228
|
+
const [cmd, args] =
|
|
229
|
+
platform === "darwin"
|
|
230
|
+
? ["open", existsSync("/Applications/Google Chrome.app") ? ["-a", "Google Chrome", url] : [url]]
|
|
231
|
+
: platform === "win32"
|
|
232
|
+
? ["explorer.exe", [url]]
|
|
233
|
+
: ["xdg-open", [url]];
|
|
234
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
235
|
+
return true;
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
174
241
|
// macOS: put the folder on the clipboard and open the extensions page, so step 3 is a paste.
|
|
175
242
|
export function helpInstall(folder, platform = process.platform) {
|
|
176
243
|
if (platform !== "darwin" || process.env.GU_NO_OPEN_APPS) return false;
|
|
@@ -257,6 +324,86 @@ export function findDevInstalls({ roots = browserRoots(), extDir = extensionDir(
|
|
|
257
324
|
return found;
|
|
258
325
|
}
|
|
259
326
|
|
|
327
|
+
// THE PUBLISHED EXTENSION, FOUND IN CHROME'S OWN RECORDS (#386, GitHub #176). Its ID is fixed by the
|
|
328
|
+
// store (the last part of the store URL), so a profile has it when its preferences list that ID
|
|
329
|
+
// from anywhere but an unpacked folder. Disabled is told apart: it is installed but cannot connect.
|
|
330
|
+
export const STORE_EXTENSION_ID = "ldfcjapmdbgdkdhlbdkbkiankneabagj";
|
|
331
|
+
|
|
332
|
+
export function findStoreInstalls({ roots = browserRoots(), id = STORE_EXTENSION_ID } = {}) {
|
|
333
|
+
const found = [];
|
|
334
|
+
for (const { browser, dir } of roots) {
|
|
335
|
+
let profiles = [];
|
|
336
|
+
try {
|
|
337
|
+
profiles = readdirSync(dir).filter((p) => p === "Default" || /^Profile \d+$/.test(p));
|
|
338
|
+
} catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
for (const profile of profiles) {
|
|
342
|
+
for (const file of ["Secure Preferences", "Preferences"]) {
|
|
343
|
+
let e;
|
|
344
|
+
try {
|
|
345
|
+
e = JSON.parse(readFileSync(join(dir, profile, file), "utf8"))?.extensions?.settings?.[id];
|
|
346
|
+
} catch {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (!e || UNPACKED.has(e.location)) continue;
|
|
350
|
+
const reasons = Array.isArray(e.disable_reasons) ? e.disable_reasons.length : Number(e.disable_reasons) || 0;
|
|
351
|
+
found.push({ browser, profile, id, disabled: e.state === 0 || reasons > 0 });
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return found;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Where the install stands, and what to tell the user — PURE, so every stage can be tested.
|
|
361
|
+
* → { stage, lines }: "remove-dev" (a developer copy would block the store one, #148), "add" (not
|
|
362
|
+
* in Chrome yet), "enable" (added but switched off), "connect" (added, not connected yet), "done".
|
|
363
|
+
*/
|
|
364
|
+
export function installStage({ dev = [], store = [], status = {}, storeUrl = STORE_URL } = {}) {
|
|
365
|
+
if (status.connected && status.installType === "normal") {
|
|
366
|
+
return { stage: "done", lines: [`✓ gu browser is connected (Chrome Web Store, v${status.version ?? "?"}).`] };
|
|
367
|
+
}
|
|
368
|
+
if (dev.length) {
|
|
369
|
+
return {
|
|
370
|
+
stage: "remove-dev",
|
|
371
|
+
lines: [
|
|
372
|
+
"1. First remove the developer copy of gu browser — only one can connect.",
|
|
373
|
+
" In chrome://extensions (gu opened it), click Remove on this one:",
|
|
374
|
+
...dev.map((d) => ` ${d.browser} · ${d.profile} · ID ${d.id}`),
|
|
375
|
+
" Waiting for it to be removed…",
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (!store.length) {
|
|
380
|
+
return {
|
|
381
|
+
stage: "add",
|
|
382
|
+
lines: [
|
|
383
|
+
"1. Add gu browser to Chrome: on its store page, click \"Add to Chrome\".",
|
|
384
|
+
` ${storeUrl}`,
|
|
385
|
+
" (gu opened the page for you.) Waiting for it to be added…",
|
|
386
|
+
],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (store.every((s) => s.disabled)) {
|
|
390
|
+
return {
|
|
391
|
+
stage: "enable",
|
|
392
|
+
lines: [
|
|
393
|
+
"2. gu browser is added but switched off. In chrome://extensions, turn it on.",
|
|
394
|
+
" Waiting for it to be turned on…",
|
|
395
|
+
],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
stage: "connect",
|
|
400
|
+
lines: [
|
|
401
|
+
"2. gu browser is added. Waiting for it to connect to gu…",
|
|
402
|
+
" Keep Chrome open — it connects within 30 s, or click its icon → Connect now.",
|
|
403
|
+
],
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
260
407
|
// gu's own leftovers of the developer install, in ~/.gonext.
|
|
261
408
|
export function localLeftovers() {
|
|
262
409
|
return [extensionDir(), statePath(), seenPath(), logPath()].filter((p) => existsSync(p));
|
package/gonext_agent_chat.py
CHANGED
|
@@ -8771,6 +8771,25 @@ def _ws_delete_targets(argv):
|
|
|
8771
8771
|
runner = runner[:-4]
|
|
8772
8772
|
if runner in _WS_DELETE_RUNNERS:
|
|
8773
8773
|
return [a for a in argv[1:] if a and not a.startswith("-") and a != "--"]
|
|
8774
|
+
if runner in ("mv", "move"):
|
|
8775
|
+
# A MOVE REMOVES ITS SOURCE (#381, GitHub #171), measured: `mv '/Applications/Google
|
|
8776
|
+
# Chrome.app' /tmp/Google-Chrome-old.app` ran without a question while the same with rm
|
|
8777
|
+
# would have asked. The sources are every operand but the destination — or, with
|
|
8778
|
+
# -t DIR / --target-directory=DIR, every operand.
|
|
8779
|
+
ops, target_given, skip = [], False, False
|
|
8780
|
+
for a in argv[1:]:
|
|
8781
|
+
if skip:
|
|
8782
|
+
skip = False
|
|
8783
|
+
continue
|
|
8784
|
+
if a in ("-t", "--target-directory"):
|
|
8785
|
+
target_given, skip = True, True
|
|
8786
|
+
continue
|
|
8787
|
+
if a.startswith("--target-directory="):
|
|
8788
|
+
target_given = True
|
|
8789
|
+
continue
|
|
8790
|
+
if a and not a.startswith("-") and a != "--":
|
|
8791
|
+
ops.append(a)
|
|
8792
|
+
return ops if target_given else ops[:-1]
|
|
8774
8793
|
if runner == "find" and any(a in ("-delete",) for a in argv[1:]):
|
|
8775
8794
|
# `find <paths…> <expression>`: the paths come before the first expression token.
|
|
8776
8795
|
out = []
|
|
@@ -8805,7 +8824,8 @@ def _ws_delete_outside_reason(argv, wd, roots=None):
|
|
|
8805
8824
|
outside.append(t)
|
|
8806
8825
|
if not outside:
|
|
8807
8826
|
return None
|
|
8808
|
-
|
|
8827
|
+
verb = "moves" if os.path.basename(argv[0]).lower() in ("mv", "move") else "deletes"
|
|
8828
|
+
return (f"it {verb} files OUTSIDE the project (" + ", ".join(outside[:3])
|
|
8809
8829
|
+ (" …" if len(outside) > 3 else "") + ")")
|
|
8810
8830
|
|
|
8811
8831
|
|
|
@@ -16101,7 +16121,9 @@ def run_agent_chat(cfg):
|
|
|
16101
16121
|
_emit({"type": "step", "text": "Using gu's own Chrome window" if where == "chrome"
|
|
16102
16122
|
else "Using your Chrome (gu extension)"})
|
|
16103
16123
|
if not_connected:
|
|
16104
|
-
_emit({"type": "step", "text": "⚠
|
|
16124
|
+
_emit({"type": "step", "text": ("⚠ gu's browser bridge failed to start — /browser shows why"
|
|
16125
|
+
if str(res).startswith(_gb.BRIDGE_FAILED)
|
|
16126
|
+
else "⚠ No browser gu can use — /browser shows why")})
|
|
16105
16127
|
msg = _gb.install_text(str(res))
|
|
16106
16128
|
elif not ok:
|
|
16107
16129
|
msg = f"Error: browser {a} failed: {res}"
|
package/gu-repl.mjs
CHANGED
|
@@ -137,9 +137,9 @@ import {
|
|
|
137
137
|
trimWords,
|
|
138
138
|
} from "./terminal-layout.mjs";
|
|
139
139
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
140
|
-
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
140
|
+
import { isScaffoldStep, isGroupableStep, stepGroupMark, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
141
141
|
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
142
|
-
import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, openExtensionsPage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
|
|
142
|
+
import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, findStoreInstalls, installStage, openExtensionsPage, openStorePage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
|
|
143
143
|
import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPanelRows, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
144
144
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
145
145
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
@@ -420,6 +420,8 @@ const bold = paint(1);
|
|
|
420
420
|
// toward white (no bold/underline/anchor) so it subtly signals "hovered" without looking like
|
|
421
421
|
// a link. 97 = bright white.
|
|
422
422
|
const hoverThought = (s) => `\x1b[97m${s}\x1b[0m`;
|
|
423
|
+
// A thought at rest: the terminal's own text colour (#383) — no longer dim.
|
|
424
|
+
const thoughtText = (s) => s;
|
|
423
425
|
|
|
424
426
|
// The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
|
|
425
427
|
// SWALLOW them from the terminal (the local ticker below drives progress instead), and
|
|
@@ -563,7 +565,10 @@ const BULLET_STYLE = {
|
|
|
563
565
|
// A look-up (grep/pgrep/rg…) that found nothing: not an error — a dim grey ✗, dim text.
|
|
564
566
|
soft: { mark: CROSS, markColor: dim, textColor: dim },
|
|
565
567
|
ok: { mark: CHECK, markColor: accent, textColor: dim },
|
|
566
|
-
|
|
568
|
+
// #383 (GitHub #173): a "●" row that stands alone is the agent saying something — a warning, a
|
|
569
|
+
// wait for approval — so its text is white. Look-ups and finished commands are activity and go
|
|
570
|
+
// into the dim counted group instead (step-group.mjs); they never reach this style.
|
|
571
|
+
info: { mark: BULLET, markColor: dim, textColor: brightText },
|
|
567
572
|
};
|
|
568
573
|
const classifyBullet = (text) => BULLET_STYLE[bulletKind(text)];
|
|
569
574
|
|
|
@@ -1985,9 +1990,9 @@ rl.on("line", (l) => {
|
|
|
1985
1990
|
// Enter ends any drag: if a release was lost (focus left the window mid-drag) the highlight
|
|
1986
1991
|
// would otherwise stay painted over text nobody is selecting — see endDrag.
|
|
1987
1992
|
endDrag();
|
|
1988
|
-
// A list picker
|
|
1989
|
-
// let that Enter submit a stray empty line
|
|
1990
|
-
if (listPickerActive) {
|
|
1993
|
+
// A list picker — or a panel in the input box's place (#384) — owns the keyboard (its Enter
|
|
1994
|
+
// resolves via the keypress handler): never let that Enter submit a stray empty line.
|
|
1995
|
+
if (listPickerActive || zonePanel) {
|
|
1991
1996
|
rl.line = "";
|
|
1992
1997
|
rl.cursor = 0;
|
|
1993
1998
|
return;
|
|
@@ -2985,8 +2990,42 @@ function zoneNotice(width) {
|
|
|
2985
2990
|
* carries — off the line being typed (INPUT_HEAD_ROWS). All of it lives below the scrolling region, so all of it stays put — including
|
|
2986
2991
|
* what the user is typing, which is why gu renders that line itself (see inputZoneRows).
|
|
2987
2992
|
*/
|
|
2993
|
+
// A PANEL IN PLACE OF THE INPUT BOX (#384, GitHub #174) — Claude Code's Shell details. While one
|
|
2994
|
+
// is open it owns the bottom zone from the separator down: the input box, the notice row and the
|
|
2995
|
+
// bar give way to it, a turn's live status (if any) stays above it, and Esc gives the zone back.
|
|
2996
|
+
// `rows(width, maxRows)` → the panel's rows (it may use fewer); `onKey(key)` → true when handled.
|
|
2997
|
+
let zonePanel = null;
|
|
2998
|
+
|
|
2999
|
+
function openZonePanel(panel) {
|
|
3000
|
+
closeZonePanel();
|
|
3001
|
+
zonePanel = { ...panel, timer: panel.refreshMs ? setInterval(() => paintBar(), panel.refreshMs) : null };
|
|
3002
|
+
paintBar();
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
function closeZonePanel() {
|
|
3006
|
+
if (!zonePanel) return;
|
|
3007
|
+
if (zonePanel.timer) clearInterval(zonePanel.timer);
|
|
3008
|
+
const done = zonePanel.onClose;
|
|
3009
|
+
zonePanel = null;
|
|
3010
|
+
paintBar();
|
|
3011
|
+
if (done) done();
|
|
3012
|
+
}
|
|
3013
|
+
|
|
2988
3014
|
function frozenZone() {
|
|
2989
3015
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
3016
|
+
if (zonePanel) {
|
|
3017
|
+
const status = footerActive ? [] : [...pendingEchoRows(), ...liveStatusRows];
|
|
3018
|
+
// At least four rows of conversation stay visible, as with a tall input (maxInputRows).
|
|
3019
|
+
const room = Math.max(3, viewportRows() - status.length - 2 - 4);
|
|
3020
|
+
let body = [];
|
|
3021
|
+
try { body = zonePanel.rows(width, room) || []; } catch { body = []; }
|
|
3022
|
+
const rule = dim("─".repeat(width));
|
|
3023
|
+
const rows = status.concat([rule], body.slice(0, room).map((r) => clipVisible(r, width - 1)), [rule]);
|
|
3024
|
+
return {
|
|
3025
|
+
rows, statusRows: status.length, noticeRow: false, headRows: 1, rowCount: 1,
|
|
3026
|
+
caretRow: 0, caretCol: 1, offsetAt: () => 0,
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
2990
3029
|
// The status rows sit at the TOP of the zone — directly under the transcript, which is
|
|
2991
3030
|
// visually where the block already appeared, so nothing jumps. Everything below is unchanged.
|
|
2992
3031
|
//
|
|
@@ -3596,12 +3635,18 @@ const hoverCtl = createHoverController({
|
|
|
3596
3635
|
* that is the one place they are told apart.
|
|
3597
3636
|
*/
|
|
3598
3637
|
const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
|
|
3599
|
-
const paint = (r) => (hovered ? hoverThought(r) : dim(r));
|
|
3600
3638
|
if (block.kind === "steps") {
|
|
3639
|
+
// Activity (look-ups, finished commands): dim, like a ✓ row; bright while hovered.
|
|
3640
|
+
const paint = (r) => (hovered ? hoverThought(r) : dim(r));
|
|
3641
|
+
const ok = stepGroupMark(block.items) === "ok";
|
|
3601
3642
|
return stepGroupRows(block.items, { width: thoughtWidth(), collapsed }).map(
|
|
3602
|
-
(r) => (r.head ? markLine(dim(BULLET)) : GUTTER) + paint(r.text)
|
|
3643
|
+
(r) => (r.head ? markLine(ok ? accent(CHECK) : dim(BULLET)) : GUTTER) + paint(r.text)
|
|
3603
3644
|
);
|
|
3604
3645
|
}
|
|
3646
|
+
// THE AGENT'S OWN WORDS ARE WHITE (#383, GitHub #173) — what it found, what it will do next.
|
|
3647
|
+
// They were grey scaffolding (#148), as dim as the commands, and the user missed them; now
|
|
3648
|
+
// only activity recedes. Hover still lifts the block to bright white, the cue it can open.
|
|
3649
|
+
const paint = (r) => (hovered ? hoverThought(r) : thoughtText(r));
|
|
3605
3650
|
const rows = thoughtBlockRows(block.prose, {
|
|
3606
3651
|
width: thoughtWidth(),
|
|
3607
3652
|
collapsed,
|
|
@@ -3615,7 +3660,7 @@ const thoughtWidth = () =>
|
|
|
3615
3660
|
Math.max(24, (process.stdout.columns || 80) - GUTTER.length - 1);
|
|
3616
3661
|
|
|
3617
3662
|
/**
|
|
3618
|
-
* Print one step's reasoning into the transcript, collapsed
|
|
3663
|
+
* Print one step's reasoning into the transcript, collapsed — in the normal text colour since #383 (grey before, task #148).
|
|
3619
3664
|
*
|
|
3620
3665
|
* Grey and collapsed for the same reason: this is ON by default, so it has to stay scaffolding.
|
|
3621
3666
|
* Progress recedes and the answer is the only full-bright thing on screen — a page of bright
|
|
@@ -3656,7 +3701,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
|
|
|
3656
3701
|
lines: THOUGHT_COLLAPSED_LINES,
|
|
3657
3702
|
});
|
|
3658
3703
|
const rowsBeforeMerge = screenRows().length;
|
|
3659
|
-
const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER +
|
|
3704
|
+
const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + thoughtText(r)));
|
|
3660
3705
|
if (newEnd !== null) {
|
|
3661
3706
|
prev.prose = merged;
|
|
3662
3707
|
prev.to = newEnd;
|
|
@@ -3675,7 +3720,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
|
|
|
3675
3720
|
// as its error text. A thought is printed when its step's reasoning ends, BEFORE that step's
|
|
3676
3721
|
// tools report: it introduces what comes next, it does not belong to the row above. Only for a
|
|
3677
3722
|
// NEW block — a continuation is merged into the previous one above and never gets here.
|
|
3678
|
-
process.stdout.write(thoughtPrintSeq(rows.map((r) => GUTTER +
|
|
3723
|
+
process.stdout.write(thoughtPrintSeq(rows.map((r) => GUTTER + thoughtText(r)), { gap }));
|
|
3679
3724
|
// Record where it landed. The blank is above `from`, so the block is still exactly its rows. Measured AFTER the write so a trim during it cannot skew the
|
|
3680
3725
|
// range: the last completed line is length-2 (the final entry is the still-open line).
|
|
3681
3726
|
const endAbs = transcript.dropped() + transcript.length() - 2;
|
|
@@ -4474,6 +4519,15 @@ if (process.stdin.isTTY) {
|
|
|
4474
4519
|
// #247: the restored question was erased (Backspace, Ctrl+U, a picker taking the line) — it
|
|
4475
4520
|
// is let go, exactly as with Ctrl+C, so the next message does not REPLACE it in history.
|
|
4476
4521
|
if (prefillFor && (listPickerActive || restoredQuestionLetGo(rl.line, key))) prefillFor = null;
|
|
4522
|
+
// A panel holds the input box's place (#384): its keys, and nothing reaches the line.
|
|
4523
|
+
if (zonePanel) {
|
|
4524
|
+
if (key && (key.name === "escape" || (key.ctrl && key.name === "c"))) closeZonePanel();
|
|
4525
|
+
else if (zonePanel.onKey) zonePanel.onKey(key, _ch);
|
|
4526
|
+
rl.line = "";
|
|
4527
|
+
rl.cursor = 0;
|
|
4528
|
+
rl.historyIndex = -1;
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4477
4531
|
// An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
|
|
4478
4532
|
if (listPickerActive) {
|
|
4479
4533
|
onListKey(key);
|
|
@@ -9566,30 +9620,100 @@ function printShellDetails(rec) {
|
|
|
9566
9620
|
// /browser (#363): the extension's connection, else how to install it.
|
|
9567
9621
|
async function showBrowser() {
|
|
9568
9622
|
const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
|
|
9569
|
-
const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())) });
|
|
9623
|
+
const lines = browserReport(st, { allowed: allowedIn(resolve(process.cwd())), storeInstalled: findStoreInstalls().length > 0 });
|
|
9570
9624
|
console.log("");
|
|
9571
9625
|
for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 && st.connected ? accent(l) : l.startsWith("⚠") ? yellow(l) : dim(l)));
|
|
9572
9626
|
console.log("");
|
|
9573
9627
|
paintBar();
|
|
9574
9628
|
}
|
|
9575
9629
|
|
|
9576
|
-
// /browser install (#150): the extension, for the agent to
|
|
9577
|
-
//
|
|
9630
|
+
// /browser install (#150; guided since #386, GitHub #176): the published extension, for the agent to
|
|
9631
|
+
// use YOUR Chrome — the way Claude Code sets up its Chrome extension. gu walks it through to the end:
|
|
9632
|
+
// a developer copy is removed first (it would block the store one, #148), then the store page is
|
|
9633
|
+
// opened, and the panel in place of the input box (#384) shows each stage live until the store copy
|
|
9634
|
+
// is CONNECTED — or Esc stops waiting. gu's own window still needs none of this.
|
|
9578
9635
|
async function browserInstall() {
|
|
9579
|
-
|
|
9636
|
+
if (listPickerActive || approvalActive) return;
|
|
9637
|
+
const done = (line) => {
|
|
9638
|
+
console.log("\n" + GUTTER + line + "\n");
|
|
9639
|
+
paintBar();
|
|
9640
|
+
};
|
|
9580
9641
|
if (!STORE_URL) {
|
|
9642
|
+
// No published listing configured (GU_BROWSER_STORE_URL=""): the developer way, as before.
|
|
9643
|
+
let folder = extensionDir();
|
|
9581
9644
|
try {
|
|
9582
9645
|
folder = installExtensionFiles();
|
|
9583
9646
|
} catch (e) {
|
|
9584
9647
|
console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
|
|
9585
9648
|
}
|
|
9649
|
+
await bridgeStatus().catch(() => null);
|
|
9650
|
+
console.log("");
|
|
9651
|
+
for (const l of installSteps(folder, process.platform, "")) console.log(GUTTER + dim(l));
|
|
9652
|
+
if (helpInstall(folder)) console.log(GUTTER + dim("(Opened chrome://extensions and copied the folder to your clipboard.)"));
|
|
9653
|
+
console.log("");
|
|
9654
|
+
paintBar();
|
|
9655
|
+
return;
|
|
9586
9656
|
}
|
|
9587
|
-
await bridgeStatus().catch(() =>
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
|
|
9657
|
+
let status = await bridgeStatus().catch(() => ({ running: false, connected: false }));
|
|
9658
|
+
const opened = new Set();
|
|
9659
|
+
const state = () => {
|
|
9660
|
+
const st = installStage({ dev: findDevInstalls(), store: findStoreInstalls(), status });
|
|
9661
|
+
// Open the page each stage needs, once.
|
|
9662
|
+
if (st.stage === "remove-dev" && !opened.has("ext")) { opened.add("ext"); openExtensionsPage(); }
|
|
9663
|
+
if (st.stage === "add" && !opened.has("store")) { opened.add("store"); openStorePage(); }
|
|
9664
|
+
if (st.stage === "enable" && !opened.has("ext2")) { opened.add("ext2"); openExtensionsPage(); }
|
|
9665
|
+
return st;
|
|
9666
|
+
};
|
|
9667
|
+
let st = state();
|
|
9668
|
+
if (st.stage === "done") return done(accent(st.lines[0]));
|
|
9669
|
+
if (!barPinned()) {
|
|
9670
|
+
// No zone to wait in: say what to do once.
|
|
9671
|
+
console.log("");
|
|
9672
|
+
for (const l of st.lines) console.log(GUTTER + dim(l));
|
|
9673
|
+
console.log(GUTTER + dim("Then type /browser — it should say connected.") + "\n");
|
|
9674
|
+
paintBar();
|
|
9675
|
+
return;
|
|
9676
|
+
}
|
|
9677
|
+
let polling = false;
|
|
9678
|
+
const poll = setInterval(async () => {
|
|
9679
|
+
if (polling) return;
|
|
9680
|
+
polling = true;
|
|
9681
|
+
try {
|
|
9682
|
+
status = await bridgeStatus({ waitMs: 0 }).catch(() => status);
|
|
9683
|
+
st = state();
|
|
9684
|
+
if (st.stage === "done") {
|
|
9685
|
+
clearInterval(poll);
|
|
9686
|
+
closeZonePanel();
|
|
9687
|
+
done(accent(st.lines[0]) + dim(" The agent can use your Chrome now."));
|
|
9688
|
+
}
|
|
9689
|
+
} finally {
|
|
9690
|
+
polling = false;
|
|
9691
|
+
}
|
|
9692
|
+
}, 2000);
|
|
9693
|
+
openZonePanel({
|
|
9694
|
+
refreshMs: 1000,
|
|
9695
|
+
rows: (width) => {
|
|
9696
|
+
// A row the panel would clip is WRAPPED instead: the store link is 84 characters, and a link
|
|
9697
|
+
// cut short at 80 columns cannot be copied.
|
|
9698
|
+
const room = Math.max(20, width - 1 - GUTTER.length);
|
|
9699
|
+
const wrap = (l) => {
|
|
9700
|
+
const out = [];
|
|
9701
|
+
for (let rest = l; rest.length; rest = rest.slice(room)) out.push(rest.slice(0, room));
|
|
9702
|
+
return out.length ? out : [""];
|
|
9703
|
+
};
|
|
9704
|
+
return [
|
|
9705
|
+
GUTTER + bold(accent("Install gu browser")) + dim(" — so the agent can use your own Chrome"),
|
|
9706
|
+
"",
|
|
9707
|
+
...st.lines.flatMap((l) => wrap(/https?:\/\//.test(l) ? l.trim() : l).map((r) => GUTTER + (/^\d\./.test(l) ? r : dim(r)))),
|
|
9708
|
+
"",
|
|
9709
|
+
GUTTER + dim("Esc to stop waiting · /browser install picks up where you left off"),
|
|
9710
|
+
];
|
|
9711
|
+
},
|
|
9712
|
+
onClose: () => {
|
|
9713
|
+
clearInterval(poll);
|
|
9714
|
+
if (st.stage !== "done") done(dim("(stopped waiting — /browser install picks up where you left off)"));
|
|
9715
|
+
},
|
|
9716
|
+
});
|
|
9593
9717
|
}
|
|
9594
9718
|
|
|
9595
9719
|
// /browser cleanup (#145, #147): remove the developer (unpacked) install so the Chrome Web Store
|
|
@@ -9668,52 +9792,57 @@ async function showShells() {
|
|
|
9668
9792
|
}
|
|
9669
9793
|
}
|
|
9670
9794
|
const rec = entries[pick].rec;
|
|
9671
|
-
//
|
|
9672
|
-
|
|
9673
|
-
|
|
9674
|
-
|
|
9675
|
-
|
|
9795
|
+
// IN PLACE OF THE INPUT BOX (#384, GitHub #174), the way Claude Code shows it: from the top of
|
|
9796
|
+
// the bottom zone, live, until Esc — which gives the input box and the "N shell(s)" link back.
|
|
9797
|
+
// Nothing goes into the scrollback, and it works during a turn too: it holds two keys (Esc, s)
|
|
9798
|
+
// and swallows the rest, as a picker does. The shell keeps running when it closes.
|
|
9799
|
+
shellsViewed.add(String(rec.id));
|
|
9800
|
+
if (!barPinned()) {
|
|
9801
|
+
printShellDetails(rec); // no zone to draw in (a short window, piped output): print, as before
|
|
9676
9802
|
return;
|
|
9677
9803
|
}
|
|
9678
|
-
|
|
9679
|
-
|
|
9680
|
-
// picker wipes when it closes. Its height is fixed when it opens (what the screen allows, at most
|
|
9681
|
-
// the details plus 15 output lines) and it redraws once a second, so a build's runtime and
|
|
9682
|
-
// newest output move while you watch.
|
|
9683
|
-
shellsViewed.add(String(rec.id));
|
|
9684
|
-
const panel = (rowsWanted) => {
|
|
9804
|
+
let note = "";
|
|
9805
|
+
const rowsFor = (width, room) => {
|
|
9685
9806
|
const st = shellState(rec);
|
|
9686
|
-
let output = logTail(rec.log, { lines:
|
|
9807
|
+
let output = logTail(rec.log, { lines: 60 });
|
|
9687
9808
|
let outputFrom = "";
|
|
9688
9809
|
if (!output.length) {
|
|
9689
9810
|
const lf = commandLogFile(rec.command, rec.workdir);
|
|
9690
9811
|
if (lf) {
|
|
9691
|
-
output = logTail(lf, { lines:
|
|
9812
|
+
output = logTail(lf, { lines: 60 });
|
|
9692
9813
|
outputFrom = lf.startsWith(String(rec.workdir || "\0") + "/") ? lf.slice(String(rec.workdir).length + 1) : lf;
|
|
9693
9814
|
}
|
|
9694
9815
|
}
|
|
9816
|
+
const footer = note || (st.state === "running" ? "Esc to close · s to stop it" : "Esc to close");
|
|
9817
|
+
// As tall as it needs — its own detail rows (a long command wraps to 4), up to 15 output
|
|
9818
|
+
// lines and the footer — never taller than the room.
|
|
9819
|
+
const headRows = renderShellDetails({ rec, st, output: [], outputFrom, width: width - 1, gutter: GUTTER }).length - 1;
|
|
9820
|
+
const want = Math.min(room, headRows + Math.max(1, Math.min(15, output.length)) + 1);
|
|
9695
9821
|
return shellPanelRows({
|
|
9696
|
-
rec, st, output, outputFrom, width:
|
|
9697
|
-
footer: "Esc to close",
|
|
9822
|
+
rec, st, output, outputFrom, width: width - 1, gutter: GUTTER, rows: want, footer,
|
|
9698
9823
|
style: { title: (t) => bold(accent(t)), label: bold, running: accent, ok: accent, bad: red, dim },
|
|
9699
9824
|
});
|
|
9700
9825
|
};
|
|
9701
|
-
|
|
9702
|
-
|
|
9703
|
-
|
|
9704
|
-
:
|
|
9705
|
-
|
|
9706
|
-
|
|
9707
|
-
|
|
9708
|
-
|
|
9709
|
-
|
|
9710
|
-
|
|
9711
|
-
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9826
|
+
openZonePanel({
|
|
9827
|
+
rows: rowsFor,
|
|
9828
|
+
refreshMs: 1000,
|
|
9829
|
+
onKey: (key) => {
|
|
9830
|
+
if (key?.name === "s" && !key.ctrl && !key.meta && shellState(rec).state === "running") {
|
|
9831
|
+
note = "stopping…";
|
|
9832
|
+
paintBar();
|
|
9833
|
+
killShell(rec).then(() => {
|
|
9834
|
+
note = "";
|
|
9835
|
+
console.log(GUTTER + accent(`✓ stopped shell ${rec.id}`) + dim(" — the agent sees it as killed next turn."));
|
|
9836
|
+
refreshShells();
|
|
9837
|
+
paintBar();
|
|
9838
|
+
});
|
|
9839
|
+
}
|
|
9840
|
+
},
|
|
9841
|
+
onClose: () => {
|
|
9842
|
+
refreshShells();
|
|
9843
|
+
paintBar();
|
|
9844
|
+
},
|
|
9845
|
+
});
|
|
9717
9846
|
}
|
|
9718
9847
|
async function refreshOtherAgents() {
|
|
9719
9848
|
if (!barPinned() || following) return;
|
|
@@ -10699,11 +10828,18 @@ async function approvalPrompt(command) {
|
|
|
10699
10828
|
const [folder = "", what = ""] = command.slice(BROWSER_PREFIX.length).split("\t");
|
|
10700
10829
|
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
10701
10830
|
process.stdout.write(
|
|
10702
|
-
"\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min =
|
|
10831
|
+
"\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = Yes, this time)") + "\n" +
|
|
10703
10832
|
dim(` It wants to: ${what || "open a page"}`) + "\n" +
|
|
10704
|
-
dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n"
|
|
10833
|
+
dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n" +
|
|
10834
|
+
// #386: the way to "your Chrome", said where the question is asked.
|
|
10835
|
+
(STORE_URL && !findStoreInstalls().length
|
|
10836
|
+
? dim(" To use your own Chrome (your logins): /browser install adds the gu extension.") + "\n"
|
|
10837
|
+
: "")
|
|
10705
10838
|
);
|
|
10706
|
-
|
|
10839
|
+
// "Yes, this time" is THE default (#380, GitHub #167): highlighted, so Enter allows it once, and
|
|
10840
|
+
// also what an unanswered question becomes — the countdown used to sit on "No", which read as
|
|
10841
|
+
// the default. Remembering the folder is never automatic; it takes an explicit choice.
|
|
10842
|
+
const idx = await pickFromList(["Yes, this time", "Yes, always in this folder", "No"], 0, { timeoutMs: 170000, timeoutIndex: 0 });
|
|
10707
10843
|
if (idx === 1) {
|
|
10708
10844
|
try {
|
|
10709
10845
|
allowIn(folder || resolve(process.cwd()), true);
|
|
@@ -12391,17 +12527,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12391
12527
|
/** Is the open group still followed by nothing but thought blocks? */
|
|
12392
12528
|
const stepGroupStillOpen = () => {
|
|
12393
12529
|
if (!stepGroup || !barPinned() || scrolledBack()) return false;
|
|
12394
|
-
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
|
|
12402
|
-
end = thoughtBlocks[i].to;
|
|
12403
|
-
}
|
|
12404
|
-
return end === transcriptTail();
|
|
12530
|
+
if (thoughtBlocks.indexOf(stepGroup) < 0) return false; // dropped from the registry (it is capped)
|
|
12531
|
+
// ONLY WHILE IT IS THE LAST THING ON SCREEN (#383, GitHub #173). It used to stay open across
|
|
12532
|
+
// the agent's notes, so a look-up made AFTER a note was counted in the row ABOVE it. With
|
|
12533
|
+
// finished commands joining groups and notes now white — the part the user reads — that put
|
|
12534
|
+
// activity above the note that came before it ("Chrome opened but the log still shows…" under
|
|
12535
|
+
// commands it had not seen yet). A note closes the group; the next activity starts a new one
|
|
12536
|
+
// below it, so the history reads in order: activity, note, activity, note.
|
|
12537
|
+
return stepGroup.to === transcriptTail();
|
|
12405
12538
|
};
|
|
12406
12539
|
const addLookupStep = (text) => {
|
|
12407
12540
|
if (stepGroupStillOpen()) {
|
package/gu_browser.py
CHANGED
|
@@ -8,6 +8,7 @@ model reads. No model or smolagents import here — it is tested on its own.
|
|
|
8
8
|
|
|
9
9
|
import base64
|
|
10
10
|
import json
|
|
11
|
+
import re
|
|
11
12
|
import os
|
|
12
13
|
import shutil
|
|
13
14
|
import subprocess
|
|
@@ -71,7 +72,9 @@ def install_extension_files():
|
|
|
71
72
|
|
|
72
73
|
# The Chrome Web Store page of the gu extension, once it is published (#149/#150). Until then the
|
|
73
74
|
# extension is the developer path only — and nobody needs it: gu's own Chrome window works without.
|
|
74
|
-
|
|
75
|
+
# The published Chrome Web Store listing (#385, GitHub #175); the env var still overrides it.
|
|
76
|
+
PUBLISHED_STORE_URL = "https://chromewebstore.google.com/detail/gu-browser/ldfcjapmdbgdkdhlbdkbkiankneabagj"
|
|
77
|
+
STORE_URL = os.environ.get("GU_BROWSER_STORE_URL", PUBLISHED_STORE_URL)
|
|
75
78
|
|
|
76
79
|
|
|
77
80
|
def allow_path():
|
|
@@ -122,8 +125,45 @@ def last_seen():
|
|
|
122
125
|
return {}
|
|
123
126
|
|
|
124
127
|
|
|
128
|
+
BRIDGE_FAILED = "the gu browser bridge did not start"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def bridge_error(log_text):
|
|
132
|
+
"""The error a bridge that died on start left in its log — the last one, in one line — or "".
|
|
133
|
+
gu 1.0.838 shipped without browser-chrome.mjs: the bridge died with ERR_MODULE_NOT_FOUND, and
|
|
134
|
+
the agent was told "no Chrome" and reinstalled a Chrome that was fine (#381, GitHub #170)."""
|
|
135
|
+
lines = [l.strip() for l in (log_text or "").splitlines() if l.strip()]
|
|
136
|
+
# Only what came after the last successful start counts.
|
|
137
|
+
for i in range(len(lines) - 1, -1, -1):
|
|
138
|
+
if "listening on 127.0.0.1" in lines[i]:
|
|
139
|
+
lines = lines[i + 1:]
|
|
140
|
+
break
|
|
141
|
+
pat = re.compile(r"Cannot find (?:module|package)|ERR_[A-Z_]+|\b\w*Error\b:|cannot listen|EADDRINUSE|EACCES")
|
|
142
|
+
hits = [l for l in lines if pat.search(l)]
|
|
143
|
+
if not hits:
|
|
144
|
+
return ""
|
|
145
|
+
line = hits[-1] if "Cannot find" not in " ".join(hits) else next(l for l in hits if "Cannot find" in l)
|
|
146
|
+
return re.sub(r"\s+", " ", line)[:240]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def bridge_log_tail(max_bytes=16000):
|
|
150
|
+
try:
|
|
151
|
+
with open(os.path.join(data_dir(), "browser-bridge.log"), "rb") as f:
|
|
152
|
+
f.seek(0, 2)
|
|
153
|
+
f.seek(max(0, f.tell() - max_bytes))
|
|
154
|
+
return f.read().decode("utf-8", "replace")
|
|
155
|
+
except OSError:
|
|
156
|
+
return ""
|
|
157
|
+
|
|
158
|
+
|
|
125
159
|
def install_text(reason="not connected"):
|
|
126
160
|
"""What the model is told when the extension is not there: stop and ask the user."""
|
|
161
|
+
if str(reason).startswith(BRIDGE_FAILED):
|
|
162
|
+
# A fault in gu itself — never "install Chrome" (#170): the agent took that literally.
|
|
163
|
+
return (f"BROWSER NOT AVAILABLE — {reason}. This is a problem in gu's own browser bridge, NOT a "
|
|
164
|
+
"missing or broken Chrome: do NOT install, reinstall, move or delete any browser. STOP "
|
|
165
|
+
"and tell the user, with that error: update gu (npm install -g @tiens.nguyen/gu-cli@latest), "
|
|
166
|
+
"restart it, and ask again; the bridge's log is ~/.gonext/browser-bridge.log.")
|
|
127
167
|
if last_seen().get("installType") == "normal":
|
|
128
168
|
# The Chrome Web Store copy is installed: never point at the developer folder again (#145).
|
|
129
169
|
return (f"BROWSER NOT AVAILABLE ({reason}): the gu Chrome extension (Chrome Web Store) is "
|
|
@@ -174,13 +214,15 @@ def _start_bridge(log=None):
|
|
|
174
214
|
logf = open(os.path.join(data_dir(), "browser-bridge.log"), "ab")
|
|
175
215
|
kw = {"start_new_session": True} if os.name != "nt" else {
|
|
176
216
|
"creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
|
|
177
|
-
subprocess.Popen([_node(), BRIDGE], stdin=subprocess.DEVNULL, stdout=logf, stderr=logf,
|
|
178
|
-
|
|
217
|
+
proc = subprocess.Popen([_node(), BRIDGE], stdin=subprocess.DEVNULL, stdout=logf, stderr=logf,
|
|
218
|
+
env=dict(os.environ), **kw)
|
|
179
219
|
logf.close()
|
|
180
220
|
if log:
|
|
181
221
|
log("browser: started the bridge")
|
|
182
222
|
end = time.time() + 6
|
|
183
223
|
while time.time() < end:
|
|
224
|
+
if proc.poll() is not None: # it died: no point waiting the full 6 s (#170)
|
|
225
|
+
return None
|
|
184
226
|
st = _read_state()
|
|
185
227
|
if st:
|
|
186
228
|
try:
|
|
@@ -206,7 +248,8 @@ def connect(wait=True, log=None, on_wait=None, own=False):
|
|
|
206
248
|
st = _start_bridge(log)
|
|
207
249
|
started = True
|
|
208
250
|
if not st:
|
|
209
|
-
|
|
251
|
+
err = bridge_error(bridge_log_tail())
|
|
252
|
+
return None, f"{BRIDGE_FAILED}: {err}" if err else f"{BRIDGE_FAILED} (is node installed?)"
|
|
210
253
|
# A Chrome extension's worker sleeps; its alarm wakes it to reconnect within 30 s. Wait that
|
|
211
254
|
# long only when an extension has connected before — otherwise it is most likely not installed.
|
|
212
255
|
seen = os.path.exists(seen_path())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.840",
|
|
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",
|
|
@@ -129,6 +129,7 @@
|
|
|
129
129
|
"win-path.mjs",
|
|
130
130
|
"browser-command.mjs",
|
|
131
131
|
"browser-bridge.mjs",
|
|
132
|
+
"browser-chrome.mjs",
|
|
132
133
|
"browser-extension/manifest.json",
|
|
133
134
|
"browser-extension/background.js",
|
|
134
135
|
"browser-extension/actions.js",
|
package/step-group.mjs
CHANGED
|
@@ -42,11 +42,24 @@ export function stepLabel(text) {
|
|
|
42
42
|
return /^[A-Za-z][A-Za-z0-9 _.\-/]*$/.test(label) ? label : "";
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// A COMMAND THAT FINISHED joins the group too (#383, GitHub #173): a turn that checks eight tools
|
|
46
|
+
// printed eight "✓ Command finished → …" rows in a column. They are activity, like the look-ups —
|
|
47
|
+
// one counted row, a click from the commands. A FAILED command never joins: it is the row to see.
|
|
48
|
+
const COMMAND_DONE = /^Command finished → /;
|
|
49
|
+
export const isCommandStep = (text) => COMMAND_DONE.test(String(text ?? "").trim());
|
|
50
|
+
|
|
51
|
+
/** Does this step join a group? `kind` is bulletKind()'s: neutral look-ups, and finished commands. */
|
|
46
52
|
export function isGroupableStep(text, kind) {
|
|
53
|
+
if (kind === "ok") return isCommandStep(text);
|
|
47
54
|
return kind === "info" && !isScaffoldStep(text) && stepLabel(text) !== "";
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
/** The group's marker: ✓ when every step in it is a finished command, else the neutral ●. */
|
|
58
|
+
export function stepGroupMark(items) {
|
|
59
|
+
const list = items ?? [];
|
|
60
|
+
return list.length && list.every(isCommandStep) ? "ok" : "info";
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
// "Searching code" reads as work in progress; a finished group is said in the past. Only the first
|
|
51
64
|
// word changes, and only when it is one of these — anything else is shown as the agent wrote it.
|
|
52
65
|
const PAST = {
|
|
@@ -68,7 +81,9 @@ export function stepGroupSummary(items) {
|
|
|
68
81
|
const label = stepLabel(it) || String(it ?? "").trim();
|
|
69
82
|
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
70
83
|
}
|
|
71
|
-
return [...counts]
|
|
84
|
+
return [...counts]
|
|
85
|
+
.map(([label, n]) => (label === "Command finished" ? `ran ${n} command${n > 1 ? "s" : ""}` : pastLabel(label) + (n > 1 ? ` ${n}×` : "")))
|
|
86
|
+
.join(" · ");
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
function wrapWords(text, width) {
|