@anusornneal/chat-relay 0.11.0 → 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.
@@ -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 = 32 * 1024;
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
- let targetWidth = Math.min(Number(args.maxWidth || 960), originalInfo.width);
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 qualities = [...new Set([requestedQuality, 55, 45, 35, 25, 20].filter((q) => q <= requestedQuality))];
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 <= MAX_SCREENSHOT_BINARY_BYTES) {
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 = 96;
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 = cleanText(value, 120).replace(/\\/g, "/");
11
- if (normalized.length <= 72) return 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 <= 2) return "…" + normalized.slice(-68);
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, 220);
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 "Ping agent";
164
+ return "Check agent connection";
52
165
  case "agent.config":
53
- return "Get agent config";
166
+ return "Read agent configuration";
54
167
  case "agent.recentCalls":
55
- return `Get recent tool calls · limit ${number(payload.limit, 50)}`;
168
+ return `Read recent activity · up to ${number(payload.limit, 50)} calls`;
56
169
  case "agent.lifecycle.status":
57
- return "Get lifecycle status";
170
+ return "Check agent lifecycle";
58
171
  case "agent.lifecycle.drain":
59
- return "Drain agent";
172
+ return "Pause new long-running work";
60
173
  case "agent.lifecycle.resume":
61
- return "Resume agent";
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 `Stat ${compactPath(payload.path)}`;
179
+ return `Inspect ${compactPath(payload.path)}`;
67
180
  case "fs.list":
