@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 +6 -2
- package/package.json +1 -1
- package/src/ask.ts +5 -4
- package/src/bridge.ts +15 -2
- package/src/popup.ts +83 -0
- package/src/protocol.ts +5 -0
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.
|
|
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
|
|
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
|
+
"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.
|
|
20
|
-
*
|
|
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" }
|