@tiens.nguyen/gu-cli 1.0.832 → 1.0.834
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/README.md +1 -0
- package/browser-bridge.mjs +274 -0
- package/browser-command.mjs +116 -0
- package/browser-extension/README.md +111 -0
- package/browser-extension/background.js +483 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +35 -0
- package/browser-extension/popup.html +22 -0
- package/browser-extension/popup.js +22 -0
- package/gonext_agent_chat.py +339 -19
- package/gu-cli.mjs +3 -0
- package/gu-repl.mjs +66 -2
- package/gu_browser.py +263 -0
- package/gu_test_digest.py +352 -0
- package/package.json +10 -1
- package/shell-details.mjs +58 -4
package/gu-repl.mjs
CHANGED
|
@@ -139,7 +139,8 @@ import {
|
|
|
139
139
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
140
140
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
141
141
|
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
142
|
-
import {
|
|
142
|
+
import { browserReport, bridgeStatus, extensionDir, helpInstall, installExtensionFiles } from "./browser-command.mjs";
|
|
143
|
+
import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
143
144
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
144
145
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
145
146
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -953,6 +954,7 @@ const COMMANDS = [
|
|
|
953
954
|
{ name: "/open-presets", usage: "/open-presets [name]", desc: "your whole setup now, keys included, read-only in your editor — to copy to another computer; with a name, edit that preset" },
|
|
954
955
|
{ name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
|
|
955
956
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
957
|
+
{ name: "/browser", desc: "the gu Chrome extension the agent uses to open and click web pages (UI tests) — is it connected, and how to install it" },
|
|
956
958
|
{ name: "/shells", desc: "long commands the agent left running in the background — status, runtime, command, output; stop one" },
|
|
957
959
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
958
960
|
{ name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
|
|
@@ -9503,6 +9505,33 @@ function refreshShells() {
|
|
|
9503
9505
|
shellEntries = readShells().map((rec) => ({ rec, st: shellState(rec) }));
|
|
9504
9506
|
const after = shellsNotice(shellEntries, { viewed: shellsViewed, since: SHELLS_SINCE })?.text ?? "";
|
|
9505
9507
|
if (after !== before) paintBar();
|
|
9508
|
+
wakeForShells();
|
|
9509
|
+
}
|
|
9510
|
+
|
|
9511
|
+
/**
|
|
9512
|
+
* WHEN A BACKGROUND SHELL FINISHES, THE AGENT HEARS ABOUT IT (#362) — Claude Code tells its model
|
|
9513
|
+
* "background command completed" and it recaps or carries on. A shell that ends DURING a turn is
|
|
9514
|
+
* told on the next step (the agent's step_callback); one that ends after the turn starts a short
|
|
9515
|
+
* recap turn here — only at the empty main prompt (never into a question you are answering or a
|
|
9516
|
+
* line you are typing), only for this folder's shells, and never for one you stopped yourself.
|
|
9517
|
+
* GU_SHELL_RECAP=0 turns it off.
|
|
9518
|
+
*/
|
|
9519
|
+
let mainPromptWaiting = false;
|
|
9520
|
+
const SHELL_DONE_PREFIX = "\u0000shell-done:";
|
|
9521
|
+
function wakeForShells() {
|
|
9522
|
+
if (process.env.GU_SHELL_RECAP === "0") return;
|
|
9523
|
+
if (!mainPromptWaiting || following || listPickerActive || approvalActive || (rl?.line ?? "") !== "") return;
|
|
9524
|
+
const due = shellsToRecap(shellEntries, { cwd: process.cwd(), since: SHELLS_SINCE });
|
|
9525
|
+
if (!due.length) return;
|
|
9526
|
+
const ids = due.map((r) => String(r.id));
|
|
9527
|
+
try {
|
|
9528
|
+
if (!markAnnounced(ids)) return; // another terminal got there first
|
|
9529
|
+
} catch {
|
|
9530
|
+
return;
|
|
9531
|
+
}
|
|
9532
|
+
for (const id of ids) shellsViewed.add(id);
|
|
9533
|
+
mainPromptWaiting = false;
|
|
9534
|
+
submitSyntheticLine(SHELL_DONE_PREFIX + ids.join(","));
|
|
9506
9535
|
}
|
|
9507
9536
|
|
|
9508
9537
|
/** The Shell details block for one shell, printed into the transcript. */
|
|
@@ -9534,6 +9563,23 @@ function printShellDetails(rec) {
|
|
|
9534
9563
|
* details, then refresh / stop it / close. During a turn it only prints the details — a picker
|
|
9535
9564
|
* would fight the turn for the keyboard, as showOtherAgents explains.
|
|
9536
9565
|
*/
|
|
9566
|
+
// /browser (#363): the extension's connection, else how to install it.
|
|
9567
|
+
async function showBrowser() {
|
|
9568
|
+
let folder = extensionDir();
|
|
9569
|
+
try {
|
|
9570
|
+
folder = installExtensionFiles();
|
|
9571
|
+
} catch (e) {
|
|
9572
|
+
console.log(GUTTER + yellow(`could not copy the extension to ${folder}: ${e.message}`));
|
|
9573
|
+
}
|
|
9574
|
+
const st = await bridgeStatus().catch(() => ({ running: false, connected: false }));
|
|
9575
|
+
const lines = browserReport(st, folder);
|
|
9576
|
+
console.log("");
|
|
9577
|
+
for (const [i, l] of lines.entries()) console.log(GUTTER + (i === 0 ? (st.connected ? accent(l) : l) : dim(l)));
|
|
9578
|
+
if (!st.connected && st.running && helpInstall(folder)) console.log(GUTTER + dim("(Opened chrome://extensions and copied the folder to your clipboard.)"));
|
|
9579
|
+
console.log("");
|
|
9580
|
+
paintBar();
|
|
9581
|
+
}
|
|
9582
|
+
|
|
9537
9583
|
async function showShells() {
|
|
9538
9584
|
if (listPickerActive || approvalActive) return;
|
|
9539
9585
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
@@ -14007,6 +14053,7 @@ async function main() {
|
|
|
14007
14053
|
// the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
|
|
14008
14054
|
// in place (\r + refresh) rather than stacking ">> >> >> " on one row.
|
|
14009
14055
|
let lineRaw;
|
|
14056
|
+
mainPromptWaiting = true; // a finished background shell may start a recap turn now (#362)
|
|
14010
14057
|
for (;;) {
|
|
14011
14058
|
lineRaw = await nextLine();
|
|
14012
14059
|
const echoed = enterEchoed;
|
|
@@ -14035,8 +14082,21 @@ async function main() {
|
|
|
14035
14082
|
}
|
|
14036
14083
|
}
|
|
14037
14084
|
enterEchoed = false;
|
|
14085
|
+
mainPromptWaiting = false;
|
|
14038
14086
|
footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
|
|
14039
14087
|
if (lineRaw === null) break; // stdin closed
|
|
14088
|
+
// A RECAP TURN FOR A FINISHED BACKGROUND SHELL (#362). Nobody typed it, so it is not echoed as
|
|
14089
|
+
// a question under ">>" — one dim line says what started it, then the turn runs as usual.
|
|
14090
|
+
let shellRecap = false;
|
|
14091
|
+
if (lineRaw.startsWith(SHELL_DONE_PREFIX)) {
|
|
14092
|
+
const ids = lineRaw.slice(SHELL_DONE_PREFIX.length).split(",");
|
|
14093
|
+
const entries = readShells().map((rec) => ({ rec, st: shellState(rec) })).filter((e) => ids.includes(String(e.rec.id)));
|
|
14094
|
+
if (!entries.length) continue;
|
|
14095
|
+
shellRecap = true;
|
|
14096
|
+
alignOutputToLineStart();
|
|
14097
|
+
console.log("\n" + GUTTER + dim(`⏺ background ${entries.length === 1 ? "shell" : "shells"} finished — ${entries.map((e) => `${e.rec.id} ${shellStatusText(e.st)}`).join(", ")} · gu asks the agent to recap`));
|
|
14098
|
+
lineRaw = recapQuestion(entries);
|
|
14099
|
+
}
|
|
14040
14100
|
const line = lineRaw.trim();
|
|
14041
14101
|
// #302: what was typed for THIS line (before expansion), and whether it was sent from a
|
|
14042
14102
|
// stopped turn's restored question.
|
|
@@ -14051,7 +14111,7 @@ async function main() {
|
|
|
14051
14111
|
// carries a NUL and a raw jobId, and echoing it as a question would put both in the
|
|
14052
14112
|
// scrollback under a ">>" that claims the user wrote them. Its branch prints its own
|
|
14053
14113
|
// "⇄ following: …" line, which is the readable version of the same event.
|
|
14054
|
-
if (barPinned() && line && !line.startsWith(ATTACH_PREFIX)) {
|
|
14114
|
+
if (barPinned() && line && !line.startsWith(ATTACH_PREFIX) && !shellRecap) {
|
|
14055
14115
|
inputVisible = false; // the zone goes back to an empty prompt for the turn
|
|
14056
14116
|
// START THIS LINE AT COLUMN 0, don't assume it is already there.
|
|
14057
14117
|
//
|
|
@@ -14147,6 +14207,10 @@ async function main() {
|
|
|
14147
14207
|
await showShells();
|
|
14148
14208
|
continue;
|
|
14149
14209
|
}
|
|
14210
|
+
if (line === "/browser") {
|
|
14211
|
+
await showBrowser();
|
|
14212
|
+
continue;
|
|
14213
|
+
}
|
|
14150
14214
|
if (line === "/model") {
|
|
14151
14215
|
await chooseModel();
|
|
14152
14216
|
continue;
|
package/gu_browser.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""The agent's browser tool, Python side (task #363, GitHub #144).
|
|
2
|
+
|
|
3
|
+
The agent runs in a new process every turn, so it never holds the Chrome extension itself: it talks
|
|
4
|
+
to browser-bridge.mjs (a small local Node process that does) over HTTP, starting it when needed.
|
|
5
|
+
This module finds or starts the bridge, sends one command, and turns the answer into the text the
|
|
6
|
+
model reads. No model or smolagents import here — it is tested on its own.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
20
|
+
EXT_SRC = os.path.join(HERE, "browser-extension")
|
|
21
|
+
EXT_FILES = ("manifest.json", "background.js", "popup.html", "popup.js",
|
|
22
|
+
"icons/icon16.png", "icons/icon48.png", "icons/icon128.png")
|
|
23
|
+
BRIDGE = os.path.join(HERE, "browser-bridge.mjs")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def data_dir():
|
|
27
|
+
return os.path.join(os.path.expanduser("~"), ".gonext")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def state_path():
|
|
31
|
+
return os.environ.get("GU_BROWSER_STATE") or os.path.join(data_dir(), "browser-bridge.json")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def seen_path():
|
|
35
|
+
# Written the first time an extension answers: from then on a missing connection means "Chrome
|
|
36
|
+
# is asleep or closed", worth a longer wait, not "never installed".
|
|
37
|
+
return os.path.join(os.path.dirname(state_path()), "browser-extension-seen")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extension_dir():
|
|
41
|
+
"""The folder the user loads into Chrome. A fixed place under ~/.gonext, not the npm package:
|
|
42
|
+
an update moves the package, and Chrome would lose an unpacked extension loaded from there."""
|
|
43
|
+
return os.environ.get("GU_BROWSER_EXT_DIR") or os.path.join(data_dir(), "browser-extension")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def install_extension_files():
|
|
47
|
+
"""Copy the extension to extension_dir() when it is missing or older. Returns the folder."""
|
|
48
|
+
dst = extension_dir()
|
|
49
|
+
try:
|
|
50
|
+
with open(os.path.join(EXT_SRC, "manifest.json"), encoding="utf-8") as f:
|
|
51
|
+
want = f.read()
|
|
52
|
+
try:
|
|
53
|
+
with open(os.path.join(dst, "manifest.json"), encoding="utf-8") as f:
|
|
54
|
+
have = f.read()
|
|
55
|
+
except OSError:
|
|
56
|
+
have = ""
|
|
57
|
+
def same(n):
|
|
58
|
+
try:
|
|
59
|
+
with open(os.path.join(dst, n), "rb") as a, open(os.path.join(EXT_SRC, n), "rb") as b:
|
|
60
|
+
return a.read() == b.read()
|
|
61
|
+
except OSError:
|
|
62
|
+
return False
|
|
63
|
+
if have != want or not all(same(n) for n in EXT_FILES):
|
|
64
|
+
os.makedirs(os.path.join(dst, "icons"), exist_ok=True)
|
|
65
|
+
for n in EXT_FILES:
|
|
66
|
+
shutil.copyfile(os.path.join(EXT_SRC, n), os.path.join(dst, n))
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
return dst
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def install_text(reason="not connected"):
|
|
73
|
+
"""What the model is told when the extension is not there: stop and ask the user."""
|
|
74
|
+
folder = install_extension_files()
|
|
75
|
+
return (
|
|
76
|
+
f"BROWSER NOT AVAILABLE ({reason}): the gu Chrome extension is not installed, or Chrome is "
|
|
77
|
+
"not running. Do NOT try to work around it with curl or a headless script for a UI check — "
|
|
78
|
+
"STOP and ask the user to install it, then continue when they say it is done. Tell them:\n"
|
|
79
|
+
" 1. Open Chrome and go to chrome://extensions\n"
|
|
80
|
+
" 2. Turn on Developer mode (top right)\n"
|
|
81
|
+
f" 3. Click \"Load unpacked\" and choose the folder {folder}\n"
|
|
82
|
+
" 4. (Or type /browser in the gu terminal for these steps and the connection status.)\n"
|
|
83
|
+
"gu only uses its own tabs (a tab group named \"gu\"); the user's other tabs are never touched.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _node():
|
|
87
|
+
return os.environ.get("GU_NODE") or shutil.which("node") or "node"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_state():
|
|
91
|
+
try:
|
|
92
|
+
with open(state_path(), encoding="utf-8") as f:
|
|
93
|
+
st = json.load(f)
|
|
94
|
+
return st if st.get("port") and st.get("token") else None
|
|
95
|
+
except (OSError, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _http(st, method, path, body=None, timeout=10):
|
|
100
|
+
req = urllib.request.Request(
|
|
101
|
+
f"http://127.0.0.1:{st['port']}{path}", method=method,
|
|
102
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
103
|
+
headers={"authorization": f"Bearer {st['token']}", "content-type": "application/json"})
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
106
|
+
return json.loads(r.read() or b"{}")
|
|
107
|
+
except urllib.error.HTTPError as e:
|
|
108
|
+
try:
|
|
109
|
+
return json.loads(e.read() or b"{}") | {"_status": e.code}
|
|
110
|
+
except ValueError:
|
|
111
|
+
return {"_status": e.code, "error": str(e)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _start_bridge(log=None):
|
|
115
|
+
"""Start browser-bridge.mjs detached (it outlives this turn) and wait for its state file."""
|
|
116
|
+
os.makedirs(data_dir(), exist_ok=True)
|
|
117
|
+
logf = open(os.path.join(data_dir(), "browser-bridge.log"), "ab")
|
|
118
|
+
kw = {"start_new_session": True} if os.name != "nt" else {
|
|
119
|
+
"creationflags": getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
|
|
120
|
+
subprocess.Popen([_node(), BRIDGE], stdin=subprocess.DEVNULL, stdout=logf, stderr=logf,
|
|
121
|
+
env=dict(os.environ), **kw)
|
|
122
|
+
logf.close()
|
|
123
|
+
if log:
|
|
124
|
+
log("browser: started the bridge")
|
|
125
|
+
end = time.time() + 6
|
|
126
|
+
while time.time() < end:
|
|
127
|
+
st = _read_state()
|
|
128
|
+
if st:
|
|
129
|
+
try:
|
|
130
|
+
_http(st, "GET", "/status", timeout=2)
|
|
131
|
+
return st
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
time.sleep(0.15)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def connect(wait=True, log=None, on_wait=None):
|
|
139
|
+
"""→ (state, None) when the extension is connected, else (None, reason)."""
|
|
140
|
+
st = _read_state()
|
|
141
|
+
started = False
|
|
142
|
+
try:
|
|
143
|
+
if not st:
|
|
144
|
+
raise OSError("no bridge")
|
|
145
|
+
_http(st, "GET", "/status", timeout=3)
|
|
146
|
+
except OSError:
|
|
147
|
+
st = _start_bridge(log)
|
|
148
|
+
started = True
|
|
149
|
+
if not st:
|
|
150
|
+
return None, "the gu browser bridge did not start (is node installed?)"
|
|
151
|
+
# A Chrome extension's worker sleeps; its alarm wakes it to reconnect within 30 s. Wait that
|
|
152
|
+
# long only when an extension has connected before — otherwise it is most likely not installed.
|
|
153
|
+
seen = os.path.exists(seen_path())
|
|
154
|
+
wait_ms = (35000 if (seen and started) else 8000 if seen else 4000) if wait else 0
|
|
155
|
+
if wait_ms >= 8000 and on_wait:
|
|
156
|
+
on_wait()
|
|
157
|
+
try:
|
|
158
|
+
s = _http(st, "GET", f"/status?wait_ms={wait_ms}", timeout=wait_ms / 1000 + 5)
|
|
159
|
+
except OSError as e:
|
|
160
|
+
return None, f"the gu browser bridge is not answering ({e})"
|
|
161
|
+
if not s.get("connected"):
|
|
162
|
+
return None, "extension not connected"
|
|
163
|
+
if not seen:
|
|
164
|
+
try:
|
|
165
|
+
with open(seen_path(), "w") as f:
|
|
166
|
+
f.write(str(s.get("version") or ""))
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
return st, None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def command(cmd, log=None, on_wait=None):
|
|
173
|
+
"""Send one command. → (ok, result_or_error, not_connected)."""
|
|
174
|
+
st, why = connect(log=log, on_wait=on_wait)
|
|
175
|
+
if not st:
|
|
176
|
+
return False, why, True
|
|
177
|
+
timeout_s = 75 if cmd.get("action") in ("open", "wait", "js") else 40
|
|
178
|
+
try:
|
|
179
|
+
r = _http(st, "POST", "/cmd", body=dict(cmd, timeout_ms=(timeout_s - 5) * 1000), timeout=timeout_s)
|
|
180
|
+
except OSError as e:
|
|
181
|
+
return False, f"the browser bridge did not answer ({e})", False
|
|
182
|
+
if r.get("_status") == 503:
|
|
183
|
+
return False, "extension not connected", True
|
|
184
|
+
if not r.get("ok"):
|
|
185
|
+
return False, str(r.get("error") or "failed"), False
|
|
186
|
+
return True, r.get("result") or {}, False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ── what the model reads ────────────────────────────────────────────────────────────────────
|
|
190
|
+
def _head(res):
|
|
191
|
+
out = f"Page: {res.get('title') or '(no title)'} — {res.get('url') or ''}"
|
|
192
|
+
for d in res.get("dialogs") or []:
|
|
193
|
+
out += f"\nA dialog opened and gu accepted it: {d}"
|
|
194
|
+
errs = res.get("errors") or []
|
|
195
|
+
if errs:
|
|
196
|
+
out += "\nNEW console errors:\n" + "\n".join(" " + e for e in errs)
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def format_read(res):
|
|
201
|
+
els = res.get("elements") or []
|
|
202
|
+
lines = [_head(res), f"Viewport {res.get('viewport')}, scrolled {res.get('scroll')} px."]
|
|
203
|
+
if els:
|
|
204
|
+
lines.append("Interactive elements in view — click/type by [number] (good until the next read):")
|
|
205
|
+
lines += [" " + e for e in els]
|
|
206
|
+
else:
|
|
207
|
+
lines.append("No interactive elements in view.")
|
|
208
|
+
if res.get("offscreen"):
|
|
209
|
+
lines.append(f"(+{res['offscreen']} more off screen — scroll to reach them)")
|
|
210
|
+
text = (res.get("text") or "").strip()
|
|
211
|
+
lines.append("Page text:\n" + (text or "(empty)"))
|
|
212
|
+
return "\n".join(lines)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def format_result(action, res):
|
|
216
|
+
a = action
|
|
217
|
+
if a == "tabs":
|
|
218
|
+
tabs = res.get("tabs") or []
|
|
219
|
+
if not tabs:
|
|
220
|
+
return "gu has no tab open."
|
|
221
|
+
return "gu's tabs:\n" + "\n".join(
|
|
222
|
+
f" {t['id']}{' (current)' if t.get('current') else ''}: {t.get('title') or ''} — {t.get('url')}" for t in tabs)
|
|
223
|
+
if a == "read":
|
|
224
|
+
return format_read(res)
|
|
225
|
+
if a == "close":
|
|
226
|
+
return f"Closed tab {res.get('closed')}."
|
|
227
|
+
out = _head(res)
|
|
228
|
+
if a in ("click", "double_click", "right_click", "hover"):
|
|
229
|
+
out = f"{a.replace('_', ' ').capitalize()} at {res.get('at')} on {res.get('hit')}.\n" + out
|
|
230
|
+
if a == "scroll":
|
|
231
|
+
out += f"\nScrolled to {res.get('scroll')} px."
|
|
232
|
+
if a == "js":
|
|
233
|
+
out += f"\nResult: {res.get('value')}"
|
|
234
|
+
if a == "console":
|
|
235
|
+
msgs = res.get("console") or []
|
|
236
|
+
out += ("\nConsole since the last read:\n" + "\n".join(f" [{m.get('level')}] {m.get('text')}" for m in msgs)
|
|
237
|
+
if msgs else "\nConsole: nothing new.")
|
|
238
|
+
if a in ("open", "click", "double_click", "type", "key", "back", "reload"):
|
|
239
|
+
out += "\n(read or screenshot to see the page now)"
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def decode_screenshot(res, max_edge=1568):
|
|
244
|
+
"""The screenshot as a PIL image at the page's CSS size, so its pixels ARE click coordinates.
|
|
245
|
+
→ (image, note). Chrome captures in device pixels (2× on a Retina screen)."""
|
|
246
|
+
from io import BytesIO
|
|
247
|
+
from PIL import Image
|
|
248
|
+
im = Image.open(BytesIO(base64.b64decode(res.get("image") or ""))).convert("RGB")
|
|
249
|
+
w, h = int(res.get("width") or im.width), int(res.get("height") or im.height)
|
|
250
|
+
if (w, h) != im.size and w > 0 and h > 0:
|
|
251
|
+
im = im.resize((w, h), Image.LANCZOS)
|
|
252
|
+
note = f"Screenshot {im.width}×{im.height} — its pixels are the page's x,y for click."
|
|
253
|
+
if max(im.size) > max_edge:
|
|
254
|
+
k = max_edge / max(im.size)
|
|
255
|
+
im = im.resize((max(1, int(im.width * k)), max(1, int(im.height * k))), Image.LANCZOS)
|
|
256
|
+
note = (f"Screenshot shrunk to {im.width}×{im.height}; the page is {w}×{h} — multiply a point "
|
|
257
|
+
f"in it by {1 / k:.3f} for click x,y.")
|
|
258
|
+
return im, note
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__": # tiny manual check: python3 gu_browser.py open https://example.com
|
|
262
|
+
ok, res, _ = command({"action": sys.argv[1], "url": sys.argv[2] if len(sys.argv) > 2 else ""})
|
|
263
|
+
print(format_result(sys.argv[1], res) if ok else res)
|