oira666_pi-watcher 0.0.3 → 0.0.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 -3
- package/package.json +1 -1
- package/src/index.ts +14 -0
- package/src/ui-copy.ts +7 -2
- package/src/ui.ts +45 -9
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A persistent side-agent conversation for your running [Pi](https://pi.dev) agent.
|
|
4
4
|
|
|
5
|
-
**Package:** `oira666_pi-watcher` · **Version:** `0.0.
|
|
5
|
+
**Package:** `oira666_pi-watcher` · **Version:** `0.0.4` · **License:** MIT
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -23,18 +23,21 @@ pi -e ./src/index.ts
|
|
|
23
23
|
|
|
24
24
|
| Command | Action |
|
|
25
25
|
| --- | --- |
|
|
26
|
+
| **Ctrl+W** | Toggle the Watcher panel without sending a prompt or stopping either agent. |
|
|
26
27
|
| `/watcher` | Create/resume the side agent and show its conversation. **Sends no prompt.** |
|
|
27
28
|
| `/watcher Why is it retrying?` | Open and send exactly that question. |
|
|
28
29
|
| `/watcher model` | Open the side agent's model picker. Does not prompt either agent. |
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
Ctrl+W replaces Pi's usual delete-word-left shortcut while Watcher is loaded.
|
|
32
|
+
|
|
33
|
+
These are human-facing TUI controls, not tools exposed to the main agent. They work while the main agent is running, including during compaction.
|
|
31
34
|
|
|
32
35
|
The panel opens immediately while the side process initializes its inherited extensions and MCPs in the background. You can type straight away; a submitted question waits for initialization and is sent once ready. `/stop` cancels a question waiting for startup. Opening without a question still sends no prompt. Saved history and the selected model appear when initialization finishes; reopening an already initialized side agent reuses it.
|
|
33
36
|
|
|
34
37
|
Inside the overlay:
|
|
35
38
|
|
|
36
39
|
- Type a question and press **Enter**. Use **Shift+Enter** for a newline.
|
|
37
|
-
- **Esc** returns to the main view without stopping either agent.
|
|
40
|
+
- **Ctrl+W** closes the panel from any Watcher view. Press it again to reopen. **Esc** also returns from chat to the main view without stopping either agent. Conversation, draft, and scroll position are retained.
|
|
38
41
|
- `/model` opens a searchable provider/model picker. Its selection affects **only the side agent** and is persisted in that side conversation. You can change models while the side agent is running, just like in Pi; switching does not abort the current reply.
|
|
39
42
|
- `/stop` cancels only the side agent and clears its queued messages.
|
|
40
43
|
- `/copy` copies to the system clipboard, because terminal mouse selection spans the full terminal width. Messages are numbered in the transcript (`You #n`, `Watcher #n`):
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -269,6 +269,20 @@ export default function watcher(pi: ExtensionAPI): void {
|
|
|
269
269
|
pi.on("session_compact", () => { compacting = false; mainStatus(); });
|
|
270
270
|
pi.on("session_compact_failed", () => { compacting = false; mainStatus(); });
|
|
271
271
|
|
|
272
|
+
pi.registerShortcut("ctrl+w", {
|
|
273
|
+
description: "Toggle the Watcher panel",
|
|
274
|
+
handler: async (ctx) => {
|
|
275
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
276
|
+
try {
|
|
277
|
+
if (side && isCurrent(side) && side.store.state.open) {
|
|
278
|
+
side.overlay?.abort();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
await show(await ensure(ctx));
|
|
282
|
+
} catch (error) { ctx.ui.notify(`Watcher: ${errorText(error)}`, "error"); }
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
272
286
|
pi.registerCommand("watcher", {
|
|
273
287
|
description: "Open the side agent; /watcher <question> to ask; /watcher model to choose its model",
|
|
274
288
|
handler: async (args, ctx) => {
|
package/src/ui-copy.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
7
7
|
import { messageNumbers } from "./ui-render.ts";
|
|
8
|
-
import type { WatcherMessage } from "./ui-types.ts";
|
|
8
|
+
import type { WatcherLiveState, WatcherMessage } from "./ui-types.ts";
|
|
9
9
|
|
|
10
10
|
export const COPY_USAGE = "/copy [me] [n|all] — e.g. /copy (last Watcher reply), /copy 3 (Watcher #3), /copy me (your last), /copy me 2, /copy all";
|
|
11
11
|
|
|
@@ -19,7 +19,12 @@ function clean(text: string): string {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/** Resolve `/copy` arguments against the stored conversation. */
|
|
22
|
-
export function selectForCopy(messages: ReadonlyArray<WatcherMessage>, args: string): CopySelection {
|
|
22
|
+
export function selectForCopy(messages: ReadonlyArray<WatcherMessage>, args: string, live?: WatcherLiveState): CopySelection {
|
|
23
|
+
// Copy a snapshot of the visible reply without committing it to history.
|
|
24
|
+
if (live?.text) {
|
|
25
|
+
const prefix = live.truncated ? "… (earlier output not retained)\n" : "";
|
|
26
|
+
messages = [...messages, { role: "assistant", text: prefix + live.text }];
|
|
27
|
+
}
|
|
23
28
|
const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
24
29
|
let role: "user" | "assistant" = "assistant";
|
|
25
30
|
let which: "last" | "all" | number = "last";
|
package/src/ui.ts
CHANGED
|
@@ -113,6 +113,7 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
113
113
|
private mode: Mode = "chat";
|
|
114
114
|
private editor: Editor;
|
|
115
115
|
private approvalEditor?: Editor;
|
|
116
|
+
private commandEditor?: Editor;
|
|
116
117
|
private approvalId?: string;
|
|
117
118
|
private approvalSel = 1; // 0 send, 1 edit (default, no accidental send), 2 cancel
|
|
118
119
|
|
|
@@ -181,9 +182,10 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
private syncEditorFocus(): void {
|
|
184
|
-
const chat = this.mode === "chat" && !this.currentApproval();
|
|
185
|
+
const chat = !this.commandEditor && this.mode === "chat" && !this.currentApproval();
|
|
185
186
|
this.editor.focused = this._focused && chat;
|
|
186
|
-
if (this.approvalEditor) this.approvalEditor.focused = this._focused && this.mode === "approval-edit";
|
|
187
|
+
if (this.approvalEditor) this.approvalEditor.focused = this._focused && !this.commandEditor && this.mode === "approval-edit";
|
|
188
|
+
if (this.commandEditor) this.commandEditor.focused = this._focused;
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
// ---- lifecycle -------------------------------------------------------------------------
|
|
@@ -195,6 +197,7 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
195
197
|
this.renderer.invalidate();
|
|
196
198
|
this.editor.invalidate();
|
|
197
199
|
this.approvalEditor?.invalidate();
|
|
200
|
+
this.commandEditor?.invalidate();
|
|
198
201
|
this.pickerList?.invalidate();
|
|
199
202
|
}
|
|
200
203
|
|
|
@@ -310,11 +313,19 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
310
313
|
|
|
311
314
|
handleInput(data: string): void {
|
|
312
315
|
if (this.closed) return;
|
|
316
|
+
// Focused overlays own input, so the main editor's shortcut cannot close us.
|
|
317
|
+
if (matchesKey(data, "ctrl+w")) {
|
|
318
|
+
this.close("closed");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
313
321
|
if (this.notice) {
|
|
314
322
|
this.notice = undefined;
|
|
315
323
|
this.requestRender();
|
|
316
324
|
}
|
|
317
|
-
if (this.
|
|
325
|
+
if (this.commandEditor) {
|
|
326
|
+
if (this.isClose(data)) this.commandEditor = undefined;
|
|
327
|
+
else this.commandEditor.handleInput(data);
|
|
328
|
+
} else if (this.mode === "model") {
|
|
318
329
|
this.handlePickerInput(data);
|
|
319
330
|
} else if (this.mode === "approval-edit") {
|
|
320
331
|
this.handleApprovalEditInput(data);
|
|
@@ -349,10 +360,7 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
349
360
|
const cmd = /^\/(model|stop|help|copy)(?:\s+([\s\S]*))?$/.exec(text);
|
|
350
361
|
if (cmd) {
|
|
351
362
|
this.clearEditor();
|
|
352
|
-
|
|
353
|
-
else if (cmd[1] === "stop") this.doStop();
|
|
354
|
-
else if (cmd[1] === "copy") this.doCopy(cmd[2] ?? "");
|
|
355
|
-
else this.setNotice(`Enter send · Shift+Enter newline · PgUp/PgDn scroll · /model [filter] · /stop · ${COPY_USAGE} · Esc close (main agent untouched)`);
|
|
363
|
+
this.runLocalCommand(cmd[1], cmd[2] ?? "");
|
|
356
364
|
return;
|
|
357
365
|
}
|
|
358
366
|
if (this.sideBusy()) {
|
|
@@ -385,8 +393,29 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
385
393
|
this.store.setDraft("");
|
|
386
394
|
}
|
|
387
395
|
|
|
396
|
+
private runLocalCommand(command: string, args: string): void {
|
|
397
|
+
if (command === "model") this.openPicker(args.trim());
|
|
398
|
+
else if (command === "stop") this.doStop();
|
|
399
|
+
else if (command === "copy") this.doCopy(args);
|
|
400
|
+
else this.setNotice(`Enter send · Shift+Enter newline · PgUp/PgDn scroll · /model [filter] · /stop · ${COPY_USAGE} · Ctrl+W / Esc close (main agent untouched)`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Allow local commands while an approval owns the normal input controls. */
|
|
404
|
+
private openCommandEditor(): void {
|
|
405
|
+
const editor = new Editor(this.tui, this.editorTheme(), { paddingX: 0 });
|
|
406
|
+
editor.setText("/");
|
|
407
|
+
editor.onSubmit = (raw) => {
|
|
408
|
+
const cmd = /^\/(model|stop|help|copy)(?:\s+([\s\S]*))?$/.exec(raw.trim());
|
|
409
|
+
if (cmd) {
|
|
410
|
+
this.commandEditor = undefined;
|
|
411
|
+
this.runLocalCommand(cmd[1], cmd[2] ?? "");
|
|
412
|
+
} else editor.setText(raw);
|
|
413
|
+
};
|
|
414
|
+
this.commandEditor = editor;
|
|
415
|
+
}
|
|
416
|
+
|
|
388
417
|
private doCopy(args: string): void {
|
|
389
|
-
const sel = selectForCopy(this.store.state.messages, args);
|
|
418
|
+
const sel = selectForCopy(this.store.state.messages, args, this.store.state.live);
|
|
390
419
|
if (!sel.ok) {
|
|
391
420
|
this.setNotice(sel.error, "warn");
|
|
392
421
|
return;
|
|
@@ -549,6 +578,10 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
549
578
|
}
|
|
550
579
|
|
|
551
580
|
private handleApprovalInput(data: string): void {
|
|
581
|
+
if (data === "/" || decodeKittyPrintable(data) === "/") {
|
|
582
|
+
this.openCommandEditor();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
552
585
|
const a = this.currentApproval();
|
|
553
586
|
if (!a) return;
|
|
554
587
|
if (this.isClose(data)) {
|
|
@@ -712,6 +745,7 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
712
745
|
else if (inner >= 76) txt = "Enter send · ⇧Enter newline · PgUp/PgDn scroll · /model · /copy · /stop · Esc close";
|
|
713
746
|
else if (inner >= 50) txt = "Enter send · PgUp/PgDn · /model · /copy · /stop · Esc";
|
|
714
747
|
else txt = "Enter send · Esc close";
|
|
748
|
+
txt = `Ctrl+W close · ${txt}`;
|
|
715
749
|
return th.fg("dim", ` ${txt}`);
|
|
716
750
|
}
|
|
717
751
|
|
|
@@ -729,7 +763,9 @@ export class WatcherOverlay implements Component, Focusable {
|
|
|
729
763
|
const approval = this.currentApproval();
|
|
730
764
|
const editorCap = Math.max(3, Math.min(8, Math.floor(H * 0.35)));
|
|
731
765
|
|
|
732
|
-
if (this.
|
|
766
|
+
if (this.commandEditor) {
|
|
767
|
+
for (const l of this.capEditorLines(this.commandEditor.render(inner), editorCap)) lines.push(frame(l));
|
|
768
|
+
} else if (this.mode === "model") {
|
|
733
769
|
lines.push(frame(` ${th.fg("accent", th.bold("Select side model"))} ${th.fg("dim", "(independent of main)")}`));
|
|
734
770
|
lines.push(frame(` ${th.fg("muted", "filter:")} ${this.pickerFilter}${th.fg("dim", "▏")}`));
|
|
735
771
|
if (this.pickerLoading) lines.push(frame(th.fg("dim", " loading models…")));
|