68
- return `List ${compactPath(payload.path)} · depth ${number(payload.depth)} · limit ${number(payload.limit, 100)}`;
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 `Filesystem batch · ${Array.isArray(payload.operations) ? payload.operations.length : 0} ops`;
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
- return `Write ${compactPath(payload.path)} · ${payload.mode || "rewrite"} · ${bytes} B`;
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 ? " · recursive" : ""}`;
204
+ return `Delete ${compactPath(payload.path)}${payload.recursive ? " and contents" : ""}`;
89
205
  case "fs.search.start":
90
- return `Search "${cleanText(payload.pattern, 56)}" in ${compactPath(payload.path)}`;
206
+ return `Search "${cleanText(payload.pattern, 72)}" · ${compactPath(payload.path)}`;
91
207
  case "fs.search.results":
92
- return `Read search results · offset ${number(payload.offset)} · ${number(payload.length, 50)} rows`;
208
+ return `Read search results · ${number(payload.length, 50)} items from offset ${number(payload.offset)}`;
93
209
 
94
210
  case "process.list":
95
- return `List processes${payload.filter ? ` · "${cleanText(payload.filter, 52)}"` : ""}`;
211
+ return `Find processes${payload.filter ? ` matching "${cleanText(payload.filter, 52)}"` : ""}`;
96
212
  case "process.kill":
97
- return `Kill process ${number(payload.pid)}`;
213
+ return `Stop process ${number(payload.pid)}`;
98
214
 
99
215
  case "terminal.exec":
100
- return `Run ${redactCommand(payload.command)}${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
216
+ return withCwd(humanizeCommand(payload.command), payload.cwd);
101
217
  case "terminal.start":
102
- return `Start ${redactCommand(payload.command)}${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
218
+ return withCwd(`Start background task · ${humanizeCommand(payload.command).replace(/^Run /, "")}`, payload.cwd);
103
219
  case "terminal.shell.start":
104
- return `Start shell${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
220
+ return withCwd("Open interactive shell", payload.cwd);
105
221
  case "terminal.read":
106
- return `Read terminal ${cleanText(payload.sessionId, 32)} · after #${number(payload.afterSeq)}`;
222
+ return `Read terminal output · ${cleanText(payload.sessionId, 24)} · after #${number(payload.afterSeq)}`;
107
223
  case "terminal.write":
108
- return `Write terminal ${cleanText(payload.sessionId, 32)} · ${String(payload.input ?? "").length} chars`;
224
+ return `Send input to terminal · ${String(payload.input ?? "").length} chars`;
109
225
  case "terminal.kill":
110
- return `Stop terminal ${cleanText(payload.sessionId, 32)}`;
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 "Get terminal status";
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 · concurrency ${number(payload.concurrency, 1)}`;
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 `Get batch status ${cleanText(payload.batchId, 32)}`;
234
+ return `Check batch progress · ${cleanText(payload.batchId, 24)}`;
119
235
  case "terminal.batch.read":
120
- return `Read batch ${cleanText(payload.batchId, 32)}`;
236
+ return `Read batch output · ${cleanText(payload.batchId, 24)}`;
121
237
  case "terminal.batch.cancel":
122
- return `Cancel batch ${cleanText(payload.batchId, 32)}`;
238
+ return `Cancel batch · ${cleanText(payload.batchId, 24)}`;
123
239
 
124
240
  case "desktop.screenshot":
125
- return "Capture screen";
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},${y}`;
254
+ return `${button[0]?.toUpperCase() || "L"}${button.slice(1)} click · x ${x}, y ${y}`;
139
255
  }
140
256
  case "desktop.keyboard.input":
141
- return `Keyboard input · ${countKeyboardInput(payload)}`;
257
+ return `Type on keyboard · ${countKeyboardInput(payload)}`;
142
258
  case "desktop.step":
143
- return `Desktop step · ${Array.isArray(payload.actions) ? payload.actions.length : 0} actions`;
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()), 100) || "Tool call";
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
- $maxBytes = 32768
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 = @([long]$requestedQuality, [long][Math]::Min($requestedQuality, 42), [long][Math]::Min($requestedQuality, 30), 20L) | Select-Object -Unique
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
- for ($sizeAttempt = 0; $sizeAttempt -lt 3 -and $null -eq $bytes; $sizeAttempt++) {
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,8 +1,23 @@
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;
17
+ const MIN_FRAME_WIDTH = 60;
18
+ const MAX_FRAME_WIDTH = 179;
19
+ const FRAME_FIXED_ROWS = 11;
20
+ const FRAME_BOTTOM_MARGIN = 1;
6
21
 
7
22
  function clamp(value, min, max) {
8
23
  return Math.max(min, Math.min(max, value));
@@ -16,13 +31,34 @@ function truncate(value, width) {
16
31
  return text.slice(0, width - 1) + "…";
17
32
  }
18
33
 
19
- function field(label, value, width) {
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) {
20
40
  const valueWidth = Math.max(1, width - LABEL_WIDTH);
21
- return truncate(String(label).padEnd(LABEL_WIDTH) + truncate(value, valueWidth), width).padEnd(width);
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
+ }
22
58
  }
23
59
 
24
- export function formatTwoColumnHeader(state, width = 110) {
25
- const usable = clamp(Number(width) || 110, 72, 180);
60
+ export function formatTwoColumnHeader(state, width = 110, { color = false } = {}) {
61
+ const usable = clamp(Number(width) || 110, MIN_FRAME_WIDTH, MAX_FRAME_WIDTH);
26
62
  const leftWidth = Math.floor((usable - GAP) / 2);
27
63
  const rightWidth = usable - GAP - leftWidth;
28
64
  const rows = [
@@ -32,9 +68,13 @@ export function formatTwoColumnHeader(state, width = 110) {
32
68
  ["Terminal", state.terminal ? "enabled" : "disabled", "Desktop", state.desktop ? "enabled" : "disabled"],
33
69
  ];
34
70
  return rows
35
- .map(([leftLabel, leftValue, rightLabel, rightValue]) =>
36
- field(leftLabel, leftValue, leftWidth) + " ".repeat(GAP) + field(rightLabel, rightValue, rightWidth).trimEnd(),
37
- )
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
+ })
38
78
  .join("\n");
39
79
  }
40
80
 
@@ -70,14 +110,61 @@ function clock(value) {
70
110
  return date.toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
71
111
  }
72
112
 
73
- export function formatTransactionRow(item, width = 110) {
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 } = {}) {
74
142
  const usable = clamp(Number(width) || 110, 60, 180);
75
143
  const status = item.status === "running" ? "●" : item.ok === false ? "✕" : "✓";
76
144
  const prefix = `${clock(item.at)} ${status} `;
145
+ const continuationPrefix = " ".repeat(prefix.length);
77
146
  const duration = formatDuration(item.durationMs, item.status);
78
147
  const suffix = " " + duration.padStart(8);
79
- const summaryWidth = Math.max(12, usable - prefix.length - suffix.length);
80
- return prefix + truncate(item.summary || item.action || "Tool call", summaryWidth).padEnd(summaryWidth) + suffix;
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");
81
168
  }
