@anusornneal/chat-relay 0.11.1 → 0.11.2
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/agent/desktop-manager.mjs +4 -3
- package/agent/macos-desktop-runner.mjs +10 -4
- package/agent/toolcall-summary.mjs +157 -41
- package/agent/windows-desktop-worker.ps1 +12 -5
- package/cli/tui.mjs +123 -15
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { BoundedLane } from "./capability-scheduler.mjs";
|
|
|
2
2
|
import { PersistentDesktopRunner } from "./windows-desktop-runner.mjs";
|
|
3
3
|
import { MacOSDesktopRunner } from "./macos-desktop-runner.mjs";
|
|
4
4
|
|
|
5
|
-
const MAX_SCREENSHOT_BINARY_BYTES =
|
|
5
|
+
const MAX_SCREENSHOT_BINARY_BYTES = 2 * 1024 * 1024;
|
|
6
6
|
const MAX_TEXT_LENGTH = 8192;
|
|
7
7
|
const MAX_CLIPBOARD_TEXT_LENGTH = 8192;
|
|
8
8
|
const MIN_SCREENSHOT_WIDTH = 320;
|
|
@@ -201,8 +201,9 @@ export class DesktopManager {
|
|
|
201
201
|
return { ok: false, error: "invalid_monitor" };
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
const native = input.native === true;
|
|
204
205
|
const maxWidth = input.maxWidth === undefined ? 960 : Number(input.maxWidth);
|
|
205
|
-
if (!Number.isInteger(maxWidth) || maxWidth < MIN_SCREENSHOT_WIDTH || maxWidth > MAX_SCREENSHOT_WIDTH) {
|
|
206
|
+
if (!native && (!Number.isInteger(maxWidth) || maxWidth < MIN_SCREENSHOT_WIDTH || maxWidth > MAX_SCREENSHOT_WIDTH)) {
|
|
206
207
|
return { ok: false, error: "invalid_max_width" };
|
|
207
208
|
}
|
|
208
209
|
const quality = input.quality === undefined ? 58 : Number(input.quality);
|
|
@@ -210,7 +211,7 @@ export class DesktopManager {
|
|
|
210
211
|
return { ok: false, error: "invalid_quality" };
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
const result = await this.runner("screenshot", { monitor, maxWidth, quality });
|
|
214
|
+
const result = await this.runner("screenshot", { monitor, maxWidth, quality, native });
|
|
214
215
|
return this.normalizeScreenshotResult(result);
|
|
215
216
|
}
|
|
216
217
|
|
|
@@ -124,15 +124,20 @@ export class MacOSDesktopRunner {
|
|
|
124
124
|
return { ok: false, error: "capture_failed" };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
const native = args.native === true;
|
|
128
|
+
let targetWidth = native ? originalInfo.width : Math.min(Number(args.maxWidth || 960), originalInfo.width);
|
|
128
129
|
const requestedQuality = Math.max(20, Math.min(85, Number(args.quality || 58)));
|
|
129
|
-
const
|
|
130
|
+
const maxBytes = native ? (2 * 1024 * 1024) : MAX_SCREENSHOT_BINARY_BYTES;
|
|
131
|
+
const qualities = [...new Set((native
|
|
132
|
+
? [requestedQuality, 75, 60, 45, 30, 20]
|
|
133
|
+
: [requestedQuality, 55, 45, 35, 25, 20]
|
|
134
|
+
).filter((q) => q <= requestedQuality))];
|
|
130
135
|
let data = null;
|
|
131
136
|
let finalWidth = 0;
|
|
132
137
|
let finalHeight = 0;
|
|
133
138
|
let finalQuality = requestedQuality;
|
|
134
139
|
|
|
135
|
-
for (let attempt = 0; attempt < 5 && !data; attempt += 1) {
|
|
140
|
+
for (let attempt = 0; attempt < (native ? 1 : 5) && !data; attempt += 1) {
|
|
136
141
|
for (const quality of qualities) {
|
|
137
142
|
await fs.rm(output, { force: true });
|
|
138
143
|
await execNative("/usr/bin/sips", [
|
|
@@ -143,7 +148,7 @@ export class MacOSDesktopRunner {
|
|
|
143
148
|
"--out", output,
|
|
144
149
|
]);
|
|
145
150
|
const bytes = await fs.readFile(output);
|
|
146
|
-
if (bytes.byteLength > 0 && bytes.byteLength <=
|
|
151
|
+
if (bytes.byteLength > 0 && bytes.byteLength <= maxBytes) {
|
|
147
152
|
const resized = parseSipsSize(await execNative("/usr/bin/sips", [
|
|
148
153
|
"-g", "pixelWidth", "-g", "pixelHeight", output,
|
|
149
154
|
]));
|
|
@@ -181,6 +186,7 @@ export class MacOSDesktopRunner {
|
|
|
181
186
|
virtualDesktopHeight: Number(info.virtualDesktopHeight),
|
|
182
187
|
monitorCount: info.screens.length,
|
|
183
188
|
jpegQuality: finalQuality,
|
|
189
|
+
native,
|
|
184
190
|
timing: {
|
|
185
191
|
captureMs,
|
|
186
192
|
encodeMs: Math.max(0, Date.now() - encodeStartedAt),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const MAX_TEXT =
|
|
1
|
+
const MAX_TEXT = 160;
|
|
2
2
|
|
|
3
3
|
function cleanText(value, max = MAX_TEXT) {
|
|
4
4
|
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
@@ -6,16 +6,33 @@ function cleanText(value, max = MAX_TEXT) {
|
|
|
6
6
|
return text.slice(0, Math.max(0, max - 1)) + "…";
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
function normalizedPath(value) {
|
|
10
|
+
return cleanText(value, 260).replace(/\\/g, "/");
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
function compactPath(value) {
|
|
10
|
-
const normalized =
|
|
11
|
-
if (normalized
|
|
14
|
+
const normalized = normalizedPath(value);
|
|
15
|
+
if (!normalized) return "-";
|
|
16
|
+
|
|
17
|
+
const projectMatch = normalized.match(/\/Projects\/[^/]+\/(.+)$/i);
|
|
18
|
+
if (projectMatch?.[1]) return cleanText(projectMatch[1], 120);
|
|
19
|
+
|
|
12
20
|
const parts = normalized.split("/").filter(Boolean);
|
|
13
|
-
if (parts.length <=
|
|
21
|
+
if (parts.length <= 3) return cleanText(normalized, 120);
|
|
14
22
|
return "…/" + parts.slice(-3).join("/");
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
function cwdLabel(value) {
|
|
26
|
+
const normalized = normalizedPath(value).replace(/\/$/, "");
|
|
27
|
+
if (!normalized) return "";
|
|
28
|
+
const projectMatch = normalized.match(/\/Projects\/([^/]+)(?:\/.*)?$/i);
|
|
29
|
+
if (projectMatch?.[1]) return projectMatch[1];
|
|
30
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
31
|
+
return parts.at(-1) || normalized;
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
function redactCommand(value) {
|
|
18
|
-
let text = cleanText(value,
|
|
35
|
+
let text = cleanText(value, 520);
|
|
19
36
|
const patterns = [
|
|
20
37
|
/(authorization\s*[:=]\s*bearer\s+)[^\s"']+/ig,
|
|
21
38
|
/((?:token|password|passwd|secret|api[_-]?key|access[_-]?key)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s;]+)/ig,
|
|
@@ -30,6 +47,13 @@ function number(value, fallback = 0) {
|
|
|
30
47
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
31
48
|
}
|
|
32
49
|
|
|
50
|
+
function humanBytes(value) {
|
|
51
|
+
const bytes = Math.max(0, Number(value) || 0);
|
|
52
|
+
if (bytes < 1024) return Math.round(bytes) + " B";
|
|
53
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0) + " KB";
|
|
54
|
+
return (bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0) + " MB";
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
function lineRange(payload) {
|
|
34
58
|
const offset = Math.max(0, number(payload.offset));
|
|
35
59
|
const length = Math.max(1, number(payload.length, 200));
|
|
@@ -43,106 +67,198 @@ function countKeyboardInput(payload) {
|
|
|
43
67
|
return "input";
|
|
44
68
|
}
|
|
45
69
|
|
|
70
|
+
function quotedCommitMessage(command) {
|
|
71
|
+
const match = command.match(/git\s+commit\s+-m\s+["']([^"']+)["']/i);
|
|
72
|
+
return match?.[1] ? cleanText(match[1], 96) : "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function diffTargets(command) {
|
|
76
|
+
const match = command.match(/git\s+diff(?:\s+--check)?\s+--\s+([^;]+)/i);
|
|
77
|
+
if (!match?.[1]) return [];
|
|
78
|
+
return (match[1].match(/"[^"]+"|'[^']+'|\S+/g) || [])
|
|
79
|
+
.map((value) => value.replace(/^["']|["']$/g, ""))
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function humanizeCommand(value) {
|
|
84
|
+
const command = redactCommand(value);
|
|
85
|
+
const lower = command.toLowerCase();
|
|
86
|
+
|
|
87
|
+
const pushTag = command.match(/git\s+push\s+origin\s+main[\s\S]*?git\s+push\s+origin\s+([^\s;]+)/i);
|
|
88
|
+
if (pushTag?.[1]) return `Push main and ${cleanText(pushTag[1], 48)} to origin`;
|
|
89
|
+
|
|
90
|
+
if (/node\s+--test\s+test[\\/]cli-tui\.test\.mjs/i.test(command) &&
|
|
91
|
+
/npm\s+run\s+test:platform/i.test(command) &&
|
|
92
|
+
/git\s+diff\s+--check/i.test(command)) {
|
|
93
|
+
return "Validate terminal UI · unit tests, platform smoke, and Git checks";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const version = command.match(/npm\s+version\s+([^\s;]+)/i);
|
|
97
|
+
const commitMessage = quotedCommitMessage(command);
|
|
98
|
+
if (version?.[1] && commitMessage) {
|
|
99
|
+
return `Prepare release ${cleanText(version[1], 32)} · ${commitMessage}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (/git\s+status\s+--short/i.test(command) && /git\s+branch\s+--show-current/i.test(command) && /git\s+log\b/i.test(command)) {
|
|
103
|
+
return "Check Git status, current branch, and recent commits";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (/git\s+status\b/i.test(command) && /git\s+log\b/i.test(command)) {
|
|
107
|
+
return "Check Git status and recent commits";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (/git\s+tag\s+--list/i.test(command) && /git\s+status\b/i.test(command)) {
|
|
111
|
+
return "Check release tag and Git status";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (/git\s+add\b/i.test(command) && /git\s+commit\b/i.test(command)) {
|
|
115
|
+
return commitMessage ? `Commit changes · ${commitMessage}` : "Stage and commit changes";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (/git\s+diff\s+--check/i.test(command) && !/git\s+diff\s+--\s+/i.test(command)) {
|
|
119
|
+
return "Check changes for whitespace errors";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (/git\s+diff\b/i.test(command)) {
|
|
123
|
+
const targets = diffTargets(command);
|
|
124
|
+
if (targets.length === 1) return `Review changes · ${compactPath(targets[0])}`;
|
|
125
|
+
if (targets.length > 1) return `Review changes · ${targets.length} files`;
|
|
126
|
+
return "Review Git changes";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (/npm\s+publish(?:\s|$)/i.test(command)) {
|
|
130
|
+
return "Publish package to npm";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const npmView = command.match(/npm\s+view\s+([^\s;]+)\s+version/i);
|
|
134
|
+
if (npmView?.[1]) return `Check published version · ${cleanText(npmView[1], 72)}`;
|
|
135
|
+
|
|
136
|
+
if (/node\s+--check\b/i.test(command)) {
|
|
137
|
+
return "Check JavaScript syntax";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (/node\s+--test\b/i.test(command) && /cli-tui\.test\.mjs/i.test(command)) {
|
|
141
|
+
return "Run terminal UI tests";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const npmRun = command.match(/^npm\s+run\s+([^\s;]+)/i);
|
|
145
|
+
if (npmRun?.[1] && !/[;&|]/.test(command)) {
|
|
146
|
+
return `Run npm script · ${cleanText(npmRun[1], 64)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (lower === "git status" || lower === "git status --short") return "Check Git status";
|
|
150
|
+
|
|
151
|
+
return `Run ${command}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function withCwd(summary, cwd) {
|
|
155
|
+
const label = cwdLabel(cwd);
|
|
156
|
+
return label ? `${summary} · in ${label}` : summary;
|
|
157
|
+
}
|
|
158
|
+
|
|
46
159
|
export function humanizeToolCall(payload = {}) {
|
|
47
160
|
const action = String(payload?.action || "unknown");
|
|
48
161
|
|
|
49
162
|
switch (action) {
|
|
50
163
|
case "ping":
|
|
51
|
-
return "
|
|
164
|
+
return "Check agent connection";
|
|
52
165
|
case "agent.config":
|
|
53
|
-
return "
|
|
166
|
+
return "Read agent configuration";
|
|
54
167
|
case "agent.recentCalls":
|
|
55
|
-
return `
|
|
168
|
+
return `Read recent activity · up to ${number(payload.limit, 50)} calls`;
|
|
56
169
|
case "agent.lifecycle.status":
|
|
57
|
-
return "
|
|
170
|
+
return "Check agent lifecycle";
|
|
58
171
|
case "agent.lifecycle.drain":
|
|
59
|
-
return "
|
|
172
|
+
return "Pause new long-running work";
|
|
60
173
|
case "agent.lifecycle.resume":
|
|
61
|
-
return "Resume
|
|
174
|
+
return "Resume normal work";
|
|
62
175
|
case "agent.lifecycle.restart":
|
|
63
|
-
return "Restart agent";
|
|
176
|
+
return "Restart local agent";
|
|
64
177
|
|
|
65
178
|
case "fs.stat":
|
|
66
|
-
return `
|
|
179
|
+
return `Inspect ${compactPath(payload.path)}`;
|
|
67
180
|
case "fs.list":
|
|
68
|
-
return `
|
|
181
|
+
return `Browse ${compactPath(payload.path)} · depth ${number(payload.depth)} · up to ${number(payload.limit, 100)} items`;
|
|
69
182
|
case "fs.read":
|
|
70
183
|
return `Read ${compactPath(payload.path)} · ${lineRange(payload)}`;
|
|
71
184
|
case "fs.readMany":
|
|
72
185
|
return `Read ${Array.isArray(payload.paths) ? payload.paths.length : 0} files`;
|
|
73
186
|
case "fs.batch":
|
|
74
|
-
return `
|
|
187
|
+
return `Inspect filesystem · ${Array.isArray(payload.operations) ? payload.operations.length : 0} operations`;
|
|
75
188
|
case "fs.artifact":
|
|
76
189
|
return `Export ${compactPath(payload.path)}`;
|
|
77
190
|
case "fs.write": {
|
|
78
191
|
const bytes = Buffer.byteLength(String(payload.content ?? ""), "utf8");
|
|
79
|
-
|
|
192
|
+
const mode = payload.mode === "append" ? "append" : "replace file";
|
|
193
|
+
return `Write ${compactPath(payload.path)} · ${mode} · ${humanBytes(bytes)}`;
|
|
194
|
+
}
|
|
195
|
+
case "fs.edit": {
|
|
196
|
+
const count = Math.max(1, number(payload.expectedReplacements, 1));
|
|
197
|
+
return `Edit ${compactPath(payload.path)} · ${count} change${count === 1 ? "" : "s"}`;
|
|
80
198
|
}
|
|
81
|
-
case "fs.edit":
|
|
82
|
-
return `Edit ${compactPath(payload.path)} · replace ×${number(payload.expectedReplacements, 1)}`;
|
|
83
199
|
case "fs.mkdir":
|
|
84
200
|
return `Create folder ${compactPath(payload.path)}`;
|
|
85
201
|
case "fs.move":
|
|
86
202
|
return `Move ${compactPath(payload.source)} → ${compactPath(payload.destination)}`;
|
|
87
203
|
case "fs.delete":
|
|
88
|
-
return `Delete ${compactPath(payload.path)}${payload.recursive ? "
|
|
204
|
+
return `Delete ${compactPath(payload.path)}${payload.recursive ? " and contents" : ""}`;
|
|
89
205
|
case "fs.search.start":
|
|
90
|
-
return `Search "${cleanText(payload.pattern,
|
|
206
|
+
return `Search "${cleanText(payload.pattern, 72)}" · ${compactPath(payload.path)}`;
|
|
91
207
|
case "fs.search.results":
|
|
92
|
-
return `Read search results ·
|
|
208
|
+
return `Read search results · ${number(payload.length, 50)} items from offset ${number(payload.offset)}`;
|
|
93
209
|
|
|
94
210
|
case "process.list":
|
|
95
|
-
return `
|
|
211
|
+
return `Find processes${payload.filter ? ` matching "${cleanText(payload.filter, 52)}"` : ""}`;
|
|
96
212
|
case "process.kill":
|
|
97
|
-
return `
|
|
213
|
+
return `Stop process ${number(payload.pid)}`;
|
|
98
214
|
|
|
99
215
|
case "terminal.exec":
|
|
100
|
-
return
|
|
216
|
+
return withCwd(humanizeCommand(payload.command), payload.cwd);
|
|
101
217
|
case "terminal.start":
|
|
102
|
-
return `Start ${
|
|
218
|
+
return withCwd(`Start background task · ${humanizeCommand(payload.command).replace(/^Run /, "")}`, payload.cwd);
|
|
103
219
|
case "terminal.shell.start":
|
|
104
|
-
return
|
|
220
|
+
return withCwd("Open interactive shell", payload.cwd);
|
|
105
221
|
case "terminal.read":
|
|
106
|
-
return `Read terminal ${cleanText(payload.sessionId,
|
|
222
|
+
return `Read terminal output · ${cleanText(payload.sessionId, 24)} · after #${number(payload.afterSeq)}`;
|
|
107
223
|
case "terminal.write":
|
|
108
|
-
return `
|
|
224
|
+
return `Send input to terminal · ${String(payload.input ?? "").length} chars`;
|
|
109
225
|
case "terminal.kill":
|
|
110
|
-
return `Stop terminal ${cleanText(payload.sessionId,
|
|
226
|
+
return `Stop terminal session · ${cleanText(payload.sessionId, 24)}`;
|
|
111
227
|
case "terminal.list":
|
|
112
228
|
return "List terminal sessions";
|
|
113
229
|
case "terminal.observability":
|
|
114
|
-
return "
|
|
230
|
+
return "Check terminal activity";
|
|
115
231
|
case "terminal.batch.start":
|
|
116
|
-
return `Run terminal batch · ${Array.isArray(payload.jobs) ? payload.jobs.length : 0} jobs ·
|
|
232
|
+
return `Run terminal batch · ${Array.isArray(payload.jobs) ? payload.jobs.length : 0} jobs · ${number(payload.concurrency, 1)} at a time`;
|
|
117
233
|
case "terminal.batch.status":
|
|
118
|
-
return `
|
|
234
|
+
return `Check batch progress · ${cleanText(payload.batchId, 24)}`;
|
|
119
235
|
case "terminal.batch.read":
|
|
120
|
-
return `Read batch ${cleanText(payload.batchId,
|
|
236
|
+
return `Read batch output · ${cleanText(payload.batchId, 24)}`;
|
|
121
237
|
case "terminal.batch.cancel":
|
|
122
|
-
return `Cancel batch ${cleanText(payload.batchId,
|
|
238
|
+
return `Cancel batch · ${cleanText(payload.batchId, 24)}`;
|
|
123
239
|
|
|
124
240
|
case "desktop.screenshot":
|
|
125
|
-
return "Capture
|
|
241
|
+
return "Capture desktop screenshot";
|
|
126
242
|
case "desktop.clipboard.read":
|
|
127
243
|
return "Read clipboard";
|
|
128
244
|
case "desktop.clipboard.write":
|
|
129
245
|
return `Write clipboard · ${String(payload.text ?? payload.content ?? "").length} chars`;
|
|
130
246
|
case "desktop.window.list":
|
|
131
|
-
return "List windows";
|
|
247
|
+
return "List open windows";
|
|
132
248
|
case "desktop.window.focus":
|
|
133
|
-
return `Focus window ${cleanText(payload.title ?? payload.windowId ?? payload.handle ?? "", 64)}`;
|
|
249
|
+
return `Focus window · ${cleanText(payload.title ?? payload.windowId ?? payload.handle ?? "", 64)}`;
|
|
134
250
|
case "desktop.mouse.click": {
|
|
135
251
|
const button = cleanText(payload.button || "left", 12);
|
|
136
252
|
const x = number(payload.x);
|
|
137
253
|
const y = number(payload.y);
|
|
138
|
-
return `${button[0]?.toUpperCase() || "L"}${button.slice(1)} click · ${x}
|
|
254
|
+
return `${button[0]?.toUpperCase() || "L"}${button.slice(1)} click · x ${x}, y ${y}`;
|
|
139
255
|
}
|
|
140
256
|
case "desktop.keyboard.input":
|
|
141
|
-
return `
|
|
257
|
+
return `Type on keyboard · ${countKeyboardInput(payload)}`;
|
|
142
258
|
case "desktop.step":
|
|
143
|
-
return `Desktop
|
|
259
|
+
return `Desktop actions · ${Array.isArray(payload.actions) ? payload.actions.length : 0} steps`;
|
|
144
260
|
|
|
145
261
|
default:
|
|
146
|
-
return cleanText(action.replace(/[._]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()),
|
|
262
|
+
return cleanText(action.replace(/[._]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()), 140) || "Tool call";
|
|
147
263
|
}
|
|
148
264
|
}
|
|
@@ -228,20 +228,26 @@ function Invoke-Screenshot($Payload) {
|
|
|
228
228
|
Select-Object -First 1
|
|
229
229
|
if ($null -eq $codec) { return Result-Error "capture_failed" }
|
|
230
230
|
|
|
231
|
-
$
|
|
231
|
+
$native = $Payload.native -eq $true
|
|
232
|
+
$maxBytes = if ($native) { 2097152 } else { 32768 }
|
|
232
233
|
$requestedMaxWidth = if ($null -eq $Payload.maxWidth) { 960 } else { [int]$Payload.maxWidth }
|
|
233
|
-
if ($requestedMaxWidth -lt 320 -or $requestedMaxWidth -gt 1920) { return Result-Error "invalid_max_width" }
|
|
234
|
+
if (-not $native -and ($requestedMaxWidth -lt 320 -or $requestedMaxWidth -gt 1920)) { return Result-Error "invalid_max_width" }
|
|
234
235
|
$requestedQuality = if ($null -eq $Payload.quality) { 58 } else { [int]$Payload.quality }
|
|
235
236
|
if ($requestedQuality -lt 20 -or $requestedQuality -gt 85) { return Result-Error "invalid_quality" }
|
|
236
|
-
$targetWidth = [Math]::Min($bounds.Width, $requestedMaxWidth)
|
|
237
|
+
$targetWidth = if ($native) { $bounds.Width } else { [Math]::Min($bounds.Width, $requestedMaxWidth) }
|
|
237
238
|
$targetHeight = [Math]::Max(1, [int][Math]::Round($bounds.Height * ($targetWidth / [double]$bounds.Width)))
|
|
238
|
-
$qualities =
|
|
239
|
+
$qualities = if ($native) {
|
|
240
|
+
@([long]$requestedQuality, 75L, 60L, 45L, 30L, 20L) | Where-Object { $_ -le $requestedQuality } | Select-Object -Unique
|
|
241
|
+
} else {
|
|
242
|
+
@([long]$requestedQuality, [long][Math]::Min($requestedQuality, 42), [long][Math]::Min($requestedQuality, 30), 20L) | Select-Object -Unique
|
|
243
|
+
}
|
|
239
244
|
$bytes = $null
|
|
240
245
|
$finalWidth = $targetWidth
|
|
241
246
|
$finalHeight = $targetHeight
|
|
242
247
|
$finalQuality = $requestedQuality
|
|
243
248
|
|
|
244
|
-
|
|
249
|
+
$maxSizeAttempts = if ($native) { 1 } else { 3 }
|
|
250
|
+
for ($sizeAttempt = 0; $sizeAttempt -lt $maxSizeAttempts -and $null -eq $bytes; $sizeAttempt++) {
|
|
245
251
|
$resized = New-Object System.Drawing.Bitmap $targetWidth, $targetHeight
|
|
246
252
|
$draw = [System.Drawing.Graphics]::FromImage($resized)
|
|
247
253
|
try {
|
|
@@ -305,6 +311,7 @@ function Invoke-Screenshot($Payload) {
|
|
|
305
311
|
virtualDesktopHeight = $virtualBounds.Height
|
|
306
312
|
monitorCount = $monitorCount
|
|
307
313
|
jpegQuality = $finalQuality
|
|
314
|
+
native = $native
|
|
308
315
|
desktopOriginX = $bounds.Left
|
|
309
316
|
desktopOriginY = $bounds.Top
|
|
310
317
|
desktopWidth = $bounds.Width
|
package/cli/tui.mjs
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { humanizeToolCall } from "../agent/toolcall-summary.mjs";
|
|
2
2
|
|
|
3
3
|
const ESC = "\x1b[";
|
|
4
|
+
const RESET = "\x1b[0m";
|
|
5
|
+
const STYLE = {
|
|
6
|
+
reset: RESET,
|
|
7
|
+
bold: "\x1b[1m",
|
|
8
|
+
dim: "\x1b[2m",
|
|
9
|
+
cyan: "\x1b[36m",
|
|
10
|
+
green: "\x1b[32m",
|
|
11
|
+
yellow: "\x1b[33m",
|
|
12
|
+
red: "\x1b[31m",
|
|
13
|
+
gray: "\x1b[90m",
|
|
14
|
+
};
|
|
4
15
|
const LABEL_WIDTH = 12;
|
|
5
16
|
const GAP = 3;
|
|
6
17
|
const MIN_FRAME_WIDTH = 60;
|
|
@@ -20,12 +31,33 @@ function truncate(value, width) {
|
|
|
20
31
|
return text.slice(0, width - 1) + "…";
|
|
21
32
|
}
|
|
22
33
|
|
|
23
|
-
function
|
|
34
|
+
function paint(value, ...styles) {
|
|
35
|
+
if (!styles.length) return String(value ?? "");
|
|
36
|
+
return styles.join("") + String(value ?? "") + RESET;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function field(label, value, width, color = false, valueStyle = null) {
|
|
24
40
|
const valueWidth = Math.max(1, width - LABEL_WIDTH);
|
|
25
|
-
|
|
41
|
+
const plainLabel = String(label).padEnd(LABEL_WIDTH);
|
|
42
|
+
const plainValue = truncate(value, valueWidth);
|
|
43
|
+
const plain = truncate(plainLabel + plainValue, width).padEnd(width);
|
|
44
|
+
if (!color) return plain;
|
|
45
|
+
const renderedLabel = paint(plainLabel, STYLE.gray);
|
|
46
|
+
const renderedValue = valueStyle ? paint(plainValue, valueStyle) : plainValue;
|
|
47
|
+
return renderedLabel + renderedValue + " ".repeat(Math.max(0, width - plainLabel.length - plainValue.length));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function statusStyle(value) {
|
|
51
|
+
switch (String(value || "").toLowerCase()) {
|
|
52
|
+
case "connected": return STYLE.green;
|
|
53
|
+
case "connecting":
|
|
54
|
+
case "reconnecting": return STYLE.yellow;
|
|
55
|
+
case "offline": return STYLE.red;
|
|
56
|
+
default: return STYLE.gray;
|
|
57
|
+
}
|
|
26
58
|
}
|
|
27
59
|
|
|
28
|
-
export function formatTwoColumnHeader(state, width = 110) {
|
|
60
|
+
export function formatTwoColumnHeader(state, width = 110, { color = false } = {}) {
|
|
29
61
|
const usable = clamp(Number(width) || 110, MIN_FRAME_WIDTH, MAX_FRAME_WIDTH);
|
|
30
62
|
const leftWidth = Math.floor((usable - GAP) / 2);
|
|
31
63
|
const rightWidth = usable - GAP - leftWidth;
|
|
@@ -36,9 +68,13 @@ export function formatTwoColumnHeader(state, width = 110) {
|
|
|
36
68
|
["Terminal", state.terminal ? "enabled" : "disabled", "Desktop", state.desktop ? "enabled" : "disabled"],
|
|
37
69
|
];
|
|
38
70
|
return rows
|
|
39
|
-
.map(([leftLabel, leftValue, rightLabel, rightValue]) =>
|
|
40
|
-
|
|
41
|
-
|
|
71
|
+
.map(([leftLabel, leftValue, rightLabel, rightValue]) => {
|
|
72
|
+
const leftStyle = leftLabel === "Status" ? statusStyle(state.status) : null;
|
|
73
|
+
const rightStyle = rightLabel === "Reconnects" && Number(state.reconnects || 0) > 0 ? STYLE.yellow : null;
|
|
74
|
+
return field(leftLabel, leftValue, leftWidth, color, leftStyle)
|
|
75
|
+
+ " ".repeat(GAP)
|
|
76
|
+
+ field(rightLabel, rightValue, rightWidth, color, rightStyle).trimEnd();
|
|
77
|
+
})
|
|
42
78
|
.join("\n");
|
|
43
79
|
}
|
|
44
80
|
|
|
@@ -74,14 +110,61 @@ function clock(value) {
|
|
|
74
110
|
return date.toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
75
111
|
}
|
|
76
112
|
|
|
77
|
-
|
|
113
|
+
function wrapText(value, width, maxLines = 3) {
|
|
114
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
115
|
+
const usable = Math.max(1, Number(width) || 1);
|
|
116
|
+
if (!text) return [""];
|
|
117
|
+
const lines = [];
|
|
118
|
+
let remaining = text;
|
|
119
|
+
|
|
120
|
+
while (remaining && lines.length < maxLines) {
|
|
121
|
+
if (remaining.length <= usable) {
|
|
122
|
+
lines.push(remaining);
|
|
123
|
+
remaining = "";
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let cut = remaining.lastIndexOf(" ", usable);
|
|
128
|
+
if (cut < Math.floor(usable * 0.45)) cut = usable;
|
|
129
|
+
lines.push(remaining.slice(0, cut).trimEnd());
|
|
130
|
+
remaining = remaining.slice(cut).trimStart();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (remaining && lines.length) {
|
|
134
|
+
const index = lines.length - 1;
|
|
135
|
+
lines[index] = truncate(lines[index] + " " + remaining, usable);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return lines.length ? lines : [""];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function formatTransactionRows(item, width = 110, { color = false } = {}) {
|
|
78
142
|
const usable = clamp(Number(width) || 110, 60, 180);
|
|
79
143
|
const status = item.status === "running" ? "●" : item.ok === false ? "✕" : "✓";
|
|
80
144
|
const prefix = `${clock(item.at)} ${status} `;
|
|
145
|
+
const continuationPrefix = " ".repeat(prefix.length);
|
|
81
146
|
const duration = formatDuration(item.durationMs, item.status);
|
|
82
147
|
const suffix = " " + duration.padStart(8);
|
|
83
|
-
const summaryWidth = Math.max(
|
|
84
|
-
|
|
148
|
+
const summaryWidth = Math.max(18, usable - prefix.length - suffix.length);
|
|
149
|
+
const summaryLines = wrapText(item.summary || item.action || "Tool call", summaryWidth, 3);
|
|
150
|
+
|
|
151
|
+
return summaryLines.map((line, index) => {
|
|
152
|
+
if (index === 0) {
|
|
153
|
+
if (!color) return prefix + line.padEnd(summaryWidth) + suffix;
|
|
154
|
+
const time = paint(clock(item.at), STYLE.gray);
|
|
155
|
+
const glyphStyle = item.status === "running" ? STYLE.yellow : item.ok === false ? STYLE.red : STYLE.green;
|
|
156
|
+
const glyph = paint(status, glyphStyle, STYLE.bold);
|
|
157
|
+
const summary = item.status === "running" ? paint(line.padEnd(summaryWidth), STYLE.bold) : line.padEnd(summaryWidth);
|
|
158
|
+
const timing = paint(suffix, STYLE.gray);
|
|
159
|
+
return `${time} ${glyph} ${summary}${timing}`;
|
|
160
|
+
}
|
|
161
|
+
if (!color) return continuationPrefix + line;
|
|
162
|
+
return " ".repeat(prefix.length) + paint(line, STYLE.dim);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function formatTransactionRow(item, width = 110, options) {
|
|
167
|
+
return formatTransactionRows(item, width, options).join("\n");
|
|
85
168
|
}
|
|
86
169
|
|
|
87
170
|
function normalizeRelay(value) {
|
|
@@ -99,6 +182,13 @@ export function shouldUseTui(options = {}, stdout = process.stdout) {
|
|
|
99
182
|
return Boolean(stdout?.isTTY);
|
|
100
183
|
}
|
|
101
184
|
|
|
185
|
+
export function shouldUseColor(stdout = process.stdout, env = process.env) {
|
|
186
|
+
if (!stdout?.isTTY) return false;
|
|
187
|
+
if (Object.prototype.hasOwnProperty.call(env || {}, "NO_COLOR")) return false;
|
|
188
|
+
if (String(env?.TERM || "").toLowerCase() === "dumb") return false;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
102
192
|
export class RemoteTui {
|
|
103
193
|
constructor({ config, version, output = process.stdout }) {
|
|
104
194
|
this.output = output;
|
|
@@ -113,6 +203,7 @@ export class RemoteTui {
|
|
|
113
203
|
desktop: config.desktopEnabled === true,
|
|
114
204
|
};
|
|
115
205
|
this.version = version || "-";
|
|
206
|
+
this.colorEnabled = shouldUseColor(output);
|
|
116
207
|
this.transactions = [];
|
|
117
208
|
this.timer = null;
|
|
118
209
|
this.closed = false;
|
|
@@ -191,21 +282,38 @@ export class RemoteTui {
|
|
|
191
282
|
const header = formatTwoColumnHeader({
|
|
192
283
|
...this.state,
|
|
193
284
|
uptimeMs: Date.now() - this.startedAt,
|
|
194
|
-
}, width);
|
|
285
|
+
}, width, { color: this.colorEnabled });
|
|
195
286
|
// Fixed frame content occupies 11 rows. Leave one spare row at the
|
|
196
287
|
// bottom so Windows Terminal never scrolls the screen while repainting.
|
|
197
288
|
const availableRows = Math.max(1, height - FRAME_FIXED_ROWS - FRAME_BOTTOM_MARGIN);
|
|
198
|
-
const rows =
|
|
289
|
+
const rows = [];
|
|
290
|
+
for (let index = this.transactions.length - 1; index >= 0 && rows.length < availableRows; index--) {
|
|
291
|
+
const group = formatTransactionRows(this.transactions[index], width, { color: this.colorEnabled });
|
|
292
|
+
const remaining = availableRows - rows.length;
|
|
293
|
+
if (group.length <= remaining) {
|
|
294
|
+
rows.unshift(...group);
|
|
295
|
+
} else if (rows.length === 0) {
|
|
296
|
+
rows.unshift(...group.slice(0, remaining));
|
|
297
|
+
} else {
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
199
301
|
while (rows.length < Math.min(availableRows, 4)) rows.unshift("");
|
|
200
302
|
|
|
303
|
+
const title = this.colorEnabled
|
|
304
|
+
? paint("Chat Relay", STYLE.bold, STYLE.cyan) + paint(` v${this.version}`, STYLE.gray)
|
|
305
|
+
: `Chat Relay v${this.version}`;
|
|
306
|
+
const divider = this.colorEnabled ? paint(separator, STYLE.gray) : separator;
|
|
307
|
+
const section = this.colorEnabled ? paint("Transactions", STYLE.bold, STYLE.cyan) : "Transactions";
|
|
308
|
+
|
|
201
309
|
const screen = [
|
|
202
|
-
|
|
203
|
-
|
|
310
|
+
title,
|
|
311
|
+
divider,
|
|
204
312
|
"",
|
|
205
313
|
header,
|
|
206
314
|
"",
|
|
207
|
-
|
|
208
|
-
|
|
315
|
+
divider,
|
|
316
|
+
section,
|
|
209
317
|
"",
|
|
210
318
|
...rows,
|
|
211
319
|
].join("\n");
|
package/package.json
CHANGED