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 +7 -1
- package/package.json +1 -1
- package/src/ui-copy.ts +56 -0
- package/src/ui-render.ts +18 -5
- package/src/ui.ts +31 -7
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.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
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
|
|
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
|
|
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 >=
|
|
689
|
-
else if (inner >=
|
|
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,
|
|
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
|
|