@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 +17 -11
- package/README.zh-CN.md +13 -8
- package/docs/extensions/README.md +1 -1
- package/docs/extensions/board.md +286 -123
- package/docs/extensions/bytefly.md +381 -223
- package/docs/extensions/file-browse.md +14 -7
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +56 -11
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +45 -17
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +23 -5
- package/extensions/board/format.ts +107 -9
- package/extensions/board/join.ts +1 -1
- package/extensions/board/page.ts +170 -94
- package/extensions/board/result.ts +9 -0
- package/extensions/board/schema.ts +22 -7
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +1 -0
- package/extensions/board/tools.ts +186 -21
- package/extensions/board/transport/router.ts +27 -108
- package/extensions/board/transport/server.ts +18 -29
- package/extensions/board/types.ts +67 -0
- package/extensions/board/view/client.ts +23 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +5 -171
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +48 -88
- package/extensions/board/view/render.ts +213 -190
- package/extensions/board/view/styles.ts +65 -48
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/transport/router.ts +0 -2
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +6 -7
- package/extensions/file-browse/view/markdown.ts +11 -13
- package/extensions/file-browse/view/raw-html.ts +18 -13
- package/extensions/job/runtime.ts +4 -4
- package/extensions/plan/feedback.ts +40 -16
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +17 -1
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +31 -2
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -1
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +41 -8
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +6 -5
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +86 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/store.ts +239 -52
- package/lib/board/view.ts +94 -10
- package/lib/format.ts +22 -0
- package/lib/http/serve.ts +3 -0
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +14 -1
- package/lib/session-roster.ts +74 -3
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/glyphs.ts +2 -3
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/package.json +1 -1
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The chrome around the panel's body: the
|
|
3
|
-
*
|
|
4
|
-
* the
|
|
2
|
+
* The chrome around the panel's body: the header rule with the screen's
|
|
3
|
+
* attention, the section tabs with their counts and the write scope, a group
|
|
4
|
+
* heading, the hint row that a transient notice replaces, and the frame all three
|
|
5
|
+
* sections share.
|
|
5
6
|
*
|
|
6
|
-
* Every section draws its body through
|
|
7
|
-
* spacing, roles, or the
|
|
7
|
+
* Every section draws its body through {@link panelFrame}, so the three cannot
|
|
8
|
+
* drift on spacing, roles, width arithmetic, or height: the body is padded to one
|
|
9
|
+
* target measured across all three sections, which is what keeps the dock from
|
|
10
|
+
* resizing when the tabs change.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
11
15
|
import { sanitizeLine } from "../../../../lib/format.ts";
|
|
12
16
|
import {
|
|
13
17
|
detailBlockRows,
|
|
@@ -17,51 +21,86 @@ import {
|
|
|
17
21
|
type ViewportRowsSource,
|
|
18
22
|
} from "../../../../lib/tui/chrome.ts";
|
|
19
23
|
import { clip, fitCells, splitRow } from "../../../../lib/tui/columns.ts";
|
|
20
|
-
import {
|
|
24
|
+
import { formatRange, type ListCursor } from "../../../../lib/tui/cursor.ts";
|
|
25
|
+
import { SEPARATORS } from "../../../../lib/tui/glyphs.ts";
|
|
21
26
|
import { BODY_INDENT } from "../../../../lib/tui/indent.ts";
|
|
22
27
|
import { emptyState } from "../../../../lib/tui/rows.ts";
|
|
28
|
+
import { titleRule } from "../../../../lib/tui/rules.ts";
|
|
23
29
|
import { paint } from "../../../../lib/tui/tokens.ts";
|
|
24
|
-
import {
|
|
30
|
+
import { sectionLabel, type SummarySegment } from "../format.ts";
|
|
25
31
|
import type { PanelSection } from "../types.ts";
|
|
26
32
|
import { selectableRange, type PanelEntry } from "./entries.ts";
|
|
33
|
+
import { composeRows } from "./panes.ts";
|
|
27
34
|
|
|
28
35
|
/** Body rows when the terminal height is unknown. */
|
|
29
36
|
export const SCREEN_DEFAULT_ROWS = 16;
|
|
30
|
-
/** Fixed rows around the body: header,
|
|
37
|
+
/** Fixed rows around the body: header, tabs, blank, blank, hint, closing rule. */
|
|
31
38
|
export const FRAME_ROWS = 6;
|
|
32
|
-
/** Detail rows reserved for the focused row
|
|
39
|
+
/** Detail rows reserved for the focused row in the single-column layout. */
|
|
33
40
|
export const DETAIL_ROWS = 3;
|
|
34
|
-
/** Body rows that must survive before the
|
|
41
|
+
/** Body rows that must survive before the bottom pane collapses to nothing. */
|
|
35
42
|
export const MIN_BODY_ROWS = 3;
|
|
36
43
|
/** Detail rows when the terminal height is unknown and the focused row is expanded. */
|
|
37
44
|
export const EXPANDED_DETAIL_FALLBACK = 8;
|
|
38
|
-
/** Fixed body offset in the frame: header,
|
|
45
|
+
/** Fixed body offset in the frame: header, tabs, blank line. */
|
|
39
46
|
export const BODY_START = 3;
|
|
40
47
|
|
|
41
|
-
/** The body indent, shared by the note and
|
|
48
|
+
/** The body indent, shared by the note and any focused-row block. */
|
|
42
49
|
export const INDENT = " ".repeat(BODY_INDENT);
|
|
43
50
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The header rule: the screen's name first, the attention it found at the right
|
|
53
|
+
* edge. The right side is dropped whole on a narrow terminal rather than clipped
|
|
54
|
+
* into half a word; the tabs below carry the same facts.
|
|
55
|
+
*/
|
|
56
|
+
export function headerLine(theme: Theme, width: number, title: string, right: readonly SummarySegment[]): string {
|
|
57
|
+
const w = Math.max(1, width);
|
|
58
|
+
const separator = paint(theme, "faint", SEPARATORS.item);
|
|
59
|
+
const text = right.map((segment) => paint(theme, segment.color, segment.text)).join(separator);
|
|
60
|
+
const rightWidth = visibleWidth(text);
|
|
61
|
+
if (text === "" || rightWidth + title.length + 4 > w) return titleRule(theme, w, title);
|
|
62
|
+
return `${titleRule(theme, w - rightWidth - 1, title)} ${text}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The tab row: every section with how many rows it holds, then any filter chips,
|
|
67
|
+
* and the scope a change is written to at the right edge. The active tab takes the
|
|
68
|
+
* accent; the counts stay faint so the names read first.
|
|
69
|
+
*/
|
|
70
|
+
export function tabsLine(
|
|
71
|
+
theme: Theme,
|
|
72
|
+
width: number,
|
|
73
|
+
active: PanelSection,
|
|
74
|
+
counts: Record<PanelSection, number>,
|
|
75
|
+
options: { chips?: readonly SummarySegment[]; scope?: string } = {},
|
|
76
|
+
): string {
|
|
77
|
+
const separator = paint(theme, "faint", SEPARATORS.item);
|
|
78
|
+
const tabs = (["extensions", "skills", "settings"] as const).map(
|
|
79
|
+
(section) =>
|
|
80
|
+
paint(theme, section === active ? "accent" : "faint", sectionLabel(section)) +
|
|
81
|
+
paint(theme, "faint", ` ${counts[section]}`),
|
|
82
|
+
);
|
|
83
|
+
const chips = options.chips ?? [];
|
|
84
|
+
const left = [
|
|
85
|
+
` ${tabs.join(separator)}`,
|
|
86
|
+
...(chips.length > 0 ? [` ${chips.map((chip) => paint(theme, chip.color, chip.text)).join(separator)}`] : []),
|
|
87
|
+
].join("");
|
|
88
|
+
const right = options.scope === undefined ? "" : paint(theme, "meta", `writes ${options.scope}`);
|
|
89
|
+
return clip(splitRow(left, right, width), width);
|
|
51
90
|
}
|
|
52
91
|
|
|
53
92
|
/** One section's counts as whole segments, so a narrow screen drops a count. */
|
|
54
93
|
export function countsLine(theme: Theme, width: number, segments: readonly SummarySegment[]): string {
|
|
55
|
-
const separator = paint(theme, "faint",
|
|
94
|
+
const separator = paint(theme, "faint", SEPARATORS.item);
|
|
56
95
|
const styled = segments.map((segment) => paint(theme, segment.color, segment.text));
|
|
57
96
|
return fitCells(styled, Math.max(1, width - 2), separator);
|
|
58
97
|
}
|
|
59
98
|
|
|
60
|
-
/** One group heading: its label and how many rows sit under it. */
|
|
99
|
+
/** One group heading: its label, and how many rows sit under it. */
|
|
61
100
|
export function groupLine(theme: Theme, width: number, entry: { label: string; count: number }): string {
|
|
62
101
|
const label = paint(theme, "meta", sanitizeLine(entry.label));
|
|
63
|
-
const count = paint(theme, "faint", `(${entry.count})`);
|
|
64
|
-
return clip(` ${label}
|
|
102
|
+
const count = entry.count > 0 ? paint(theme, "faint", ` (${entry.count})`) : "";
|
|
103
|
+
return clip(` ${label}${count}`, width);
|
|
65
104
|
}
|
|
66
105
|
|
|
67
106
|
/** The key-hint row, or a section's notice that replaces it. */
|
|
@@ -74,7 +113,7 @@ export function noticeLine(theme: Theme, width: number, notice: string): string
|
|
|
74
113
|
return clip(`${INDENT}${paint(theme, "meta", sanitizeLine(notice))}`, width);
|
|
75
114
|
}
|
|
76
115
|
|
|
77
|
-
/** The saved-then-reload marker, shared by
|
|
116
|
+
/** The saved-then-reload marker, shared by every focused-row block. */
|
|
78
117
|
export function savedLine(theme: Theme, width: number, dirty: boolean): string[] {
|
|
79
118
|
if (!dirty) return [];
|
|
80
119
|
return [clip(`${INDENT}${paint(theme, "warn", "Saved · reload to apply")}`, width)];
|
|
@@ -87,50 +126,18 @@ export function totalRows(source: ViewportRowsSource | undefined): number | unde
|
|
|
87
126
|
}
|
|
88
127
|
|
|
89
128
|
/**
|
|
90
|
-
*
|
|
91
|
-
* expanded. Zero when there is no free row for a pane at all,
|
|
92
|
-
* terminal keeps its body
|
|
129
|
+
* The rows the bottom pane gets in the single-column layout: the fixed pane, or
|
|
130
|
+
* the free height when expanded. Zero when there is no free row for a pane at all,
|
|
131
|
+
* so a short terminal keeps its body.
|
|
93
132
|
*/
|
|
94
133
|
export function detailRows(expanded: boolean, viewportRows: ViewportRowsSource | undefined): number {
|
|
95
134
|
const total = totalRows(viewportRows);
|
|
96
135
|
if (total === undefined) return expanded ? EXPANDED_DETAIL_FALLBACK : DETAIL_ROWS;
|
|
97
|
-
// One row pays for the blank line the frame puts above the pane.
|
|
98
136
|
const available = total - FRAME_ROWS - MIN_BODY_ROWS - 1;
|
|
99
137
|
if (available < 1) return 0;
|
|
100
138
|
return expanded ? available : Math.min(DETAIL_ROWS, available);
|
|
101
139
|
}
|
|
102
140
|
|
|
103
|
-
/**
|
|
104
|
-
* Drop optional rows until the frame fits the viewport: the range row first,
|
|
105
|
-
* then the project note, then the blank above the detail pane, then the pane.
|
|
106
|
-
* The header, summary, one body row, and the hint always survive; a terminal
|
|
107
|
-
* shorter than those is left to the host.
|
|
108
|
-
*/
|
|
109
|
-
export function fitOptionalLines(options: {
|
|
110
|
-
viewportRows: ViewportRowsSource | undefined;
|
|
111
|
-
body: string[];
|
|
112
|
-
note: string | undefined;
|
|
113
|
-
range: string | undefined;
|
|
114
|
-
detail: string[];
|
|
115
|
-
}): { body: string[]; range: string | undefined; detail: string[]; noteShown: boolean; detailBlank: boolean } {
|
|
116
|
-
const total = totalRows(options.viewportRows);
|
|
117
|
-
let { body, range, detail } = options;
|
|
118
|
-
let noteShown = options.note !== undefined && body[0] === options.note;
|
|
119
|
-
if (total === undefined) return { body, range, detail, noteShown, detailBlank: true };
|
|
120
|
-
const fits = (blank: boolean): boolean =>
|
|
121
|
-
FRAME_ROWS + body.length + (range === undefined ? 0 : 1) + detailBlockRows(detail, blank) <= total;
|
|
122
|
-
if (!fits(true)) range = undefined;
|
|
123
|
-
if (!fits(true) && noteShown) {
|
|
124
|
-
body = body.slice(1);
|
|
125
|
-
noteShown = false;
|
|
126
|
-
}
|
|
127
|
-
// The separator goes before the pane does: the focused row's findings are
|
|
128
|
-
// worth more than the blank above them.
|
|
129
|
-
const detailBlank = fits(true) || !fits(false);
|
|
130
|
-
if (!fits(false)) detail = [];
|
|
131
|
-
return { body, range, detail, noteShown, detailBlank };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
141
|
/** What {@link panelFrame} draws: the window, the body, and the chrome around it. */
|
|
135
142
|
export interface PanelFrameInput {
|
|
136
143
|
theme: Theme;
|
|
@@ -138,59 +145,89 @@ export interface PanelFrameInput {
|
|
|
138
145
|
cursor: ListCursor;
|
|
139
146
|
width: number;
|
|
140
147
|
header: string;
|
|
141
|
-
|
|
148
|
+
tabs: string;
|
|
142
149
|
entries: readonly PanelEntry[];
|
|
143
150
|
note?: string;
|
|
151
|
+
/** The focused row's block, drawn under the list below the pane breakpoint. */
|
|
144
152
|
detail: string[];
|
|
145
153
|
hint: string;
|
|
146
154
|
/** The line a section shows in place of its body when it has no entries. */
|
|
147
155
|
emptyLabel?: string;
|
|
156
|
+
/**
|
|
157
|
+
* The tallest section's row count. The body is padded to it (bounded by the
|
|
158
|
+
* viewport), so every section draws the same height.
|
|
159
|
+
*/
|
|
160
|
+
tallest: number;
|
|
161
|
+
/** The side pane's rows, in the wide layout only. */
|
|
162
|
+
side?: (rows: number) => string[];
|
|
148
163
|
draw: (entry: PanelEntry, index: number) => string | undefined;
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
/** The frame both sections share, and the state the caller has to remember. */
|
|
167
|
+
export interface PanelFrameResult {
|
|
168
|
+
lines: string[];
|
|
169
|
+
/** Whether the project note survived the row budget, for the click mapping. */
|
|
170
|
+
noteShown: boolean;
|
|
171
|
+
/** Body rows drawn, so a caller can map a click to a row. */
|
|
172
|
+
bodyRows: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
151
175
|
/**
|
|
152
|
-
* The frame
|
|
153
|
-
*
|
|
154
|
-
* chrome around them.
|
|
155
|
-
*
|
|
176
|
+
* The frame every section draws: the cursor window, the padded body, the range
|
|
177
|
+
* row when the list scrolls, the optional rows that give way on a short terminal,
|
|
178
|
+
* and the chrome around them.
|
|
179
|
+
*
|
|
180
|
+
* The height comes from one place. With a side pane the body is the whole free
|
|
181
|
+
* height, capped by the tallest section; without one the bottom pane is paid for
|
|
182
|
+
* first and the body gets what is left. Either way `target` is the same for all
|
|
183
|
+
* three sections at a given terminal size.
|
|
156
184
|
*/
|
|
157
|
-
export function panelFrame(input: PanelFrameInput):
|
|
158
|
-
const { theme, viewportRows, cursor, width, entries, note,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
185
|
+
export function panelFrame(input: PanelFrameInput): PanelFrameResult {
|
|
186
|
+
const { theme, viewportRows, cursor, width, entries, note, hint } = input;
|
|
187
|
+
const total = totalRows(viewportRows);
|
|
188
|
+
const wide = input.side !== undefined;
|
|
189
|
+
|
|
190
|
+
// Costs that a short terminal can shed, in the order they go: the bottom pane
|
|
191
|
+
// first (its facts move nowhere, but the list matters more), then the note.
|
|
192
|
+
let detail = wide ? [] : input.detail;
|
|
193
|
+
let noteShown = note !== undefined;
|
|
194
|
+
const budgetFor = (): number => {
|
|
195
|
+
if (total === undefined) return Math.max(MIN_BODY_ROWS, input.tallest);
|
|
196
|
+
return total - FRAME_ROWS - detailBlockRows(wide ? [] : detail) - (noteShown ? 1 : 0);
|
|
197
|
+
};
|
|
198
|
+
if (budgetFor() < MIN_BODY_ROWS && !wide) detail = [];
|
|
199
|
+
if (budgetFor() < MIN_BODY_ROWS && noteShown) noteShown = false;
|
|
200
|
+
|
|
201
|
+
const target = Math.max(1, Math.min(budgetFor(), Math.max(1, input.tallest)));
|
|
202
|
+
const capacity = Math.max(1, target - (noteShown && note !== undefined ? 1 : 0));
|
|
203
|
+
const scrolling = entries.length > capacity;
|
|
204
|
+
cursor.visible = Math.max(1, capacity - (scrolling ? 1 : 0));
|
|
165
205
|
cursor.sync(entries.length);
|
|
166
206
|
const { start, end } = cursor.window(entries.length);
|
|
167
207
|
|
|
168
208
|
const body: string[] = [];
|
|
169
|
-
if (note) body.push(note);
|
|
209
|
+
if (note !== undefined && noteShown) body.push(note);
|
|
170
210
|
if (entries.length === 0 && input.emptyLabel !== undefined) {
|
|
171
211
|
body.push(...emptyState(theme, width, { label: input.emptyLabel }));
|
|
172
212
|
}
|
|
173
213
|
for (let index = start; index < end; index += 1) {
|
|
174
|
-
const line = draw(entries[index], index);
|
|
214
|
+
const line = input.draw(entries[index], index);
|
|
175
215
|
if (line !== undefined) body.push(line);
|
|
176
216
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}),
|
|
194
|
-
noteShown: fitted.noteShown,
|
|
195
|
-
};
|
|
217
|
+
if (scrolling) {
|
|
218
|
+
body.push(formatRange(theme, width, selectableRange(entries, { start, end })));
|
|
219
|
+
}
|
|
220
|
+
while (body.length < target) body.push("");
|
|
221
|
+
|
|
222
|
+
const side = input.side === undefined ? undefined : input.side(target);
|
|
223
|
+
const lines = dockListFrame({
|
|
224
|
+
theme,
|
|
225
|
+
width,
|
|
226
|
+
header: input.header,
|
|
227
|
+
summary: input.tabs,
|
|
228
|
+
body: side === undefined ? body : composeRows(theme, width, body, side),
|
|
229
|
+
detail: detail.length > 0 ? detail : undefined,
|
|
230
|
+
hint,
|
|
231
|
+
});
|
|
232
|
+
return { lines, noteShown, bodyRows: target };
|
|
196
233
|
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control center's key table: the binding, the hint row it appears in, and
|
|
3
|
+
* the help block `?` prints.
|
|
4
|
+
*
|
|
5
|
+
* One table, so a help line cannot drift from a binding: the component dispatches
|
|
6
|
+
* through {@link keyFor}, the hint row is {@link hintsFor}, and the help block is
|
|
7
|
+
* {@link helpRows}. Movement is not here — `lib/tui/cursor.ts` owns the arrows,
|
|
8
|
+
* `j`/`k`, PgUp/PgDn and Home/End for every screen in the package — so the help
|
|
9
|
+
* block names movement in its own row and the table stays the actions this screen
|
|
10
|
+
* alone defines.
|
|
11
|
+
*
|
|
12
|
+
* Value-only: pure functions, the theme passed in.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { ScreenHint } from "../../../../lib/tui/chrome.ts";
|
|
18
|
+
import { clip, padTo } from "../../../../lib/tui/columns.ts";
|
|
19
|
+
import { isCloseKey } from "../../../../lib/tui/cursor.ts";
|
|
20
|
+
import { BODY_INDENT } from "../../../../lib/tui/indent.ts";
|
|
21
|
+
import { paint } from "../../../../lib/tui/tokens.ts";
|
|
22
|
+
import type { PanelSection } from "../types.ts";
|
|
23
|
+
import { hintLine, noticeLine } from "./frame.ts";
|
|
24
|
+
import { editHints } from "./settings-view.ts";
|
|
25
|
+
|
|
26
|
+
/** One action the screen binds. */
|
|
27
|
+
export type PanelKeyId =
|
|
28
|
+
| "close"
|
|
29
|
+
| "previousSection"
|
|
30
|
+
| "nextSection"
|
|
31
|
+
| "toggle"
|
|
32
|
+
| "change"
|
|
33
|
+
| "detail"
|
|
34
|
+
| "scope"
|
|
35
|
+
| "filter"
|
|
36
|
+
| "attention"
|
|
37
|
+
| "refresh"
|
|
38
|
+
| "reload"
|
|
39
|
+
| "reset"
|
|
40
|
+
| "json"
|
|
41
|
+
| "inherit"
|
|
42
|
+
| "help";
|
|
43
|
+
|
|
44
|
+
/** One binding, its hint label, and what the help block says about it. */
|
|
45
|
+
export interface PanelKey {
|
|
46
|
+
id: PanelKeyId;
|
|
47
|
+
/** The keys as the hint row and the help block print them. */
|
|
48
|
+
label: string;
|
|
49
|
+
/** The sections that offer it. A key absent from a section is not hinted there. */
|
|
50
|
+
sections: readonly PanelSection[];
|
|
51
|
+
/** One plain sentence for the help block. */
|
|
52
|
+
help: string;
|
|
53
|
+
/** Whether `data` is this key. */
|
|
54
|
+
match(data: string): boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every section, and the two resource lists that share a set of keys. */
|
|
58
|
+
const ALL: readonly PanelSection[] = ["extensions", "skills", "settings"];
|
|
59
|
+
const LISTS: readonly PanelSection[] = ["extensions", "skills"];
|
|
60
|
+
|
|
61
|
+
/** Shift+Tab arrives as this sequence on terminals without the Kitty protocol. */
|
|
62
|
+
const SHIFT_TAB = "\u001b[Z";
|
|
63
|
+
|
|
64
|
+
/** The screen's keys, in help order. */
|
|
65
|
+
export const PANEL_KEYS: readonly PanelKey[] = [
|
|
66
|
+
{
|
|
67
|
+
id: "close",
|
|
68
|
+
label: "Esc",
|
|
69
|
+
sections: ALL,
|
|
70
|
+
help: "Close the screen",
|
|
71
|
+
match: (data) => isCloseKey(data),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "previousSection",
|
|
75
|
+
label: "←",
|
|
76
|
+
sections: ALL,
|
|
77
|
+
help: "Previous section",
|
|
78
|
+
match: (data) =>
|
|
79
|
+
matchesKey(data, Key.left) || data === "h" || matchesKey(data, Key.shift("tab")) || data === SHIFT_TAB,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "nextSection",
|
|
83
|
+
label: "→",
|
|
84
|
+
sections: ALL,
|
|
85
|
+
help: "Next section",
|
|
86
|
+
match: (data) => matchesKey(data, Key.right) || data === "l" || matchesKey(data, Key.tab),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "toggle",
|
|
90
|
+
label: "Space",
|
|
91
|
+
sections: LISTS,
|
|
92
|
+
help: "Enable or disable, and re-probe when it changes",
|
|
93
|
+
match: (data) => matchesKey(data, Key.space),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "detail",
|
|
97
|
+
label: "Enter",
|
|
98
|
+
sections: LISTS,
|
|
99
|
+
help: "Show or hide the focused row's findings",
|
|
100
|
+
match: (data) => matchesKey(data, Key.enter),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "change",
|
|
104
|
+
label: "Space",
|
|
105
|
+
sections: ["settings"],
|
|
106
|
+
help: "Change the focused value",
|
|
107
|
+
match: (data) => matchesKey(data, Key.space) || matchesKey(data, Key.enter),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "reset",
|
|
111
|
+
label: "u",
|
|
112
|
+
sections: ["settings"],
|
|
113
|
+
help: "Reset the focused value to its default",
|
|
114
|
+
match: (data) => data === "u",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "json",
|
|
118
|
+
label: "J",
|
|
119
|
+
sections: ["settings"],
|
|
120
|
+
help: "Show the whole `bytefly.json` section the value lives in",
|
|
121
|
+
match: (data) => data === "J",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "scope",
|
|
125
|
+
label: "s",
|
|
126
|
+
sections: ALL,
|
|
127
|
+
help: "Switch where a change is written: Global or Project",
|
|
128
|
+
match: (data) => data === "s",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "filter",
|
|
132
|
+
label: "/",
|
|
133
|
+
sections: ALL,
|
|
134
|
+
help: "Filter the rows by text; Esc clears it",
|
|
135
|
+
match: (data) => matchesKey(data, Key.slash),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "attention",
|
|
139
|
+
label: "!",
|
|
140
|
+
sections: ALL,
|
|
141
|
+
help: "Show only the rows that need attention",
|
|
142
|
+
match: (data) => matchesKey(data, Key.exclamation),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "refresh",
|
|
146
|
+
label: "r",
|
|
147
|
+
sections: ["extensions"],
|
|
148
|
+
help: "Re-probe every extension's requirements",
|
|
149
|
+
match: (data) => data === "r",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "inherit",
|
|
153
|
+
label: "x",
|
|
154
|
+
sections: LISTS,
|
|
155
|
+
help: "Drop the Project override, so Global applies again",
|
|
156
|
+
match: (data) => data === "x",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "reload",
|
|
160
|
+
label: "R",
|
|
161
|
+
sections: ALL,
|
|
162
|
+
help: "Reload now, to apply what you changed",
|
|
163
|
+
match: (data) => data === "R",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: "help",
|
|
167
|
+
label: "?",
|
|
168
|
+
sections: ALL,
|
|
169
|
+
help: "Show every key, in place of the focused row's facts",
|
|
170
|
+
match: (data) => matchesKey(data, Key.question),
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/** The binding for `id`; a table with no row would be a bug, not a key state. */
|
|
175
|
+
export function keyOf(id: PanelKeyId): PanelKey {
|
|
176
|
+
const key = PANEL_KEYS.find((entry) => entry.id === id);
|
|
177
|
+
if (key === undefined) throw new Error(`No panel key "${id}"`);
|
|
178
|
+
return key;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The action `data` asks for in `section`, or `undefined` for anything else. */
|
|
182
|
+
export function keyFor(data: string, section: PanelSection): PanelKeyId | undefined {
|
|
183
|
+
for (const key of PANEL_KEYS) {
|
|
184
|
+
if (key.sections.includes(section) && key.match(data)) return key.id;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** What the hint row needs to know about the screen's state. */
|
|
190
|
+
export interface HintState {
|
|
191
|
+
/** The focused row's findings are expanded (lists only). */
|
|
192
|
+
expanded: boolean;
|
|
193
|
+
/** A probe is in flight, so the refresh key is not offered. */
|
|
194
|
+
refreshing: boolean;
|
|
195
|
+
/** Project scope can hold an override, Global cannot (lists only). */
|
|
196
|
+
projectScope: boolean;
|
|
197
|
+
/** Something was changed this session, so the reload key applies. */
|
|
198
|
+
dirty: boolean;
|
|
199
|
+
/** The help block is open. */
|
|
200
|
+
help: boolean;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One pale, dim hint, or an accented primary one. */
|
|
204
|
+
function hint(text: string, primary = false): ScreenHint {
|
|
205
|
+
return primary ? { text, color: "accent" } : text;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The hint row for a section, most useful first: whole hints drop from the right
|
|
210
|
+
* as the width runs out, so the close and section keys survive on a narrow
|
|
211
|
+
* terminal and `?` is the one that leaves first.
|
|
212
|
+
*/
|
|
213
|
+
export function hintsFor(section: PanelSection, state: HintState): ScreenHint[] {
|
|
214
|
+
const hints: ScreenHint[] = [
|
|
215
|
+
hint("Esc close"),
|
|
216
|
+
hint("←/→ section"),
|
|
217
|
+
section === "settings" ? hint("Space change", true) : hint("Space toggle", true),
|
|
218
|
+
...(section === "settings" ? [] : [hint(state.expanded ? "Enter collapse" : "Enter detail", true)]),
|
|
219
|
+
hint("/ filter"),
|
|
220
|
+
];
|
|
221
|
+
if (section === "settings") {
|
|
222
|
+
hints.push(hint("u reset"), hint(state.help ? "? hide" : "? keys"));
|
|
223
|
+
return hints;
|
|
224
|
+
}
|
|
225
|
+
hints.push(hint(state.help ? "? hide" : "? keys"));
|
|
226
|
+
if (section === "extensions") hints.push(hint(state.refreshing ? "refreshing…" : "r refresh"));
|
|
227
|
+
if (state.dirty) hints.push(hint("R reload", true));
|
|
228
|
+
hints.push(hint("s scope"));
|
|
229
|
+
if (state.projectScope) hints.push(hint("x inherit"));
|
|
230
|
+
hints.push(hint("↑/↓ select"));
|
|
231
|
+
return hints;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The hint row a section shows: an editor's own keys while one is open, else the
|
|
236
|
+
* transient notice, else the section's hints. The three cases are exclusive, so a
|
|
237
|
+
* notice never shares the row with a hint that could not work.
|
|
238
|
+
*/
|
|
239
|
+
export function hintRow(options: {
|
|
240
|
+
theme: Theme;
|
|
241
|
+
width: number;
|
|
242
|
+
section: PanelSection;
|
|
243
|
+
state: HintState;
|
|
244
|
+
/** A settings row or the filter is being typed into. */
|
|
245
|
+
editing: boolean;
|
|
246
|
+
/** The last refusal or failure, shown until the next key. */
|
|
247
|
+
notice?: string;
|
|
248
|
+
}): string {
|
|
249
|
+
const { theme, width, section, state, editing, notice } = options;
|
|
250
|
+
if (editing) return hintLine(theme, width, editHints());
|
|
251
|
+
if (notice !== undefined) return noticeLine(theme, width, notice);
|
|
252
|
+
return hintLine(theme, width, hintsFor(section, state));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One help row: the keys it names, and what they do.
|
|
257
|
+
*
|
|
258
|
+
* A row naming one binding takes that binding's own sentence, so the two cannot
|
|
259
|
+
* disagree; a row that explains a verb for this section — `!` narrows settings to
|
|
260
|
+
* what changed, not to what is broken — states its own.
|
|
261
|
+
*/
|
|
262
|
+
interface HelpRow {
|
|
263
|
+
/** Table bindings this row documents; empty for a row over shared movement keys. */
|
|
264
|
+
ids: readonly PanelKeyId[];
|
|
265
|
+
/** The keys as printed; joined from the bindings unless the row sets its own. */
|
|
266
|
+
label?: string;
|
|
267
|
+
/** What the keys do here; the single binding's own sentence when omitted. */
|
|
268
|
+
help?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Rows over the movement keys, which `lib/tui/cursor.ts` owns for every screen. */
|
|
272
|
+
const MOVEMENT: readonly HelpRow[] = [
|
|
273
|
+
{ ids: [], label: "↑/↓ j/k", help: "Move to the next or previous row" },
|
|
274
|
+
{ ids: [], label: "g G", help: "Jump to the first or last row" },
|
|
275
|
+
{ ids: [], label: "[ ]", help: "Jump to the next or previous group" },
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
/** The help block per section: the table's own rows, plus movement and the close pair. */
|
|
279
|
+
const HELP: Record<PanelSection, readonly HelpRow[]> = {
|
|
280
|
+
extensions: [
|
|
281
|
+
{ ids: ["close", "previousSection", "nextSection"], help: "Close, or change section" },
|
|
282
|
+
...MOVEMENT,
|
|
283
|
+
{ ids: ["toggle"], help: "Enable or disable the focused extension" },
|
|
284
|
+
{ ids: ["detail"], help: "Show or hide its findings" },
|
|
285
|
+
{ ids: ["attention"], help: "Show only the rows that need attention" },
|
|
286
|
+
{ ids: ["filter"], help: "Filter the rows by text; Esc clears it" },
|
|
287
|
+
{ ids: ["scope"], help: "Switch where a change is written" },
|
|
288
|
+
{ ids: ["refresh"], help: "Re-probe every extension's requirements" },
|
|
289
|
+
{ ids: ["inherit"], help: "Drop the Project override" },
|
|
290
|
+
{ ids: ["reload"], help: "Reload to apply what you changed" },
|
|
291
|
+
{ ids: ["help"], help: "Show or hide this key list" },
|
|
292
|
+
],
|
|
293
|
+
skills: [
|
|
294
|
+
{ ids: ["close", "previousSection", "nextSection"], help: "Close, or change section" },
|
|
295
|
+
...MOVEMENT,
|
|
296
|
+
{ ids: ["toggle"], help: "Enable or disable the focused skill" },
|
|
297
|
+
{ ids: ["detail"], help: "Show or hide what its `SKILL.md` declares" },
|
|
298
|
+
{ ids: ["attention"], help: "Show only the rows that need attention" },
|
|
299
|
+
{ ids: ["filter"], help: "Filter the rows by text; Esc clears it" },
|
|
300
|
+
{ ids: ["scope"], help: "Switch where a change is written" },
|
|
301
|
+
{ ids: ["inherit"], help: "Drop the Project override" },
|
|
302
|
+
{ ids: ["reload"], help: "Reload to apply what you changed" },
|
|
303
|
+
{ ids: ["help"], help: "Show or hide this key list" },
|
|
304
|
+
],
|
|
305
|
+
settings: [
|
|
306
|
+
{ ids: ["close", "previousSection", "nextSection"], help: "Close, or change section" },
|
|
307
|
+
...MOVEMENT,
|
|
308
|
+
{ ids: ["change"], help: "Change the focused value" },
|
|
309
|
+
{ ids: ["reset"], help: "Reset it to its default" },
|
|
310
|
+
{ ids: ["json"], help: "Show the section it lives in" },
|
|
311
|
+
{ ids: ["attention"], help: "Show only the values you changed" },
|
|
312
|
+
{ ids: ["filter"], help: "Filter the rows by text; Esc clears it" },
|
|
313
|
+
{ ids: ["scope"], help: "Switch where a change is written" },
|
|
314
|
+
{ ids: ["reload"], help: "Reload to apply what you changed" },
|
|
315
|
+
{ ids: ["help"], help: "Show or hide this key list" },
|
|
316
|
+
],
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/** The bindings a section's help block documents, for the drift test. */
|
|
320
|
+
export function helpKeyIds(section: PanelSection): PanelKeyId[] {
|
|
321
|
+
return HELP[section].flatMap((row) => [...row.ids]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The keys a help row prints, from the bindings it names. */
|
|
325
|
+
function helpLabel(row: HelpRow): string {
|
|
326
|
+
if (row.label !== undefined) return row.label;
|
|
327
|
+
return row.ids.map((id) => keyOf(id).label).join(" ");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** What a help row says: its own sentence, or the single binding's. */
|
|
331
|
+
function helpText(row: HelpRow): string {
|
|
332
|
+
if (row.help !== undefined) return row.help;
|
|
333
|
+
return row.ids.map((id) => keyOf(id).help).join("; ");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The help block for a section: one `keys action` line per row, the key column
|
|
338
|
+
* padded to the widest label so the sentences line up. Every binding the section
|
|
339
|
+
* offers has a row, and every row names a binding the section offers —
|
|
340
|
+
* `keys.test.ts` holds both directions.
|
|
341
|
+
*/
|
|
342
|
+
export function helpRows(theme: Theme, width: number, section: PanelSection): string[] {
|
|
343
|
+
const rows = HELP[section].map((row) => ({ label: helpLabel(row), help: helpText(row) }));
|
|
344
|
+
const column = Math.max(...rows.map((row) => row.label.length)) + 2;
|
|
345
|
+
const indent = " ".repeat(BODY_INDENT);
|
|
346
|
+
return rows.map((row) =>
|
|
347
|
+
clip(
|
|
348
|
+
`${indent}${paint(theme, "faint", padTo(row.label, column))}${paint(theme, "meta", row.help)}`,
|
|
349
|
+
Math.max(1, width),
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
}
|