@gavin-hu/my-pi-agent 0.8.1 → 0.9.0
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 +8 -7
- package/README.zh-CN.md +7 -7
- package/assets/file-browse.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +49 -36
- package/extensions/bytefly/doctor/format.ts +36 -10
- package/extensions/bytefly/doctor/tools.ts +6 -5
- package/extensions/bytefly/footer/config.ts +1 -1
- package/extensions/bytefly/footer/render/format.ts +5 -18
- package/extensions/bytefly/footer/render/layout.ts +2 -1
- package/extensions/bytefly/footer/render/segments.ts +51 -78
- package/extensions/bytefly/header/component.ts +33 -16
- package/extensions/bytefly/header/config.ts +28 -18
- package/extensions/bytefly/index.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +69 -35
- package/extensions/bytefly/panel/format.ts +8 -8
- package/extensions/bytefly/panel/tui.ts +45 -70
- package/extensions/bytefly/separator/config.ts +16 -8
- package/extensions/bytefly/separator/format.ts +13 -40
- package/extensions/bytefly/separator/types.ts +0 -10
- package/extensions/document/README.md +1 -1
- package/extensions/document/render.ts +11 -10
- package/extensions/document/tool.ts +1 -1
- package/extensions/file-browse/README.md +51 -9
- package/extensions/file-browse/client.ts +103 -2
- package/extensions/file-browse/config.ts +12 -0
- package/extensions/file-browse/diff.ts +187 -0
- package/extensions/file-browse/git.ts +176 -1
- package/extensions/file-browse/html.ts +385 -19
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +33 -0
- package/extensions/file-browse/router.ts +94 -2
- package/extensions/file-browse/server.ts +4 -1
- package/extensions/goal/format.ts +11 -10
- package/extensions/goal/tools.ts +6 -5
- package/extensions/goal/tui.ts +1 -1
- package/extensions/job/README.md +2 -2
- package/extensions/job/format.ts +36 -13
- package/extensions/job/index.ts +1 -1
- package/extensions/job/tools.ts +11 -10
- package/extensions/job/tui.ts +141 -220
- package/extensions/job/ui.ts +24 -49
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/tools.ts +4 -3
- package/extensions/memory/tui.ts +32 -36
- package/extensions/plan/README.md +64 -34
- package/extensions/plan/blocks.ts +44 -2
- package/extensions/plan/feedback.ts +20 -5
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/runtime.ts +3 -2
- package/extensions/plan/tools.ts +23 -22
- package/extensions/plan/tui.ts +21 -27
- package/extensions/plan/web-client.ts +335 -30
- package/extensions/plan/web-page.ts +110 -30
- package/extensions/plan/web-router.ts +6 -4
- package/extensions/plan/web.ts +41 -10
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +12 -11
- package/extensions/question/tui.ts +132 -76
- package/extensions/rewind/README.md +1 -1
- package/extensions/rewind/format.ts +29 -1
- package/extensions/rewind/rewind-tui.ts +66 -62
- package/extensions/rewind/status.ts +3 -2
- package/extensions/subagent/render.ts +62 -47
- package/extensions/todo/README.md +2 -2
- package/extensions/todo/format.ts +31 -10
- package/extensions/todo/tools.ts +5 -4
- package/extensions/todo/tui.ts +45 -26
- package/extensions/watch/README.md +2 -2
- package/extensions/watch/format.ts +33 -13
- package/extensions/watch/index.ts +1 -1
- package/extensions/watch/tools.ts +12 -7
- package/extensions/watch/tui.ts +135 -194
- package/extensions/watch/ui.ts +24 -46
- package/extensions/web-access/fetch/tool.ts +12 -11
- package/extensions/web-access/search/tool.ts +16 -13
- package/extensions/wechat/README.md +2 -2
- package/extensions/wechat/index.ts +4 -3
- package/extensions/wechat/login-screen.ts +8 -5
- package/extensions/wechat/status-screen.ts +41 -22
- package/extensions/wechat/tools.ts +6 -5
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/index.ts +1 -1
- package/extensions/worktree/runtime.ts +1 -1
- package/lib/README.md +10 -7
- package/lib/requirements.ts +2 -2
- package/lib/{tui.ts → tui/chrome.ts} +9 -30
- package/lib/tui/columns.ts +96 -0
- package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
- package/lib/tui/gauge.ts +78 -0
- package/lib/tui/index.ts +25 -0
- package/lib/tui/line-pane.ts +173 -0
- package/lib/tui/rows.ts +109 -0
- package/lib/tui/rules.ts +88 -0
- package/lib/tui/status-controller.ts +107 -0
- package/lib/tui/tokens.ts +58 -0
- package/package.json +1 -1
- /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
12
|
-
import { reuseText, toolDetails } from "../../lib/tui.ts";
|
|
12
|
+
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
13
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
13
14
|
import {
|
|
14
15
|
compareWatches,
|
|
15
16
|
formatCallText,
|
|
@@ -209,13 +210,13 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
|
|
|
209
210
|
return text;
|
|
210
211
|
}
|
|
211
212
|
if (details.error) {
|
|
212
|
-
text.setText(theme
|
|
213
|
+
text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
|
|
213
214
|
return text;
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
if (details.action === "list" && details.watches) {
|
|
217
218
|
if (details.watches.length === 0) {
|
|
218
|
-
text.setText(theme
|
|
219
|
+
text.setText(paint(theme, "meta", "No watchers."));
|
|
219
220
|
return text;
|
|
220
221
|
}
|
|
221
222
|
const ordered = [...details.watches].sort(compareWatches);
|
|
@@ -232,7 +233,7 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
|
|
|
232
233
|
const all = raw === "" ? [] : raw.split("\n");
|
|
233
234
|
const label = details.action === "matches" ? "matches" : "output";
|
|
234
235
|
if (all.length === 0) {
|
|
235
|
-
text.setText(theme
|
|
236
|
+
text.setText(paint(theme, "meta", `No ${label} yet.`));
|
|
236
237
|
return text;
|
|
237
238
|
}
|
|
238
239
|
const shownLines = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
|
|
@@ -251,7 +252,11 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
|
|
|
251
252
|
if (details.action === "clear") {
|
|
252
253
|
const count = details.cleared ?? 0;
|
|
253
254
|
text.setText(
|
|
254
|
-
|
|
255
|
+
paint(
|
|
256
|
+
theme,
|
|
257
|
+
"meta",
|
|
258
|
+
count === 0 ? "Nothing to clear." : `Cleared ${count} watcher${count === 1 ? "" : "s"}.`,
|
|
259
|
+
),
|
|
255
260
|
);
|
|
256
261
|
return text;
|
|
257
262
|
}
|
|
@@ -259,12 +264,12 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
|
|
|
259
264
|
if (details.watch) {
|
|
260
265
|
let line = formatWatchOutcomeLine(details.watch, theme);
|
|
261
266
|
if (details.action === "stop") {
|
|
262
|
-
line += theme
|
|
267
|
+
line += paint(theme, "warn", details.stopped ? " · stopped" : " · already stopped");
|
|
263
268
|
} else if (details.action === "add") {
|
|
264
269
|
const badges: string[] = [];
|
|
265
270
|
if (details.watch.wake) badges.push("wake");
|
|
266
271
|
if (details.watch.fromStart) badges.push("from start");
|
|
267
|
-
if (badges.length > 0) line += theme
|
|
272
|
+
if (badges.length > 0) line += paint(theme, "faint", ` · ${badges.join(" · ")}`);
|
|
268
273
|
}
|
|
269
274
|
text.setText(line);
|
|
270
275
|
return text;
|
package/extensions/watch/tui.ts
CHANGED
|
@@ -3,48 +3,49 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `WatchListComponent` is the dismissible `/watch` screen with a focused detail
|
|
5
5
|
* pane and a matches view. It reads live state through injected closures and
|
|
6
|
-
* tracks the selection by id so a live re-sort cannot move the cursor
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* tracks the selection by id so a live re-sort cannot move the cursor, through
|
|
7
|
+
* the shared `IdCursor` and `LinePane`.
|
|
8
|
+
*
|
|
9
|
+
* The screen is drawn from the design system: a label-first `titleRule`, one
|
|
10
|
+
* `listRow` grammar, and `emptyState`. Matched lines arrive already sanitized by
|
|
11
|
+
* the runtime, so no untrusted escape sequence reaches the terminal here.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from "
|
|
21
|
-
import {
|
|
22
|
-
clampScroll,
|
|
23
|
-
fitRows,
|
|
24
|
-
formatRange,
|
|
25
|
-
isCloseKey,
|
|
26
|
-
keepVisible,
|
|
27
|
-
navIntent,
|
|
28
|
-
navigateScroll,
|
|
29
|
-
selectionMarker,
|
|
30
|
-
wheelDelta,
|
|
31
|
-
} from "../../lib/list-cursor.ts";
|
|
32
|
-
import { screenHeader, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
|
|
33
|
-
import { expandHint } from "../../lib/ui.ts";
|
|
34
|
-
import { compareWatches, formatWatchDetail, shortLabel, statusGlyph } from "./format.ts";
|
|
15
|
+
import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
16
|
+
import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
|
|
17
|
+
import { clip, fitCells } from "../../lib/tui/columns.ts";
|
|
18
|
+
import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "../../lib/tui/cursor.ts";
|
|
19
|
+
import { expandHint, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
20
|
+
import { LinePane } from "../../lib/tui/line-pane.ts";
|
|
21
|
+
import { emptyState, listRow } from "../../lib/tui/rows.ts";
|
|
22
|
+
import { titleRule } from "../../lib/tui/rules.ts";
|
|
23
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
24
|
+
import { compareWatches, formatWatchDetail, shortLabel, statusGlyphChar, statusRole } from "./format.ts";
|
|
35
25
|
import type { WatchRecord } from "./types.ts";
|
|
36
26
|
|
|
37
27
|
/** Rows the `/watch` screen body shows when the terminal height is unknown. */
|
|
38
28
|
const SCREEN_DEFAULT_ROWS = 14;
|
|
39
29
|
|
|
40
|
-
/**
|
|
30
|
+
/** Fixed rows around the list body: header, summary, blank, blank, hint, blank. */
|
|
31
|
+
const LIST_CHROME_ROWS = 7;
|
|
32
|
+
|
|
33
|
+
/** Fixed rows around the matches body: header, subtitle, blank, blank, hint, blank. */
|
|
34
|
+
const LINES_CHROME_ROWS = 6;
|
|
35
|
+
|
|
36
|
+
/** A watcher's stable identity, for the id-anchored cursor. */
|
|
37
|
+
const watchId = (watch: WatchRecord): string => watch.id;
|
|
38
|
+
|
|
39
|
+
/** One watcher row: marker, status glyph, id, label, and a right-aligned count. */
|
|
41
40
|
function watchRow(watch: WatchRecord, theme: Theme, width: number, selected = false): string {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
return listRow(theme, width, {
|
|
42
|
+
selected,
|
|
43
|
+
glyph: statusGlyphChar(watch.status),
|
|
44
|
+
glyphRole: statusRole(watch.status),
|
|
45
|
+
label: `${watch.id} ${shortLabel(watch)}`,
|
|
46
|
+
trailing: `${watch.matchCount}`,
|
|
47
|
+
trailingRole: "faint",
|
|
48
|
+
});
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
/** Input for the transcript result: a themed watcher list or a matched-line tail. */
|
|
@@ -81,22 +82,16 @@ export class WatchResult implements Component {
|
|
|
81
82
|
if (this.input.kind === "list") {
|
|
82
83
|
for (const watch of this.input.watches) lines.push(watchRow(watch, this.theme, w));
|
|
83
84
|
if (this.input.more > 0) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
);
|
|
85
|
+
const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
|
|
86
|
+
lines.push(clip(` ${note}`, w));
|
|
87
87
|
}
|
|
88
88
|
} else {
|
|
89
89
|
if (this.input.earlier > 0) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`${this.theme.fg("dim", `… ${this.input.earlier} earlier ${this.input.label}`)} ${expandHint(this.theme)}`,
|
|
93
|
-
w,
|
|
94
|
-
"…",
|
|
95
|
-
),
|
|
96
|
-
);
|
|
90
|
+
const note = `${paint(this.theme, "faint", `… ${this.input.earlier} earlier ${this.input.label}`)} ${expandHint(this.theme)}`;
|
|
91
|
+
lines.push(clip(note, w));
|
|
97
92
|
}
|
|
98
93
|
for (const line of this.input.lines) {
|
|
99
|
-
lines.push(this.theme.fg("toolOutput",
|
|
94
|
+
lines.push(this.theme.fg("toolOutput", clip(line, w)));
|
|
100
95
|
}
|
|
101
96
|
}
|
|
102
97
|
return lines;
|
|
@@ -116,19 +111,12 @@ type PendingConfirm = { kind: "stop"; id: string; label: string } | { kind: "cle
|
|
|
116
111
|
/** Dismissible `/watch` screen: a selectable list with a focus pane and a matches view. */
|
|
117
112
|
export class WatchListComponent implements Component {
|
|
118
113
|
private mode: "list" | "lines" = "list";
|
|
119
|
-
/**
|
|
120
|
-
private
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
private lineWatchId: string | undefined;
|
|
124
|
-
private lineScroll = 0;
|
|
125
|
-
private lineLines: string[] = [];
|
|
126
|
-
private lineTitle = "";
|
|
127
|
-
private lineMore = false;
|
|
128
|
-
private follow = true;
|
|
114
|
+
/** Focus anchored to a watcher id; the list is re-sorted from live state each render. */
|
|
115
|
+
private readonly cursor = new IdCursor();
|
|
116
|
+
/** The open matches tail. */
|
|
117
|
+
private readonly pane = new LinePane();
|
|
129
118
|
private confirm: PendingConfirm | undefined;
|
|
130
119
|
private notice: string | undefined;
|
|
131
|
-
private visible = SCREEN_DEFAULT_ROWS;
|
|
132
120
|
|
|
133
121
|
constructor(
|
|
134
122
|
private readonly watches: () => WatchRecord[],
|
|
@@ -143,81 +131,43 @@ export class WatchListComponent implements Component {
|
|
|
143
131
|
|
|
144
132
|
/** Replace the matches pane contents (called by the command's poll loop). */
|
|
145
133
|
setLines(title: string, lines: string[], meta: { more?: boolean } = {}): void {
|
|
146
|
-
|
|
147
|
-
const linesChanged = !this.sameLines(lines);
|
|
148
|
-
const moreChanged = meta.more !== undefined && meta.more !== this.lineMore;
|
|
149
|
-
this.lineTitle = title;
|
|
150
|
-
this.lineLines = lines;
|
|
151
|
-
if (meta.more !== undefined) this.lineMore = meta.more;
|
|
152
|
-
if (this.follow) this.lineScroll = this.maxLineScroll;
|
|
153
|
-
else this.lineScroll = Math.min(this.lineScroll, this.maxLineScroll);
|
|
154
|
-
if (titleChanged || linesChanged || moreChanged) this.requestRender();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
private sameLines(next: string[]): boolean {
|
|
158
|
-
if (next.length !== this.lineLines.length) return false;
|
|
159
|
-
for (let i = 0; i < next.length; i++) if (next[i] !== this.lineLines[i]) return false;
|
|
160
|
-
return true;
|
|
134
|
+
if (this.pane.update({ title, lines, more: meta.more })) this.requestRender();
|
|
161
135
|
}
|
|
162
136
|
|
|
163
137
|
/** The watcher whose matches pane is open, or undefined in list mode. */
|
|
164
138
|
currentLineId(): string | undefined {
|
|
165
|
-
return this.mode === "lines" ? this.
|
|
139
|
+
return this.mode === "lines" && this.pane.id ? this.pane.id : undefined;
|
|
166
140
|
}
|
|
167
141
|
|
|
168
142
|
/** Re-read the open matches pane from the runtime. */
|
|
169
143
|
refreshLines(id: string): void {
|
|
170
144
|
const result = this.callbacks.lines(id);
|
|
171
|
-
if (result
|
|
145
|
+
if (result && this.pane.update({ lines: result.lines, more: result.more })) this.requestRender();
|
|
172
146
|
}
|
|
173
147
|
|
|
174
148
|
private get ordered(): WatchRecord[] {
|
|
175
149
|
return [...this.watches()].sort(compareWatches);
|
|
176
150
|
}
|
|
177
151
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const index = this.selectedId ? ordered.findIndex((watch) => watch.id === this.selectedId) : -1;
|
|
181
|
-
return index >= 0 ? index : 0;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
private setSelection(next: number): void {
|
|
152
|
+
/** Focus watcher `next` (clamped) and keep it visible. */
|
|
153
|
+
private select(next: number): void {
|
|
185
154
|
const ordered = this.ordered;
|
|
186
155
|
if (ordered.length === 0) return;
|
|
187
|
-
|
|
188
|
-
this.selectedId = ordered[index].id;
|
|
189
|
-
this.listScroll = Math.max(0, keepVisible(this.listScroll, index, this.visible));
|
|
156
|
+
this.cursor.select(ordered, watchId, next);
|
|
190
157
|
this.requestRender();
|
|
191
158
|
}
|
|
192
159
|
|
|
193
|
-
private clampSelection(): void {
|
|
194
|
-
const ordered = this.ordered;
|
|
195
|
-
if (ordered.length === 0) {
|
|
196
|
-
this.selectedId = undefined;
|
|
197
|
-
this.listScroll = 0;
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
this.selectedId = ordered[this.indexOfSelected(ordered)].id;
|
|
201
|
-
this.listScroll = clampScroll(this.listScroll, ordered.length, this.visible);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
private get maxLineScroll(): number {
|
|
205
|
-
return Math.max(0, this.lineLines.length - this.visible);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
160
|
private openLines(): void {
|
|
209
|
-
const watch = this.ordered[this.
|
|
161
|
+
const watch = this.ordered[this.cursor.indexOf(this.ordered, watchId)];
|
|
210
162
|
if (!watch) return;
|
|
211
163
|
this.mode = "lines";
|
|
212
|
-
this.lineWatchId = watch.id;
|
|
213
|
-
this.lineScroll = 0;
|
|
214
|
-
this.follow = true;
|
|
215
|
-
this.lineLines = [];
|
|
216
|
-
this.lineTitle = `${watch.id} ${shortLabel(watch)}`;
|
|
217
|
-
this.lineMore = false;
|
|
218
164
|
const result = this.callbacks.lines(watch.id);
|
|
219
|
-
|
|
220
|
-
|
|
165
|
+
this.pane.open(watch.id, {
|
|
166
|
+
title: `${watch.id} ${shortLabel(watch)}`,
|
|
167
|
+
lines: result?.lines ?? [],
|
|
168
|
+
more: result?.more ?? false,
|
|
169
|
+
});
|
|
170
|
+
this.requestRender();
|
|
221
171
|
}
|
|
222
172
|
|
|
223
173
|
handleInput(data: string): void {
|
|
@@ -233,8 +183,12 @@ export class WatchListComponent implements Component {
|
|
|
233
183
|
const delta = wheelDelta(event);
|
|
234
184
|
if (delta === undefined) return undefined;
|
|
235
185
|
if (this.confirm) return { handled: true };
|
|
236
|
-
if (this.mode === "list")
|
|
237
|
-
|
|
186
|
+
if (this.mode === "list") {
|
|
187
|
+
this.select(this.cursor.indexOf(this.ordered, watchId) + delta);
|
|
188
|
+
} else {
|
|
189
|
+
this.pane.setScroll(this.pane.scroll + delta);
|
|
190
|
+
this.requestRender();
|
|
191
|
+
}
|
|
238
192
|
return { handled: true };
|
|
239
193
|
}
|
|
240
194
|
|
|
@@ -248,25 +202,25 @@ export class WatchListComponent implements Component {
|
|
|
248
202
|
this.requestRender();
|
|
249
203
|
}
|
|
250
204
|
const ordered = this.ordered;
|
|
251
|
-
const index = this.
|
|
205
|
+
const index = this.cursor.indexOf(ordered, watchId);
|
|
252
206
|
switch (navIntent(data)) {
|
|
253
207
|
case "up":
|
|
254
|
-
this.
|
|
208
|
+
this.select(index - 1);
|
|
255
209
|
return;
|
|
256
210
|
case "down":
|
|
257
|
-
this.
|
|
211
|
+
this.select(index + 1);
|
|
258
212
|
return;
|
|
259
213
|
case "pageUp":
|
|
260
|
-
this.
|
|
214
|
+
this.select(index - this.cursor.visible);
|
|
261
215
|
return;
|
|
262
216
|
case "pageDown":
|
|
263
|
-
this.
|
|
217
|
+
this.select(index + this.cursor.visible);
|
|
264
218
|
return;
|
|
265
219
|
case "home":
|
|
266
|
-
this.
|
|
220
|
+
this.select(0);
|
|
267
221
|
return;
|
|
268
222
|
case "end":
|
|
269
|
-
this.
|
|
223
|
+
this.select(ordered.length - 1);
|
|
270
224
|
return;
|
|
271
225
|
}
|
|
272
226
|
if (matchesKey(data, Key.enter) || data === "l") {
|
|
@@ -295,7 +249,7 @@ export class WatchListComponent implements Component {
|
|
|
295
249
|
this.confirm = undefined;
|
|
296
250
|
if (pending.kind === "stop") this.callbacks.stop(pending.id);
|
|
297
251
|
else this.callbacks.clear();
|
|
298
|
-
this.
|
|
252
|
+
this.cursor.clamp(this.ordered, watchId);
|
|
299
253
|
this.requestRender();
|
|
300
254
|
return;
|
|
301
255
|
}
|
|
@@ -312,36 +266,24 @@ export class WatchListComponent implements Component {
|
|
|
312
266
|
return `Clear ${pending.count} stopped watcher${pending.count === 1 ? "" : "s"}? y/N`;
|
|
313
267
|
}
|
|
314
268
|
|
|
315
|
-
private setLineScroll(next: number): void {
|
|
316
|
-
this.lineScroll = clampScroll(next, this.lineLines.length, this.visible);
|
|
317
|
-
this.follow = this.lineScroll >= this.maxLineScroll;
|
|
318
|
-
this.requestRender();
|
|
319
|
-
}
|
|
320
|
-
|
|
321
269
|
private handleLinesInput(data: string): void {
|
|
322
270
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
|
|
323
271
|
this.mode = "list";
|
|
324
|
-
this.
|
|
272
|
+
this.pane.close();
|
|
325
273
|
this.requestRender();
|
|
326
274
|
return;
|
|
327
275
|
}
|
|
328
|
-
|
|
329
|
-
if (next !== undefined) {
|
|
330
|
-
this.setLineScroll(next);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
if (data === "g") this.setLineScroll(0);
|
|
334
|
-
else if (data === "G") this.setLineScroll(this.maxLineScroll);
|
|
276
|
+
if (this.pane.handleKey(data)) this.requestRender();
|
|
335
277
|
}
|
|
336
278
|
|
|
337
279
|
private summary(watches: WatchRecord[], width: number): string {
|
|
338
|
-
const
|
|
339
|
-
let text = this.theme.fg("muted", `${watches.length} watcher${watches.length === 1 ? "" : "s"}`);
|
|
280
|
+
const cells = [paint(this.theme, "meta", `${watches.length} watcher${watches.length === 1 ? "" : "s"}`)];
|
|
340
281
|
const running = watches.filter((watch) => watch.status === "running").length;
|
|
341
282
|
const matched = watches.reduce((sum, watch) => sum + watch.matchCount, 0);
|
|
342
|
-
if (running > 0)
|
|
343
|
-
if (matched > 0)
|
|
344
|
-
|
|
283
|
+
if (running > 0) cells.push(paint(this.theme, "meta", `${running} running`));
|
|
284
|
+
if (matched > 0) cells.push(paint(this.theme, "accent", `${matched} matches`));
|
|
285
|
+
const separator = paint(this.theme, "meta", SEPARATORS.item);
|
|
286
|
+
return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
|
|
345
287
|
}
|
|
346
288
|
|
|
347
289
|
render(width: number): string[] {
|
|
@@ -351,77 +293,76 @@ export class WatchListComponent implements Component {
|
|
|
351
293
|
|
|
352
294
|
private renderList(w: number): string[] {
|
|
353
295
|
const watches = this.ordered;
|
|
354
|
-
this.
|
|
355
|
-
const index = this.
|
|
296
|
+
this.cursor.clamp(watches, watchId);
|
|
297
|
+
const index = this.cursor.indexOf(watches, watchId);
|
|
356
298
|
const focused = watches[index];
|
|
357
299
|
const detail = focused ? formatWatchDetail(focused, Date.now()) : [];
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
300
|
+
this.cursor.visible = fitRows(
|
|
301
|
+
this.viewportRowsSource,
|
|
302
|
+
watches.length,
|
|
303
|
+
LIST_CHROME_ROWS + detail.length,
|
|
304
|
+
SCREEN_DEFAULT_ROWS,
|
|
305
|
+
);
|
|
306
|
+
this.cursor.clamp(watches, watchId);
|
|
362
307
|
|
|
363
|
-
const
|
|
308
|
+
const body: string[] = [];
|
|
309
|
+
let range: string | undefined;
|
|
364
310
|
if (watches.length === 0) {
|
|
365
|
-
|
|
311
|
+
body.push(...emptyState(this.theme, w, { label: "No watchers." }));
|
|
366
312
|
} else {
|
|
367
|
-
const end = Math.min(watches.length, this.
|
|
368
|
-
for (let i = this.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (this.listScroll > 0 || end < watches.length) {
|
|
372
|
-
lines.push(formatRange(this.theme, w, { start: this.listScroll, end, total: watches.length }));
|
|
313
|
+
const end = Math.min(watches.length, this.cursor.scroll + this.cursor.visible);
|
|
314
|
+
for (let i = this.cursor.scroll; i < end; i++) body.push(watchRow(watches[i], this.theme, w, i === index));
|
|
315
|
+
if (this.cursor.scroll > 0 || end < watches.length) {
|
|
316
|
+
range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: watches.length });
|
|
373
317
|
}
|
|
374
318
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
319
|
+
|
|
320
|
+
const hint = this.confirm
|
|
321
|
+
? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
|
|
322
|
+
: this.notice
|
|
323
|
+
? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
|
|
324
|
+
: screenHint(this.theme, w, [
|
|
325
|
+
"Esc close",
|
|
326
|
+
"↑/↓ or j/k select",
|
|
327
|
+
"Enter or l matches",
|
|
328
|
+
"x or K stop",
|
|
329
|
+
"d clear stopped",
|
|
330
|
+
"PgUp/PgDn",
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
return dockListFrame({
|
|
334
|
+
header: titleRule(this.theme, w, "Watch"),
|
|
335
|
+
summary: this.summary(watches, w),
|
|
336
|
+
body,
|
|
337
|
+
range,
|
|
338
|
+
// The detail pane is separated from the list by a blank line.
|
|
339
|
+
detail: ["", ...detail.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w))],
|
|
340
|
+
hint,
|
|
341
|
+
});
|
|
394
342
|
}
|
|
395
343
|
|
|
396
344
|
private renderLines(w: number): string[] {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const parts: string[] = [];
|
|
405
|
-
if (this.lineMore && this.lineLines.length > 0) parts.push(`showing last ${this.lineLines.length} lines`);
|
|
406
|
-
if (this.lineLines.length > 0) parts.push(this.follow ? "following" : "paused");
|
|
407
|
-
const titleLine = truncateToWidth(` ${this.theme.fg("dim", parts.join(" · "))}`, w);
|
|
408
|
-
const lines: string[] = [screenHeader(this.theme, w, label), titleLine, ""];
|
|
409
|
-
if (this.lineLines.length === 0) {
|
|
410
|
-
lines.push(truncateToWidth(` ${this.theme.fg("dim", "No matches yet.")}`, w));
|
|
345
|
+
const visible = fitRows(this.viewportRowsSource, this.pane.count, LINES_CHROME_ROWS, SCREEN_DEFAULT_ROWS);
|
|
346
|
+
this.pane.fit(visible);
|
|
347
|
+
|
|
348
|
+
const body: string[] = [];
|
|
349
|
+
let range: string | undefined;
|
|
350
|
+
if (this.pane.count === 0) {
|
|
351
|
+
body.push(...emptyState(this.theme, w, { label: "No matches yet." }));
|
|
411
352
|
} else {
|
|
412
|
-
const end =
|
|
413
|
-
for (let i =
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (this.lineScroll > 0 || end < this.lineLines.length) {
|
|
417
|
-
lines.push(
|
|
418
|
-
formatRange(this.theme, w, { start: this.lineScroll, end, total: this.lineLines.length, label: "line" }),
|
|
419
|
-
);
|
|
353
|
+
const { start, end } = this.pane.window();
|
|
354
|
+
for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
|
|
355
|
+
if (start > 0 || end < this.pane.count) {
|
|
356
|
+
range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
|
|
420
357
|
}
|
|
421
358
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
359
|
+
|
|
360
|
+
return dockListFrame({
|
|
361
|
+
header: titleRule(this.theme, w, this.pane.title("Watch Matches")),
|
|
362
|
+
summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
|
|
363
|
+
body,
|
|
364
|
+
range,
|
|
365
|
+
hint: screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]),
|
|
366
|
+
});
|
|
426
367
|
}
|
|
427
368
|
}
|
package/extensions/watch/ui.ts
CHANGED
|
@@ -3,26 +3,19 @@
|
|
|
3
3
|
* alert.
|
|
4
4
|
*
|
|
5
5
|
* Kept separate from the runtime so the runtime owns lifecycle (the watcher
|
|
6
|
-
* table, the tail clock) and this owns "how watchers are shown".
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
6
|
+
* table, the tail clock) and this owns "how watchers are shown". The chips come
|
|
7
|
+
* from the shared `lib/tui/status-controller.ts`, so only the vocabulary — the
|
|
8
|
+
* keys, glyphs, and counts — is watch-specific.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { GLYPHS, STATUS_KEYS } from "../../lib/
|
|
12
|
+
import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
13
|
+
import { createStatusController, type StatusChip, type StatusController } from "../../lib/tui/status-controller.ts";
|
|
13
14
|
import type { WatchConfig } from "./config.ts";
|
|
14
15
|
import { shortLabel } from "./format.ts";
|
|
15
16
|
import type { WatchRecord } from "./types.ts";
|
|
16
17
|
|
|
17
|
-
export interface UiController {
|
|
18
|
-
/** Remember the live context; later calls use it when no context is passed. */
|
|
19
|
-
attach(ctx: ExtensionContext): void;
|
|
20
|
-
/** Forget the context, for example on shutdown or reload. */
|
|
21
|
-
detach(): void;
|
|
22
|
-
/** Repaint the chips using the attached context. */
|
|
23
|
-
paint(): void;
|
|
24
|
-
/** Publish the footer chips (and attach `ctx` when given). */
|
|
25
|
-
setStatus(ctx?: ExtensionContext): void;
|
|
18
|
+
export interface UiController extends StatusController {
|
|
26
19
|
/** Alert the user to a new match; called once per unreported burst. */
|
|
27
20
|
notifyMatch(watch: WatchRecord, line: string): void;
|
|
28
21
|
}
|
|
@@ -35,41 +28,21 @@ export interface UiOptions {
|
|
|
35
28
|
|
|
36
29
|
export function createUiController(options: UiOptions): UiController {
|
|
37
30
|
const { getWatches, getUnseenMatches, getConfig } = options;
|
|
31
|
+
// The match alert also needs the context, so it is mirrored here.
|
|
38
32
|
let ctx: ExtensionContext | undefined;
|
|
39
33
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return text;
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const setStatus = (next?: ExtensionContext): void => {
|
|
49
|
-
if (next) ctx = next;
|
|
50
|
-
const target = ctx;
|
|
51
|
-
if (!target) return;
|
|
52
|
-
try {
|
|
53
|
-
if (!getConfig().showStatus) {
|
|
54
|
-
target.ui.setStatus(STATUS_KEYS.watch, undefined);
|
|
55
|
-
target.ui.setStatus(STATUS_KEYS.watchMatches, undefined);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
34
|
+
const controller = createStatusController({
|
|
35
|
+
keys: [STATUS_KEYS.watch, STATUS_KEYS.watchMatches],
|
|
36
|
+
enabled: () => getConfig().showStatus,
|
|
37
|
+
chips: (): StatusChip[] => {
|
|
58
38
|
let running = 0;
|
|
59
39
|
for (const watch of getWatches()) if (watch.status === "running") running++;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
STATUS_KEYS.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
STATUS_KEYS.watchMatches,
|
|
67
|
-
unseen > 0 ? theme(target, "error", `${GLYPHS.warn} ${unseen}`) : undefined,
|
|
68
|
-
);
|
|
69
|
-
} catch {
|
|
70
|
-
// UI may be unavailable in non-interactive modes.
|
|
71
|
-
}
|
|
72
|
-
};
|
|
40
|
+
return [
|
|
41
|
+
{ key: STATUS_KEYS.watch, glyph: GLYPHS.target, count: running, role: "accent" },
|
|
42
|
+
{ key: STATUS_KEYS.watchMatches, glyph: GLYPHS.warn, count: getUnseenMatches(), role: "bad" },
|
|
43
|
+
];
|
|
44
|
+
},
|
|
45
|
+
});
|
|
73
46
|
|
|
74
47
|
const notifyMatch = (watch: WatchRecord, line: string): void => {
|
|
75
48
|
const target = ctx;
|
|
@@ -84,14 +57,19 @@ export function createUiController(options: UiOptions): UiController {
|
|
|
84
57
|
return {
|
|
85
58
|
attach: (next) => {
|
|
86
59
|
ctx = next;
|
|
60
|
+
controller.attach(next);
|
|
87
61
|
},
|
|
88
62
|
detach: () => {
|
|
89
63
|
ctx = undefined;
|
|
64
|
+
controller.detach();
|
|
90
65
|
},
|
|
91
66
|
paint: () => {
|
|
92
|
-
|
|
67
|
+
controller.paint();
|
|
68
|
+
},
|
|
69
|
+
setStatus: (next) => {
|
|
70
|
+
if (next) ctx = next;
|
|
71
|
+
controller.setStatus(next);
|
|
93
72
|
},
|
|
94
|
-
setStatus,
|
|
95
73
|
notifyMatch,
|
|
96
74
|
};
|
|
97
75
|
}
|