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 +12 -3
- package/package.json +1 -1
- package/src/index.ts +14 -0
- package/src/ui-copy.ts +61 -0
- package/src/ui-render.ts +18 -5
- package/src/ui.ts +73 -13
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,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
|
-
|
|
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.
|
|
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
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
|
|
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.
|
|
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
|
-
|
|
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 >=
|
|
689
|
-
else if (inner >=
|
|
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,
|
|
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.
|
|
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…")));
|