@yunazgr/pi-companion 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Lightweight, local-first remote control for Pi sessions.
4
4
 
5
- **New in 0.3.3:** Provider usage now shows measured session tokens and cost without pretending they are subscription quotas. Compatible extensions can still supply real weekly/5-hour limits. Mobile sessions, automations and usage use cards; the overview has compact activity filters; automation buttons share a consistent style; pull-to-refresh is gesture-only. No data or provider credentials are collected by the daemon outside the already shared session metadata.
5
+ **New in 0.3.4:** Custom Pi terminal popups now appear in Companion with a claymorphism shell, scrollable terminal view, adjustable text size, and touch-friendly navigation and input controls. Interact with custom slash-command screens or question pickers from either device; native question sheets still work for standard dialogs and `jar_ask`.
6
6
 
7
7
  **New in 0.3.2:** responsive automation and run-history tables, rounded status chips, matching clay-icon headers, and a single-column automation workspace with full-width mobile controls. **Edit automation** scrolls to and focuses the inline editor. Pull to refresh Overview, Sessions, Automations, or Settings—with a gesture; unsaved settings stay intact. Camera-policy guidance now explains HTTPS proxy configuration. Existing rich results, staged job editing, optional retries, and answers-only automation sessions are retained. See [automations](docs/automations.md).
8
8
 
@@ -151,7 +151,11 @@ A paired device cannot access sessions that have not explicitly enabled remote c
151
151
 
152
152
  Pi's `companion_ask_user` tool asks one to four questions at once. Each question can offer options with descriptions, allow several choices (`multiSelect`), and accept a free-text "Other" answer; a question without options is free text. The browser shows them in a bottom sheet within thumb reach; "Later" hides it until you tap the waiting-question badge.
153
153
 
154
- Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`) are relayed to the same sheet while the terminal dialog stays open: whichever side answers first wins and the other closes. Question tools that draw their own `ctx.ui.custom` picker are relayed through a small adapter: pi-jar's `jar_ask` is supported, and a companion answer completes its terminal picker. Other `ctx.ui.custom` components and `ctx.ui.editor` stay terminal-only because they cannot be answered from outside. Pending questions are part of the session snapshot, so a browser that connects later still sees them.
154
+ Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`) are relayed to the same sheet while the terminal dialog stays open: whichever side answers first wins and the other closes. Question tools that draw their own `ctx.ui.custom` picker can use a native-sheet adapter: pi-jar's `jar_ask` is supported, and a companion answer completes its terminal picker. Standard dialogs work for any extension, not just `jar_ask`. Pending questions are part of the session snapshot, so a browser that connects later still sees them.
155
+
156
+ Other `ctx.ui.custom` components are mirrored in a terminal-style popup, including custom slash-command screens. ANSI colors and selection highlighting are preserved inside a clay-styled, vertically and horizontally scrollable viewer. Text-size controls and view-scroll buttons make long or wide screens easier to read; separate navigation controls and expandable extra keys operate the original component. The input bar stays available while the popup body scrolls, and unsent text is retained if the connection drops. Enter submits according to the extension's keybindings; Close sends Escape so the component supplies its own cancellation result. Components that ignore Escape may remain open. Local completion closes the companion view, and pending frames survive browser reconnects. Ending sharing stops mirroring without closing the local component.
157
+
158
+ Custom popups use the local terminal's rendered width (scroll horizontally on narrow screens), not an independently resized terminal. Mouse-only interactions, terminal images, and direct terminal I/O are not mirrored. RPC automation sessions cannot mirror custom terminal components because Pi does not create them in RPC mode. `ctx.ui.editor` remains terminal-only.
155
159
 
156
160
  ## Refreshing your workspace
157
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunazgr/pi-companion",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Local-first remote control for Pi sessions: live activity feed, steering, git diff, file drop and phone pairing from a single Rust daemon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AskAnswers, AskOption, AskQuestion, AskRequest } from "./protocol.js";
3
+ import type { PopupRelay } from "./popup.js";
3
4
 
4
5
  export type AskInput = Omit<AskRequest, "requestId" | "createdAt">;
5
6
 
@@ -16,10 +17,10 @@ const RELAYED = Symbol.for("pi-companion.dialog-relay");
16
17
  *
17
18
  * Pi hands every extension the same ui object, so patching it once per object covers all of
18
19
  * them. The terminal dialog stays open; whichever side answers first wins and the other is
19
- * dismissed. `editor` takes no AbortSignal, so it stays terminal-only. `custom` dialogs are
20
- * relayed only for known question tools (see ToolDialogRelay).
20
+ * dismissed. `editor` takes no AbortSignal, so it stays terminal-only. Known custom question
21
+ * tools use native sheets (ToolDialogRelay); other custom components use terminal mirroring.
21
22
  */
