oira666_pi-watcher 0.0.2 → 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 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.2` · **License:** MIT
5
+ **Package:** `oira666_pi-watcher` · **Version:** `0.0.4` · **License:** MIT
6
6
 
7
7
  ## Install
8
8
 
@@ -23,20 +23,29 @@ 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
- These are human-facing TUI commands, not tools exposed to the main agent. They work while the main agent is running, including during compaction.
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. Run `/watcher` again to return; conversation, draft, and scroll position are retained.
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.
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`):
44
+ - `/copy`: last Watcher reply · `/copy 3`: Watcher #3
45
+ - `/copy me`: your last message · `/copy me 2`: You #2
46
+ - `/copy all`: the whole side conversation as Markdown
47
+
48
+ It uses Pi's clipboard support (native clipboard, `wl-copy`/`xclip`/`xsel`, or OSC 52 over SSH). Copying never sends a prompt.
40
49
  - `/help` shows controls. **Page Up/Down**, **Ctrl+Home/End**, or the mouse wheel scroll the conversation.
41
50
  - Watcher never modifies the main status bar. Reopen the overlay to see pending approvals; finished replies can trigger a notification.
42
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-watcher",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "A persistent side-agent conversation for observing and steering your running Pi agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `/copy` support for the watcher overlay. Terminal selection cannot be limited to the overlay
3
+ * column, so messages are copied to the system clipboard instead. Pure parsing/selection here;
4
+ * the clipboard write itself is injected (Pi's copyToClipboard by default).
5
+ */
6
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
7
+ import { messageNumbers } from "./ui-render.ts";
8
+ import type { WatcherLiveState, WatcherMessage } from "./ui-types.ts";
9
+
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
+
12
+ export type CopySelection = { ok: true; text: string; label: string } | { ok: false; error: string };
13
+
14
+ const ME = new Set(["me", "my", "mine", "you", "user"]);
15
+ const AGENT = new Set(["agent", "watcher", "assistant", "reply", "w"]);
16
+
17
+ function clean(text: string): string {
18
+ return stripTerminalSequences(text).replace(/\r\n?/g, "\n");
19
+ }
20
+
21
+ /** Resolve `/copy` arguments against the stored conversation. */
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
+ }
28
+ const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
29
+ let role: "user" | "assistant" = "assistant";
30
+ let which: "last" | "all" | number = "last";
31
+ for (const w of words) {
32
+ const n = /^#?(\d+)$/.exec(w);
33
+ if (ME.has(w)) role = "user";
34
+ else if (AGENT.has(w)) role = "assistant";
35
+ else if (w === "all") which = "all";
36
+ else if (w === "last") which = "last";
37
+ else if (n) which = Number(n[1]);
38
+ else return { ok: false, error: `Unknown /copy argument "${w}". Usage: ${COPY_USAGE}` };
39
+ }
40
+
41
+ if (which === "all") {
42
+ const numbers = messageNumbers(messages);
43
+ const parts: string[] = [];
44
+ messages.forEach((m, i) => {
45
+ const text = clean(m.text).trim();
46
+ if (m.role === "user") parts.push(`## You #${numbers[i]}\n\n${text}`);
47
+ else if (m.role === "assistant") parts.push(`## Watcher #${numbers[i]}\n\n${text}`);
48
+ else parts.push(`> ⚙ ${text.split("\n").join("\n> ")}`);
49
+ });
50
+ if (!parts.length) return { ok: false, error: "Nothing to copy: the side conversation is empty." };
51
+ return { ok: true, text: `${parts.join("\n\n")}\n`, label: "whole conversation" };
52
+ }
53
+
54
+ const own = messages.filter((m) => m.role === role);
55
+ const name = role === "user" ? "You" : "Watcher";
56
+ if (!own.length) return { ok: false, error: role === "user" ? "You have not sent any messages yet." : "No Watcher reply yet." };
57
+ const index = which === "last" ? own.length : which;
58
+ const msg = own[index - 1];
59
+ if (!msg) return { ok: false, error: `No ${name} #${index}. Available: #1–#${own.length}.` };
60
+ return { ok: true, text: clean(msg.text), label: `${name} #${index}` };
61
+ }
package/src/ui-render.ts CHANGED
@@ -28,9 +28,20 @@ function fit(lines: string[], width: number): string[] {
28
28
 
29
29
  interface MsgCacheEntry {
30
30
  width: number;
31
+ number: number;
31
32
  lines: string[];
32
33
  }
33
34
 
35
+ /**
36
+ * 1-based per-role number of each message (user and assistant counted separately; tool entries get 0).
37
+ * Shown as "You #n" / "Watcher #n" and used by `/copy`.
38
+ */
39
+ export function messageNumbers(messages: ReadonlyArray<WatcherMessage>): number[] {
40
+ let user = 0;
41
+ let assistant = 0;
42
+ return messages.map((m) => (m.role === "user" ? ++user : m.role === "assistant" ? ++assistant : 0));
43
+ }
44
+
34
45
  export class TranscriptRenderer {
35
46
  private getTheme: () => Theme;
36
47
  private msgCache = new WeakMap<WatcherMessage, MsgCacheEntry>();
@@ -67,11 +78,13 @@ export class TranscriptRenderer {
67
78
  const theme = this.getTheme();
68
79
  const lines: string[] = [];
69
80
  if (start > 0) lines.push(theme.fg("dim", truncateToWidth(`… ${start} earlier message${start === 1 ? "" : "s"} not shown`, width, "…")), "");
81
+ const numbers = messageNumbers(messages);
70
82
  for (let i = start; i < total; i++) {
71
83
  const m = messages[i]!;
84
+ const number = numbers[i]!;
72
85
  let entry = this.msgCache.get(m);
73
- if (!entry || entry.width !== width) {
74
- entry = { width, lines: this.renderMessage(m, width) };
86
+ if (!entry || entry.width !== width || entry.number !== number) {
87
+ entry = { width, number, lines: this.renderMessage(m, width, number) };
75
88
  this.msgCache.set(m, entry);
76
89
  }
77
90
  for (const l of entry.lines) lines.push(l);
@@ -80,16 +93,16 @@ export class TranscriptRenderer {
80
93
  return lines;
81
94
  }
82
95
 
83
- private renderMessage(m: WatcherMessage, width: number): string[] {
96
+ private renderMessage(m: WatcherMessage, width: number, number: number): string[] {
84
97
  const theme = this.getTheme();
85
98
  const { text, cut } = sanitizeText(m.text, WATCHER_LIMITS.maxRenderedMessageChars);
86
99
  const note = cut ? [theme.fg("dim", " … (message truncated)")] : [];
87
100
  const out: string[] = [];
88
101
  if (m.role === "user") {
89
- out.push(theme.fg("accent", theme.bold("You")));
102
+ out.push(theme.fg("accent", theme.bold("You")) + theme.fg("dim", ` #${number}`));
90
103
  for (const l of wrapTextWithAnsi(text, Math.max(1, width - 2))) out.push(` ${l}`);
91
104
  } else if (m.role === "assistant") {
92
- out.push(theme.fg("success", theme.bold("Watcher")));
105
+ out.push(theme.fg("success", theme.bold("Watcher")) + theme.fg("dim", ` #${number}`));
93
106
  const md = new Markdown(text, 1, 0, getMarkdownTheme());
94
107
  for (const l of md.render(width)) out.push(l);
95
108
  } else {
package/src/ui.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * this module renders the store, collects input, and calls WatcherController callbacks.
6
6
  * It never aborts the main agent and never performs steering itself.
7
7
  */
8
- import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { copyToClipboard, type Theme } from "@earendil-works/pi-coding-agent";
9
9
  import {
10
10
  type Component,
11
11
  Editor,
@@ -24,6 +24,7 @@ import {
24
24
  wrapTextWithAnsi,
25
25
  decodeKittyPrintable,
26
26
  } from "@earendil-works/pi-tui";
27
+ import { COPY_USAGE, selectForCopy } from "./ui-copy.ts";
27
28
  import { TranscriptRenderer, sanitizeText } from "./ui-render.ts";
28
29
  import { WatcherStore } from "./ui-store.ts";
29
30
  import {
@@ -37,7 +38,8 @@ import {
37
38
 
38
39
  export * from "./ui-types.ts";
39
40
  export { WatcherStore, normalizeMessage, type EndTurnOptions, type WatcherStoreInit } from "./ui-store.ts";
40
- export { TranscriptRenderer, sanitizeText } from "./ui-render.ts";
41
+ export { TranscriptRenderer, sanitizeText, messageNumbers } from "./ui-render.ts";
42
+ export { selectForCopy, COPY_USAGE, type CopySelection } from "./ui-copy.ts";
41
43
 
42
44
  export interface OpenWatcherUIOptions {
43
45
  store: WatcherStore;
@@ -48,6 +50,8 @@ export interface OpenWatcherUIOptions {
48
50
  signal?: AbortSignal;
49
51
  /** "model": open the side-model picker immediately (e.g. `/watcher model`). Never sends a prompt. */
50
52
  initialAction?: "model";
53
+ /** Clipboard writer used by `/copy`. Default: Pi's copyToClipboard. */
54
+ copyText?: (text: string) => Promise<void>;
51
55
  }
52
56
 
53
57
  type Mode = "chat" | "model" | "approval-edit";
@@ -102,12 +106,14 @@ export class WatcherOverlay implements Component, Focusable {
102
106
  private title: string;
103
107
  private done: (r: WatcherUIResult) => void;
104
108
  private renderer: TranscriptRenderer;
109
+ private copyText: (text: string) => Promise<void>;
105
110
 
106
111
  private _focused = false;
107
112
  private closed = false;
108
113
  private mode: Mode = "chat";
109
114
  private editor: Editor;
110
115
  private approvalEditor?: Editor;
116
+ private commandEditor?: Editor;
111
117
  private approvalId?: string;
112
118
  private approvalSel = 1; // 0 send, 1 edit (default, no accidental send), 2 cancel
113
119
 
@@ -144,6 +150,7 @@ export class WatcherOverlay implements Component, Focusable {
144
150
  this.title = options.title ?? "Watcher";
145
151
  this.done = done;
146
152
  this.renderer = new TranscriptRenderer(() => this.theme);
153
+ this.copyText = options.copyText ?? copyToClipboard;
147
154
 
148
155
  this.editor = new Editor(tui, this.editorTheme(), { paddingX: 0 });
149
156
  if (this.store.state.draft) this.editor.setText(this.store.state.draft);
@@ -175,9 +182,10 @@ export class WatcherOverlay implements Component, Focusable {
175
182
  }
176
183
 
177
184
  private syncEditorFocus(): void {
178
- const chat = this.mode === "chat" && !this.currentApproval();
185
+ const chat = !this.commandEditor && this.mode === "chat" && !this.currentApproval();
179
186
  this.editor.focused = this._focused && chat;
180
- 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;
181
189
  }
182
190
 
183
191
  // ---- lifecycle -------------------------------------------------------------------------
@@ -189,6 +197,7 @@ export class WatcherOverlay implements Component, Focusable {
189
197
  this.renderer.invalidate();
190
198
  this.editor.invalidate();
191
199
  this.approvalEditor?.invalidate();
200
+ this.commandEditor?.invalidate();
192
201
  this.pickerList?.invalidate();
193
202
  }
194
203
 
@@ -304,11 +313,19 @@ export class WatcherOverlay implements Component, Focusable {
304
313
 
305
314
  handleInput(data: string): void {
306
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
+ }
307
321
  if (this.notice) {
308
322
  this.notice = undefined;
309
323
  this.requestRender();
310
324
  }
311
- if (this.mode === "model") {
325
+ if (this.commandEditor) {
326
+ if (this.isClose(data)) this.commandEditor = undefined;
327
+ else this.commandEditor.handleInput(data);
328
+ } else if (this.mode === "model") {
312
329
  this.handlePickerInput(data);
313
330
  } else if (this.mode === "approval-edit") {
314
331
  this.handleApprovalEditInput(data);
@@ -340,12 +357,10 @@ export class WatcherOverlay implements Component, Focusable {
340
357
  private onEditorSubmit(raw: string): void {
341
358
  const text = raw.trim();
342
359
  if (!text) return;
343
- const cmd = /^\/(model|stop|help)(?:\s+([\s\S]*))?$/.exec(text);
360
+ const cmd = /^\/(model|stop|help|copy)(?:\s+([\s\S]*))?$/.exec(text);
344
361
  if (cmd) {
345
362
  this.clearEditor();
346
- if (cmd[1] === "model") this.openPicker((cmd[2] ?? "").trim());
347
- else if (cmd[1] === "stop") this.doStop();
348
- else this.setNotice("Enter send · Shift+Enter newline · PgUp/PgDn scroll · /model [filter] · /stop · Esc close (main agent untouched)");
363
+ this.runLocalCommand(cmd[1], cmd[2] ?? "");
349
364
  return;
350
365
  }
351
366
  if (this.sideBusy()) {
@@ -378,6 +393,44 @@ export class WatcherOverlay implements Component, Focusable {
378
393
  this.store.setDraft("");
379
394
  }
380
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
+
417
+ private doCopy(args: string): void {
418
+ const sel = selectForCopy(this.store.state.messages, args, this.store.state.live);
419
+ if (!sel.ok) {
420
+ this.setNotice(sel.error, "warn");
421
+ return;
422
+ }
423
+ this.guarded(
424
+ async () => {
425
+ await this.copyText(sel.text);
426
+ if (!this.closed) this.setNotice(`Copied ${sel.label} (${sel.text.length.toLocaleString("en-US")} chars)`);
427
+ },
428
+ (err) => {
429
+ if (!this.closed) this.setNotice(`Copy failed: ${errMsg(err)}`, "error");
430
+ },
431
+ );
432
+ }
433
+
381
434
  private doStop(): void {
382
435
  const status = this.store.state.side.status;
383
436
  if (status !== "running") {
@@ -525,6 +578,10 @@ export class WatcherOverlay implements Component, Focusable {
525
578
  }
526
579
 
527
580
  private handleApprovalInput(data: string): void {
581
+ if (data === "/" || decodeKittyPrintable(data) === "/") {
582
+ this.openCommandEditor();
583
+ return;
584
+ }
528
585
  const a = this.currentApproval();
529
586
  if (!a) return;
530
587
  if (this.isClose(data)) {
@@ -685,9 +742,10 @@ export class WatcherOverlay implements Component, Focusable {
685
742
  if (this.mode === "model") txt = "type filter · ↑↓ · Enter select · Esc back";
686
743
  else if (this.mode === "approval-edit") txt = "Enter send edited · Shift+Enter newline · Esc back";
687
744
  else if (this.currentApproval()) txt = "←/→ select · Enter confirm · Esc close (stays pending)";
688
- else if (inner >= 70) txt = "Enter send · ⇧Enter newline · PgUp/PgDn scroll · /model · /stop · Esc close";
689
- else if (inner >= 44) txt = "Enter send · PgUp/PgDn · /model · /stop · Esc";
745
+ else if (inner >= 76) txt = "Enter send · ⇧Enter newline · PgUp/PgDn scroll · /model · /copy · /stop · Esc close";
746
+ else if (inner >= 50) txt = "Enter send · PgUp/PgDn · /model · /copy · /stop · Esc";
690
747
  else txt = "Enter send · Esc close";
748
+ txt = `Ctrl+W close · ${txt}`;
691
749
  return th.fg("dim", ` ${txt}`);
692
750
  }
693
751
 
@@ -698,14 +756,16 @@ export class WatcherOverlay implements Component, Focusable {
698
756
  if (this.notice) {
699
757
  const color = this.notice.level === "error" ? "error" : this.notice.level === "warn" ? "warning" : "accent";
700
758
  const { text } = sanitizeText(this.notice.text, 600);
701
- const wrapped = wrapTextWithAnsi(text, Math.max(1, inner - 3)).slice(0, 3);
759
+ const wrapped = wrapTextWithAnsi(text, Math.max(1, inner - 3)).slice(0, 5);
702
760
  wrapped.forEach((l, i) => lines.push(frame(` ${th.fg(color, i === 0 ? "! " : " ")}${th.fg(color, l)}`)));
703
761
  }
704
762
 
705
763
  const approval = this.currentApproval();
706
764
  const editorCap = Math.max(3, Math.min(8, Math.floor(H * 0.35)));
707
765
 
708
- if (this.mode === "model") {
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") {
709
769
  lines.push(frame(` ${th.fg("accent", th.bold("Select side model"))} ${th.fg("dim", "(independent of main)")}`));
710
770
  lines.push(frame(` ${th.fg("muted", "filter:")} ${this.pickerFilter}${th.fg("dim", "▏")}`));
711
771
  if (this.pickerLoading) lines.push(frame(th.fg("dim", " loading models…")));