@anusornneal/chat-relay 0.10.2 → 0.11.1

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.
@@ -30,7 +30,7 @@ export class AgentConnectionState {
30
30
  constructor(options = {}) {
31
31
  this.baseReconnectMs = boundedInteger(options.baseReconnectMs, 2000, 100, 60_000);
32
32
  this.maxReconnectMs = boundedInteger(options.maxReconnectMs, 30_000, this.baseReconnectMs, 120_000);
33
- this.heartbeatMs = boundedInteger(options.heartbeatMs, 15_000, 5_000, 60_000);
33
+ this.heartbeatMs = boundedInteger(options.heartbeatMs, 30_000, 5_000, 60_000);
34
34
  this.processStartedAt = new Date().toISOString();
35
35
  this.state = "starting";
36
36
  this.connectedAt = null;
@@ -7,6 +7,7 @@ import { CapabilityScheduler } from "./capability-scheduler.mjs";
7
7
  import { AgentConnectionState } from "./connection-state.mjs";
8
8
  import { buildAgentHello } from "./protocol.mjs";
9
9
  import { AgentLifecycle, AGENT_RESTART_EXIT_CODE } from "./lifecycle.mjs";
10
+ import { humanizeToolCall } from "./toolcall-summary.mjs";
10
11
  import {
11
12
  HeartbeatAckWatchdog,
12
13
  HEARTBEAT_ACK_TIMEOUT_REASON,
@@ -19,6 +20,24 @@ const agentName = process.env.AGENT_NAME || agentId;
19
20
  const reconnectMs = Number(process.env.RECONNECT_MS ?? 2000);
20
21
  const terminalEnabled = process.env.TERMINAL_ENABLED === "1";
21
22
  const desktopEnabled = process.env.DESKTOP_ENABLED === "1";
23
+ const ipcUiEnabled = process.env.CHAT_RELAY_UI === "ipc";
24
+
25
+ function emitUi(event, payload = {}) {
26
+ if (!ipcUiEnabled || typeof process.send !== "function") return;
27
+ try { process.send({ type: "chat-relay-ui", event, at: new Date().toISOString(), ...payload }); } catch {}
28
+ }
29
+
30
+ function plainLog(...args) {
31
+ if (!ipcUiEnabled) console.log(...args);
32
+ }
33
+
34
+ function plainError(...args) {
35
+ if (!ipcUiEnabled) {
36
+ console.error(...args);
37
+ return;
38
+ }
39
+ emitUi("system", { level: "error", message: args.map((item) => String(item)).join(" ") });
40
+ }
22
41
  const desktopAdapter = createDesktopPlatformAdapter({ platform: process.platform });
23
42
  const agentHello = buildAgentHello({
24
43
  platform: desktopAdapter.platform,
@@ -78,7 +97,7 @@ const TRANSPORT_PING_MS = Math.max(5000, Math.min(Number(process.env.AGENT_TRANS
78
97
  const TRANSPORT_PONG_TIMEOUT_MS = Math.max(3000, Math.min(Number(process.env.AGENT_TRANSPORT_PONG_TIMEOUT_MS) || 8000, 30000));
79
98
  const FAST_RECONNECT_MS = Math.max(100, Math.min(Number(process.env.AGENT_FAST_RECONNECT_MS) || 250, 5000));
80
99
  const FAST_RECONNECT_STABLE_MS = Math.max(5000, Math.min(Number(process.env.AGENT_FAST_RECONNECT_STABLE_MS) || 30000, 300000));
81
- const HEARTBEAT_ACK_TIMEOUT_MS = Math.max(15000, Math.min(Number(process.env.AGENT_HEARTBEAT_ACK_TIMEOUT_MS) || 45000, 180000));
100
+ const HEARTBEAT_ACK_TIMEOUT_MS = Math.max(15000, Math.min(Number(process.env.AGENT_HEARTBEAT_ACK_TIMEOUT_MS) || 90000, 180000));
82
101
  const heartbeatAckWatchdog = new HeartbeatAckWatchdog({
83
102
  timeoutMs: HEARTBEAT_ACK_TIMEOUT_MS,
84
103
  onTimeout: () => {
@@ -104,7 +123,7 @@ function currentWorkSummary() {
104
123
  const lifecycle = new AgentLifecycle({ workSummary: currentWorkSummary });
105
124
 
106
125
  if (!relayUrl || !agentToken) {
107
- console.error("RELAY_URL and AGENT_TOKEN are required");
126
+ plainError("RELAY_URL and AGENT_TOKEN are required");
108
127
  process.exit(1);
109
128
  }
110
129
 
@@ -358,8 +377,15 @@ function scheduleReconnect(code, reason) {
358
377
  const isTransientAbnormalClose = Number(code) === 1006 && stableConnectionMs >= FAST_RECONNECT_STABLE_MS;
359
378
  const delay = isTransientAbnormalClose ? FAST_RECONNECT_MS : connectionState.nextDelay();
360
379
  connectionState.scheduleReconnect(delay);
380
+ emitUi("connection", {
381
+ state: "reconnecting",
382
+ reconnects: connectionState.snapshot().reconnectCount,
383
+ delayMs: delay,
384
+ closeCode: code ?? null,
385
+ reason: reason || "socket_closed",
386
+ });
361
387
  clearReconnectTimer();
362
- console.log(`Reconnect scheduled in ${delay}ms`);
388
+ plainLog(`Reconnect scheduled in ${delay}ms`);
363
389
  reconnectTimer = setTimeout(() => {
364
390
  reconnectTimer = null;
365
391
  connect();
@@ -380,7 +406,7 @@ function requireProtocolUpdate(details = {}) {
380
406
  desktop.close();
381
407
  const expected = details.expectedProtocolVersion ?? "current";
382
408
  const received = details.receivedProtocolVersion ?? agentHello.protocolVersion;
383
- console.error(`Agent protocol is incompatible (local=${received}, relay=${expected}). Update Chat Relay, then reconnect.`);
409
+ plainError(`Agent protocol is incompatible (local=${received}, relay=${expected}). Update Chat Relay, then reconnect.`);
384
410
  setImmediate(() => process.exit(3));
385
411
  }
386
412
 
@@ -395,8 +421,8 @@ function requireReauthorization(reason = "credential_revoked") {
395
421
  try { socket?.terminate(); } catch {}
396
422
  terminals.close();
397
423
  desktop.close();
398
- console.error("Agent credential was revoked or rejected.");
399
- console.error('Recovery: run "chat-relay login --force", then "chat-relay remote".');
424
+ plainError("Agent credential was revoked or rejected.");
425
+ plainError('Recovery: run "chat-relay login --force", then "chat-relay remote".');
400
426
  setImmediate(() => process.exit(2));
401
427
  }
402
428
 
@@ -404,10 +430,14 @@ function connect() {
404
430
  if (stopping || reauthorizationRequired) return;
405
431
  clearReconnectTimer();
406
432
  connectionState.markConnecting();
407
- console.log(`Connecting to ${wsUrl}`);
408
- console.log(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
433
+ emitUi("connection", {
434
+ state: connectionState.snapshot().reconnectCount > 0 ? "reconnecting" : "connecting",
435
+ reconnects: connectionState.snapshot().reconnectCount,
436
+ });
437
+ plainLog(`Connecting to ${wsUrl}`);
438
+ plainLog(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
409
439
  const desktopConfig = desktop.getConfig();
410
- console.log("Desktop access: " + (!desktopEnabled ? "disabled" : desktopConfig.supported ? "enabled" : `unsupported (${desktopConfig.platform})`));
440
+ plainLog("Desktop access: " + (!desktopEnabled ? "disabled" : desktopConfig.supported ? "enabled" : `unsupported (${desktopConfig.platform})`));
411
441
 
412
442
  const socket = new WebSocket(wsUrl, {
413
443
  headers: { Authorization: `Bearer ${agentToken}` },
@@ -418,8 +448,9 @@ function connect() {
418
448
  if (socket !== activeSocket) return;
419
449
  socket.send(JSON.stringify(agentHello));
420
450
  connectionState.markConnected();
451
+ emitUi("connection", { state: "connected", reconnects: connectionState.snapshot().reconnectCount });
421
452
  startHeartbeat(socket);
422
- console.log("Agent connected");
453
+ plainLog("Agent connected");
423
454
  });
424
455
 
425
456
  socket.on("pong", () => {
@@ -458,6 +489,8 @@ function connect() {
458
489
  const action = message.payload && typeof message.payload === "object"
459
490
  ? String(message.payload.action ?? "unknown")
460
491
  : "unknown";
492
+ const callSummary = humanizeToolCall(message.payload);
493
+ emitUi("tool:start", { requestId: message.requestId, action, summary: callSummary });
461
494
  let scheduleMeta = { lane: "unknown", queueWaitMs: 0, queueDepthAtStart: 0, activeAtStart: 0 };
462
495
  let handlerDurationMs = 0;
463
496
  try {
@@ -474,6 +507,7 @@ function connect() {
474
507
  recentCalls.push({
475
508
  at: new Date().toISOString(),
476
509
  action,
510
+ summary: callSummary,
477
511
  durationMs: Date.now() - startedAt,
478
512
  queueWaitMs: scheduleMeta.queueWaitMs,
479
513
  handlerDurationMs,
@@ -483,6 +517,14 @@ function connect() {
483
517
  ok: !(result && typeof result === "object" && result.ok === false),
484
518
  });
485
519
  if (recentCalls.length > 100) recentCalls.shift();
520
+ emitUi("tool:end", {
521
+ requestId: message.requestId,
522
+ action,
523
+ summary: callSummary,
524
+ ok: !(result && typeof result === "object" && result.ok === false),
525
+ durationMs: Date.now() - startedAt,
526
+ ...(result && typeof result === "object" && result.ok === false && result.error ? { error: String(result.error) } : {}),
527
+ });
486
528
  sendSocketResponse(socket, message.requestId, result, {
487
529
  agentQueueWaitMs: scheduleMeta.queueWaitMs,
488
530
  agentHandlerMs: handlerDurationMs,
@@ -492,6 +534,7 @@ function connect() {
492
534
  recentCalls.push({
493
535
  at: new Date().toISOString(),
494
536
  action,
537
+ summary: callSummary,
495
538
  durationMs: Date.now() - startedAt,
496
539
  queueWaitMs: scheduleMeta.queueWaitMs,
497
540
  handlerDurationMs,
@@ -500,6 +543,14 @@ function connect() {
500
543
  error: error instanceof Error ? error.message : "agent_error",
501
544
  });
502
545
  if (recentCalls.length > 100) recentCalls.shift();
546
+ emitUi("tool:end", {
547
+ requestId: message.requestId,
548
+ action,
549
+ summary: callSummary,
550
+ ok: false,
551
+ durationMs: Date.now() - startedAt,
552
+ error: error instanceof Error ? error.message : "agent_error",
553
+ });
503
554
  sendSocketResponse(socket, message.requestId, {
504
555
  ok: false,
505
556
  error: error instanceof Error ? error.message : "agent_error",
@@ -527,7 +578,7 @@ function connect() {
527
578
  activeSocket = null;
528
579
  stopHeartbeat();
529
580
  const reasonText = reason.toString();
530
- console.log("Agent disconnected (" + code + ") " + reasonText);
581
+ plainLog("Agent disconnected (" + code + ") " + reasonText);
531
582
  if (code === 4001 || reasonText === "credential_revoked") {
532
583
  requireReauthorization("credential_revoked");
533
584
  return;
@@ -542,7 +593,7 @@ function connect() {
542
593
  socket.on("error", (error) => {
543
594
  if (socket === activeSocket && !stopping && !reauthorizationRequired) {
544
595
  connectionState.markSocketError(error.message);
545
- console.error("WebSocket error:", error.message);
596
+ plainError("WebSocket error:", error.message);
546
597
  }
547
598
  });
548
599
  }
@@ -0,0 +1,148 @@
1
+ const MAX_TEXT = 96;
2
+
3
+ function cleanText(value, max = MAX_TEXT) {
4
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
5
+ if (text.length <= max) return text;
6
+ return text.slice(0, Math.max(0, max - 1)) + "…";
7
+ }
8
+
9
+ function compactPath(value) {
10
+ const normalized = cleanText(value, 120).replace(/\\/g, "/");
11
+ if (normalized.length <= 72) return normalized || "-";
12
+ const parts = normalized.split("/").filter(Boolean);
13
+ if (parts.length <= 2) return "…" + normalized.slice(-68);
14
+ return "…/" + parts.slice(-3).join("/");
15
+ }
16
+
17
+ function redactCommand(value) {
18
+ let text = cleanText(value, 220);
19
+ const patterns = [
20
+ /(authorization\s*[:=]\s*bearer\s+)[^\s"']+/ig,
21
+ /((?:token|password|passwd|secret|api[_-]?key|access[_-]?key)\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s;]+)/ig,
22
+ /(--(?:token|password|secret|api-key|access-key)(?:=|\s+))("[^"]*"|'[^']*'|[^\s;]+)/ig,
23
+ ];
24
+ for (const pattern of patterns) text = text.replace(pattern, "$1[redacted]");
25
+ return text;
26
+ }
27
+
28
+ function number(value, fallback = 0) {
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) ? parsed : fallback;
31
+ }
32
+
33
+ function lineRange(payload) {
34
+ const offset = Math.max(0, number(payload.offset));
35
+ const length = Math.max(1, number(payload.length, 200));
36
+ return `lines ${offset + 1}–${offset + length}`;
37
+ }
38
+
39
+ function countKeyboardInput(payload) {
40
+ if (Array.isArray(payload.actions)) return payload.actions.length + " action" + (payload.actions.length === 1 ? "" : "s");
41
+ if (typeof payload.text === "string") return payload.text.length + " chars";
42
+ if (typeof payload.input === "string") return payload.input.length + " chars";
43
+ return "input";
44
+ }
45
+
46
+ export function humanizeToolCall(payload = {}) {
47
+ const action = String(payload?.action || "unknown");
48
+
49
+ switch (action) {
50
+ case "ping":
51
+ return "Ping agent";
52
+ case "agent.config":
53
+ return "Get agent config";
54
+ case "agent.recentCalls":
55
+ return `Get recent tool calls · limit ${number(payload.limit, 50)}`;
56
+ case "agent.lifecycle.status":
57
+ return "Get lifecycle status";
58
+ case "agent.lifecycle.drain":
59
+ return "Drain agent";
60
+ case "agent.lifecycle.resume":
61
+ return "Resume agent";
62
+ case "agent.lifecycle.restart":
63
+ return "Restart agent";
64
+
65
+ case "fs.stat":
66
+ return `Stat ${compactPath(payload.path)}`;
67
+ case "fs.list":
68
+ return `List ${compactPath(payload.path)} · depth ${number(payload.depth)} · limit ${number(payload.limit, 100)}`;
69
+ case "fs.read":
70
+ return `Read ${compactPath(payload.path)} · ${lineRange(payload)}`;
71
+ case "fs.readMany":
72
+ return `Read ${Array.isArray(payload.paths) ? payload.paths.length : 0} files`;
73
+ case "fs.batch":
74
+ return `Filesystem batch · ${Array.isArray(payload.operations) ? payload.operations.length : 0} ops`;
75
+ case "fs.artifact":
76
+ return `Export ${compactPath(payload.path)}`;
77
+ case "fs.write": {
78
+ const bytes = Buffer.byteLength(String(payload.content ?? ""), "utf8");
79
+ return `Write ${compactPath(payload.path)} · ${payload.mode || "rewrite"} · ${bytes} B`;
80
+ }
81
+ case "fs.edit":
82
+ return `Edit ${compactPath(payload.path)} · replace ×${number(payload.expectedReplacements, 1)}`;
83
+ case "fs.mkdir":
84
+ return `Create folder ${compactPath(payload.path)}`;
85
+ case "fs.move":
86
+ return `Move ${compactPath(payload.source)} → ${compactPath(payload.destination)}`;
87
+ case "fs.delete":
88
+ return `Delete ${compactPath(payload.path)}${payload.recursive ? " · recursive" : ""}`;
89
+ case "fs.search.start":
90
+ return `Search "${cleanText(payload.pattern, 56)}" in ${compactPath(payload.path)}`;
91
+ case "fs.search.results":
92
+ return `Read search results · offset ${number(payload.offset)} · ${number(payload.length, 50)} rows`;
93
+
94
+ case "process.list":
95
+ return `List processes${payload.filter ? ` · "${cleanText(payload.filter, 52)}"` : ""}`;
96
+ case "process.kill":
97
+ return `Kill process ${number(payload.pid)}`;
98
+
99
+ case "terminal.exec":
100
+ return `Run ${redactCommand(payload.command)}${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
101
+ case "terminal.start":
102
+ return `Start ${redactCommand(payload.command)}${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
103
+ case "terminal.shell.start":
104
+ return `Start shell${payload.cwd ? ` · in ${compactPath(payload.cwd)}` : ""}`;
105
+ case "terminal.read":
106
+ return `Read terminal ${cleanText(payload.sessionId, 32)} · after #${number(payload.afterSeq)}`;
107
+ case "terminal.write":
108
+ return `Write terminal ${cleanText(payload.sessionId, 32)} · ${String(payload.input ?? "").length} chars`;
109
+ case "terminal.kill":
110
+ return `Stop terminal ${cleanText(payload.sessionId, 32)}`;
111
+ case "terminal.list":
112
+ return "List terminal sessions";
113
+ case "terminal.observability":
114
+ return "Get terminal status";
115
+ case "terminal.batch.start":
116
+ return `Run terminal batch · ${Array.isArray(payload.jobs) ? payload.jobs.length : 0} jobs · concurrency ${number(payload.concurrency, 1)}`;
117
+ case "terminal.batch.status":
118
+ return `Get batch status ${cleanText(payload.batchId, 32)}`;
119
+ case "terminal.batch.read":
120
+ return `Read batch ${cleanText(payload.batchId, 32)}`;
121
+ case "terminal.batch.cancel":
122
+ return `Cancel batch ${cleanText(payload.batchId, 32)}`;
123
+
124
+ case "desktop.screenshot":
125
+ return "Capture screen";
126
+ case "desktop.clipboard.read":
127
+ return "Read clipboard";
128
+ case "desktop.clipboard.write":
129
+ return `Write clipboard · ${String(payload.text ?? payload.content ?? "").length} chars`;
130
+ case "desktop.window.list":
131
+ return "List windows";
132
+ case "desktop.window.focus":
133
+ return `Focus window ${cleanText(payload.title ?? payload.windowId ?? payload.handle ?? "", 64)}`;
134
+ case "desktop.mouse.click": {
135
+ const button = cleanText(payload.button || "left", 12);
136
+ const x = number(payload.x);
137
+ const y = number(payload.y);
138
+ return `${button[0]?.toUpperCase() || "L"}${button.slice(1)} click · ${x},${y}`;
139
+ }
140
+ case "desktop.keyboard.input":
141
+ return `Keyboard input · ${countKeyboardInput(payload)}`;
142
+ case "desktop.step":
143
+ return `Desktop step · ${Array.isArray(payload.actions) ? payload.actions.length : 0} actions`;
144
+
145
+ default:
146
+ return cleanText(action.replace(/[._]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()), 100) || "Tool call";
147
+ }
148
+ }
package/cli/index.mjs CHANGED
@@ -11,6 +11,8 @@ function parseOptions(args) {
11
11
  else if (arg === "--force") options.force = true;
12
12
  else if (arg === "--desktop") options.desktopEnabled = true;
13
13
  else if (arg === "--no-desktop") options.desktopEnabled = false;
14
+ else if (arg === "--plain") options.tuiEnabled = false;
15
+ else if (arg === "--tui") options.tuiEnabled = true;
14
16
  else if (arg === "--relay") options.relayUrl = args[++index];
15
17
  else if (arg.startsWith("--relay=")) options.relayUrl = arg.slice(8);
16
18
  else if (arg === "--root") options.allowedRoot = args[++index];
@@ -44,6 +46,8 @@ Options:
44
46
  --agent-id <id> Stable agent identifier
45
47
  --desktop Enable Windows desktop screenshot/input access
46
48
  --no-desktop Disable desktop access (default)
49
+ --plain Use legacy line-by-line logs
50
+ --tui Force interactive terminal UI
47
51
  --no-open Do not open the browser automatically
48
52
  --force Force a new login
49
53
 
package/cli/remote.mjs CHANGED
@@ -4,6 +4,8 @@ import path from "node:path";
4
4
  import { loadConfig, saveConfig } from "./config.mjs";
5
5
  import { login, requestJson } from "./device-login.mjs";
6
6
  import { acquireRunnerOwnership, RunnerAlreadyActiveError } from "./runner-ownership.mjs";
7
+ import { getAgentVersion } from "../agent/protocol.mjs";
8
+ import { RemoteTui, shouldUseTui } from "./tui.mjs";
7
9
 
8
10
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
11
  let stopping = false;
@@ -55,10 +57,11 @@ function printSummary(config) {
55
57
  console.log("");
56
58
  }
57
59
 
58
- function spawnAgent(config) {
60
+ function spawnAgent(config, { tui = null } = {}) {
59
61
  const agentPath = path.join(packageRoot, "agent", "local-agent.mjs");
60
62
  const cwd = String(config.allowedRoots || process.cwd()).split(";")[0] || process.cwd();
61
- return spawn(process.execPath, [agentPath], {
63
+ const useTui = Boolean(tui);
64
+ const child = spawn(process.execPath, [agentPath], {
62
65
  cwd,
63
66
  env: {
64
67
  ...process.env,
@@ -69,10 +72,13 @@ function spawnAgent(config) {
69
72
  TERMINAL_ENABLED: config.terminalEnabled === false ? "0" : "1",
70
73
  DESKTOP_ENABLED: config.desktopEnabled === true ? "1" : "0",
71
74
  ALLOWED_ROOTS: config.allowedRoots,
75
+ CHAT_RELAY_UI: useTui ? "ipc" : "plain",
72
76
  },
73
- stdio: "inherit",
77
+ stdio: useTui ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
74
78
  windowsHide: false,
75
79
  });
80
+ if (useTui) child.on("message", (message) => tui.handleMessage(message));
81
+ return child;
76
82
  }
77
83
 
78
84
  export async function remote(options = {}) {
@@ -91,6 +97,7 @@ export async function remote(options = {}) {
91
97
  console.error(error.message);
92
98
  return 1;
93
99
  }
100
+ let tui = null;
94
101
  try {
95
102
  if (options.allowedRoot && options.allowedRoot !== config.allowedRoots) {
96
103
  config = { ...config, allowedRoots: options.allowedRoot };
@@ -101,10 +108,13 @@ export async function remote(options = {}) {
101
108
  saveConfig(config);
102
109
  }
103
110
 
104
- printSummary(config);
111
+ const useTui = shouldUseTui(options);
112
+ tui = useTui ? new RemoteTui({ config, version: getAgentVersion() }) : null;
113
+ if (tui) tui.start();
114
+ else printSummary(config);
105
115
 
106
116
  while (!stopping) {
107
- const child = spawnAgent(config);
117
+ const child = spawnAgent(config, { tui });
108
118
 
109
119
  const stop = () => {
110
120
  stopping = true;
@@ -145,6 +155,7 @@ export async function remote(options = {}) {
145
155
  }
146
156
  }
147
157
  } finally {
158
+ try { tui?.stop?.(); } catch {}
148
159
  await ownership.release();
149
160
  }
150
161
  }
package/cli/tui.mjs ADDED
@@ -0,0 +1,215 @@
1
+ import { humanizeToolCall } from "../agent/toolcall-summary.mjs";
2
+
3
+ const ESC = "\x1b[";
4
+ const LABEL_WIDTH = 12;
5
+ const GAP = 3;
6
+ const MIN_FRAME_WIDTH = 60;
7
+ const MAX_FRAME_WIDTH = 179;
8
+ const FRAME_FIXED_ROWS = 11;
9
+ const FRAME_BOTTOM_MARGIN = 1;
10
+
11
+ function clamp(value, min, max) {
12
+ return Math.max(min, Math.min(max, value));
13
+ }
14
+
15
+ function truncate(value, width) {
16
+ const text = String(value ?? "");
17
+ if (width <= 0) return "";
18
+ if (text.length <= width) return text;
19
+ if (width === 1) return "…";
20
+ return text.slice(0, width - 1) + "…";
21
+ }
22
+
23
+ function field(label, value, width) {
24
+ const valueWidth = Math.max(1, width - LABEL_WIDTH);
25
+ return truncate(String(label).padEnd(LABEL_WIDTH) + truncate(value, valueWidth), width).padEnd(width);
26
+ }
27
+
28
+ export function formatTwoColumnHeader(state, width = 110) {
29
+ const usable = clamp(Number(width) || 110, MIN_FRAME_WIDTH, MAX_FRAME_WIDTH);
30
+ const leftWidth = Math.floor((usable - GAP) / 2);
31
+ const rightWidth = usable - GAP - leftWidth;
32
+ const rows = [
33
+ ["Account", state.account || "-", "Agent", state.agent || "-"],
34
+ ["Status", statusText(state.status), "Relay", state.relay || "-"],
35
+ ["Uptime", formatUptime(state.uptimeMs), "Reconnects", String(state.reconnects ?? 0)],
36
+ ["Terminal", state.terminal ? "enabled" : "disabled", "Desktop", state.desktop ? "enabled" : "disabled"],
37
+ ];
38
+ return rows
39
+ .map(([leftLabel, leftValue, rightLabel, rightValue]) =>
40
+ field(leftLabel, leftValue, leftWidth) + " ".repeat(GAP) + field(rightLabel, rightValue, rightWidth).trimEnd(),
41
+ )
42
+ .join("\n");
43
+ }
44
+
45
+ function statusText(value) {
46
+ switch (String(value || "").toLowerCase()) {
47
+ case "connected": return "● Connected";
48
+ case "connecting": return "◐ Connecting";
49
+ case "reconnecting": return "◐ Reconnecting";
50
+ case "stopping": return "○ Stopping";
51
+ case "offline": return "○ Offline";
52
+ default: return "○ Starting";
53
+ }
54
+ }
55
+
56
+ function formatUptime(ms) {
57
+ const totalSeconds = Math.max(0, Math.floor(Number(ms || 0) / 1000));
58
+ const hours = Math.floor(totalSeconds / 3600);
59
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
60
+ const seconds = totalSeconds % 60;
61
+ return [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":");
62
+ }
63
+
64
+ function formatDuration(ms, status) {
65
+ if (status === "running") return "running";
66
+ const value = Math.max(0, Number(ms) || 0);
67
+ if (value < 1000) return Math.round(value) + "ms";
68
+ return (value / 1000).toFixed(value < 10000 ? 1 : 0) + "s";
69
+ }
70
+
71
+ function clock(value) {
72
+ const date = value ? new Date(value) : new Date();
73
+ if (Number.isNaN(date.getTime())) return "--:--:--";
74
+ return date.toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
75
+ }
76
+
77
+ export function formatTransactionRow(item, width = 110) {
78
+ const usable = clamp(Number(width) || 110, 60, 180);
79
+ const status = item.status === "running" ? "●" : item.ok === false ? "✕" : "✓";
80
+ const prefix = `${clock(item.at)} ${status} `;
81
+ const duration = formatDuration(item.durationMs, item.status);
82
+ const suffix = " " + duration.padStart(8);
83
+ const summaryWidth = Math.max(12, usable - prefix.length - suffix.length);
84
+ return prefix + truncate(item.summary || item.action || "Tool call", summaryWidth).padEnd(summaryWidth) + suffix;
85
+ }
86
+
87
+ function normalizeRelay(value) {
88
+ try {
89
+ const url = new URL(String(value));
90
+ return url.host + (url.pathname && url.pathname !== "/" ? url.pathname : "");
91
+ } catch {
92
+ return String(value || "-").replace(/^https?:\/\//, "").replace(/\/$/, "");
93
+ }
94
+ }
95
+
96
+ export function shouldUseTui(options = {}, stdout = process.stdout) {
97
+ if (options.tuiEnabled === false) return false;
98
+ if (options.tuiEnabled === true) return true;
99
+ return Boolean(stdout?.isTTY);
100
+ }
101
+
102
+ export class RemoteTui {
103
+ constructor({ config, version, output = process.stdout }) {
104
+ this.output = output;
105
+ this.startedAt = Date.now();
106
+ this.state = {
107
+ account: config.user?.name || config.user?.login || "signed in",
108
+ agent: config.agentName || config.agentId || "-",
109
+ relay: normalizeRelay(config.relayUrl),
110
+ status: "starting",
111
+ reconnects: 0,
112
+ terminal: config.terminalEnabled !== false,
113
+ desktop: config.desktopEnabled === true,
114
+ };
115
+ this.version = version || "-";
116
+ this.transactions = [];
117
+ this.timer = null;
118
+ this.closed = false;
119
+ this.renderPending = false;
120
+ }
121
+
122
+ start() {
123
+ if (this.closed) return;
124
+ // Use the terminal's alternate screen so periodic redraws never accumulate
125
+ // in scrollback. Keep one column unused to avoid automatic line wrapping.
126
+ this.output.write(ESC + "?1049h" + ESC + "?25l" + ESC + "2J" + ESC + "H");
127
+ this.render();
128
+ this.timer = setInterval(() => this.render(), 1000);
129
+ this.timer.unref?.();
130
+ }
131
+
132
+ stop() {
133
+ if (this.closed) return;
134
+ this.closed = true;
135
+ if (this.timer) clearInterval(this.timer);
136
+ this.timer = null;
137
+ this.output.write(ESC + "?25h" + ESC + "?1049l");
138
+ }
139
+
140
+ handleMessage(message) {
141
+ if (!message || message.type !== "chat-relay-ui") return;
142
+ if (message.event === "connection") {
143
+ this.state.status = message.state || this.state.status;
144
+ if (Number.isFinite(Number(message.reconnects))) this.state.reconnects = Number(message.reconnects);
145
+ } else if (message.event === "tool:start") {
146
+ this.transactions.push({
147
+ requestId: message.requestId,
148
+ at: message.at || new Date().toISOString(),
149
+ action: message.action,
150
+ summary: message.summary || humanizeToolCall({ action: message.action }),
151
+ status: "running",
152
+ ok: null,
153
+ });
154
+ if (this.transactions.length > 100) this.transactions.splice(0, this.transactions.length - 100);
155
+ } else if (message.event === "tool:end") {
156
+ const item = [...this.transactions].reverse().find((entry) => entry.requestId === message.requestId);
157
+ if (item) {
158
+ item.status = "done";
159
+ item.ok = message.ok !== false;
160
+ item.durationMs = message.durationMs;
161
+ if (message.error && message.ok === false) item.summary += ` · ${truncate(message.error, 48)}`;
162
+ }
163
+ } else if (message.event === "system") {
164
+ this.transactions.push({
165
+ requestId: "system-" + Date.now() + "-" + Math.random(),
166
+ at: message.at || new Date().toISOString(),
167
+ summary: message.message || "Agent event",
168
+ status: "done",
169
+ ok: message.level !== "error",
170
+ });
171
+ if (this.transactions.length > 100) this.transactions.splice(0, this.transactions.length - 100);
172
+ }
173
+ this.scheduleRender();
174
+ }
175
+
176
+ scheduleRender() {
177
+ if (this.closed || this.renderPending) return;
178
+ this.renderPending = true;
179
+ setImmediate(() => {
180
+ this.renderPending = false;
181
+ this.render();
182
+ });
183
+ }
184
+
185
+ render() {
186
+ if (this.closed) return;
187
+ const terminalWidth = Number(this.output.columns) || 110;
188
+ const width = clamp(terminalWidth - 1, MIN_FRAME_WIDTH, MAX_FRAME_WIDTH);
189
+ const height = Math.max(16, Number(this.output.rows) || 30);
190
+ const separator = "─".repeat(width);
191
+ const header = formatTwoColumnHeader({
192
+ ...this.state,
193
+ uptimeMs: Date.now() - this.startedAt,
194
+ }, width);
195
+ // Fixed frame content occupies 11 rows. Leave one spare row at the
196
+ // bottom so Windows Terminal never scrolls the screen while repainting.
197
+ const availableRows = Math.max(1, height - FRAME_FIXED_ROWS - FRAME_BOTTOM_MARGIN);
198
+ const rows = this.transactions.slice(-availableRows).map((item) => formatTransactionRow(item, width));
199
+ while (rows.length < Math.min(availableRows, 4)) rows.unshift("");
200
+
201
+ const screen = [
202
+ `Chat Relay v${this.version}`,
203
+ separator,
204
+ "",
205
+ header,
206
+ "",
207
+ separator,
208
+ "Transactions",
209
+ "",
210
+ ...rows,
211
+ ].join("\n");
212
+
213
+ this.output.write(ESC + "2J" + ESC + "H" + screen);
214
+ }
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anusornneal/chat-relay",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
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": {