@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
package/extensions/job/tui.ts
CHANGED
|
@@ -6,34 +6,24 @@
|
|
|
6
6
|
* computed from `Date.now()` at render, so the command can refresh the screen
|
|
7
7
|
* without rebuilding the component.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* The screen is drawn from the design system: a label-first `titleRule`, one
|
|
10
|
+
* `listRow` grammar, `emptyState`, and the shared `IdCursor` (a live job list is
|
|
11
|
+
* re-sorted under the focus) and `LinePane` (the log tail).
|
|
12
|
+
*
|
|
13
|
+
* Log text arrives already sanitized by the runtime, so no untrusted escape
|
|
14
|
+
* sequence reaches the terminal through here.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "
|
|
24
|
-
import {
|
|
25
|
-
clampScroll,
|
|
26
|
-
fitRows,
|
|
27
|
-
formatRange,
|
|
28
|
-
isCloseKey,
|
|
29
|
-
keepVisible,
|
|
30
|
-
navIntent,
|
|
31
|
-
navigateScroll,
|
|
32
|
-
selectionMarker,
|
|
33
|
-
wheelDelta,
|
|
34
|
-
} from "../../lib/list-cursor.ts";
|
|
35
|
-
import { screenHeader, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
|
|
36
|
-
import { expandHint } from "../../lib/ui.ts";
|
|
18
|
+
import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
19
|
+
import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
|
|
20
|
+
import { clip, fitCells } from "../../lib/tui/columns.ts";
|
|
21
|
+
import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "../../lib/tui/cursor.ts";
|
|
22
|
+
import { expandHint, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
23
|
+
import { LinePane } from "../../lib/tui/line-pane.ts";
|
|
24
|
+
import { emptyState, listRow } from "../../lib/tui/rows.ts";
|
|
25
|
+
import { titleRule } from "../../lib/tui/rules.ts";
|
|
26
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
37
27
|
import {
|
|
38
28
|
compareJobs,
|
|
39
29
|
elapsedMs,
|
|
@@ -42,7 +32,8 @@ import {
|
|
|
42
32
|
jobCounts,
|
|
43
33
|
sanitizeLogLine,
|
|
44
34
|
shortLabel,
|
|
45
|
-
|
|
35
|
+
statusGlyphChar,
|
|
36
|
+
statusRole,
|
|
46
37
|
type JobCounts,
|
|
47
38
|
} from "./format.ts";
|
|
48
39
|
import type { JobRecord } from "./types.ts";
|
|
@@ -50,18 +41,26 @@ import type { JobRecord } from "./types.ts";
|
|
|
50
41
|
/** Rows the `/jobs` screen body shows when the terminal height is unknown. */
|
|
51
42
|
const SCREEN_DEFAULT_ROWS = 14;
|
|
52
43
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
44
|
+
/** Fixed rows around the list body: header, summary, blank, blank, hint, blank. */
|
|
45
|
+
const LIST_CHROME_ROWS = 7;
|
|
46
|
+
|
|
47
|
+
/** Fixed rows around the log body: header, subtitle, blank, blank, hint, blank. */
|
|
48
|
+
const LOG_CHROME_ROWS = 6;
|
|
49
|
+
|
|
50
|
+
/** A job's stable identity, for the id-anchored cursor. */
|
|
51
|
+
const jobId = (job: JobRecord): string => job.id;
|
|
52
|
+
|
|
53
|
+
/** One job row: selection marker, status glyph, id, label, and a right-aligned
|
|
54
|
+
* elapsed value. `listRow` owns the clipping and the alignment. */
|
|
58
55
|
function jobRow(job: JobRecord, theme: Theme, width: number, selected = false): string {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
return listRow(theme, width, {
|
|
57
|
+
selected,
|
|
58
|
+
glyph: statusGlyphChar(job.status),
|
|
59
|
+
glyphRole: statusRole(job.status),
|
|
60
|
+
label: `${job.id} ${shortLabel(job)}`,
|
|
61
|
+
trailing: formatDuration(elapsedMs(job, Date.now())),
|
|
62
|
+
trailingRole: "faint",
|
|
63
|
+
});
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
/** Input for the transcript result: a themed job list or a log tail. */
|
|
@@ -98,22 +97,16 @@ export class JobResult implements Component {
|
|
|
98
97
|
if (this.input.kind === "list") {
|
|
99
98
|
for (const job of this.input.jobs) lines.push(jobRow(job, this.theme, w));
|
|
100
99
|
if (this.input.more > 0) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
);
|
|
100
|
+
const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
|
|
101
|
+
lines.push(clip(` ${note}`, w));
|
|
104
102
|
}
|
|
105
103
|
} else {
|
|
106
104
|
if (this.input.earlier > 0) {
|
|
107
|
-
lines.
|
|
108
|
-
|
|
109
|
-
`${this.theme.fg("dim", `… ${this.input.earlier} earlier lines`)} ${expandHint(this.theme)}`,
|
|
110
|
-
w,
|
|
111
|
-
"…",
|
|
112
|
-
),
|
|
113
|
-
);
|
|
105
|
+
const note = `${paint(this.theme, "faint", `… ${this.input.earlier} earlier lines`)} ${expandHint(this.theme)}`;
|
|
106
|
+
lines.push(clip(note, w));
|
|
114
107
|
}
|
|
115
108
|
for (const line of this.input.lines) {
|
|
116
|
-
lines.push(this.theme.fg("toolOutput",
|
|
109
|
+
lines.push(this.theme.fg("toolOutput", clip(line, w)));
|
|
117
110
|
}
|
|
118
111
|
}
|
|
119
112
|
return lines;
|
|
@@ -135,24 +128,14 @@ type PendingConfirm =
|
|
|
135
128
|
/** Dismissible `/jobs` screen: a selectable list with a focus pane and log view. */
|
|
136
129
|
export class JobListComponent implements Component {
|
|
137
130
|
private mode: "list" | "logs" = "list";
|
|
138
|
-
/**
|
|
139
|
-
private
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
private logJobId: string | undefined;
|
|
143
|
-
private logScroll = 0;
|
|
144
|
-
private logLines: string[] = [];
|
|
145
|
-
private logTitle = "";
|
|
146
|
-
/** Absolute log-file path of the open job, and whether earlier lines exist. */
|
|
147
|
-
private logPath: string | undefined;
|
|
148
|
-
private logMore = false;
|
|
149
|
-
private follow = true;
|
|
131
|
+
/** Focus anchored to a job id; the list is re-sorted from live state each render. */
|
|
132
|
+
private readonly cursor = new IdCursor();
|
|
133
|
+
/** The open log tail. */
|
|
134
|
+
private readonly pane = new LinePane();
|
|
150
135
|
/** Pending destructive action awaiting y/N. */
|
|
151
136
|
private confirm: PendingConfirm | undefined;
|
|
152
137
|
/** Transient feedback shown in place of the hint, e.g. a no-op action. */
|
|
153
138
|
private notice: string | undefined;
|
|
154
|
-
/** Rows shown at once; recomputed from the terminal height on every render. */
|
|
155
|
-
private visible = SCREEN_DEFAULT_ROWS;
|
|
156
139
|
|
|
157
140
|
constructor(
|
|
158
141
|
private readonly jobs: () => JobRecord[],
|
|
@@ -167,94 +150,47 @@ export class JobListComponent implements Component {
|
|
|
167
150
|
|
|
168
151
|
/** Replace the log pane contents (called by the command's poll loop). */
|
|
169
152
|
setLogs(title: string, lines: string[], meta: { more?: boolean; logPath?: string } = {}): void {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
this.logTitle = title;
|
|
175
|
-
this.logLines = lines;
|
|
176
|
-
if (meta.more !== undefined) this.logMore = meta.more;
|
|
177
|
-
if (meta.logPath !== undefined) this.logPath = meta.logPath;
|
|
178
|
-
if (this.follow) this.logScroll = this.maxLogScroll;
|
|
179
|
-
else this.logScroll = Math.min(this.logScroll, this.maxLogScroll);
|
|
180
|
-
// The poll fires every 500ms; skip the repaint when nothing changed so an
|
|
181
|
-
// idle log pane does not redraw (and fight the user's scroll) constantly.
|
|
182
|
-
if (titleChanged || linesChanged || moreChanged || pathChanged) this.requestRender();
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private sameLines(next: string[]): boolean {
|
|
186
|
-
if (next.length !== this.logLines.length) return false;
|
|
187
|
-
for (let i = 0; i < next.length; i++) {
|
|
188
|
-
if (next[i] !== this.logLines[i]) return false;
|
|
189
|
-
}
|
|
190
|
-
return true;
|
|
153
|
+
// The poll fires every 500ms; `LinePane.update` skips the repaint when
|
|
154
|
+
// nothing changed so an idle log pane does not redraw (and fight the
|
|
155
|
+
// user's scroll) constantly.
|
|
156
|
+
if (this.pane.update({ title, lines, more: meta.more, subtitle: meta.logPath })) this.requestRender();
|
|
191
157
|
}
|
|
192
158
|
|
|
193
159
|
/** The job whose log pane is open, or undefined in list mode. */
|
|
194
160
|
currentLogId(): string | undefined {
|
|
195
|
-
return this.mode === "logs" ? this.
|
|
161
|
+
return this.mode === "logs" && this.pane.id ? this.pane.id : undefined;
|
|
196
162
|
}
|
|
197
163
|
|
|
198
164
|
/** Re-read the open log pane from the runtime. */
|
|
199
165
|
refreshLogs(id: string): void {
|
|
200
166
|
const result = this.callbacks.logs(id);
|
|
201
|
-
if (result
|
|
167
|
+
if (result && this.pane.update({ lines: result.lines, more: result.more })) this.requestRender();
|
|
202
168
|
}
|
|
203
169
|
|
|
204
170
|
private get ordered(): JobRecord[] {
|
|
205
171
|
return [...this.jobs()].sort(compareJobs);
|
|
206
172
|
}
|
|
207
173
|
|
|
208
|
-
/**
|
|
209
|
-
private
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Scroll the list so `index` is visible. */
|
|
216
|
-
private ensureVisible(index: number): void {
|
|
217
|
-
this.listScroll = Math.max(0, keepVisible(this.listScroll, index, this.visible));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
private get maxLogScroll(): number {
|
|
221
|
-
return Math.max(0, this.logLines.length - this.visible);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private setSelection(next: number): void {
|
|
225
|
-
const ordered = this.ordered;
|
|
226
|
-
if (ordered.length === 0) return;
|
|
227
|
-
const index = Math.min(Math.max(0, next), ordered.length - 1);
|
|
228
|
-
this.selectedId = ordered[index].id;
|
|
229
|
-
this.ensureVisible(index);
|
|
174
|
+
/** Focus job `next` (clamped) and keep it visible. */
|
|
175
|
+
private select(next: number): void {
|
|
176
|
+
const jobs = this.ordered;
|
|
177
|
+
if (jobs.length === 0) return;
|
|
178
|
+
this.cursor.select(jobs, jobId, next);
|
|
230
179
|
this.requestRender();
|
|
231
180
|
}
|
|
232
181
|
|
|
233
|
-
private clampSelection(): void {
|
|
234
|
-
const ordered = this.ordered;
|
|
235
|
-
if (ordered.length === 0) {
|
|
236
|
-
this.selectedId = undefined;
|
|
237
|
-
this.listScroll = 0;
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
this.selectedId = ordered[this.indexOfSelected(ordered)].id;
|
|
241
|
-
this.listScroll = clampScroll(this.listScroll, ordered.length, this.visible);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
182
|
private openLogs(): void {
|
|
245
|
-
const job = this.ordered[this.
|
|
183
|
+
const job = this.ordered[this.cursor.indexOf(this.ordered, jobId)];
|
|
246
184
|
if (!job) return;
|
|
247
185
|
this.mode = "logs";
|
|
248
|
-
this.logJobId = job.id;
|
|
249
|
-
this.logScroll = 0;
|
|
250
|
-
this.follow = true;
|
|
251
|
-
this.logLines = [];
|
|
252
|
-
this.logTitle = `${job.id} ${shortLabel(job)}`;
|
|
253
|
-
this.logPath = job.logPath;
|
|
254
|
-
this.logMore = false;
|
|
255
186
|
const result = this.callbacks.logs(job.id);
|
|
256
|
-
|
|
257
|
-
|
|
187
|
+
this.pane.open(job.id, {
|
|
188
|
+
title: `${job.id} ${shortLabel(job)}`,
|
|
189
|
+
lines: result?.lines ?? [],
|
|
190
|
+
more: result?.more ?? false,
|
|
191
|
+
subtitle: job.logPath ? sanitizeLogLine(job.logPath) : undefined,
|
|
192
|
+
});
|
|
193
|
+
this.requestRender();
|
|
258
194
|
}
|
|
259
195
|
|
|
260
196
|
handleInput(data: string): void {
|
|
@@ -272,9 +208,10 @@ export class JobListComponent implements Component {
|
|
|
272
208
|
if (delta === undefined) return undefined;
|
|
273
209
|
if (this.confirm) return { handled: true };
|
|
274
210
|
if (this.mode === "list") {
|
|
275
|
-
this.
|
|
211
|
+
this.select(this.cursor.indexOf(this.ordered, jobId) + delta);
|
|
276
212
|
} else {
|
|
277
|
-
this.
|
|
213
|
+
this.pane.setScroll(this.pane.scroll + delta);
|
|
214
|
+
this.requestRender();
|
|
278
215
|
}
|
|
279
216
|
return { handled: true };
|
|
280
217
|
}
|
|
@@ -290,25 +227,25 @@ export class JobListComponent implements Component {
|
|
|
290
227
|
this.requestRender();
|
|
291
228
|
}
|
|
292
229
|
const ordered = this.ordered;
|
|
293
|
-
const index = this.
|
|
230
|
+
const index = this.cursor.indexOf(ordered, jobId);
|
|
294
231
|
switch (navIntent(data)) {
|
|
295
232
|
case "up":
|
|
296
|
-
this.
|
|
233
|
+
this.select(index - 1);
|
|
297
234
|
return;
|
|
298
235
|
case "down":
|
|
299
|
-
this.
|
|
236
|
+
this.select(index + 1);
|
|
300
237
|
return;
|
|
301
238
|
case "pageUp":
|
|
302
|
-
this.
|
|
239
|
+
this.select(index - this.cursor.visible);
|
|
303
240
|
return;
|
|
304
241
|
case "pageDown":
|
|
305
|
-
this.
|
|
242
|
+
this.select(index + this.cursor.visible);
|
|
306
243
|
return;
|
|
307
244
|
case "home":
|
|
308
|
-
this.
|
|
245
|
+
this.select(0);
|
|
309
246
|
return;
|
|
310
247
|
case "end":
|
|
311
|
-
this.
|
|
248
|
+
this.select(ordered.length - 1);
|
|
312
249
|
return;
|
|
313
250
|
}
|
|
314
251
|
if (matchesKey(data, Key.enter) || data === "l") this.openLogs();
|
|
@@ -346,7 +283,7 @@ export class JobListComponent implements Component {
|
|
|
346
283
|
if (pending.kind === "kill") this.callbacks.kill(pending.id);
|
|
347
284
|
else {
|
|
348
285
|
this.callbacks.clear();
|
|
349
|
-
this.
|
|
286
|
+
this.cursor.clamp(this.ordered, jobId);
|
|
350
287
|
}
|
|
351
288
|
this.requestRender();
|
|
352
289
|
return;
|
|
@@ -369,35 +306,22 @@ export class JobListComponent implements Component {
|
|
|
369
306
|
return `Clear ${pending.count} finished job${pending.count === 1 ? "" : "s"}?${note} y/N`;
|
|
370
307
|
}
|
|
371
308
|
|
|
372
|
-
private setLogScroll(next: number): void {
|
|
373
|
-
this.logScroll = clampScroll(next, this.logLines.length, this.visible);
|
|
374
|
-
// Follow the tail only while the view is parked at the bottom.
|
|
375
|
-
this.follow = this.logScroll >= this.maxLogScroll;
|
|
376
|
-
this.requestRender();
|
|
377
|
-
}
|
|
378
|
-
|
|
379
309
|
private handleLogInput(data: string): void {
|
|
380
310
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
|
|
381
311
|
this.mode = "list";
|
|
382
|
-
this.
|
|
312
|
+
this.pane.close();
|
|
383
313
|
this.requestRender();
|
|
384
314
|
return;
|
|
385
315
|
}
|
|
386
|
-
|
|
387
|
-
if (next !== undefined) {
|
|
388
|
-
this.setLogScroll(next);
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
if (data === "g") this.setLogScroll(0);
|
|
392
|
-
else if (data === "G") this.setLogScroll(this.maxLogScroll);
|
|
316
|
+
if (this.pane.handleKey(data)) this.requestRender();
|
|
393
317
|
}
|
|
394
318
|
|
|
395
319
|
private summary(counts: JobCounts, width: number): string {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
if (counts.
|
|
399
|
-
|
|
400
|
-
return
|
|
320
|
+
const cells = [paint(this.theme, "meta", `${counts.total} job${counts.total === 1 ? "" : "s"}`)];
|
|
321
|
+
if (counts.running > 0) cells.push(paint(this.theme, "meta", `${counts.running} running`));
|
|
322
|
+
if (counts.failed > 0) cells.push(paint(this.theme, "bad", `${counts.failed} failed`));
|
|
323
|
+
const separator = paint(this.theme, "meta", SEPARATORS.item);
|
|
324
|
+
return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
|
|
401
325
|
}
|
|
402
326
|
|
|
403
327
|
render(width: number): string[] {
|
|
@@ -407,80 +331,77 @@ export class JobListComponent implements Component {
|
|
|
407
331
|
|
|
408
332
|
private renderList(w: number): string[] {
|
|
409
333
|
const jobs = this.ordered;
|
|
410
|
-
this.
|
|
411
|
-
const index = this.
|
|
334
|
+
this.cursor.clamp(jobs, jobId);
|
|
335
|
+
const index = this.cursor.indexOf(jobs, jobId);
|
|
412
336
|
const focused = jobs[index];
|
|
413
337
|
const detail = focused ? formatJobDetail(focused, Date.now()) : [];
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
338
|
+
this.cursor.visible = fitRows(
|
|
339
|
+
this.viewportRowsSource,
|
|
340
|
+
jobs.length,
|
|
341
|
+
LIST_CHROME_ROWS + detail.length,
|
|
342
|
+
SCREEN_DEFAULT_ROWS,
|
|
343
|
+
);
|
|
344
|
+
this.cursor.clamp(jobs, jobId);
|
|
345
|
+
this.cursor.ensureVisible(this.cursor.indexOf(jobs, jobId));
|
|
419
346
|
|
|
420
|
-
const
|
|
347
|
+
const body: string[] = [];
|
|
348
|
+
let range: string | undefined;
|
|
421
349
|
if (jobs.length === 0) {
|
|
422
|
-
|
|
350
|
+
body.push(...emptyState(this.theme, w, { label: "No background jobs." }));
|
|
423
351
|
} else {
|
|
424
|
-
const end = Math.min(jobs.length, this.
|
|
425
|
-
for (let i = this.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
if (this.listScroll > 0 || end < jobs.length) {
|
|
429
|
-
lines.push(formatRange(this.theme, w, { start: this.listScroll, end, total: jobs.length }));
|
|
352
|
+
const end = Math.min(jobs.length, this.cursor.scroll + this.cursor.visible);
|
|
353
|
+
for (let i = this.cursor.scroll; i < end; i++) body.push(jobRow(jobs[i], this.theme, w, i === index));
|
|
354
|
+
if (this.cursor.scroll > 0 || end < jobs.length) {
|
|
355
|
+
range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: jobs.length });
|
|
430
356
|
}
|
|
431
357
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
358
|
+
|
|
359
|
+
const hint = this.confirm
|
|
360
|
+
? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
|
|
361
|
+
: this.notice
|
|
362
|
+
? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
|
|
363
|
+
: screenHint(this.theme, w, [
|
|
364
|
+
"Esc close",
|
|
365
|
+
"↑/↓ or j/k select",
|
|
366
|
+
"Enter or l logs",
|
|
367
|
+
"d or K kill",
|
|
368
|
+
"x clear finished",
|
|
369
|
+
"PgUp/PgDn",
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
return dockListFrame({
|
|
373
|
+
header: titleRule(this.theme, w, "Jobs"),
|
|
374
|
+
summary: this.summary(jobCounts(jobs), w),
|
|
375
|
+
body,
|
|
376
|
+
range,
|
|
377
|
+
// The detail pane is separated from the list by a blank line.
|
|
378
|
+
detail: ["", ...detail.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w))],
|
|
379
|
+
hint,
|
|
380
|
+
});
|
|
451
381
|
}
|
|
452
382
|
|
|
453
383
|
private renderLogs(w: number): string[] {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const parts: string[] = [];
|
|
462
|
-
if (this.logPath) parts.push(sanitizeLogLine(this.logPath));
|
|
463
|
-
if (this.logMore && this.logLines.length > 0) parts.push(`showing last ${this.logLines.length} lines`);
|
|
464
|
-
// Say whether the pane follows the tail or the user has scrolled away from it.
|
|
465
|
-
if (this.logLines.length > 0) parts.push(this.follow ? "following" : "paused");
|
|
466
|
-
const pathLine = truncateToWidth(` ${this.theme.fg("dim", parts.join(" · "))}`, w);
|
|
467
|
-
const lines: string[] = [screenHeader(this.theme, w, label), pathLine, ""];
|
|
468
|
-
if (this.logLines.length === 0) {
|
|
469
|
-
lines.push(truncateToWidth(` ${this.theme.fg("dim", "No output yet.")}`, w));
|
|
384
|
+
const visible = fitRows(this.viewportRowsSource, this.pane.count, LOG_CHROME_ROWS, SCREEN_DEFAULT_ROWS);
|
|
385
|
+
this.pane.fit(visible);
|
|
386
|
+
|
|
387
|
+
const body: string[] = [];
|
|
388
|
+
let range: string | undefined;
|
|
389
|
+
if (this.pane.count === 0) {
|
|
390
|
+
body.push(...emptyState(this.theme, w, { label: "No output yet." }));
|
|
470
391
|
} else {
|
|
471
|
-
const end =
|
|
472
|
-
for (let i =
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (this.logScroll > 0 || end < this.logLines.length) {
|
|
476
|
-
lines.push(
|
|
477
|
-
formatRange(this.theme, w, { start: this.logScroll, end, total: this.logLines.length, label: "line" }),
|
|
478
|
-
);
|
|
392
|
+
const { start, end } = this.pane.window();
|
|
393
|
+
for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
|
|
394
|
+
if (start > 0 || end < this.pane.count) {
|
|
395
|
+
range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
|
|
479
396
|
}
|
|
480
397
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
398
|
+
|
|
399
|
+
return dockListFrame({
|
|
400
|
+
header: titleRule(this.theme, w, this.pane.title("Job Logs")),
|
|
401
|
+
summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
|
|
402
|
+
body,
|
|
403
|
+
range,
|
|
404
|
+
hint: screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]),
|
|
405
|
+
});
|
|
485
406
|
}
|
|
486
407
|
}
|
package/extensions/job/ui.ts
CHANGED
|
@@ -2,27 +2,19 @@
|
|
|
2
2
|
* Presentation for the jobs extension: the footer status chips.
|
|
3
3
|
*
|
|
4
4
|
* Kept separate from the runtime so the runtime owns lifecycle (the job table,
|
|
5
|
-
* registry, clock) and this owns "how jobs are shown".
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* current mode.
|
|
5
|
+
* registry, clock) and this owns "how jobs are shown". The chips themselves come
|
|
6
|
+
* from the shared `lib/tui/status-controller.ts`, so only the vocabulary — the
|
|
7
|
+
* keys, glyphs, and counts — is job-specific.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { GLYPHS, STATUS_KEYS } from "../../lib/
|
|
11
|
+
import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
12
|
+
import { createStatusController, type StatusChip, type StatusController } from "../../lib/tui/status-controller.ts";
|
|
13
13
|
import type { JobsConfig } from "./config.ts";
|
|
14
14
|
import { pendingFailures, shortLabel } from "./format.ts";
|
|
15
15
|
import type { JobRecord } from "./types.ts";
|
|
16
16
|
|
|
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;
|
|
17
|
+
export interface UiController extends StatusController {
|
|
26
18
|
/** Notify the user that a job failed, when a UI is attached. */
|
|
27
19
|
notifyFailure(job: JobRecord): void;
|
|
28
20
|
}
|
|
@@ -34,43 +26,21 @@ export interface UiOptions {
|
|
|
34
26
|
|
|
35
27
|
export function createUiController(options: UiOptions): UiController {
|
|
36
28
|
const { getJobs, getConfig } = options;
|
|
29
|
+
// The notification also needs the context, so it is mirrored here.
|
|
37
30
|
let ctx: ExtensionContext | undefined;
|
|
38
31
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return text;
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const setStatus = (next?: ExtensionContext): void => {
|
|
48
|
-
if (next) ctx = next;
|
|
49
|
-
const target = ctx;
|
|
50
|
-
if (!target) return;
|
|
51
|
-
try {
|
|
52
|
-
if (!getConfig().showStatus) {
|
|
53
|
-
target.ui.setStatus(STATUS_KEYS.jobs, undefined);
|
|
54
|
-
target.ui.setStatus(STATUS_KEYS.jobsFailure, undefined);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
32
|
+
const controller = createStatusController({
|
|
33
|
+
keys: [STATUS_KEYS.jobs, STATUS_KEYS.jobsFailure],
|
|
34
|
+
enabled: () => getConfig().showStatus,
|
|
35
|
+
chips: (): StatusChip[] => {
|
|
57
36
|
const jobs = [...getJobs()];
|
|
58
37
|
const running = jobs.filter((job) => job.status === "running").length;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
);
|
|
66
|
-
target.ui.setStatus(
|
|
67
|
-
STATUS_KEYS.jobsFailure,
|
|
68
|
-
unseenFailures > 0 ? theme(target, "error", `${GLYPHS.jobsFailure} ${unseenFailures}`) : undefined,
|
|
69
|
-
);
|
|
70
|
-
} catch {
|
|
71
|
-
// UI may be unavailable in non-interactive modes.
|
|
72
|
-
}
|
|
73
|
-
};
|
|
38
|
+
return [
|
|
39
|
+
{ key: STATUS_KEYS.jobs, glyph: GLYPHS.jobsRunning, count: running, role: "accent" },
|
|
40
|
+
{ key: STATUS_KEYS.jobsFailure, glyph: GLYPHS.jobsFailure, count: pendingFailures(jobs).length, role: "bad" },
|
|
41
|
+
];
|
|
42
|
+
},
|
|
43
|
+
});
|
|
74
44
|
|
|
75
45
|
const notifyFailure = (job: JobRecord): void => {
|
|
76
46
|
const target = ctx;
|
|
@@ -86,14 +56,19 @@ export function createUiController(options: UiOptions): UiController {
|
|
|
86
56
|
return {
|
|
87
57
|
attach: (next) => {
|
|
88
58
|
ctx = next;
|
|
59
|
+
controller.attach(next);
|
|
89
60
|
},
|
|
90
61
|
detach: () => {
|
|
91
62
|
ctx = undefined;
|
|
63
|
+
controller.detach();
|
|
92
64
|
},
|
|
93
65
|
paint: () => {
|
|
94
|
-
|
|
66
|
+
controller.paint();
|
|
67
|
+
},
|
|
68
|
+
setStatus: (next) => {
|
|
69
|
+
if (next) ctx = next;
|
|
70
|
+
controller.setStatus(next);
|
|
95
71
|
},
|
|
96
|
-
setStatus,
|
|
97
72
|
notifyFailure,
|
|
98
73
|
};
|
|
99
74
|
}
|
|
@@ -205,7 +205,7 @@ when consolidating.
|
|
|
205
205
|
| `runtime.ts` | Session view of both stores, stamp-based refresh, trust gating, mutations. |
|
|
206
206
|
| `format.ts` | Pure call/result/notice text and the bounded injection body. |
|
|
207
207
|
| `errors.ts` | `messageOf`, the shared error-text extraction for the tool and command. |
|
|
208
|
-
| `tui.ts` | The `/memory` picker component (note list, inline editor, scope filter). |
|
|
208
|
+
| `tui.ts` | The `/memory` picker component (note list, inline editor, scope filter), drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
|
|
209
209
|
| `tools.ts` | `registerTool("memory", …)`. |
|
|
210
210
|
| `commands.ts` | `registerCommand("memory", …)`. |
|
|
211
211
|
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
14
|
-
import { reuseText, toolDetails } from "../../lib/tui.ts";
|
|
14
|
+
import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
|
|
15
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
15
16
|
import { messageOf } from "./errors.ts";
|
|
16
17
|
import { formatCallText, formatResultText, scopeLabel } from "./format.ts";
|
|
17
18
|
import type { MemoryRuntime } from "./runtime.ts";
|
|
@@ -142,7 +143,7 @@ export function registerTools(pi: ExtensionAPI, runtime: MemoryRuntime): void {
|
|
|
142
143
|
args as { action?: unknown; text?: unknown; to?: unknown; scope?: unknown },
|
|
143
144
|
context.argsComplete,
|
|
144
145
|
);
|
|
145
|
-
text.setText(theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + theme
|
|
146
|
+
text.setText(theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "meta", body));
|
|
146
147
|
return text;
|
|
147
148
|
},
|
|
148
149
|
|
|
@@ -155,7 +156,7 @@ export function registerTools(pi: ExtensionAPI, runtime: MemoryRuntime): void {
|
|
|
155
156
|
return text;
|
|
156
157
|
}
|
|
157
158
|
if (details.error) {
|
|
158
|
-
text.setText(theme
|
|
159
|
+
text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
|
|
159
160
|
return text;
|
|
160
161
|
}
|
|
161
162
|
text.setText(formatResultText(details, expanded));
|