22
- export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools: ToolDialogRelay) {
23
+ export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools: ToolDialogRelay, popups?: PopupRelay) {
23
24
  const target = ui as ExtensionUIContext & { [RELAYED]?: true };
24
25
  if (target[RELAYED]) return;
25
26
  target[RELAYED] = true;
@@ -32,7 +33,7 @@ export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools:
32
33
 
33
34
  ui.custom = (factory, options) => {
34
35
  const complete = tools.claim();
35
- if (!complete) return custom(factory, options);
36
+ if (!complete) return custom(popups ? popups.wrap(factory) : factory, options);
36
37
  return custom((tui, theme, keybindings, done) => {
37
38
  complete((result) => done(result as never));
38
39
  return factory(tui, theme, keybindings, done);
package/src/bridge.ts CHANGED
@@ -6,6 +6,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
6
6
  import { relayDialogs, ToolDialogRelay, type AskChannel, type AskInput } from "./ask.js";
7
7
  import { ensureDaemon } from "./daemon.js";
8
8
  import { TelemetryRelay } from "./telemetry.js";
9
+ import { PopupRelay } from "./popup.js";
9
10
  import type { AskAnswers, AskRequest, BridgeMessage, ServerMessage, SessionSnapshot, TempFile } from "./protocol.js";
10
11
 
11
12
  const execFileAsync = promisify(execFile);
@@ -14,6 +15,10 @@ export class CompanionBridge implements AskChannel {
14
15
  readonly sessionId = randomUUID();
15
16
  /** Relays question tools that ask through ctx.ui.custom (fed from tool_execution_* events). */
16
17
  readonly toolDialogs = new ToolDialogRelay(this);
18
+ readonly popups = new PopupRelay(popups => {
19
+ this.snapshot.popups = popups;
20
+ this.send({ type: "session.update", session: { popups } });
21
+ });
17
22
  private ws?: WebSocket;
18
23
  private ctx?: ExtensionContext;
19
24
  private reconnect?: NodeJS.Timeout;
@@ -48,7 +53,7 @@ export class CompanionBridge implements AskChannel {
48
53
 
49
54
  setContext(ctx: ExtensionContext) {
50
55
  this.ctx = ctx;
51
- if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs);
56
+ if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs, this.popups);
52
57
  this.refreshMetadata();
53
58
  }
54
59
 
@@ -115,6 +120,7 @@ export class CompanionBridge implements AskChannel {
115
120
  // End only Companion sharing: Pi itself and its local history keep running.
116
121
  this.restoreDialogs?.();
117
122
  this.restoreDialogs = undefined;
123
+ this.popups.clear();
118
124
  this.restoreStatus?.();
119
125
  this.restoreStatus = undefined;
120
126
  if (this.metadataTimer) clearInterval(this.metadataTimer);
@@ -144,7 +150,7 @@ export class CompanionBridge implements AskChannel {
144
150
  /** Only an explicit /companion command may activate this session's bridge. */
145
151
  activate() {
146
152
  this.activated = true;
147
- if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs);
153
+ if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs, this.popups);
148
154
  if (!this.snapshot.remoteEnabled) return;
149
155
  const ui = this.ctx?.ui;
150
156
  if (ui && typeof ui.setStatus === "function" && !this.restoreStatus) {
@@ -233,6 +239,7 @@ export class CompanionBridge implements AskChannel {
233
239
  this.closed = true;
234
240
  this.restoreDialogs?.();
235
241
  this.restoreDialogs = undefined;
242
+ this.popups.clear();
236
243
  this.restoreStatus?.();
237
244
  this.restoreStatus = undefined;
238
245
  if (this.metadataTimer) clearInterval(this.metadataTimer);
@@ -335,6 +342,12 @@ export class CompanionBridge implements AskChannel {
335
342
  case "git_diff":
336
343
  await this.sendGitDiff(Boolean(command.staged));
337
344
  break;
345
+ case "popup_input":
346
+ this.popups.input(command.popupId, command.data);
347
+ break;
348
+ case "popup_close":
349
+ this.popups.close(command.popupId);
350
+ break;
338
351
  case "ask_answer":
339
352
  this.asks.get(command.requestId)?.settle(command.answers ?? {});
340
353
  break;
package/src/popup.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
3
+ import type { TerminalPopup } from "./protocol.js";
4
+
5
+ type CustomFactory = Parameters<ExtensionUIContext["custom"]>[0];
6
+ type FactoryArgs = Parameters<CustomFactory>;
7
+ type PopupFactory<T> = (tui: FactoryArgs[0], theme: FactoryArgs[1], keys: FactoryArgs[2], done: (result: T) => void) => ReturnType<CustomFactory>;
8
+
9
+ /** Mirrors rendered terminal lines, never serializes or evaluates extension code in the browser. */
10
+ export class PopupRelay {
11
+ private frameTimer?: NodeJS.Timeout;
12
+ private generation = 0;
13
+ private active = new Map<string, { frame: TerminalPopup; input: (data: string) => void; close: () => void }>();
14
+ constructor(private publish: (popups: TerminalPopup[]) => void) {}
15
+
16
+ wrap<T>(factory: PopupFactory<T>) {
17
+ return async (...args: Parameters<typeof factory>) => {
18
+ const [tui, theme, keys, done] = args;
19
+ const generation = this.generation;
20
+ const id = randomUUID();
21
+ let finished = false;
22
+ const remove = () => {
23
+ finished = true;
24
+ if (this.active.delete(id)) this.flush();
25
+ };
26
+ const finish = (result: T) => { if (finished) return; remove(); done(result); };
27
+ const component = await factory(tui, theme, keys, finish);
28
+ if (finished || generation !== this.generation) return component;
29
+ const entry = {
30
+ frame: { id, lines: [], width: 0 } as TerminalPopup,
31
+ input: (data: string) => { component.handleInput?.(data); tui.requestRender(); },
32
+ // Let the component produce its own cancellation result; arbitrary factories
33
+ // may require a tagged result rather than undefined.
34
+ close: () => { component.handleInput?.("\x1b"); tui.requestRender(); }
35
+ };
36
+ this.active.set(id, entry);
37
+ // Patch the instance rather than proxying it: preserve focus, cursor and mouse behavior.
38
+ const render = component.render;
39
+ const dispose = component.dispose;
40
+ component.render = function(width) {
41
+ const lines = render.call(this, width);
42
+ if (!finished && relay.active.has(id)) {
43
+ let budget = 128_000;
44
+ const bounded: string[] = [];
45
+ for (const line of lines.slice(0, 500)) {
46
+ const text = line.slice(0, Math.min(16384, budget));
47
+ bounded.push(text);
48
+ budget -= text.length;
49
+ if (budget <= 0) break;
50
+ }
51
+ const frame = { id, width, lines: bounded };
52
+ if (JSON.stringify(frame) !== JSON.stringify(entry.frame)) {
53
+ const firstFrame = entry.frame.width === 0;
54
+ entry.frame = frame;
55
+ if (firstFrame) relay.flush();
56
+ else relay.scheduleFrame();
57
+ }
58
+ }
59
+ return lines;
60
+ };
61
+ const relay = this;
62
+ component.dispose = function() { remove(); dispose?.call(this); };
63
+ return component;
64
+ };
65
+ }
66
+
67
+ input(id: string, data: unknown) {
68
+ if (typeof data === "string" && data.length > 0 && data.length <= 4096) this.active.get(id)?.input(data);
69
+ }
70
+ close(id: string) { this.active.get(id)?.close(); }
71
+ /** Stop mirroring without terminating the user's local dialog. */
72
+ clear() { this.generation++; if (!this.active.size) return; this.active.clear(); this.flush(); }
73
+ private scheduleFrame() {
74
+ if (this.frameTimer) return;
75
+ this.frameTimer = setTimeout(() => this.flush(), 50);
76
+ this.frameTimer.unref();
77
+ }
78
+ private flush() {
79
+ if (this.frameTimer) clearTimeout(this.frameTimer);
80
+ this.frameTimer = undefined;
81
+ this.publish([...this.active.values()].map(entry => entry.frame));
82
+ }
83
+ }
package/src/protocol.ts CHANGED
@@ -55,7 +55,10 @@ export type SessionTelemetry = {
55
55
  providers?: ProviderUsage[];
56
56
  };
57
57
 
58
+ export type TerminalPopup = { id: string; lines: string[]; width: number };
59
+
58
60
  export type SessionSnapshot = {
61
+ popups?: TerminalPopup[];
59
62
  id: string;
60
63
  name?: string | null;
61
64
  cwd: string;
@@ -73,6 +76,8 @@ export type SessionSnapshot = {
73
76
  };
74
77
 
75
78
  export type BrowserCommand =
79
+ | { type: "popup_input"; popupId: string; data: string }
80
+ | { type: "popup_close"; popupId: string }
76
81
  | { type: "prompt"; text: string }
77
82
  | { type: "steer"; text: string }
78
83
  | { type: "abort" }