pi-link 0.3.0 → 0.4.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.
package/bin/pi-link.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // pi-link <name> [--global|-g] [flags...]
7
7
  // Resume or create a named session, connected to link.
8
8
  // pi-link --list [--global|-g] List pi-link sessions in current cwd (or everywhere).
9
+ // pi-link --status [--json] Show terminals connected to the running hub right now.
9
10
  // pi-link --resolve <name> [--global|-g]
10
11
  // Print just the session path (machine-readable).
11
12
  // pi-link --version Print the installed pi-link version.
@@ -151,6 +152,11 @@ const useAnsi =
151
152
  const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
152
153
  const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
153
154
 
155
+ // What `--status` prints for a field the hub could not report. Declared here,
156
+ // above the dispatcher: the mode handlers are hoisted functions, but a `const`
157
+ // read from one would still be in its temporal dead zone when dispatch runs.
158
+ const UNKNOWN = "?";
159
+
154
160
  function relTime(d) {
155
161
  const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
156
162
  if (sec < 60) return `${sec}s ago`;
@@ -299,11 +305,13 @@ function fail(msg) {
299
305
  function printHelp() {
300
306
  console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
301
307
  console.error(" pi-link --list [--global|-g]");
308
+ console.error(" pi-link --status [--json]");
302
309
  console.error(" pi-link --resolve <name> [--global|-g]");
303
310
  console.error(" pi-link --version");
304
311
  console.error("");
305
312
  console.error("By default, name lookup is scoped to the current cwd.");
306
313
  console.error("--global / -g widens the search to sessions in any cwd.");
314
+ console.error("--list reads saved sessions; --status asks the running hub who is connected.");
307
315
  }
308
316
 
309
317
  function printVersion() {
@@ -322,6 +330,7 @@ function describeMode(mode) {
322
330
  case "help": return "--help";
323
331
  case "version": return "--version";
324
332
  case "list": return "--list";
333
+ case "status": return "--status";
325
334
  case "resolve": return "--resolve";
326
335
  case "launcher": return "session name";
327
336
  default: return mode;
@@ -338,10 +347,11 @@ function describeMode(mode) {
338
347
  // 5. Launcher passthrough (mode launcher) with orphan-positional rejection
339
348
 
340
349
  const state = {
341
- mode: null, // null | "help" | "version" | "list" | "resolve" | "launcher"
350
+ mode: null, // null | "help" | "version" | "list" | "status" | "resolve" | "launcher"
342
351
  resolveName: null,
343
352
  launcherName: null,
344
353
  global: false,
354
+ json: false,
345
355
  piPassthrough: [],
346
356
  };
347
357
 
@@ -383,11 +393,25 @@ for (let i = 0; i < rawArgs.length; i++) {
383
393
  break;
384
394
  }
385
395
 
396
+ // `--json` modifies --status only. Claimed before a mode exists so order does
397
+ // not matter, but never in launcher mode, where it belongs to pi.
398
+ if (a === "--json" && (state.mode === null || state.mode === "status")) {
399
+ state.json = true;
400
+ continue;
401
+ }
402
+
386
403
  // Phase 2: mode-selecting flags.
387
404
  if (a === "--list") {
388
405
  setMode("list");
389
406
  continue;
390
407
  }
408
+ // Selects the wrapper's own mode only before a session name has been seen.
409
+ // After that it is pi's flag, exactly like `--json` above — intercepting it
410
+ // unconditionally would break `pi-link foo --status`.
411
+ if (a === "--status" && state.mode !== "launcher") {
412
+ setMode("status");
413
+ continue;
414
+ }
391
415
  if (a.startsWith("--resolve=")) {
392
416
  setMode("resolve");
393
417
  if (state.resolveName !== null) fail(`--resolve specified more than once`);
@@ -416,6 +440,9 @@ for (let i = 0; i < rawArgs.length; i++) {
416
440
  if (state.mode === "list") {
417
441
  fail(`--list does not accept argument: ${a}\n Usage: pi-link --list [--global|-g]`);
418
442
  }
443
+ if (state.mode === "status") {
444
+ fail(`--status does not accept arguments: ${a}\n Usage: pi-link --status [--json]`);
445
+ }
419
446
  if (state.mode === "resolve") {
420
447
  fail(`--resolve accepts exactly one name; got extra: ${a}`);
421
448
  }
@@ -465,6 +492,14 @@ if (state.mode === "resolve") {
465
492
  }
466
493
  state.resolveName = normalized;
467
494
  }
495
+ // `--status` reads one running hub, so a cwd scope is meaningless rather than
496
+ // merely unused: silently ignoring `-g` would imply a filter that cannot exist.
497
+ if (state.mode === "status" && state.global) {
498
+ fail(`cannot combine --status and --global`);
499
+ }
500
+ if (state.json && state.mode !== "status") {
501
+ fail(`--json is only valid with --status`);
502
+ }
468
503
  if (state.mode === "launcher") {
469
504
  const normalized = normalizeName(state.launcherName);
470
505
  if (!normalized) {
@@ -488,6 +523,9 @@ switch (state.mode) {
488
523
  case "list":
489
524
  await runList(state);
490
525
  break;
526
+ case "status":
527
+ await runStatus(state);
528
+ break;
491
529
  case "resolve":
492
530
  await runResolve(state);
493
531
  break;
@@ -534,6 +572,145 @@ async function runList(state) {
534
572
  }
535
573
  }
536
574
 
575
+ // ── Status (live hub query) ────────────────────────────────────────────────
576
+ //
577
+ // `--list` reads saved history; `--status` asks the hub who is connected now.
578
+ // The two answer different questions, so they share no code path.
579
+
580
+ // Mirrors the extension's formatTokens so both renderings of one number agree.
581
+ function formatTokens(n) {
582
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
583
+ if (n >= 1_000) return `${Math.round(n / 1000)}K`;
584
+ return `${n}`;
585
+ }
586
+
587
+ // `92K/272K (34%)`, or `?/272K` when the hub has a window but no token count.
588
+ // Only reached for a validated payload, so `c` is null or a well-typed snapshot;
589
+ // a non-positive window is still possible and still means nothing to report.
590
+ function formatContext(c) {
591
+ if (!c || c.window <= 0) return UNKNOWN;
592
+ const window = formatTokens(c.window);
593
+ if (typeof c.tokens !== "number") return `${UNKNOWN}/${window}`;
594
+ return `${formatTokens(c.tokens)}/${window} (${Math.round((c.tokens / c.window) * 100)}%)`;
595
+ }
596
+
597
+ function formatAge(seconds) {
598
+ if (seconds < 60) return `${seconds}s`;
599
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
600
+ return `${Math.floor(seconds / 3600)}h`;
601
+ }
602
+
603
+ // `status` and `sinceSeconds` are an optional pair: the hub omits them for a
604
+ // terminal it has registered but not yet heard from. Absence means unknown, so
605
+ // it must render as unknown — printing `idle` there would be an invention, and
606
+ // acting on it is the misreporting this command exists to end.
607
+ function formatTerminalStatus(entry) {
608
+ if (typeof entry.status !== "string") return UNKNOWN;
609
+ return `${entry.status} (${formatAge(entry.sinceSeconds)})`;
610
+ }
611
+
612
+ function failUnsupported() {
613
+ console.error("Link hub does not support /status \u2014 update pi-link and restart terminals.");
614
+ process.exit(1);
615
+ }
616
+
617
+ function failNoHub(port) {
618
+ console.error(`No link hub running on :${port}.`);
619
+ process.exit(2);
620
+ }
621
+
622
+ // The frozen contract, checked before any field is read. Anything else on the
623
+ // port — a different service, a newer hub, a truncated proxy — must produce the
624
+ // unsupported message, never a stack trace. Unknown extra fields stay allowed.
625
+ function isContextField(c) {
626
+ if (c === null) return true;
627
+ if (!c || typeof c !== "object") return false;
628
+ return (c.tokens === null || typeof c.tokens === "number") && typeof c.window === "number";
629
+ }
630
+
631
+ function isTerminalEntry(entry, index) {
632
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
633
+ if (typeof entry.name !== "string") return false;
634
+ // Hub first, then clients — the ordering the renderer relies on.
635
+ if (entry.role !== (index === 0 ? "hub" : "client")) return false;
636
+ // `status` and `sinceSeconds` are one optional pair: both or neither.
637
+ //
638
+ // The value is checked for shape, not vocabulary. `idle`/`thinking`/
639
+ // `compacting`/`tool:<name>` are today's kinds, but that set has already grown
640
+ // once (`compacting` arrived after 0.3.0) and the CLI never branches on it — it
641
+ // only prints it. Freezing the list here would make a newer hub's fifth kind
642
+ // reject the whole payload, and only while some terminal happened to be in that
643
+ // state: an intermittent failure telling the user to update. An empty string is
644
+ // still rejected, because it renders as a blank cell with a bare duration.
645
+ const hasStatus = "status" in entry;
646
+ if (hasStatus !== ("sinceSeconds" in entry)) return false;
647
+ if (hasStatus && (typeof entry.status !== "string" || entry.status === "" || typeof entry.sinceSeconds !== "number")) {
648
+ return false;
649
+ }
650
+ if ("cwd" in entry && typeof entry.cwd !== "string") return false;
651
+ return "context" in entry && isContextField(entry.context);
652
+ }
653
+
654
+ function isStatusPayload(payload) {
655
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
656
+ if (typeof payload.hub !== "string" || typeof payload.port !== "number") return false;
657
+ // A hub always reports itself, so an empty list is not this contract.
658
+ if (!Array.isArray(payload.terminals) || payload.terminals.length === 0) return false;
659
+ if (!payload.terminals.every(isTerminalEntry)) return false;
660
+ return payload.terminals[0].name === payload.hub;
661
+ }
662
+
663
+ async function runStatus(state) {
664
+ // Deliberately unvalidated: a bad value fails the fetch and is reported with
665
+ // the port in the message, which is the diagnosis.
666
+ const port = process.env.PI_LINK_PORT ?? 9900;
667
+ // One deadline covers the whole exchange, headers and body alike, so it has to
668
+ // be readable afterwards to tell a timeout from a malformed answer.
669
+ const deadline = AbortSignal.timeout(2000);
670
+ let response;
671
+ try {
672
+ response = await fetch(`http://127.0.0.1:${port}/status`, { signal: deadline });
673
+ } catch {
674
+ // Refused, unreachable or timed out: no hub is answering at this instant.
675
+ failNoHub(port);
676
+ }
677
+
678
+ if (!response.ok) failUnsupported();
679
+
680
+ let body;
681
+ try {
682
+ body = await response.text();
683
+ } catch {
684
+ // A listener that sends headers and then stalls is still an unanswered
685
+ // query, not an old hub: every timeout reports as "no hub".
686
+ if (deadline.aborted) failNoHub(port);
687
+ failUnsupported();
688
+ }
689
+
690
+ let payload;
691
+ try {
692
+ payload = JSON.parse(body);
693
+ } catch {
694
+ failUnsupported();
695
+ }
696
+ if (!isStatusPayload(payload)) failUnsupported();
697
+
698
+ if (state.json) {
699
+ process.stdout.write(body);
700
+ return;
701
+ }
702
+
703
+ // Payload order is meaningful: the hub is first, then clients sorted by name.
704
+ console.log(
705
+ renderTable(payload.terminals, [
706
+ { header: "NAME", get: (e) => e.name },
707
+ { header: "STATUS", get: (e) => formatTerminalStatus(e) },
708
+ { header: "CONTEXT", get: (e) => formatContext(e.context) },
709
+ { header: "CWD", get: (e) => (e.cwd ? displayPath(e.cwd) : UNKNOWN), dim: true },
710
+ ]),
711
+ );
712
+ }
713
+
537
714
  async function runResolve(state) {
538
715
  const name = state.resolveName; // already normalized
539
716
  const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
package/index.ts CHANGED
@@ -12,12 +12,15 @@
12
12
 
13
13
  import {
14
14
  VERSION as PI_VERSION,
15
+ keyHint,
16
+ keyText,
15
17
  type ExtensionAPI,
16
18
  type ExtensionContext,
17
19
  } from "@earendil-works/pi-coding-agent";
18
- import { Text } from "@earendil-works/pi-tui";
20
+ import { Box, Text, type Component } from "@earendil-works/pi-tui";
19
21
  import { Type } from "typebox";
20
22
  import * as crypto from "node:crypto";
23
+ import { createServer, type Server as HttpServer } from "node:http";
21
24
  import * as os from "node:os";
22
25
 
23
26
  import { WebSocket, WebSocketServer } from "ws";
@@ -39,6 +42,8 @@ const CONNECT_HANDSHAKE_TIMEOUT_MS = 5_000;
39
42
  const FLUSH_DELAY_MS = 200;
40
43
  const BATCH_MAX_ITEMS = 20;
41
44
  const BATCH_MAX_CHARS = 16_000;
45
+ // Visual rows of an incoming message kept while Pi's global expansion is off.
46
+ const PREVIEW_ROWS = 6;
42
47
 
43
48
  // ─── Protocol ────────────────────────────────────────────────────────────────
44
49
 
@@ -150,6 +155,49 @@ function piVersionSupported(version: string): boolean {
150
155
  return prerelease === undefined; // exactly the floor: only the release qualifies
151
156
  }
152
157
 
158
+ // keyText is empty when app.tools.expand has no binding, and the native hint would then
159
+ // read " to expand" with no key in it, naming nothing the reader can press.
160
+ function expansionHint(dim: (text: string) => string): string {
161
+ return keyText("app.tools.expand")
162
+ ? keyHint("app.tools.expand", "to expand")
163
+ : dim("bind app.tools.expand to expand");
164
+ }
165
+
166
+ /**
167
+ * The first PREVIEW_ROWS rows of `content`, plus a hint when rows were dropped.
168
+ *
169
+ * The rows come from rendering `content` at the real width, not from splitting the
170
+ * raw string on newlines: one long logical line wraps into many visual rows, and
171
+ * the same message yields different rows in a narrow window. Rendering is Text's
172
+ * job here, cached by (text, width), so the preview stays a slice of the identical
173
+ * output the expanded view shows.
174
+ */
175
+ function messagePreview(
176
+ content: Text,
177
+ dim: (text: string) => string,
178
+ ): Component {
179
+ const hint = new Text("", 0, 0);
180
+ return {
181
+ render(width: number): string[] {
182
+ const rows = content.render(width);
183
+ if (rows.length <= PREVIEW_ROWS) return rows;
184
+ // Rebuilt every render so a rebound key is never shown stale; it is one short
185
+ // line, and Text still caches the content rows, which are the expensive part.
186
+ hint.setText(
187
+ dim(`... (${rows.length - PREVIEW_ROWS} more lines, `) +
188
+ expansionHint(dim) +
189
+ dim(")"),
190
+ );
191
+ // The budget covers content only, so the hint may wrap rather than be cut off.
192
+ return [...rows.slice(0, PREVIEW_ROWS), ...hint.render(width)];
193
+ },
194
+ invalidate(): void {
195
+ content.invalidate();
196
+ hint.invalidate();
197
+ },
198
+ };
199
+ }
200
+
153
201
  // ─── Extension ───────────────────────────────────────────────────────────────
154
202
 
155
203
  export default function (pi: ExtensionAPI) {
@@ -209,6 +257,9 @@ export default function (pi: ExtensionAPI) {
209
257
 
210
258
  // Hub state
211
259
  let wss: WebSocketServer | null = null;
260
+ // The hub owns the HTTP server the WS server rides on, because `wss.close()`
261
+ // never closes a server it was handed. Nulled wherever `wss` is.
262
+ let hubHttpServer: HttpServer | null = null;
212
263
  const hubClients = new Map<WebSocket, string>(); // ws → terminal name
213
264
  const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
214
265
  const hubTerminalContexts = new Map<string, ContextSnapshot>(); // hub-authoritative
@@ -226,6 +277,7 @@ export default function (pi: ExtensionAPI) {
226
277
  promise: Promise<void>;
227
278
  socket: WebSocket | null; // dialing, not yet `ws`
228
279
  server: WebSocketServer | null; // binding, not yet `wss`
280
+ httpServer: HttpServer | null; // binding, not yet `hubHttpServer`
229
281
  };
230
282
  let connectionAttempt: ConnectionAttempt | null = null;
231
283
 
@@ -600,6 +652,52 @@ export default function (pi: ExtensionAPI) {
600
652
  return Array.from(allTerminalNames()).sort();
601
653
  }
602
654
 
655
+ /**
656
+ * Hub: the `GET /status` snapshot. Pure reads — it mutates nothing and sends
657
+ * nothing, so observing the link cannot disturb it.
658
+ *
659
+ * Hub entry first, then clients sorted by name, so pollers see a stable order.
660
+ * `status`/`sinceSeconds` and `cwd` are omitted rather than invented when the
661
+ * hub has not heard them yet: a client is in `hubClients` from `register`, but
662
+ * its first `status_update` arrives a round trip later, and reporting a fresh
663
+ * peer as "idle" would be exactly the false inventory this endpoint exists to
664
+ * remove.
665
+ */
666
+ function buildStatusPayload() {
667
+ const now = Date.now();
668
+
669
+ const describe = (name: string, entryRole: "hub" | "client") => {
670
+ const status = getStatusFor(name);
671
+ const cwd = getCwdFor(name);
672
+ const context = getContextFor(name);
673
+ return {
674
+ name,
675
+ role: entryRole,
676
+ ...(status
677
+ ? {
678
+ status: statusIdentity(status),
679
+ sinceSeconds: Math.round((now - status.since) / 1000),
680
+ }
681
+ : {}),
682
+ ...(cwd ? { cwd } : {}),
683
+ context: context
684
+ ? { tokens: context.tokens, window: context.contextWindow }
685
+ : null,
686
+ };
687
+ };
688
+
689
+ return {
690
+ hub: terminalName,
691
+ port: DEFAULT_PORT,
692
+ terminals: [
693
+ describe(terminalName, "hub"),
694
+ ...Array.from(hubClients.values())
695
+ .sort()
696
+ .map((name) => describe(name, "client")),
697
+ ],
698
+ };
699
+ }
700
+
603
701
  function safeParse(data: string): LinkMessage | null {
604
702
  try {
605
703
  return JSON.parse(data);
@@ -975,10 +1073,22 @@ export default function (pi: ExtensionAPI) {
975
1073
 
976
1074
  function startHub(attempt: ConnectionAttempt): Promise<boolean> {
977
1075
  return new Promise((resolve) => {
978
- const server = new WebSocketServer({
979
- port: DEFAULT_PORT,
980
- host: "127.0.0.1",
1076
+ // Owning the HTTP server is what makes `GET /status` possible: a port-bound
1077
+ // `WebSocketServer` builds its own and answers every plain request with 426.
1078
+ // `ws` forwards this server's `listening` and `error`, so the election below
1079
+ // is unchanged.
1080
+ const httpServer = createServer((req, res) => {
1081
+ if (req.method === "GET" && req.url === "/status") {
1082
+ res.writeHead(200, { "content-type": "application/json" });
1083
+ res.end(JSON.stringify(buildStatusPayload()));
1084
+ return;
1085
+ }
1086
+ res.writeHead(404);
1087
+ res.end();
981
1088
  });
1089
+ attempt.httpServer = httpServer;
1090
+
1091
+ const server = new WebSocketServer({ server: httpServer });
982
1092
  attempt.server = server;
983
1093
 
984
1094
  // The phase settles once. `error` and a pre-listen `close` both report the
@@ -988,16 +1098,19 @@ export default function (pi: ExtensionAPI) {
988
1098
  if (settled) return;
989
1099
  settled = true;
990
1100
  if (attempt.server === server) attempt.server = null;
1101
+ if (attempt.httpServer === httpServer) attempt.httpServer = null;
991
1102
  resolve(established);
992
1103
  };
993
1104
 
994
1105
  server.on("listening", () => {
995
1106
  if (!attemptIsCurrent(attempt)) {
996
1107
  server.close();
1108
+ httpServer.close();
997
1109
  settle(false);
998
1110
  return;
999
1111
  }
1000
1112
  wss = server;
1113
+ hubHttpServer = httpServer;
1001
1114
  // If a client `/link-name` was in flight when the previous hub vanished,
1002
1115
  // this terminal is now establishing hub identity, so honor that pending
1003
1116
  // request. Otherwise keep the last hub-assigned identity — don't replay
@@ -1033,6 +1146,9 @@ export default function (pi: ExtensionAPI) {
1033
1146
  // Reached when a pending server is cancelled; a no-op once established.
1034
1147
  settle(false);
1035
1148
  });
1149
+
1150
+ // Last, so no forwarded event can arrive before its handler exists.
1151
+ httpServer.listen(DEFAULT_PORT, "127.0.0.1");
1036
1152
  });
1037
1153
  }
1038
1154
 
@@ -1128,6 +1244,7 @@ export default function (pi: ExtensionAPI) {
1128
1244
  promise: Promise.resolve(),
1129
1245
  socket: null,
1130
1246
  server: null,
1247
+ httpServer: null,
1131
1248
  };
1132
1249
  connectionAttempt = attempt;
1133
1250
  attempt.promise = runAttempt(attempt);
@@ -1172,8 +1289,13 @@ export default function (pi: ExtensionAPI) {
1172
1289
  const attempt = connectionAttempt;
1173
1290
  if (!attempt) return;
1174
1291
  connectionAttempt = null;
1175
- attempt.socket?.close();
1176
- attempt.server?.close();
1292
+ // Read every handle first: closing the WS server can settle the attempt, and
1293
+ // settling clears these fields. Closing the HTTP server is not optional — it
1294
+ // holds the port, so a skipped close squats :9900 for the whole machine.
1295
+ const { socket, server, httpServer } = attempt;
1296
+ socket?.close();
1297
+ server?.close();
1298
+ httpServer?.close();
1177
1299
  }
1178
1300
 
1179
1301
  function scheduleReconnect() {
@@ -1219,6 +1341,8 @@ export default function (pi: ExtensionAPI) {
1219
1341
  hubClients.clear();
1220
1342
  wss.close();
1221
1343
  wss = null;
1344
+ hubHttpServer?.close();
1345
+ hubHttpServer = null;
1222
1346
  }
1223
1347
 
1224
1348
  role = "disconnected";
@@ -1413,8 +1537,19 @@ export default function (pi: ExtensionAPI) {
1413
1537
  return textResult("Not connected to link", { error: "not_connected" });
1414
1538
  }
1415
1539
 
1416
- function truncatePreview(text: string) {
1417
- return text.length > 60 ? text.slice(0, 60) + "..." : text;
1540
+ // Collapsed one-line preview for the two outgoing tools. Whitespace is normalized so a
1541
+ // multiline message stays on the tool's single preview line, then clipped to 60
1542
+ // characters. The hint appears only when characters are actually hidden: expanding shows
1543
+ // the original text, so collapsing whitespace alone is not something to advertise.
1544
+ // Styling is applied per segment, like messagePreview above: the hint ends with a
1545
+ // foreground reset, so punctuation appended after it would render undimmed.
1546
+ function truncatePreview(text: string, dim: (text: string) => string) {
1547
+ const preview = text.replace(/\s+/g, " ");
1548
+ return preview.length > 60
1549
+ ? dim(preview.slice(0, 60) + "... (") +
1550
+ expansionHint(dim) +
1551
+ dim(")")
1552
+ : dim(preview);
1418
1553
  }
1419
1554
 
1420
1555
  // Shared "target not found" result for the send/compact tools.
@@ -1489,16 +1624,19 @@ export default function (pi: ExtensionAPI) {
1489
1624
  return textResult(`${verb} ${target}`, { to: params.to });
1490
1625
  },
1491
1626
 
1492
- renderCall(args, theme) {
1627
+ renderCall(args, theme, context) {
1628
+ const dim = (text: string) => theme.fg("dim", text);
1493
1629
  const preview =
1494
1630
  typeof args.message === "string"
1495
- ? truncatePreview(args.message)
1496
- : "...";
1631
+ ? context.expanded
1632
+ ? dim(args.message)
1633
+ : truncatePreview(args.message, dim)
1634
+ : dim("...");
1497
1635
  const text =
1498
1636
  theme.fg("toolTitle", theme.bold("link_send ")) +
1499
1637
  theme.fg("accent", args.to) +
1500
1638
  "\n " +
1501
- theme.fg("dim", preview);
1639
+ preview;
1502
1640
  return new Text(text, 0, 0);
1503
1641
  },
1504
1642
 
@@ -1602,11 +1740,16 @@ export default function (pi: ExtensionAPI) {
1602
1740
  });
1603
1741
  },
1604
1742
 
1605
- renderCall(args, theme) {
1743
+ renderCall(args, theme, context) {
1606
1744
  let text = theme.fg("toolTitle", theme.bold("link_compact "));
1607
1745
  text += theme.fg("accent", String(args.to));
1746
+ const dim = (t: string) => theme.fg("dim", t);
1608
1747
  if (typeof args.instructions === "string")
1609
- text += "\n " + theme.fg("dim", truncatePreview(args.instructions));
1748
+ text +=
1749
+ "\n " +
1750
+ (context.expanded
1751
+ ? dim(args.instructions)
1752
+ : truncatePreview(args.instructions, dim));
1610
1753
  return new Text(text, 0, 0);
1611
1754
  },
1612
1755
 
@@ -1838,12 +1981,22 @@ export default function (pi: ExtensionAPI) {
1838
1981
 
1839
1982
  // ── Message renderer ─────────────────────────────────────────────────────
1840
1983
 
1841
- pi.registerMessageRenderer("link", (message, _options, theme) => {
1984
+ pi.registerMessageRenderer("link", (message, options, theme) => {
1842
1985
  const from =
1843
1986
  (message.details as Record<string, unknown> | undefined)?.from ?? "link";
1844
1987
  const text =
1845
1988
  theme.fg("accent", `⚡ [${from}] `) +
1846
1989
  theme.fg("text", String(message.content));
1847
- return new Text(text, 0, 0);
1990
+ const content = new Text(text, 0, 0);
1991
+ // The same panel Pi draws around extension messages by default: returning our own
1992
+ // component bypasses the host's box, so a link message would otherwise sit unframed
1993
+ // among framed ones. `outputPad` is the user's configured output padding.
1994
+ const box = new Box(options.outputPad, 1, (t) => theme.bg("customMessageBg", t));
1995
+ box.addChild(
1996
+ options.expanded
1997
+ ? content
1998
+ : messagePreview(content, (s) => theme.fg("muted", s)),
1999
+ );
2000
+ return box;
1848
2001
  });
1849
2002
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
5
5
  "author": "alvivar",
6
6
  "license": "MIT",