@gavin-hu/my-pi-agent 0.8.0 → 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 +9 -8
- package/README.zh-CN.md +8 -8
- 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 +62 -14
- 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 +412 -23
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +98 -5
- 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 +2 -2
- /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
* it (`/rewind`, `/todos`, `/jobs`).
|
|
4
4
|
*
|
|
5
5
|
* Each screen keeps its own layout, rows, and selection model — rewind uses an
|
|
6
|
-
* index, jobs use a stable id, todos have no cursor at all — but
|
|
6
|
+
* index, jobs and watchers use a stable id, todos have no cursor at all — but
|
|
7
7
|
* they all need the same arithmetic: how many rows fit, how to keep the focus
|
|
8
8
|
* in view, which keys navigate, and how to render the "showing X–Y of N" row.
|
|
9
9
|
* That shared part lives here so a fix or a rebinding happens once.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* `ListCursor` tracks an index, which is right for a list whose shape is the
|
|
12
|
+
* screen's own. `IdCursor` tracks a stable id, which is right for a list that
|
|
13
|
+
* is re-sorted from live state on every render.
|
|
14
|
+
*
|
|
15
|
+
* No runtime dependencies beyond `lib/tui/chrome.ts` and `@earendil-works/pi-tui`; the
|
|
12
16
|
* theme is passed in and there is no cross-extension state.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
20
|
import { Key, matchesKey, truncateToWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
17
|
-
import { viewportRows, type ViewportRowsSource } from "./
|
|
21
|
+
import { viewportRows, type ViewportRowsSource } from "./chrome.ts";
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Rows that fit for a list of `itemCount`, reserving one extra line when the
|
|
@@ -147,6 +151,55 @@ export class ListCursor {
|
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Cursor and scroll for a list whose order changes under it.
|
|
156
|
+
*
|
|
157
|
+
* A live list (jobs, watchers) is re-read and re-sorted on every render, so an
|
|
158
|
+
* index would silently point at a different row. This cursor remembers the id
|
|
159
|
+
* of the focused row and re-derives its index against the current items, falling
|
|
160
|
+
* back to the first row when that id is gone. It is pure state: the screen owns
|
|
161
|
+
* `requestRender`, and assigns `visible` from {@link fitRows} each render.
|
|
162
|
+
*/
|
|
163
|
+
export class IdCursor {
|
|
164
|
+
/** Id of the focused row, or `undefined` before the first paint. */
|
|
165
|
+
id: string | undefined;
|
|
166
|
+
/** First visible row. */
|
|
167
|
+
scroll = 0;
|
|
168
|
+
/** Rows shown at once; the screen sets this before use. */
|
|
169
|
+
visible = 1;
|
|
170
|
+
|
|
171
|
+
/** Index of the focused row, or `0` when its id is gone or the list is empty. */
|
|
172
|
+
indexOf<T>(items: readonly T[], key: (item: T) => string): number {
|
|
173
|
+
if (items.length === 0) return 0;
|
|
174
|
+
const index = this.id === undefined ? -1 : items.findIndex((item) => key(item) === this.id);
|
|
175
|
+
return index >= 0 ? index : 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Re-anchor the id against the current items; never repaints. */
|
|
179
|
+
clamp<T>(items: readonly T[], key: (item: T) => string): void {
|
|
180
|
+
if (items.length === 0) {
|
|
181
|
+
this.id = undefined;
|
|
182
|
+
this.scroll = 0;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.id = key(items[this.indexOf(items, key)]);
|
|
186
|
+
this.scroll = clampScroll(this.scroll, items.length, this.visible);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Focus the row at `index` (clamped) and scroll it into view. */
|
|
190
|
+
select<T>(items: readonly T[], key: (item: T) => string, index: number): void {
|
|
191
|
+
if (items.length === 0) return;
|
|
192
|
+
const clamped = Math.min(Math.max(0, index), items.length - 1);
|
|
193
|
+
this.id = key(items[clamped]);
|
|
194
|
+
this.ensureVisible(clamped);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Scroll `index` into view without moving the focus. */
|
|
198
|
+
ensureVisible(index: number): void {
|
|
199
|
+
this.scroll = Math.max(0, keepVisible(this.scroll, index, this.visible));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
150
203
|
/**
|
|
151
204
|
* Apply a standard navigation key to `cursor`, reporting whether it handled it.
|
|
152
205
|
*
|
package/lib/tui/gauge.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gauge: filled and empty blocks, and the tone they carry.
|
|
3
|
+
*
|
|
4
|
+
* One implementation for every bar in the package — the footer's context
|
|
5
|
+
* meter, a plan's scroll position — so the block glyphs, the floor, and the
|
|
6
|
+
* tone thresholds cannot drift between surfaces.
|
|
7
|
+
*
|
|
8
|
+
* Value-only: pure functions, theme passed in.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { GLYPHS } from "./glyphs.ts";
|
|
13
|
+
import { paint, type TuiRole } from "./tokens.ts";
|
|
14
|
+
|
|
15
|
+
/** The percentages at which a gauge changes tone. */
|
|
16
|
+
export interface GaugeThresholds {
|
|
17
|
+
/** Above this (exclusive), the gauge warns. */
|
|
18
|
+
warn: number;
|
|
19
|
+
/** Above this (exclusive), the gauge is in error. */
|
|
20
|
+
danger: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The package default: warn past 70%, error past 90%. */
|
|
24
|
+
export const DEFAULT_GAUGE_THRESHOLDS: GaugeThresholds = { warn: 70, danger: 90 };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Filled and empty block counts for a percentage across `blocks` cells.
|
|
28
|
+
*
|
|
29
|
+
* The fill floors so the bar reads full only at 100%, and forces one block for
|
|
30
|
+
* any non-zero usage so a small percentage is still visible. A `null` percent
|
|
31
|
+
* (unknown) fills nothing, leaving the caller to decide whether to draw the
|
|
32
|
+
* empty track; a known `0` draws it.
|
|
33
|
+
*/
|
|
34
|
+
export function computeGauge(percent: number | null, blocks: number): { filled: number; empty: number } {
|
|
35
|
+
if (blocks <= 0) return { filled: 0, empty: 0 };
|
|
36
|
+
if (percent === null) return { filled: 0, empty: blocks };
|
|
37
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
38
|
+
const filled = Math.max(clamped > 0 ? 1 : 0, Math.floor((clamped / 100) * blocks));
|
|
39
|
+
return { filled, empty: blocks - filled };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The role a context percentage is drawn in. Neutral until it crosses a threshold. */
|
|
43
|
+
export function gaugeRole(percent: number | null, thresholds: GaugeThresholds = DEFAULT_GAUGE_THRESHOLDS): TuiRole {
|
|
44
|
+
if (percent === null) return "meta";
|
|
45
|
+
if (percent > thresholds.danger) return "bad";
|
|
46
|
+
if (percent > thresholds.warn) return "warn";
|
|
47
|
+
return "meta";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A gauge bar's content. */
|
|
51
|
+
export interface GaugeBarOptions {
|
|
52
|
+
/** The percentage, or `null` when it is unknown. */
|
|
53
|
+
percent: number | null;
|
|
54
|
+
/** Cells the track occupies. */
|
|
55
|
+
blocks: number;
|
|
56
|
+
/** Override the role instead of deriving it from the thresholds. */
|
|
57
|
+
role?: TuiRole;
|
|
58
|
+
/** Role for the empty track. Defaults to the filled role. */
|
|
59
|
+
emptyRole?: TuiRole;
|
|
60
|
+
thresholds?: GaugeThresholds;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The colored bar, or `""` when the percentage is unknown.
|
|
65
|
+
*
|
|
66
|
+
* Unknown and zero stay distinct: a known `0%` still draws the empty track so
|
|
67
|
+
* the column does not jump, while an unknown value draws nothing and leaves the
|
|
68
|
+
* caller to print a muted `?` instead.
|
|
69
|
+
*/
|
|
70
|
+
export function gaugeBar(theme: Theme, options: GaugeBarOptions): string {
|
|
71
|
+
if (options.percent === null) return "";
|
|
72
|
+
const { filled, empty } = computeGauge(options.percent, options.blocks);
|
|
73
|
+
const role = options.role ?? gaugeRole(options.percent, options.thresholds);
|
|
74
|
+
const emptyRole = options.emptyRole ?? role;
|
|
75
|
+
const track = filled > 0 ? paint(theme, role, GLYPHS.gaugeFull.repeat(filled)) : "";
|
|
76
|
+
const rest = empty > 0 ? paint(theme, emptyRole, GLYPHS.gaugeEmpty.repeat(empty)) : "";
|
|
77
|
+
return track + rest;
|
|
78
|
+
}
|
package/lib/tui/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Bytefly terminal design system: one kit for every interactive surface.
|
|
3
|
+
*
|
|
4
|
+
* Import from `lib/tui/index.ts` rather than a leaf module so the surface reads
|
|
5
|
+
* as one system. The leaves: `tokens.ts` (semantic role -> theme token),
|
|
6
|
+
* `glyphs.ts` (the shared glyph, separator, and status-key vocabulary),
|
|
7
|
+
* `chrome.ts` (screen frame, hints, viewport, overlay), `cursor.ts` (list
|
|
8
|
+
* scroll and navigation), `columns.ts` (width math), `rules.ts` (the rule
|
|
9
|
+
* grammar), `rows.ts` (list rows, checkboxes, chips, empty states), `gauge.ts`
|
|
10
|
+
* (the context gauge), and `line-pane.ts` (the scrollable log/match pane).
|
|
11
|
+
*
|
|
12
|
+
* Value-only, like every `lib/` module: pure functions, constants, and classes,
|
|
13
|
+
* with no module-level state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export * from "./chrome.ts";
|
|
17
|
+
export * from "./columns.ts";
|
|
18
|
+
export * from "./cursor.ts";
|
|
19
|
+
export * from "./gauge.ts";
|
|
20
|
+
export * from "./glyphs.ts";
|
|
21
|
+
export * from "./line-pane.ts";
|
|
22
|
+
export * from "./rows.ts";
|
|
23
|
+
export * from "./rules.ts";
|
|
24
|
+
export * from "./status-controller.ts";
|
|
25
|
+
export * from "./tokens.ts";
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scrollable text pane: the log tail a job screen opens, the match list a
|
|
3
|
+
* watcher screen opens.
|
|
4
|
+
*
|
|
5
|
+
* Both screens embed the same pane — an open row's id, a buffer of already
|
|
6
|
+
* sanitized lines, a scroll offset that follows the tail until the user scrolls
|
|
7
|
+
* away from it, a "showing last N" subtitle, and one dim range row — so the
|
|
8
|
+
* state machine lives here once. The screen owns rendering, the visible row
|
|
9
|
+
* budget, and `requestRender`; the pane owns only the state and the keys that
|
|
10
|
+
* move it.
|
|
11
|
+
*
|
|
12
|
+
* Value-only: a class with instance state, no module-level state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { clampScroll, navigateScroll } from "./cursor.ts";
|
|
16
|
+
|
|
17
|
+
/** A patch for the pane's contents; each field given replaces that field. */
|
|
18
|
+
export interface PaneSource {
|
|
19
|
+
/** Title shown after the pane's prefix, e.g. `j1 build`. */
|
|
20
|
+
title?: string;
|
|
21
|
+
/** The lines, newest last. */
|
|
22
|
+
lines?: string[];
|
|
23
|
+
/** The dim subtitle line, e.g. the log file path. Already sanitized. */
|
|
24
|
+
subtitle?: string;
|
|
25
|
+
/** Whether earlier lines exist beyond the loaded window. */
|
|
26
|
+
more?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether two line buffers are identical. */
|
|
30
|
+
function sameLines(a: readonly string[], b: readonly string[]): boolean {
|
|
31
|
+
if (a.length !== b.length) return false;
|
|
32
|
+
for (let index = 0; index < a.length; index++) {
|
|
33
|
+
if (a[index] !== b[index]) return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The scrollable text pane both the job and watch screens embed. */
|
|
39
|
+
export class LinePane {
|
|
40
|
+
/** Id of the row whose pane is open; `""` when the pane is closed. */
|
|
41
|
+
id = "";
|
|
42
|
+
/** First visible line. */
|
|
43
|
+
scroll = 0;
|
|
44
|
+
/** Rows shown at once; the screen sets this from `fitRows` each render. */
|
|
45
|
+
visible = 1;
|
|
46
|
+
/** Whether the view is parked at the tail; set false once the user scrolls up. */
|
|
47
|
+
follow = true;
|
|
48
|
+
|
|
49
|
+
private buffer: string[] = [];
|
|
50
|
+
private label = "";
|
|
51
|
+
private subtitle: string | undefined;
|
|
52
|
+
private more = false;
|
|
53
|
+
|
|
54
|
+
/** Lines loaded into the pane, oldest first. */
|
|
55
|
+
get lines(): readonly string[] {
|
|
56
|
+
return this.buffer;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Loaded line count. */
|
|
60
|
+
get count(): number {
|
|
61
|
+
return this.buffer.length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The furthest the view can scroll while keeping a full window. */
|
|
65
|
+
get maxScroll(): number {
|
|
66
|
+
return Math.max(0, this.buffer.length - this.visible);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Open the pane on a row, reset to the tail. */
|
|
70
|
+
open(id: string, source: PaneSource): void {
|
|
71
|
+
this.id = id;
|
|
72
|
+
this.buffer = [];
|
|
73
|
+
this.label = "";
|
|
74
|
+
this.subtitle = undefined;
|
|
75
|
+
this.more = false;
|
|
76
|
+
this.scroll = 0;
|
|
77
|
+
this.follow = true;
|
|
78
|
+
this.update(source);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Close the pane and drop its buffer. */
|
|
82
|
+
close(): void {
|
|
83
|
+
this.id = "";
|
|
84
|
+
this.buffer = [];
|
|
85
|
+
this.label = "";
|
|
86
|
+
this.subtitle = undefined;
|
|
87
|
+
this.more = false;
|
|
88
|
+
this.scroll = 0;
|
|
89
|
+
this.follow = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Replace the given fields.
|
|
94
|
+
*
|
|
95
|
+
* Returns whether anything visible changed, so a caller polling the pane
|
|
96
|
+
* every 500ms can skip a repaint — and leave the user's scroll alone — when
|
|
97
|
+
* the buffer is byte-identical.
|
|
98
|
+
*/
|
|
99
|
+
update(source: PaneSource): boolean {
|
|
100
|
+
const titleChanged = source.title !== undefined && source.title !== this.label;
|
|
101
|
+
const linesChanged = source.lines !== undefined && !sameLines(source.lines, this.buffer);
|
|
102
|
+
const moreChanged = source.more !== undefined && source.more !== this.more;
|
|
103
|
+
const subtitleChanged = source.subtitle !== undefined && source.subtitle !== this.subtitle;
|
|
104
|
+
|
|
105
|
+
if (source.title !== undefined) this.label = source.title;
|
|
106
|
+
if (source.lines !== undefined) this.buffer = source.lines;
|
|
107
|
+
if (source.more !== undefined) this.more = source.more;
|
|
108
|
+
if (source.subtitle !== undefined) this.subtitle = source.subtitle;
|
|
109
|
+
|
|
110
|
+
// Follow the tail; a paused view keeps its place but never past the end.
|
|
111
|
+
if (this.follow) this.scroll = this.maxScroll;
|
|
112
|
+
else this.scroll = Math.min(this.scroll, this.maxScroll);
|
|
113
|
+
|
|
114
|
+
return titleChanged || linesChanged || moreChanged || subtitleChanged;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The header label: the pane prefix, and the row's title when it has one. */
|
|
118
|
+
title(prefix: string): string {
|
|
119
|
+
return this.label ? `${prefix} · ${this.label}` : prefix;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The dim subtitle: the source (a log path), the loaded-window note, and
|
|
124
|
+
* whether the view is following. Empty when there is nothing to say.
|
|
125
|
+
*/
|
|
126
|
+
subtitleLine(): string {
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
if (this.subtitle) parts.push(this.subtitle);
|
|
129
|
+
if (this.more && this.buffer.length > 0) parts.push(`showing last ${this.buffer.length} lines`);
|
|
130
|
+
if (this.buffer.length > 0) parts.push(this.follow ? "following" : "paused");
|
|
131
|
+
return parts.join(" · ");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The visible half-open line range, clamped to the buffer. */
|
|
135
|
+
window(): { start: number; end: number } {
|
|
136
|
+
const start = Math.min(Math.max(0, this.scroll), this.maxScroll);
|
|
137
|
+
return { start, end: Math.min(this.buffer.length, start + this.visible) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Scroll to `next`, clamped; leaving the tail clears `follow`. */
|
|
141
|
+
setScroll(next: number): void {
|
|
142
|
+
this.scroll = clampScroll(next, this.buffer.length, this.visible);
|
|
143
|
+
this.follow = this.scroll >= this.maxScroll;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Adopt a new visible height for the coming render: clamp the scroll into
|
|
148
|
+
* range, and re-park a following view at the tail. Does not touch `follow`.
|
|
149
|
+
*/
|
|
150
|
+
fit(visible: number): void {
|
|
151
|
+
this.visible = visible;
|
|
152
|
+
this.scroll = clampScroll(this.scroll, this.buffer.length, this.visible);
|
|
153
|
+
if (this.follow) this.scroll = this.maxScroll;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Apply a scroll key; `false` when the key is not one the pane consumes. */
|
|
157
|
+
handleKey(data: string): boolean {
|
|
158
|
+
const next = navigateScroll(data, this.scroll, this.buffer.length, this.visible);
|
|
159
|
+
if (next !== undefined) {
|
|
160
|
+
this.setScroll(next);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (data === "g") {
|
|
164
|
+
this.setScroll(0);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (data === "G") {
|
|
168
|
+
this.setScroll(this.maxScroll);
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
package/lib/tui/rows.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row grammar: a list row, a checkbox, a status chip, a key/value row, and
|
|
3
|
+
* an empty state.
|
|
4
|
+
*
|
|
5
|
+
* Every row is one line (an empty state is its two), built from the shared
|
|
6
|
+
* token roles and the shared selection marker, and clipped with `columns.ts` so
|
|
7
|
+
* it fits the width it was given. The point is that a list row looks the same
|
|
8
|
+
* whether it is the extension screen, the job list, or the rewind timeline:
|
|
9
|
+
*
|
|
10
|
+
* ❯ ◼ extensions 7 enabled
|
|
11
|
+
*
|
|
12
|
+
* Value-only: pure functions, theme passed in.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { clip, padTo, splitRow } from "./columns.ts";
|
|
17
|
+
import { selectionMarker } from "./cursor.ts";
|
|
18
|
+
import { BODY_INDENT, GLYPHS } from "./glyphs.ts";
|
|
19
|
+
import { paint, type TuiRole } from "./tokens.ts";
|
|
20
|
+
|
|
21
|
+
/** One selectable list row. */
|
|
22
|
+
export interface ListRow {
|
|
23
|
+
/** Draw the `❯` marker in the accent role. A blank marker column is reserved either way. */
|
|
24
|
+
selected?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* A pre-themed cell between the marker and the glyph, e.g. a `checkbox`.
|
|
27
|
+
* Reserved by the caller (pass a blank when the row has none) so a column of
|
|
28
|
+
* rows stays aligned.
|
|
29
|
+
*/
|
|
30
|
+
leading?: string;
|
|
31
|
+
/** A leading status or type glyph, painted in `glyphRole`. */
|
|
32
|
+
glyph?: string;
|
|
33
|
+
glyphRole?: TuiRole;
|
|
34
|
+
/** The row's text. */
|
|
35
|
+
label: string;
|
|
36
|
+
labelRole?: TuiRole;
|
|
37
|
+
/** Right-aligned meta (a count, a time, a state). */
|
|
38
|
+
trailing?: string;
|
|
39
|
+
trailingRole?: TuiRole;
|
|
40
|
+
/** Extra columns before the marker, for a nested row. */
|
|
41
|
+
indent?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One list row: an optional indent, the selection marker, an optional glyph,
|
|
46
|
+
* the label, and right-aligned trailing meta.
|
|
47
|
+
*
|
|
48
|
+
* The marker column is always reserved, so selected and unselected rows align.
|
|
49
|
+
* The trailing meta is the anchor when the terminal is narrow (see
|
|
50
|
+
* `splitRow`), so a value at the end of the row survives.
|
|
51
|
+
*/
|
|
52
|
+
export function listRow(theme: Theme, width: number, row: ListRow): string {
|
|
53
|
+
const indent = " ".repeat(Math.max(0, Math.floor(row.indent ?? 0)));
|
|
54
|
+
const marker = selectionMarker(theme, row.selected === true);
|
|
55
|
+
const leading = row.leading ? `${row.leading} ` : "";
|
|
56
|
+
const glyph = row.glyph ? `${paint(theme, row.glyphRole ?? "info", row.glyph)} ` : "";
|
|
57
|
+
const label = paint(theme, row.labelRole ?? "label", row.label);
|
|
58
|
+
const trailing = row.trailing ? paint(theme, row.trailingRole ?? "meta", row.trailing) : "";
|
|
59
|
+
return splitRow(`${indent}${marker}${leading}${glyph}${label}`, trailing, width);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The on/off checkbox glyph, in one role per state. */
|
|
63
|
+
export function checkbox(theme: Theme, on: boolean, roles: { on?: TuiRole; off?: TuiRole } = {}): string {
|
|
64
|
+
return on ? paint(theme, roles.on ?? "ok", GLYPHS.boxOn) : paint(theme, roles.off ?? "faint", GLYPHS.boxOff);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A glyph and a short label in one color: the vocabulary of a status chip. */
|
|
68
|
+
export function chip(theme: Theme, glyph: string, label: string, role: TuiRole = "meta"): string {
|
|
69
|
+
const head = paint(theme, role, glyph);
|
|
70
|
+
return label ? `${head} ${paint(theme, role, label)}` : head;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A fixed-width label column followed by its value. */
|
|
74
|
+
export interface KeyValueRow {
|
|
75
|
+
label: string;
|
|
76
|
+
value: string;
|
|
77
|
+
/** Columns the label column occupies. Default 12. */
|
|
78
|
+
labelWidth?: number;
|
|
79
|
+
labelRole?: TuiRole;
|
|
80
|
+
valueRole?: TuiRole;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One body-indented `label value` row, the label padded to a fixed column.
|
|
85
|
+
* The `BODY_INDENT` prefix matches `emptyState`, so a screen's body rows and its
|
|
86
|
+
* empty state share one indent.
|
|
87
|
+
*/
|
|
88
|
+
export function keyValueRow(theme: Theme, width: number, row: KeyValueRow): string {
|
|
89
|
+
const labelWidth = Math.max(1, Math.floor(row.labelWidth ?? 12));
|
|
90
|
+
const label = paint(theme, row.labelRole ?? "meta", padTo(row.label, labelWidth));
|
|
91
|
+
const value = paint(theme, row.valueRole ?? "label", row.value);
|
|
92
|
+
return clip(`${" ".repeat(BODY_INDENT)}${label}${value}`, width);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A screen's empty state: what is missing, and the next action. */
|
|
96
|
+
export interface EmptyState {
|
|
97
|
+
/** One line naming what is absent, e.g. `No notes yet.`. */
|
|
98
|
+
label: string;
|
|
99
|
+
/** An optional line naming the next action. */
|
|
100
|
+
hint?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The empty-state lines: a body-indented label, then its hint. */
|
|
104
|
+
export function emptyState(theme: Theme, width: number, state: EmptyState): string[] {
|
|
105
|
+
const indent = " ".repeat(BODY_INDENT);
|
|
106
|
+
const lines = [clip(`${indent}${paint(theme, "faint", state.label)}`, width)];
|
|
107
|
+
if (state.hint) lines.push(clip(`${indent}${paint(theme, "faint", state.hint)}`, width));
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
package/lib/tui/rules.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule grammar: how a surface draws a horizontal divider.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes, built from one idea — a run of a single-column glyph, with an
|
|
5
|
+
* optional label — so a screen title and a centered rule read as one family:
|
|
6
|
+
*
|
|
7
|
+
* title Extensions ────────────────── (label first, accent)
|
|
8
|
+
* centered ╌╌╌╌╌╌ turn 3 ╌╌╌╌╌╌ (label centered)
|
|
9
|
+
*
|
|
10
|
+
* A rule degrades to a plain fill rather than truncating a label into an
|
|
11
|
+
* ellipsis, matching Pi's built-in header.
|
|
12
|
+
*
|
|
13
|
+
* Value-only: pure functions, theme passed in.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
+
import { paint, type TuiRole } from "./tokens.ts";
|
|
19
|
+
|
|
20
|
+
/** The glyph and colors a rule is drawn with. */
|
|
21
|
+
export interface RuleStyle {
|
|
22
|
+
/** Single-column fill glyph. Default `─` (a centered rule defaults to `─` too). */
|
|
23
|
+
glyph?: string;
|
|
24
|
+
/** The label's role. */
|
|
25
|
+
labelRole?: TuiRole;
|
|
26
|
+
/** The fill's role. */
|
|
27
|
+
fillRole?: TuiRole;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A centered rule's style adds the padding around the label. */
|
|
31
|
+
export interface CenteredRuleStyle extends RuleStyle {
|
|
32
|
+
/** Spaces on each side of the label. Default 1. */
|
|
33
|
+
pad?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A usable origin: terminal arithmetic starts from one column, never zero. */
|
|
37
|
+
function widthOf(width: number): number {
|
|
38
|
+
return Math.max(1, Math.floor(width));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A full-width divider in one color. */
|
|
42
|
+
export function plainRule(theme: Theme, width: number, style: RuleStyle = {}): string {
|
|
43
|
+
const w = widthOf(width);
|
|
44
|
+
return paint(theme, style.fillRole ?? "rule", (style.glyph ?? "─").repeat(w));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The label-first shape a screen or panel title uses. */
|
|
48
|
+
export function titleRule(theme: Theme, width: number, label: string, style: RuleStyle = {}): string {
|
|
49
|
+
return labelRule(theme, width, label, "title", "rule", style);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A label-first rule: the label at the margin, a fill to the edge. */
|
|
53
|
+
function labelRule(
|
|
54
|
+
theme: Theme,
|
|
55
|
+
width: number,
|
|
56
|
+
label: string,
|
|
57
|
+
labelRole: TuiRole,
|
|
58
|
+
fillRole: TuiRole,
|
|
59
|
+
style: RuleStyle,
|
|
60
|
+
): string {
|
|
61
|
+
const w = widthOf(width);
|
|
62
|
+
const text = label.trim();
|
|
63
|
+
if (!text) return plainRule(theme, w, style);
|
|
64
|
+
const labelWidth = visibleWidth(text);
|
|
65
|
+
if (labelWidth + 1 >= w) return plainRule(theme, w, style);
|
|
66
|
+
const fill = (style.glyph ?? "─").repeat(w - labelWidth - 1);
|
|
67
|
+
return paint(theme, style.labelRole ?? labelRole, text) + " " + paint(theme, style.fillRole ?? fillRole, fill);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A centered rule: fill, label, and the rest of the fill. */
|
|
71
|
+
export function centeredRule(theme: Theme, width: number, label: string, style: CenteredRuleStyle = {}): string {
|
|
72
|
+
const w = widthOf(width);
|
|
73
|
+
const glyph = style.glyph ?? "─";
|
|
74
|
+
const fillRole = style.fillRole ?? "ruleStrong";
|
|
75
|
+
if (!label.trim()) return paint(theme, fillRole, glyph.repeat(w));
|
|
76
|
+
const pad = " ".repeat(Math.max(0, Math.floor(style.pad ?? 1)));
|
|
77
|
+
const text = `${pad}${label}${pad}`;
|
|
78
|
+
const labelWidth = visibleWidth(text);
|
|
79
|
+
if (labelWidth >= w) return paint(theme, fillRole, glyph.repeat(w));
|
|
80
|
+
const remaining = w - labelWidth;
|
|
81
|
+
const left = Math.floor(remaining / 2);
|
|
82
|
+
const right = remaining - left;
|
|
83
|
+
return (
|
|
84
|
+
paint(theme, fillRole, glyph.repeat(left)) +
|
|
85
|
+
paint(theme, style.labelRole ?? "meta", text) +
|
|
86
|
+
paint(theme, fillRole, glyph.repeat(right))
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The footer status chips two extensions publish the same way.
|
|
3
|
+
*
|
|
4
|
+
* A controller remembers the last context it was attached to, repaints the keys
|
|
5
|
+
* it owns, clears them when its feature is disabled, and never throws: a
|
|
6
|
+
* non-interactive mode has no UI, so every method no-ops or falls back to plain
|
|
7
|
+
* text instead.
|
|
8
|
+
*
|
|
9
|
+
* The chips themselves are the caller's: it supplies the keys, the counts, and
|
|
10
|
+
* the roles, so `job` and `watch` share the controller but not their vocabulary.
|
|
11
|
+
* Value-only: the per-caller state lives in this closure, never in the module.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { paint, type TuiRole } from "./tokens.ts";
|
|
16
|
+
|
|
17
|
+
/** One chip a controller publishes. */
|
|
18
|
+
export interface StatusChip {
|
|
19
|
+
/** The `ctx.ui.setStatus` key. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** Glyph shown before the count. */
|
|
22
|
+
glyph: string;
|
|
23
|
+
/** Count; `0` clears the key. */
|
|
24
|
+
count: number;
|
|
25
|
+
/** The design role the chip is drawn in. */
|
|
26
|
+
role: TuiRole;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a controller publishes, read fresh on every paint. */
|
|
30
|
+
export interface StatusControllerOptions {
|
|
31
|
+
/** The keys this controller owns, cleared together when it is disabled. */
|
|
32
|
+
keys: readonly string[];
|
|
33
|
+
/** Whether the chips should be published at all. */
|
|
34
|
+
enabled(): boolean;
|
|
35
|
+
/** The chips for the current state. */
|
|
36
|
+
chips(): readonly StatusChip[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An attach/paint/clear surface for one extension's footer chips. */
|
|
40
|
+
export interface StatusController {
|
|
41
|
+
/** Remember the live context; later calls use it when none is passed. */
|
|
42
|
+
attach(ctx: ExtensionContext): void;
|
|
43
|
+
/** Forget the context, for example on shutdown or reload. */
|
|
44
|
+
detach(): void;
|
|
45
|
+
/** Republish using the attached context. */
|
|
46
|
+
paint(): void;
|
|
47
|
+
/** Publish the chips (and attach `ctx` when given). */
|
|
48
|
+
setStatus(ctx?: ExtensionContext): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A context's theme, or `undefined` when the UI is unavailable. */
|
|
52
|
+
function themeOf(target: ExtensionContext): Theme | undefined {
|
|
53
|
+
try {
|
|
54
|
+
return target.ui.theme;
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Paint in role, falling back to plain text when the theme cannot. */
|
|
61
|
+
function safePaint(theme: Theme | undefined, role: TuiRole, text: string): string {
|
|
62
|
+
if (!theme) return text;
|
|
63
|
+
try {
|
|
64
|
+
return paint(theme, role, text);
|
|
65
|
+
} catch {
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build a controller for one extension's pair of chips. */
|
|
71
|
+
export function createStatusController(options: StatusControllerOptions): StatusController {
|
|
72
|
+
let ctx: ExtensionContext | undefined;
|
|
73
|
+
|
|
74
|
+
const setStatus = (next?: ExtensionContext): void => {
|
|
75
|
+
if (next) ctx = next;
|
|
76
|
+
const target = ctx;
|
|
77
|
+
if (!target) return;
|
|
78
|
+
try {
|
|
79
|
+
if (!options.enabled()) {
|
|
80
|
+
for (const key of options.keys) target.ui.setStatus(key, undefined);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const theme = themeOf(target);
|
|
84
|
+
for (const chip of options.chips()) {
|
|
85
|
+
// A space before the count keeps the compact chrome form (`▸2`),
|
|
86
|
+
// while the two chips stay separate segments in the full form.
|
|
87
|
+
const text = `${chip.glyph} ${chip.count}`;
|
|
88
|
+
target.ui.setStatus(chip.key, chip.count > 0 ? safePaint(theme, chip.role, text) : undefined);
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// UI may be unavailable in non-interactive modes.
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
attach: (next) => {
|
|
97
|
+
ctx = next;
|
|
98
|
+
},
|
|
99
|
+
detach: () => {
|
|
100
|
+
ctx = undefined;
|
|
101
|
+
},
|
|
102
|
+
paint: () => {
|
|
103
|
+
setStatus();
|
|
104
|
+
},
|
|
105
|
+
setStatus,
|
|
106
|
+
};
|
|
107
|
+
}
|