@gavin-hu/my-pi-agent 0.8.1 → 0.10.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 +35 -8
- package/README.zh-CN.md +33 -8
- package/assets/bytefly.schema.json +17 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
- package/extensions/bytefly/README.md +306 -68
- package/extensions/bytefly/block/component.ts +282 -0
- package/extensions/bytefly/block/config.ts +24 -0
- package/extensions/bytefly/block/fallback.ts +120 -0
- package/extensions/bytefly/block/register.ts +164 -0
- package/extensions/bytefly/chrome.ts +97 -0
- package/extensions/bytefly/doctor/format.ts +36 -10
- package/extensions/bytefly/doctor/tools.ts +10 -6
- 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 +49 -21
- package/extensions/bytefly/header/config.ts +28 -21
- package/extensions/bytefly/header/register.ts +6 -1
- package/extensions/bytefly/index.ts +32 -12
- package/extensions/bytefly/message/config.ts +15 -0
- package/extensions/bytefly/message/format.ts +42 -0
- package/extensions/bytefly/message/register.ts +15 -0
- package/extensions/bytefly/notice/component.ts +107 -0
- package/extensions/bytefly/notice/config.ts +41 -0
- package/extensions/bytefly/notice/register.ts +27 -0
- package/extensions/bytefly/pad.ts +42 -0
- 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/component.ts +2 -1
- package/extensions/bytefly/separator/config.ts +16 -8
- package/extensions/bytefly/separator/format.ts +18 -38
- package/extensions/bytefly/separator/register.ts +3 -2
- package/extensions/bytefly/separator/types.ts +0 -10
- package/extensions/bytefly/working/config.ts +20 -0
- package/extensions/bytefly/working/format.ts +25 -0
- package/extensions/bytefly/working/register.ts +88 -0
- package/extensions/document/README.md +12 -5
- package/extensions/document/paging.ts +10 -10
- package/extensions/document/render.ts +17 -16
- package/extensions/document/tool.ts +1 -1
- package/extensions/file-browse/README.md +54 -10
- package/extensions/file-browse/client.ts +110 -2
- package/extensions/file-browse/config.ts +12 -0
- package/extensions/file-browse/diff.ts +187 -0
- package/extensions/file-browse/git.ts +180 -8
- package/extensions/file-browse/html.ts +401 -25
- 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/file-open/README.md +6 -0
- package/extensions/goal/README.md +16 -10
- package/extensions/goal/format.ts +13 -25
- package/extensions/goal/tools.ts +11 -22
- package/extensions/goal/tui.ts +12 -40
- package/extensions/job/README.md +22 -13
- package/extensions/job/format.ts +36 -13
- package/extensions/job/index.ts +26 -21
- package/extensions/job/tools.ts +15 -12
- package/extensions/job/tui.ts +148 -222
- package/extensions/job/ui.ts +24 -49
- package/extensions/memory/README.md +7 -1
- package/extensions/memory/format.ts +5 -3
- package/extensions/memory/tools.ts +4 -3
- package/extensions/memory/tui.ts +32 -36
- package/extensions/plan/README.md +86 -36
- 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 +43 -27
- package/extensions/plan/tui.ts +21 -27
- package/extensions/plan/web-client.ts +386 -40
- package/extensions/plan/web-page.ts +138 -33
- package/extensions/plan/web-router.ts +46 -13
- package/extensions/plan/web.ts +63 -12
- package/extensions/question/README.md +14 -2
- package/extensions/question/answers.ts +25 -18
- package/extensions/question/tools.ts +22 -16
- package/extensions/question/tui.ts +132 -76
- package/extensions/rewind/README.md +3 -1
- package/extensions/rewind/format.ts +35 -2
- package/extensions/rewind/index.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +66 -62
- package/extensions/rewind/snapshot-run.ts +1 -1
- package/extensions/rewind/status.ts +3 -2
- package/extensions/subagent/README.md +6 -0
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +108 -66
- package/extensions/todo/README.md +20 -13
- package/extensions/todo/format.ts +47 -34
- package/extensions/todo/tools.ts +6 -4
- package/extensions/todo/tui.ts +46 -26
- package/extensions/watch/README.md +16 -8
- package/extensions/watch/format.ts +33 -13
- package/extensions/watch/index.ts +29 -24
- package/extensions/watch/tools.ts +16 -9
- package/extensions/watch/tui.ts +142 -196
- package/extensions/watch/ui.ts +24 -46
- package/extensions/web-access/README.md +16 -7
- package/extensions/web-access/fetch/tool.ts +12 -11
- package/extensions/web-access/search/tool.ts +16 -13
- package/extensions/wechat/README.md +8 -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/README.md +6 -0
- 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 +11 -7
- package/lib/format.ts +17 -0
- package/lib/notice.ts +87 -0
- package/lib/requirements.ts +2 -2
- package/lib/{tui.ts → tui/chrome.ts} +17 -34
- package/lib/tui/columns.ts +96 -0
- package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
- package/lib/tui/gauge.ts +78 -0
- package/lib/{ui.ts → tui/glyphs.ts} +10 -11
- package/lib/tui/indent.ts +166 -0
- package/lib/tui/index.ts +27 -0
- package/lib/tui/line-pane.ts +173 -0
- package/lib/tui/rows.ts +143 -0
- package/lib/tui/rules.ts +94 -0
- package/lib/tui/status-controller.ts +107 -0
- package/lib/tui/tokens.ts +72 -0
- package/lib/web/base.ts +2 -1
- package/lib/web/components.ts +3 -1
- package/lib/web/layout.ts +5 -1
- package/package.json +7 -3
- package/themes/bytefly-dark.json +7 -8
- package/themes/bytefly-light.json +10 -11
- package/assets/bytefly-chrome.png +0 -0
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Text, truncateToWidth, visibleWidth, type EditorTheme, type OverlayOptions } from "@earendil-works/pi-tui";
|
|
21
|
-
import { SEPARATORS } from "./
|
|
21
|
+
import { SEPARATORS } from "./glyphs.ts";
|
|
22
|
+
import { BODY_INDENT } from "./indent.ts";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* `ctx.ui.custom` options that give a screen the whole terminal.
|
|
@@ -36,21 +37,6 @@ export const FULL_SCREEN_OVERLAY: { overlay: true; overlayOptions: OverlayOption
|
|
|
36
37
|
overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 },
|
|
37
38
|
};
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
-
* Top border with `label` centered-left, exactly `width` columns wide. When the
|
|
41
|
-
* terminal is too narrow for the title and a border on each side, it degrades to
|
|
42
|
-
* a plain rule rather than truncating the title into an ellipsis.
|
|
43
|
-
*/
|
|
44
|
-
export function screenHeader(theme: Theme, width: number, label: string): string {
|
|
45
|
-
const text = ` ${label} `;
|
|
46
|
-
const prefix = "───";
|
|
47
|
-
if (width < visibleWidth(prefix) + visibleWidth(text) + 1) {
|
|
48
|
-
return theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
|
|
49
|
-
}
|
|
50
|
-
const remaining = width - visibleWidth(prefix) - visibleWidth(text);
|
|
51
|
-
return theme.fg("borderMuted", prefix) + theme.fg("accent", text) + theme.fg("borderMuted", "─".repeat(remaining));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
40
|
/** A row count, or a live getter so a screen re-sizes with the terminal. */
|
|
55
41
|
export type ViewportRowsSource = number | (() => number | undefined);
|
|
56
42
|
|
|
@@ -80,23 +66,26 @@ export function viewportRows(source: ViewportRowsSource | undefined, options: Vi
|
|
|
80
66
|
export type ScreenHint = string | { text: string; color?: ThemeColor };
|
|
81
67
|
|
|
82
68
|
/**
|
|
83
|
-
* A
|
|
69
|
+
* A key-hint footer indented by `indent` columns (default `BODY_INDENT`, which
|
|
70
|
+
* hangs it under a screen's title rule), joined with ` · `. Plain hints are dim;
|
|
84
71
|
* an object hint uses its color (dim when omitted). Hints are added while they
|
|
85
72
|
* fit, so a narrow terminal drops whole keys instead of truncating one in half.
|
|
86
73
|
* The first hint is always kept; the result is clipped to `width`.
|
|
87
74
|
*/
|
|
88
|
-
export function screenHint(theme: Theme, width: number, hints: ScreenHint[]): string {
|
|
75
|
+
export function screenHint(theme: Theme, width: number, hints: ScreenHint[], indent = BODY_INDENT): string {
|
|
89
76
|
const target = Math.max(1, width);
|
|
77
|
+
// Keep a column for the hint itself, however deep the caller indents.
|
|
78
|
+
const lead = " ".repeat(Math.max(0, Math.min(Math.floor(indent), target - 1)));
|
|
90
79
|
const render = (hint: ScreenHint): string =>
|
|
91
80
|
typeof hint === "string" ? theme.fg("dim", hint) : theme.fg(hint.color ?? "dim", hint.text);
|
|
92
81
|
const separator = theme.fg("dim", SEPARATORS.item);
|
|
93
82
|
const kept: ScreenHint[] = [];
|
|
94
83
|
for (const hint of hints) {
|
|
95
84
|
const candidate = [...kept, hint].map(render).join(separator);
|
|
96
|
-
if (kept.length > 0 && visibleWidth(
|
|
85
|
+
if (kept.length > 0 && visibleWidth(`${lead}${candidate}`) > target) break;
|
|
97
86
|
kept.push(hint);
|
|
98
87
|
}
|
|
99
|
-
return truncateToWidth(
|
|
88
|
+
return truncateToWidth(`${lead}${kept.map(render).join(separator)}`, target);
|
|
100
89
|
}
|
|
101
90
|
|
|
102
91
|
/**
|
|
@@ -105,12 +94,8 @@ export function screenHint(theme: Theme, width: number, hints: ScreenHint[]): st
|
|
|
105
94
|
* the frame only places them.
|
|
106
95
|
*/
|
|
107
96
|
export interface DockListFrame {
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
/** Body width in columns. */
|
|
111
|
-
width: number;
|
|
112
|
-
/** The header label, e.g. `Extensions`. */
|
|
113
|
-
title: string;
|
|
97
|
+
/** The pre-rendered header line, usually `titleRule(theme, width, title)`. */
|
|
98
|
+
header: string;
|
|
114
99
|
/** A summary line under the header; a blank line follows it. */
|
|
115
100
|
summary?: string;
|
|
116
101
|
/** The list rows, or the screen's single empty-state line. */
|
|
@@ -124,18 +109,16 @@ export interface DockListFrame {
|
|
|
124
109
|
}
|
|
125
110
|
|
|
126
111
|
/**
|
|
127
|
-
* Assemble a docked list screen: the
|
|
128
|
-
* its blank line, the body, an optional range row, optional detail rows, and
|
|
112
|
+
* Assemble a docked list screen: the caller's header rule, an optional summary
|
|
113
|
+
* and its blank line, the body, an optional range row, optional detail rows, and
|
|
129
114
|
* the blank-line-padded key-hint footer.
|
|
130
115
|
*
|
|
131
|
-
* This is the convention
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* so it stays hand-rolled rather than changing its output to fit this helper.
|
|
116
|
+
* This is the convention every docked list screen shares — the bytefly panel,
|
|
117
|
+
* `/jobs`, `/watch`, `/todos`, `/memory`, and `/rewind`. The header line is built
|
|
118
|
+
* by the caller (usually `titleRule`), so the frame never picks a rule style.
|
|
135
119
|
*/
|
|
136
120
|
export function dockListFrame(frame: DockListFrame): string[] {
|
|
137
|
-
const
|
|
138
|
-
const lines: string[] = [screenHeader(frame.theme, width, frame.title)];
|
|
121
|
+
const lines: string[] = [frame.header];
|
|
139
122
|
if (frame.summary !== undefined) lines.push(frame.summary, "");
|
|
140
123
|
lines.push(...frame.body);
|
|
141
124
|
if (frame.range !== undefined) lines.push(frame.range);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Width arithmetic for the terminal: justify, fit, pad, and clip lines.
|
|
3
|
+
*
|
|
4
|
+
* Every function works in terminal columns via `visibleWidth` /
|
|
5
|
+
* `truncateToWidth`, never string length, and every result fits the width it
|
|
6
|
+
* was given. Themed strings pass through unchanged except where a cell has to
|
|
7
|
+
* be clipped, so a color survives every cell that fits.
|
|
8
|
+
*
|
|
9
|
+
* Value-only: pure functions over their arguments.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
/** A usable origin: terminal arithmetic starts from one column, never zero. */
|
|
15
|
+
function columns(width: number): number {
|
|
16
|
+
return Math.max(1, Math.floor(width));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Clip a possibly-themed string to `width` columns, marking a cut with `ellipsis`. */
|
|
20
|
+
export function clip(text: string, width: number, ellipsis = "…"): string {
|
|
21
|
+
return truncateToWidth(text, columns(width), ellipsis);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pad `text` on the right to exactly `width` columns. Never truncates. */
|
|
25
|
+
export function padTo(text: string, width: number): string {
|
|
26
|
+
const missing = columns(width) - visibleWidth(text);
|
|
27
|
+
return missing > 0 ? text + " ".repeat(missing) : text;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Left/right justify on one line: the left cell sits at the margin, the right
|
|
32
|
+
* cell at the edge, and at least `minGap` columns separate them.
|
|
33
|
+
*
|
|
34
|
+
* The right cell is the anchor — it is clipped only after the left cell has
|
|
35
|
+
* been clipped away — so a value at the end of a line (a model name, a metric)
|
|
36
|
+
* survives a tight terminal instead of vanishing. An empty right cell means a
|
|
37
|
+
* plain left-aligned line with no trailing spaces.
|
|
38
|
+
*/
|
|
39
|
+
export function splitRow(left: string, right: string, width: number, minGap = 1): string {
|
|
40
|
+
const w = columns(width);
|
|
41
|
+
if (!right) return clip(left, w);
|
|
42
|
+
const gap = Math.max(0, Math.floor(minGap));
|
|
43
|
+
const rightCell = clip(right, w);
|
|
44
|
+
const rightWidth = visibleWidth(rightCell);
|
|
45
|
+
const leftBudget = w - rightWidth - gap;
|
|
46
|
+
if (leftBudget <= 0) return rightCell;
|
|
47
|
+
return padTo(clip(left, leftBudget), w - rightWidth) + rightCell;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Join as many whole cells as fit, dropping trailing cells rather than
|
|
52
|
+
* clipping one in half. The first cell is always kept, so an over-wide line
|
|
53
|
+
* degrades to a clipped first cell instead of an empty one.
|
|
54
|
+
*/
|
|
55
|
+
export function fitCells(cells: readonly string[], width: number, separator = " "): string {
|
|
56
|
+
const w = columns(width);
|
|
57
|
+
const kept: string[] = [];
|
|
58
|
+
for (const cell of cells) {
|
|
59
|
+
const candidate = [...kept, cell].join(separator);
|
|
60
|
+
if (kept.length > 0 && visibleWidth(candidate) > w) break;
|
|
61
|
+
kept.push(cell);
|
|
62
|
+
}
|
|
63
|
+
return clip(kept.join(separator), w);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Lay atomic, already-themed cells onto lines no wider than `width`.
|
|
68
|
+
*
|
|
69
|
+
* Cells are never split: one that would not fit moves to the next line, and one
|
|
70
|
+
* wider than the whole width is clipped. Use this for a row of independent
|
|
71
|
+
* tokens (tabs, tags) where `fitCells`' single-line drop would lose one; unlike
|
|
72
|
+
* {@link fitCells} it may return several lines, and always returns one.
|
|
73
|
+
*/
|
|
74
|
+
export function flowCells(cells: readonly string[], width: number): string[] {
|
|
75
|
+
const w = columns(width);
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
let current = "";
|
|
78
|
+
let currentWidth = 0;
|
|
79
|
+
for (const cell of cells) {
|
|
80
|
+
let text = cell;
|
|
81
|
+
let cellWidth = visibleWidth(cell);
|
|
82
|
+
if (cellWidth > w) {
|
|
83
|
+
text = truncateToWidth(cell, w);
|
|
84
|
+
cellWidth = visibleWidth(text);
|
|
85
|
+
}
|
|
86
|
+
if (currentWidth > 0 && currentWidth + cellWidth > w) {
|
|
87
|
+
lines.push(current);
|
|
88
|
+
current = "";
|
|
89
|
+
currentWidth = 0;
|
|
90
|
+
}
|
|
91
|
+
current += text;
|
|
92
|
+
currentWidth += cellWidth;
|
|
93
|
+
}
|
|
94
|
+
lines.push(current);
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
@@ -3,18 +3,23 @@
|
|
|
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";
|
|
22
|
+
import { GLYPHS } from "./glyphs.ts";
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
25
|
* Rows that fit for a list of `itemCount`, reserving one extra line when the
|
|
@@ -71,7 +76,7 @@ export function wheelDelta(event: TuiMouseEvent): number | undefined {
|
|
|
71
76
|
|
|
72
77
|
/** The `❯ ` selection marker (accent) or a same-width blank. */
|
|
73
78
|
export function selectionMarker(theme: Theme, selected: boolean): string {
|
|
74
|
-
return selected ? theme.fg("accent",
|
|
79
|
+
return selected ? theme.fg("accent", `${GLYPHS.prompt} `) : " ";
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
/**
|
|
@@ -147,6 +152,55 @@ export class ListCursor {
|
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Cursor and scroll for a list whose order changes under it.
|
|
157
|
+
*
|
|
158
|
+
* A live list (jobs, watchers) is re-read and re-sorted on every render, so an
|
|
159
|
+
* index would silently point at a different row. This cursor remembers the id
|
|
160
|
+
* of the focused row and re-derives its index against the current items, falling
|
|
161
|
+
* back to the first row when that id is gone. It is pure state: the screen owns
|
|
162
|
+
* `requestRender`, and assigns `visible` from {@link fitRows} each render.
|
|
163
|
+
*/
|
|
164
|
+
export class IdCursor {
|
|
165
|
+
/** Id of the focused row, or `undefined` before the first paint. */
|
|
166
|
+
id: string | undefined;
|
|
167
|
+
/** First visible row. */
|
|
168
|
+
scroll = 0;
|
|
169
|
+
/** Rows shown at once; the screen sets this before use. */
|
|
170
|
+
visible = 1;
|
|
171
|
+
|
|
172
|
+
/** Index of the focused row, or `0` when its id is gone or the list is empty. */
|
|
173
|
+
indexOf<T>(items: readonly T[], key: (item: T) => string): number {
|
|
174
|
+
if (items.length === 0) return 0;
|
|
175
|
+
const index = this.id === undefined ? -1 : items.findIndex((item) => key(item) === this.id);
|
|
176
|
+
return index >= 0 ? index : 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Re-anchor the id against the current items; never repaints. */
|
|
180
|
+
clamp<T>(items: readonly T[], key: (item: T) => string): void {
|
|
181
|
+
if (items.length === 0) {
|
|
182
|
+
this.id = undefined;
|
|
183
|
+
this.scroll = 0;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.id = key(items[this.indexOf(items, key)]);
|
|
187
|
+
this.scroll = clampScroll(this.scroll, items.length, this.visible);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Focus the row at `index` (clamped) and scroll it into view. */
|
|
191
|
+
select<T>(items: readonly T[], key: (item: T) => string, index: number): void {
|
|
192
|
+
if (items.length === 0) return;
|
|
193
|
+
const clamped = Math.min(Math.max(0, index), items.length - 1);
|
|
194
|
+
this.id = key(items[clamped]);
|
|
195
|
+
this.ensureVisible(clamped);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Scroll `index` into view without moving the focus. */
|
|
199
|
+
ensureVisible(index: number): void {
|
|
200
|
+
this.scroll = Math.max(0, keepVisible(this.scroll, index, this.visible));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
150
204
|
/**
|
|
151
205
|
* Apply a standard navigation key to `cursor`, reporting whether it handled it.
|
|
152
206
|
*
|
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
|
+
}
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
import { keyText, type Theme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Leading glyphs for status chips and
|
|
13
|
+
* Leading glyphs for status chips, footer segments, and transcript rails.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* CJK-width profile
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* A glyph is a single terminal column: never wide, fullwidth, or emoji, which
|
|
16
|
+
* are two columns in every profile. East-Asian *ambiguous* glyphs are allowed —
|
|
17
|
+
* a CJK-width profile draws those two columns, so one costs a column there, but
|
|
18
|
+
* the vocabulary is no longer limited to narrow/neutral characters. Add a glyph
|
|
19
|
+
* only after checking its `East_Asian_Width` against `EastAsianWidth.txt`;
|
|
20
|
+
* `lib/tui/glyphs.test.ts` bans wide, fullwidth, and emoji.
|
|
21
21
|
*/
|
|
22
22
|
export const GLYPHS = {
|
|
23
23
|
branch: "⎇",
|
|
@@ -34,6 +34,7 @@ export const GLYPHS = {
|
|
|
34
34
|
gaugeFull: "▰",
|
|
35
35
|
gaugeEmpty: "▱",
|
|
36
36
|
turnRule: "╌",
|
|
37
|
+
divider: "╎",
|
|
37
38
|
check: "✓",
|
|
38
39
|
warn: "!",
|
|
39
40
|
cross: "✕",
|
|
@@ -47,18 +48,16 @@ export const GLYPHS = {
|
|
|
47
48
|
boxOff: "◻",
|
|
48
49
|
prev: "‹",
|
|
49
50
|
next: "›",
|
|
51
|
+
prompt: "❯",
|
|
50
52
|
} as const;
|
|
51
53
|
|
|
52
|
-
/** Columns before a transcript rail body glyph, shared by `goal` and `todo`. */
|
|
53
|
-
export const BODY_INDENT = 2;
|
|
54
|
-
|
|
55
54
|
/** Columns between a rail body glyph and its text. */
|
|
56
55
|
export const GLYPH_GAP = 1;
|
|
57
56
|
|
|
58
57
|
/** Padding used to join (`item`) or group (`group`) UI segments. */
|
|
59
58
|
export const SEPARATORS = {
|
|
60
59
|
item: " · ",
|
|
61
|
-
group:
|
|
60
|
+
group: ` ${GLYPHS.divider} `,
|
|
62
61
|
} as const;
|
|
63
62
|
|
|
64
63
|
/** `ctx.ui.setStatus` keys; one per extension that publishes a chip. */
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript indent: how a non-prose block sits under its headline.
|
|
3
|
+
*
|
|
4
|
+
* A tool block, a shared notice, and a subagent's quoted output all hang from one
|
|
5
|
+
* left edge. A block may lead with a headline (a tool's own call line) at the
|
|
6
|
+
* outer indent; the body sits `BODY_INDENT` further in, directly under it with no
|
|
7
|
+
* separator line, which is what keeps a tool block compact. The body's first
|
|
8
|
+
* indent column may instead carry a rail glyph, passed in pre-painted by the
|
|
9
|
+
* caller so this module stays value-only.
|
|
10
|
+
*
|
|
11
|
+
* Value-only: pure functions and constants, no theme.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Component, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
|
|
16
|
+
/** Columns the body sits in beyond a block's outer indent. */
|
|
17
|
+
export const BODY_INDENT = 2;
|
|
18
|
+
|
|
19
|
+
/** How an indented block is placed. */
|
|
20
|
+
export interface IndentOptions {
|
|
21
|
+
/** Columns the body is indented; a headline sits `BODY_INDENT` to its left. */
|
|
22
|
+
indent?: number;
|
|
23
|
+
/**
|
|
24
|
+
* A pre-painted, one-column rail glyph for the body's first indent column; the
|
|
25
|
+
* rest of the indent stays spaces. Ignored below `BODY_INDENT`, where there is
|
|
26
|
+
* no column to spare. Default: no rail.
|
|
27
|
+
*/
|
|
28
|
+
rail?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Start the rail after the headline's continuation: keep the leading run of
|
|
31
|
+
* non-blank body lines plain-indented, up to the first blank line. Set when the
|
|
32
|
+
* headline degenerated to the bare subject name (a tool's call line whose path
|
|
33
|
+
* moved onto the body), so the rail begins with the result instead of the
|
|
34
|
+
* wrapped call. Ignored without a rail. Default false.
|
|
35
|
+
*/
|
|
36
|
+
railAfterCall?: boolean;
|
|
37
|
+
/** Drop leading lines with no visible content. Default false. */
|
|
38
|
+
trimLeading?: boolean;
|
|
39
|
+
/** Drop trailing lines with no visible content. Default true. */
|
|
40
|
+
trimTrailing?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Lead with the first non-blank line as the block's headline, at
|
|
43
|
+
* `indent - BODY_INDENT`, with the body immediately after it. Default false.
|
|
44
|
+
*/
|
|
45
|
+
headline?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Suffix appended when a body line is clipped. Default empty: a body is clipped
|
|
48
|
+
* silently. The headline always clips with `…`. A notice sets `…` so a
|
|
49
|
+
* truncated row reads as truncated.
|
|
50
|
+
*/
|
|
51
|
+
bodyClip?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A line with no visible text. Tested after stripping terminal sequences, so a
|
|
56
|
+
* line that is only spaces carrying a background (a `Box`'s own padding) counts
|
|
57
|
+
* as blank rather than as content.
|
|
58
|
+
*/
|
|
59
|
+
function isBlank(line: string): boolean {
|
|
60
|
+
return stripTerminalSequences(line).trim().length === 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An image-protocol line must pass through unprefixed, or the sequence corrupts. */
|
|
64
|
+
const IMAGE_LINE = /^\u001b(?:P|_G|\]1337;)/;
|
|
65
|
+
|
|
66
|
+
/** The body indent, clamped so the prefix always leaves at least one column. */
|
|
67
|
+
function clampIndent(indent: number, width: number): number {
|
|
68
|
+
return Math.max(0, Math.min(Math.floor(indent), Math.max(0, width - 1)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The body prefix: the rail glyph in the indent's first body column, or plain
|
|
73
|
+
* spaces. The glyph must be one visible column, so the prefix is exactly `indent`
|
|
74
|
+
* columns wide; the gap after it is the remaining `BODY_INDENT - 1`.
|
|
75
|
+
*/
|
|
76
|
+
function bodyPrefix(indent: number, rail: string | undefined): string {
|
|
77
|
+
if (!rail || indent < BODY_INDENT) return " ".repeat(indent);
|
|
78
|
+
const base = Math.max(0, indent - BODY_INDENT);
|
|
79
|
+
return `${" ".repeat(base)}${rail}${" ".repeat(indent - base - 1)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Indent each line of an already-rendered block by `indent` columns, clipping
|
|
84
|
+
* each to `width`. The block should be built at `width - indent` so nothing is
|
|
85
|
+
* lost to the prefix; the clip is only a safety net. A line carrying an image
|
|
86
|
+
* protocol sequence is passed through untouched, and trailing lines with no
|
|
87
|
+
* visible content are dropped (by default). When `headline` is set, the first
|
|
88
|
+
* non-blank line leads at `indent - BODY_INDENT` and the body follows directly —
|
|
89
|
+
* no separator line — and its own leading blanks are dropped so a tool's spacing
|
|
90
|
+
* never leaves a gap under the headline. With `rail` set, the body's first indent
|
|
91
|
+
* column carries the glyph instead of a space; `railAfterCall` keeps the call's
|
|
92
|
+
* continuation lines plain, so the rail starts with the result.
|
|
93
|
+
*/
|
|
94
|
+
export function indentBlockLines(body: readonly string[], width: number, options: IndentOptions = {}): string[] {
|
|
95
|
+
const w = Math.max(1, width);
|
|
96
|
+
const indent = clampIndent(options.indent ?? 0, w);
|
|
97
|
+
const prefix = bodyPrefix(indent, options.rail);
|
|
98
|
+
const plainPrefix = " ".repeat(indent);
|
|
99
|
+
|
|
100
|
+
// The headline is the first line with something to say; it leads at the base.
|
|
101
|
+
let headline: string | undefined;
|
|
102
|
+
let rest: readonly string[] = body;
|
|
103
|
+
if (options.headline) {
|
|
104
|
+
const index = body.findIndex((line) => !IMAGE_LINE.test(line) && !isBlank(line));
|
|
105
|
+
if (index >= 0) {
|
|
106
|
+
headline = body[index];
|
|
107
|
+
rest = body.slice(index + 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A call whose subject moved onto the body keeps those lines unrailed, so the
|
|
112
|
+
// rail starts with the result rather than under a bare tool name.
|
|
113
|
+
let railStart = 0;
|
|
114
|
+
if (options.rail && options.railAfterCall) {
|
|
115
|
+
while (railStart < rest.length && !isBlank(rest[railStart] ?? "")) railStart++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
const blank: boolean[] = [];
|
|
120
|
+
for (const [index, line] of rest.entries()) {
|
|
121
|
+
if (IMAGE_LINE.test(line)) {
|
|
122
|
+
lines.push(line);
|
|
123
|
+
blank.push(false);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
lines.push(truncateToWidth(`${index < railStart ? plainPrefix : prefix}${line}`, w, options.bodyClip ?? ""));
|
|
127
|
+
blank.push(isBlank(line));
|
|
128
|
+
}
|
|
129
|
+
if (options.trimLeading) {
|
|
130
|
+
while (blank.length > 0 && blank[0]) {
|
|
131
|
+
lines.shift();
|
|
132
|
+
blank.shift();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (options.trimTrailing !== false) {
|
|
136
|
+
while (blank.length > 0 && blank[blank.length - 1]) {
|
|
137
|
+
lines.pop();
|
|
138
|
+
blank.pop();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (headline !== undefined) {
|
|
142
|
+
const base = Math.max(0, indent - BODY_INDENT);
|
|
143
|
+
lines.unshift(truncateToWidth(`${" ".repeat(base)}${headline}`, w, "…"));
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render `inner` at the width the indent leaves, so nothing is lost to the
|
|
150
|
+
* prefix. An absent `inner` renders nothing, so a tool with no output adds no
|
|
151
|
+
* blank block. The lines are not indented yet; a caller that has to inspect the
|
|
152
|
+
* block before placing it passes them to `indentBlockLines`.
|
|
153
|
+
*/
|
|
154
|
+
export function bodyLines(inner: Component | undefined, width: number, indent = 0): string[] {
|
|
155
|
+
if (!inner) return [];
|
|
156
|
+
const w = Math.max(1, width);
|
|
157
|
+
const prefixWidth = visibleWidth(bodyPrefix(clampIndent(indent, w), undefined));
|
|
158
|
+
return inner.render(Math.max(1, w - prefixWidth));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Render `inner` at the width the indent leaves, then indent it. */
|
|
162
|
+
export function indentedLines(inner: Component | undefined, width: number, options: IndentOptions = {}): string[] {
|
|
163
|
+
const w = Math.max(1, width);
|
|
164
|
+
const indent = clampIndent(options.indent ?? 0, w);
|
|
165
|
+
return indentBlockLines(bodyLines(inner, w, indent), w, { ...options, indent });
|
|
166
|
+
}
|
package/lib/tui/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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), `indent.ts` (the transcript block indent), and
|
|
11
|
+
* `line-pane.ts` (the scrollable log/match pane).
|
|
12
|
+
*
|
|
13
|
+
* Value-only, like every `lib/` module: pure functions, constants, and classes,
|
|
14
|
+
* with no module-level state.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export * from "./chrome.ts";
|
|
18
|
+
export * from "./columns.ts";
|
|
19
|
+
export * from "./cursor.ts";
|
|
20
|
+
export * from "./gauge.ts";
|
|
21
|
+
export * from "./glyphs.ts";
|
|
22
|
+
export * from "./line-pane.ts";
|
|
23
|
+
export * from "./indent.ts";
|
|
24
|
+
export * from "./rows.ts";
|
|
25
|
+
export * from "./rules.ts";
|
|
26
|
+
export * from "./status-controller.ts";
|
|
27
|
+
export * from "./tokens.ts";
|