oira666_pi-watcher 0.0.2 → 0.0.3

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.3` · **License:** MIT
6
6
 
7
7
  ## Install
8
8
 
@@ -37,6 +37,12 @@ Inside the overlay:
37
37
  - **Esc** returns to the main view without stopping either agent. Run `/watcher` again to return; conversation, draft, and scroll position are retained.
38
38
  - `/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
39
  - `/stop` cancels only the side agent and clears its queued messages.
40
+ - `/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`):
41
+ - `/copy`: last Watcher reply · `/copy 3`: Watcher #3
42
+ - `/copy me`: your last message · `/copy me 2`: You #2
43
+ - `/copy all`: the whole side conversation as Markdown
44
+
45
+ It uses Pi's clipboard support (native clipboard, `wl-copy`/`xclip`/`xsel`, or OSC 52 over SSH). Copying never sends a prompt.
40
46
  - `/help` shows controls. **Page Up/Down**, **Ctrl+Home/End**, or the mouse wheel scroll the conversation.
41
47
  - Watcher never modifies the main status bar. Reopen the overlay to see pending approvals; finished replies can trigger a notification.
42
48
 
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.3",
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/ui-copy.ts ADDED
@@ -0,0 +1,56 @@
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 { 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): CopySelection {
23
+ const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
24
+ let role: "user" | "assistant" = "assistant";
25
+ let which: "last" | "all" | number = "last";
26
+ for (const w of words) {
27
+ const n = /^#?(\d+)$/.exec(w);
28
+ if (ME.has(w)) role = "user";
29
+ else if (AGENT.has(w)) role = "assistant";
30
+ else if (w === "all") which = "all";
31
+ else if (w === "last") which = "last";
32
+ else if (n) which = Number(n[1]);
33
+ else return { ok: false, error: `Unknown /copy argument "${w}". Usage: ${COPY_USAGE}` };
34
+ }
35
+
36
+ if (which === "all") {
37
+ const numbers = messageNumbers(messages);
38
+ const parts: string[] = [];
39
+ messages.forEach((m, i) => {
40
+ const text = clean(m.text).trim();
41
+ if (m.role === "user") parts.push(`## You #${numbers[i]}\n\n${text}`);
42
+ else if (m.role === "assistant") parts.push(`## Watcher #${numbers[i]}\n\n${text}`);
43
+ else parts.push(`> ⚙ ${text.split("\n").join("\n> ")}`);
44
+ });
45
+ if (!parts.length) return { ok: false, error: "Nothing to copy: the side conversation is empty." };
46
+ return { ok: true, text: `${parts.join("\n\n")}\n`, label: "whole conversation" };
47
+ }
48
+
49
+ const own = messages.filter((m) => m.role === role);
50
+ const name = role === "user" ? "You" : "Watcher";
51
+ if (!own.length) return { ok: false, error: role === "user" ? "You have not sent any messages yet." : "No Watcher reply yet." };
52
+ const index = which === "last" ? own.length : which;
53
+ const msg = own[index - 1];
54
+ if (!msg) return { ok: false, error: `No ${name} #${index}. Available: #1–#${own.length}.` };
55
+ return { ok: true, text: clean(msg.text), label: `${name} #${index}` };
56
+ }
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,6 +106,7 @@ 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;
@@ -144,6 +149,7 @@ export class WatcherOverlay implements Component, Focusable {
144
149
  this.title = options.title ?? "Watcher";
145
150
  this.done = done;
146
151
  this.renderer = new TranscriptRenderer(() => this.theme);
152
+ this.copyText = options.copyText ?? copyToClipboard;
147
153
 
148
154
  this.editor = new Editor(tui, this.editorTheme(), { paddingX: 0 });
149
155
  if (this.store.state.draft) this.editor.setText(this.store.state.draft);
@@ -340,12 +346,13 @@ export class WatcherOverlay implements Component, Focusable {
340
346
  private onEditorSubmit(raw: string): void {
341
347
  const text = raw.trim();
342
348
  if (!text) return;
343
- const cmd = /^\/(model|stop|help)(?:\s+([\s\S]*))?$/.exec(text);
349
+ const cmd = /^\/(model|stop|help|copy)(?:\s+([\s\S]*))?$/.exec(text);
344
350
  if (cmd) {
345
351
  this.clearEditor();
346
352
  if (cmd[1] === "model") this.openPicker((cmd[2] ?? "").trim());
347
353
  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)");
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)`);
349
356
  return;
350
357
  }
351
358
  if (this.sideBusy()) {
@@ -378,6 +385,23 @@ export class WatcherOverlay implements Component, Focusable {
378
385
  this.store.setDraft("");
379
386
  }
380
387
 
388
+ private doCopy(args: string): void {
389
+ const sel = selectForCopy(this.store.state.messages, args);
390
+ if (!sel.ok) {
391
+ this.setNotice(sel.error, "warn");
392
+ return;
393
+ }
394
+ this.guarded(
395
+ async () => {
396
+ await this.copyText(sel.text);
397
+ if (!this.closed) this.setNotice(`Copied ${sel.label} (${sel.text.length.toLocaleString("en-US")} chars)`);
398
+ },
399
+ (err) => {
400
+ if (!this.closed) this.setNotice(`Copy failed: ${errMsg(err)}`, "error");
401
+ },
402
+ );
403
+ }
404
+
381
405
  private doStop(): void {
382
406
  const status = this.store.state.side.status;
383
407
  if (status !== "running") {
@@ -685,8 +709,8 @@ export class WatcherOverlay implements Component, Focusable {
685
709
  if (this.mode === "model") txt = "type filter · ↑↓ · Enter select · Esc back";
686
710
  else if (this.mode === "approval-edit") txt = "Enter send edited · Shift+Enter newline · Esc back";
687
711
  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";
712
+ else if (inner >= 76) txt = "Enter send · ⇧Enter newline · PgUp/PgDn scroll · /model · /copy · /stop · Esc close";
713
+ else if (inner >= 50) txt = "Enter send · PgUp/PgDn · /model · /copy · /stop · Esc";
690
714
  else txt = "Enter send · Esc close";
691
715
  return th.fg("dim", ` ${txt}`);
692
716
  }
@@ -698,7 +722,7 @@ export class WatcherOverlay implements Component, Focusable {
698
722
  if (this.notice) {
699
723
  const color = this.notice.level === "error" ? "error" : this.notice.level === "warn" ? "warning" : "accent";
700
724
  const { text } = sanitizeText(this.notice.text, 600);
701
- const wrapped = wrapTextWithAnsi(text, Math.max(1, inner - 3)).slice(0, 3);
725
+ const wrapped = wrapTextWithAnsi(text, Math.max(1, inner - 3)).slice(0, 5);
702
726
  wrapped.forEach((l, i) => lines.push(frame(` ${th.fg(color, i === 0 ? "! " : " ")}${th.fg(color, l)}`)));
703
727
  }
704
728