82
169
 
83
170
  function normalizeRelay(value) {
@@ -95,6 +182,13 @@ export function shouldUseTui(options = {}, stdout = process.stdout) {
95
182
  return Boolean(stdout?.isTTY);
96
183
  }
97
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
+
98
192
  export class RemoteTui {
99
193
  constructor({ config, version, output = process.stdout }) {
100
194
  this.output = output;
@@ -109,6 +203,7 @@ export class RemoteTui {
109
203
  desktop: config.desktopEnabled === true,
110
204
  };
111
205
  this.version = version || "-";
206
+ this.colorEnabled = shouldUseColor(output);
112
207
  this.transactions = [];
113
208
  this.timer = null;
114
209
  this.closed = false;
@@ -117,7 +212,9 @@ export class RemoteTui {
117
212
 
118
213
  start() {
119
214
  if (this.closed) return;
120
- this.output.write(ESC + "?25l");
215
+ // Use the terminal's alternate screen so periodic redraws never accumulate
216
+ // in scrollback. Keep one column unused to avoid automatic line wrapping.
217
+ this.output.write(ESC + "?1049h" + ESC + "?25l" + ESC + "2J" + ESC + "H");
121
218
  this.render();
122
219
  this.timer = setInterval(() => this.render(), 1000);
123
220
  this.timer.unref?.();
@@ -128,7 +225,7 @@ export class RemoteTui {
128
225
  this.closed = true;
129
226
  if (this.timer) clearInterval(this.timer);
130
227
  this.timer = null;
131
- this.output.write(ESC + "?25h\n");
228
+ this.output.write(ESC + "?25h" + ESC + "?1049l");
132
229
  }
133
230
 
134
231
  handleMessage(message) {
@@ -178,25 +275,45 @@ export class RemoteTui {
178
275
 
179
276
  render() {
180
277
  if (this.closed) return;
181
- const width = clamp(Number(this.output.columns) || 110, 72, 180);
278
+ const terminalWidth = Number(this.output.columns) || 110;
279
+ const width = clamp(terminalWidth - 1, MIN_FRAME_WIDTH, MAX_FRAME_WIDTH);
182
280
  const height = Math.max(16, Number(this.output.rows) || 30);
183
281
  const separator = "─".repeat(width);
184
282
  const header = formatTwoColumnHeader({
185
283
  ...this.state,
186
284
  uptimeMs: Date.now() - this.startedAt,
187
- }, width);
188
- const availableRows = Math.max(3, height - 10);
189
- const rows = this.transactions.slice(-availableRows).map((item) => formatTransactionRow(item, width));
285
+ }, width, { color: this.colorEnabled });
286
+ // Fixed frame content occupies 11 rows. Leave one spare row at the
287
+ // bottom so Windows Terminal never scrolls the screen while repainting.
288
+ const availableRows = Math.max(1, height - FRAME_FIXED_ROWS - FRAME_BOTTOM_MARGIN);
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
+ }
190
301
  while (rows.length < Math.min(availableRows, 4)) rows.unshift("");
191
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
+
192
309
  const screen = [
193
- `Chat Relay v${this.version}`,
194
- separator,
310
+ title,
311
+ divider,
195
312
  "",
196
313
  header,
197
314
  "",
198
- separator,
199
- "Transactions",
315
+ divider,
316
+ section,
200
317
  "",
201
318
  ...rows,
202
319
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anusornneal/chat-relay",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Zero-checkout remote MCP agent for ChatGPT with terminal, filesystem, process, multi-user, and multi-agent support.",
5
5
  "type": "module",
6
6
  "bin": {