@huseynsnmz/pi-suite 0.1.0 → 0.2.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -1
  3. package/package.json +6 -7
  4. package/packages/board/package.json +0 -2
  5. package/packages/board/src/core/rows.mjs +21 -5
  6. package/packages/board/src/ui/dashboard.ts +36 -20
  7. package/packages/commands/LICENSE +21 -0
  8. package/packages/commands/README.md +19 -0
  9. package/packages/commands/package.json +22 -0
  10. package/packages/commands/src/index.ts +43 -0
  11. package/packages/commands/src/security-review.ts +109 -0
  12. package/packages/frame/README.md +1 -1
  13. package/packages/frame/package.json +1 -4
  14. package/packages/frame/src/data/github.ts +1 -1
  15. package/packages/frame/src/data/mode.ts +28 -2
  16. package/packages/frame/src/data/snapshot.ts +1 -1
  17. package/packages/frame/src/data/start-notes.ts +1 -1
  18. package/packages/frame/src/data/totals.ts +2 -2
  19. package/packages/frame/src/data/visits.ts +1 -1
  20. package/packages/frame/src/index.ts +22 -2
  21. package/packages/frame/src/kit/LICENSE +21 -0
  22. package/packages/frame/src/kit/app/attribution.ts +30 -0
  23. package/packages/frame/src/kit/app/builder.ts +186 -0
  24. package/packages/frame/src/kit/app/claim.ts +27 -0
  25. package/packages/frame/src/kit/app/feature.ts +91 -0
  26. package/packages/frame/src/kit/app/host.ts +214 -0
  27. package/packages/frame/src/kit/app/renderers.ts +62 -0
  28. package/packages/frame/src/kit/app/scope.ts +125 -0
  29. package/packages/frame/src/kit/board/model.ts +337 -0
  30. package/packages/frame/src/kit/contract.ts +79 -0
  31. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  32. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  33. package/packages/frame/src/kit/contracts/board.ts +6 -0
  34. package/packages/frame/src/kit/contracts/by.ts +22 -0
  35. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  36. package/packages/frame/src/kit/contracts/events.ts +89 -0
  37. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  38. package/packages/frame/src/kit/contracts/names.ts +12 -0
  39. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  40. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  41. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  42. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  43. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  44. package/packages/frame/src/kit/contracts/views.ts +42 -0
  45. package/packages/frame/src/kit/control.ts +59 -0
  46. package/packages/frame/src/kit/dark-theme.ts +41 -0
  47. package/packages/frame/src/kit/decode.ts +210 -0
  48. package/packages/frame/src/kit/events/log.ts +313 -0
  49. package/packages/frame/src/kit/format.ts +25 -0
  50. package/packages/frame/src/kit/host.ts +4 -0
  51. package/packages/frame/src/kit/index.ts +25 -0
  52. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  53. package/packages/frame/src/kit/overlay-images.ts +36 -0
  54. package/packages/frame/src/kit/paths.ts +16 -0
  55. package/packages/frame/src/kit/screen/client.ts +74 -0
  56. package/packages/frame/src/kit/screen/feature.ts +64 -0
  57. package/packages/frame/src/kit/screen/model.ts +182 -0
  58. package/packages/frame/src/kit/screen/view.ts +838 -0
  59. package/packages/frame/src/kit/settings/files.ts +90 -0
  60. package/packages/frame/src/kit/settings/setting.ts +57 -0
  61. package/packages/frame/src/kit/settings/store.ts +192 -0
  62. package/packages/frame/src/kit/sources/file.ts +129 -0
  63. package/packages/frame/src/kit/sources/follow.ts +155 -0
  64. package/packages/frame/src/kit/testing.ts +260 -0
  65. package/packages/frame/src/kit/totals.ts +72 -0
  66. package/packages/frame/src/kit/ui/entry.ts +75 -0
  67. package/packages/frame/src/kit/ui/frame.ts +212 -0
  68. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  69. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  70. package/packages/frame/src/render/diff.ts +55 -2
  71. package/packages/frame/src/render/fit.ts +1 -1
  72. package/packages/frame/src/render/format.ts +1 -1
  73. package/packages/frame/src/render/segments.ts +1 -1
  74. package/packages/frame/src/render/streaming.ts +1 -1
  75. package/packages/frame/src/render/tools.ts +1 -1
  76. package/packages/frame/src/settings.ts +1 -1
  77. package/packages/frame/src/ui/editor.ts +1 -1
  78. package/packages/frame/src/ui/effort.ts +1 -1
  79. package/packages/frame/src/ui/header.ts +1 -1
  80. package/packages/frame/src/ui/outputs.ts +1 -1
  81. package/packages/frame/src/ui/pi-entries.ts +28 -6
  82. package/packages/frame/src/ui/previews.ts +1 -1
  83. package/packages/frame/src/ui/queue.ts +1 -1
  84. package/packages/frame/src/ui/todos.ts +1 -1
  85. package/packages/frame/src/ui/tool-frame.ts +1 -1
  86. package/packages/frame/src/view/state.ts +1 -1
  87. package/packages/frame/src/view/transcript.ts +1 -1
  88. package/packages/frame/src/view/view.ts +1 -1
  89. package/packages/web/package.json +1 -3
  90. package/packages/web/src/extract.ts +2 -2
  91. package/packages/web/src/markdown.ts +122 -0
  92. package/src/index.ts +1 -1
  93. package/src/settings.ts +1 -0
@@ -0,0 +1,260 @@
1
+ // Fakes for testing features without pi. Two fakes on one bus act like two extensions in one process.
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ createEventBus,
7
+ createSyntheticSourceInfo,
8
+ type EntryRenderer,
9
+ type MessageRenderer,
10
+ type EventBus,
11
+ type ExtensionAPI,
12
+ type ExtensionContext,
13
+ type RegisteredCommand,
14
+ type ToolDefinition,
15
+ type ToolInfo,
16
+ type ToolRendererResolver,
17
+ } from "@earendil-works/pi-coding-agent";
18
+
19
+ import type { FeatureScope, ScreenEntry, ScreenGroup } from "./app/feature.ts";
20
+ import { darkTheme } from "./dark-theme.ts";
21
+ import { SettingsStore } from "./settings/store.ts";
22
+
23
+ export function toolInfo(name: string, extra: Partial<ToolInfo> = {}): ToolInfo {
24
+ return {
25
+ name,
26
+ description: "",
27
+ parameters: { type: "object" } as ToolInfo["parameters"],
28
+ exposure: "direct",
29
+ sourceInfo: createSyntheticSourceInfo(`<test:${name}>`, { source: "test" }),
30
+ ...extra,
31
+ };
32
+ }
33
+
34
+ export type FakeHandler = (event: unknown, ctx: ExtensionContext) => unknown;
35
+ type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
36
+
37
+ export interface FakePi {
38
+ pi: ExtensionAPI;
39
+ handlers: Map<string, FakeHandler[]>;
40
+ commands: Map<string, CommandOptions>;
41
+ shortcuts: string[];
42
+ shortcutHandlers: Map<string, (ctx: ExtensionContext) => unknown>;
43
+ tools: ToolDefinition[];
44
+ allTools: ToolInfo[];
45
+ providers: { name: string; config: unknown }[];
46
+ renderers: Map<string, EntryRenderer>;
47
+ messageRenderers: Map<string, MessageRenderer>;
48
+ toolRenderers: ToolRendererResolver[];
49
+ entries: { customType: string; data: unknown }[];
50
+ messages: unknown[];
51
+ userMessages: unknown[];
52
+ sessionName: string | undefined;
53
+ thinkingLevel: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;
54
+ model: { provider: string; id: string } | undefined;
55
+ /** In order, returning what each handler returned. */
56
+ fire(name: string, event: unknown, ctx: ExtensionContext): Promise<unknown[]>;
57
+ count(name: string): number;
58
+ }
59
+
60
+ export function fakePi(bus: EventBus = createEventBus()): FakePi {
61
+ const handlers = new Map<string, FakeHandler[]>();
62
+ const commands = new Map<string, CommandOptions>();
63
+ const fake: FakePi = {
64
+ pi: undefined as unknown as ExtensionAPI,
65
+ handlers,
66
+ commands,
67
+ shortcuts: [],
68
+ shortcutHandlers: new Map(),
69
+ tools: [],
70
+ allTools: [],
71
+ providers: [],
72
+ renderers: new Map(),
73
+ messageRenderers: new Map(),
74
+ toolRenderers: [],
75
+ entries: [],
76
+ messages: [],
77
+ userMessages: [],
78
+ sessionName: undefined,
79
+ thinkingLevel: undefined,
80
+ model: undefined,
81
+ async fire(name, event, ctx) {
82
+ const results: unknown[] = [];
83
+ for (const handler of handlers.get(name) ?? []) {
84
+ // oxlint-disable-next-line no-await-in-loop -- pi runs handlers one at a time, in order
85
+ results.push(await handler(event, ctx));
86
+ }
87
+ return results;
88
+ },
89
+ count: (name) => handlers.get(name)?.length ?? 0,
90
+ };
91
+
92
+ fake.pi = {
93
+ on(name: string, handler: FakeHandler) {
94
+ handlers.set(name, [...(handlers.get(name) ?? []), handler]);
95
+ return () => {};
96
+ },
97
+ events: bus,
98
+ registerCommand: (name: string, options: CommandOptions) => void commands.set(name, options),
99
+ registerShortcut: (
100
+ shortcut: unknown,
101
+ options: { handler: (ctx: ExtensionContext) => unknown },
102
+ ) => {
103
+ fake.shortcuts.push(String(shortcut));
104
+ fake.shortcutHandlers.set(String(shortcut), options.handler);
105
+ },
106
+ registerTool: (tool: ToolDefinition) => void fake.tools.push(tool),
107
+ getAllTools: () => fake.allTools,
108
+ getCommands: () => [],
109
+ registerProvider: (name: unknown, config?: unknown) =>
110
+ void fake.providers.push({ name: String(name), config }),
111
+ registerEntryRenderer: (customType: string, renderer: EntryRenderer) =>
112
+ void fake.renderers.set(customType, renderer),
113
+ registerMessageRenderer: (customType: string, renderer: MessageRenderer) =>
114
+ void fake.messageRenderers.set(customType, renderer),
115
+ registerToolRenderer: (resolver: ToolRendererResolver) =>
116
+ void fake.toolRenderers.push(resolver),
117
+ appendEntry: (customType: string, data?: unknown) =>
118
+ void fake.entries.push({ customType, data }),
119
+ sendMessage: (message: unknown) => void fake.messages.push(message),
120
+ sendUserMessage: (content: unknown) => void fake.userMessages.push(content),
121
+ setSessionName: (name: string) => void (fake.sessionName = name),
122
+ getSessionName: () => fake.sessionName,
123
+ setThinkingLevel: (level: string) =>
124
+ void (fake.thinkingLevel = level as typeof fake.thinkingLevel),
125
+ setModel: (model: { provider: string; id: string }) => {
126
+ fake.model = { provider: model.provider, id: model.id };
127
+ return Promise.resolve(true);
128
+ },
129
+ } as unknown as ExtensionAPI;
130
+
131
+ return fake;
132
+ }
133
+
134
+ export interface FakeScopeOptions {
135
+ id?: string;
136
+ pi?: FakePi;
137
+ notes?: string[];
138
+ settingsPath?: string;
139
+ screen?: ScreenEntry[];
140
+ screenGroups?: ScreenGroup[];
141
+ }
142
+
143
+ /** Session hooks run on `fire("session_start")` and `fire("session_shutdown")`. */
144
+ export function fakeScope(options: FakeScopeOptions = {}): FeatureScope {
145
+ const fake = options.pi ?? fakePi();
146
+ const notes = options.notes ?? [];
147
+ const rows = options.screen ?? [];
148
+ const groups = options.screenGroups ?? [];
149
+ const settings = new SettingsStore(
150
+ options.settingsPath ?? join(tmpdir(), `pi-kit-scope-${process.pid}-${Math.random()}.json`),
151
+ );
152
+
153
+ return {
154
+ ...fake.pi,
155
+ id: options.id ?? "test",
156
+ settings,
157
+ on: fake.pi.on,
158
+ registerCommand: (name, command) => fake.pi.registerCommand(name, command),
159
+ onSessionStart: (run) => void fake.pi.on("session_start", (_event, ctx) => run(ctx)),
160
+ onShutdown: (run) => void fake.pi.on("session_shutdown", () => run()),
161
+ defer: (run, ms = 0) => void setTimeout(run, ms),
162
+ warn: (message) => void notes.push(message),
163
+ has: () => false,
164
+ screen: {
165
+ value: (row) => void rows.push({ kind: "value", ...row }),
166
+ action: (row) => void rows.push({ kind: "action", ...row }),
167
+ info: (row) => void rows.push({ kind: "info", ...row }),
168
+ rows: (provider) => void groups.push(provider),
169
+ },
170
+ };
171
+ }
172
+
173
+ /** So a test can fire a key at the terminal. */
174
+ export const keystrokes: ((data: string) => unknown)[] = [];
175
+
176
+ export function fakeContext(
177
+ notes: string[] = [],
178
+ hasUI = true,
179
+ extra: Record<string, unknown> = {},
180
+ ): ExtensionContext {
181
+ return {
182
+ hasUI,
183
+ mode: hasUI ? "tui" : "print",
184
+ cwd: "/nonexistent",
185
+ isProjectTrusted: () => false,
186
+ ...extra,
187
+ ui: {
188
+ notify: (message: string) => notes.push(message),
189
+ statuses: new Map<string, string | undefined>(),
190
+ setStatus(key: string, text: string | undefined) {
191
+ this.statuses.set(key, text);
192
+ },
193
+ onTerminalInput: (handler: (data: string) => unknown) => {
194
+ keystrokes.push(handler);
195
+ return () => {
196
+ const at = keystrokes.indexOf(handler);
197
+ if (at !== -1) keystrokes.splice(at, 1);
198
+ };
199
+ },
200
+ ...(extra.ui as Record<string, unknown> | undefined),
201
+ },
202
+ } as unknown as ExtensionContext;
203
+ }
204
+
205
+ export interface FakeTerminal {
206
+ press(...keys: string[]): void;
207
+ draw(key: string, width?: number): string[] | undefined;
208
+ text: string;
209
+ /** So a test can tell one dialog from two. */
210
+ built: number;
211
+ }
212
+
213
+ export function fakeTerminal(ctx: ExtensionContext): FakeTerminal {
214
+ const ui = ctx.ui as unknown as Record<string, unknown>;
215
+ const listen = ui.onTerminalInput as (handler: (data: string) => unknown) => () => void;
216
+ const listeners: ((data: string) => unknown)[] = [];
217
+ const widgets = new Map<
218
+ string,
219
+ { render(width: number): string[]; handleInput(data: string): void }
220
+ >();
221
+ const terminal: FakeTerminal = {
222
+ text: "",
223
+ built: 0,
224
+ press: (...keys) => {
225
+ for (const key of keys) {
226
+ for (const listener of listeners.slice()) {
227
+ const taken = listener(key) as { consume?: boolean } | undefined;
228
+ if (taken?.consume === true) break;
229
+ }
230
+ }
231
+ },
232
+ draw: (key, width = 100) => widgets.get(key)?.render(width),
233
+ };
234
+ ui.onTerminalInput = (handler: (data: string) => unknown) => {
235
+ listeners.push(handler);
236
+ const stop = listen(handler);
237
+ return () => {
238
+ stop();
239
+ const at = listeners.indexOf(handler);
240
+ if (at !== -1) listeners.splice(at, 1);
241
+ };
242
+ };
243
+ ui.setWidget = (key: string, content: unknown) => {
244
+ if (content === undefined) {
245
+ widgets.delete(key);
246
+ return;
247
+ }
248
+ const make = content as (
249
+ tui: unknown,
250
+ theme: unknown,
251
+ ) => { render(width: number): string[]; handleInput(data: string): void };
252
+ terminal.built += 1;
253
+ widgets.set(key, make({ terminal: { rows: 40 }, requestRender: () => {} }, darkTheme()));
254
+ };
255
+ ui.getEditorText = () => terminal.text;
256
+ ui.setEditorText = (text: string) => {
257
+ terminal.text = text;
258
+ };
259
+ return terminal;
260
+ }
@@ -0,0 +1,72 @@
1
+ // One sum for the terminal's footer and the phone's list, so the two cannot disagree.
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { tasksUsageOf } from "./legacy-tasks.ts";
5
+
6
+ export interface Totals {
7
+ input: number;
8
+ output: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ cost: number;
12
+ }
13
+
14
+ export interface UsageLike {
15
+ input: number;
16
+ output: number;
17
+ cacheRead: number;
18
+ cacheWrite: number;
19
+ cost: { total: number };
20
+ }
21
+
22
+ export function emptyTotals(): Totals {
23
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
24
+ }
25
+
26
+ export function promptOf(totals: Totals): number {
27
+ return totals.input + totals.cacheRead + totals.cacheWrite;
28
+ }
29
+
30
+ export function tokensOf(totals: Totals): number {
31
+ return promptOf(totals) + totals.output;
32
+ }
33
+
34
+ export function usageTotals(usage: UsageLike): Totals {
35
+ const totals = emptyTotals();
36
+ add(totals, usage);
37
+ return totals;
38
+ }
39
+
40
+ /** Every message pi recorded, including what a compaction ate. */
41
+ export function totalsOf(entries: readonly SessionEntry[]): Totals {
42
+ const totals = emptyTotals();
43
+
44
+ for (const entry of entries) {
45
+ if (entry.type === "usage") add(totals, entry.usage);
46
+ else if (entry.type === "message" && entry.message.role === "assistant")
47
+ add(totals, entry.message.usage);
48
+ else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage)
49
+ add(totals, entry.message.usage);
50
+ else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage)
51
+ add(totals, entry.usage);
52
+ else if (entry.type === "custom") addTasks(totals, tasksUsageOf(entry));
53
+ }
54
+
55
+ return totals;
56
+ }
57
+
58
+ function add(totals: Totals, usage: UsageLike): void {
59
+ totals.input += usage.input;
60
+ totals.output += usage.output;
61
+ totals.cacheRead += usage.cacheRead;
62
+ totals.cacheWrite += usage.cacheWrite;
63
+ totals.cost += usage.cost.total;
64
+ }
65
+
66
+ /** Pi does not count the removed tasks' model calls; they count here. */
67
+ function addTasks(totals: Totals, usage: ReturnType<typeof tasksUsageOf>): void {
68
+ if (usage === undefined) return;
69
+ totals.input += usage.input;
70
+ totals.output += usage.output;
71
+ totals.cost += usage.cost;
72
+ }
@@ -0,0 +1,75 @@
1
+ // The frame a transcript entry wears. See DESIGN.md.
2
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+
5
+ import { CUT, LINE } from "./glyphs.ts";
6
+
7
+ /** Both sides and a space inside each. */
8
+ export const CHANGES_X = 4;
9
+ /** Drawn as a rule across, as between a call and what it printed. */
10
+ export const CHANGES_CUT = "\u0000cut";
11
+
12
+ export interface EntryFrame {
13
+ top?: string;
14
+ topRight?: string;
15
+ bottom?: string;
16
+ bottomRight?: string;
17
+ color?: ThemeColor;
18
+ /** Something that has not happened yet, such as a message waiting to be sent. */
19
+ dashed?: boolean;
20
+ /** Pi's own blocks bring their own column, so they take 0. */
21
+ pad?: 0 | 1;
22
+ }
23
+
24
+ const SOLID = { h: LINE.h, v: LINE.v };
25
+ const DASHED = { h: LINE.hDashed, v: LINE.vDashed };
26
+
27
+ /** `CHANGES_X` columns narrower than `width` (two with no pad). */
28
+ export function entryFrame(
29
+ body: readonly string[],
30
+ width: number,
31
+ theme: Theme,
32
+ frame: EntryFrame = {},
33
+ ): string[] {
34
+ const lines = frame.dashed ? DASHED : SOLID;
35
+ const paint = (text: string) => theme.fg(frame.color ?? "border", text);
36
+ const pad = " ".repeat(frame.pad ?? 1);
37
+ const inner = Math.max(1, width - 2 - pad.length * 2);
38
+ const edge = (corners: [string, string], left?: string, right?: string) => {
39
+ const end = (right ? ` ${right} ` : "") + paint(lines.h + corners[1]);
40
+ // A long label is cut, never the corner.
41
+ const room = Math.max(0, width - 2 - visibleWidth(end) - 2);
42
+ const label = left ? ` ${truncateToWidth(left, room, CUT)} ` : "";
43
+ const start = paint(corners[0] + lines.h) + label;
44
+ const fill = Math.max(0, width - visibleWidth(start) - visibleWidth(end));
45
+ const rule = start + paint(lines.h.repeat(fill)) + end;
46
+ return fill === 0 ? truncateToWidth(rule, width, "") : rule;
47
+ };
48
+ const side = paint(lines.v);
49
+ return [
50
+ edge([LINE.topLeft, LINE.topRight], frame.top, frame.topRight),
51
+ ...body.map((line) => {
52
+ if (line === CHANGES_CUT)
53
+ return paint(LINE.teeLeft + LINE.h.repeat(width - 2) + LINE.teeRight);
54
+ const cells = visibleWidth(line);
55
+ const cut = cells > inner ? truncateToWidth(line, inner, "") : line;
56
+ const shown = cells > inner ? visibleWidth(cut) : cells;
57
+ return `${side}${pad}${cut}${" ".repeat(Math.max(0, inner - shown))}${pad}${side}`;
58
+ }),
59
+ edge([LINE.bottomLeft, LINE.bottomRight], frame.bottom, frame.bottomRight),
60
+ ];
61
+ }
62
+
63
+ /** The transcript redraws every entry on each keystroke, so an entry draws once per width. */
64
+ export function drawnByWidth(draw: (width: number) => string[]): Component {
65
+ let drawn: { width: number; lines: string[] } | undefined;
66
+ return {
67
+ render(width) {
68
+ if (drawn?.width !== width) drawn = { width, lines: draw(width) };
69
+ return drawn.lines;
70
+ },
71
+ invalidate() {
72
+ drawn = undefined;
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,212 @@
1
+ // The frame every harness dialog wears. No surface paints a background. See DESIGN.md.
2
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+
5
+ import { CUT, GLYPH, LINE } from "./glyphs.ts";
6
+
7
+ export const FRAME_PAD = 2;
8
+ /** Both sides and their padding. */
9
+ export const FRAME_X = 2 + FRAME_PAD * 2;
10
+ export const FRAME_Y = 2;
11
+
12
+ export const POINTER = GLYPH.pointer;
13
+
14
+ export type KeyHint = readonly [key: string, action: string];
15
+
16
+ /** The chosen row marks itself with `pointer`; the frame draws every row the same. */
17
+ export type FrameLine = string;
18
+
19
+ export interface FrameOptions extends BottomOptions {
20
+ title: string;
21
+ /** Counts, a step, how far along. */
22
+ status?: string;
23
+ tabs?: FrameTabs;
24
+ }
25
+
26
+ export interface FrameTabs {
27
+ titles: readonly string[];
28
+ current: number;
29
+ }
30
+
31
+ export interface BottomOptions {
32
+ /** Only the ones that do something right now. */
33
+ keys?: readonly KeyHint[];
34
+ /** What it cost, where you are. */
35
+ footnote?: string;
36
+ /** Columns into the text, meeting both edges. */
37
+ split?: number;
38
+ /** A full-screen view draws them quieter than a dialog. */
39
+ border?: ThemeColor;
40
+ }
41
+
42
+ export function frameInner(width: number): number {
43
+ return Math.max(1, width - FRAME_X);
44
+ }
45
+
46
+ export function frame(
47
+ theme: Theme,
48
+ width: number,
49
+ body: readonly FrameLine[],
50
+ options: FrameOptions,
51
+ ): string[] {
52
+ const title = options.title === "" ? "" : theme.bold(theme.fg("accent", options.title));
53
+ const column = junction(options.split, LINE.teeTop);
54
+ const { tabs } = options;
55
+ const border = options.border ?? "border";
56
+ const top = edge(
57
+ theme,
58
+ border,
59
+ width,
60
+ LINE.topLeft,
61
+ LINE.topRight,
62
+ title,
63
+ options.status ?? "",
64
+ tabs === undefined ? column : undefined,
65
+ );
66
+ const head =
67
+ tabs === undefined
68
+ ? []
69
+ : [tabsRow(theme, border, width, tabs), divider(theme, border, width, column)];
70
+ const rows = body.map((line) => row(theme, border, width, line));
71
+ return [top, ...head, ...rows, frameBottom(theme, width, options)];
72
+ }
73
+
74
+ /** The tabs start one cell into the padding, so each tab's name lines up with the body under it. */
75
+ function tabsRow(theme: Theme, border: ThemeColor, width: number, tabs: FrameTabs): string {
76
+ const inner = frameInner(width) + 2;
77
+ const line = truncateToWidth(tabsLine(theme, tabs, inner), inner, CUT);
78
+ const fill = " ".repeat(Math.max(0, inner - visibleWidth(line)));
79
+ const side = theme.fg(border, LINE.v);
80
+ const pad = " ".repeat(FRAME_PAD - 1);
81
+ return `${side}${pad}${line}${fill}${pad}${side}`;
82
+ }
83
+
84
+ /**
85
+ * The current tab sits on the selection color, so it reads as chosen in any theme. When they do not
86
+ * fit, the tabs before the current one give way first.
87
+ */
88
+ function tabsLine(theme: Theme, tabs: FrameTabs, inner: number): string {
89
+ const gap = " ";
90
+ const drawn = tabs.titles.map((title, index) =>
91
+ index === tabs.current
92
+ ? theme.bg("selectedBg", theme.bold(` ${theme.fg("accent", title)} `))
93
+ : theme.fg("muted", ` ${title} `),
94
+ );
95
+ const more = theme.fg("dim", GLYPH.ellipsisAcross);
96
+ let start = 0;
97
+ const line = () => [...(start > 0 ? [more] : []), ...drawn.slice(start)].join(gap);
98
+ while (start < tabs.current && visibleWidth(line()) > inner) start++;
99
+ return line();
100
+ }
101
+
102
+ function divider(
103
+ theme: Theme,
104
+ border: ThemeColor,
105
+ width: number,
106
+ column: Junction | undefined,
107
+ ): string {
108
+ const span = Math.max(0, width - 2);
109
+ const cells: string[] = Array.from({ length: span }, () => LINE.h);
110
+ if (column !== undefined && column.at - 1 < span) cells[column.at - 1] = column.char;
111
+ return theme.fg(border, `${LINE.teeLeft}${cells.join("")}${LINE.teeRight}`);
112
+ }
113
+
114
+ export function frameBottom(theme: Theme, width: number, options: BottomOptions = {}): string {
115
+ const keys = options.keys ?? [];
116
+ // The keys sit on whichever side of the column holds them; when neither does but the whole edge
117
+ // would, the column stops short of it.
118
+ const wanted = visibleWidth(keyHints(theme, keys));
119
+ const column = junction(options.split, LINE.teeBottom);
120
+ const before = column === undefined ? width - 6 : column.at - 4;
121
+ const after = column === undefined ? 0 : width - 6 - (column.at + 1);
122
+ const fits = wanted <= before || wanted <= after;
123
+ const cross = !fits && wanted <= width - 6 ? undefined : column;
124
+ const room = cross === undefined ? width - 6 : Math.max(before, after);
125
+ const hints = keyHints(theme, fitKeys(theme, keys, room));
126
+ const footnote = options.footnote ?? "";
127
+ const note = visibleWidth(hints) + visibleWidth(footnote) + 3 <= room ? footnote : "";
128
+ const border = options.border ?? "border";
129
+ return edge(theme, border, width, LINE.bottomLeft, LINE.bottomRight, hints, note, cross);
130
+ }
131
+
132
+ /** Whole keys only; the last one, the way out, always shows. */
133
+ function fitKeys(theme: Theme, keys: readonly KeyHint[], room: number): KeyHint[] {
134
+ const kept = [...keys];
135
+ while (kept.length > 1 && visibleWidth(keyHints(theme, kept)) > room)
136
+ kept.splice(kept.length - 2, 1);
137
+ return kept;
138
+ }
139
+
140
+ function row(theme: Theme, border: ThemeColor, width: number, text: string): string {
141
+ const inner = frameInner(width);
142
+ const cells = visibleWidth(text);
143
+ const clipped = cells > inner ? truncateToWidth(text, inner, CUT) : text;
144
+ const shown = cells > inner ? visibleWidth(clipped) : cells;
145
+ const fill = " ".repeat(Math.max(0, inner - shown));
146
+ const side = theme.fg(border, LINE.v);
147
+ return `${side}${" ".repeat(FRAME_PAD)}${clipped}${fill}${" ".repeat(FRAME_PAD)}${side}`;
148
+ }
149
+
150
+ export function rule(theme: Theme, width: number, note = ""): string {
151
+ const line = (count: number) => theme.fg("borderMuted", LINE.h.repeat(Math.max(0, count)));
152
+ if (note === "") return line(width);
153
+ return `${line(width - visibleWidth(note) - 1)} ${note}`;
154
+ }
155
+
156
+ function keyHints(theme: Theme, keys: readonly KeyHint[]): string {
157
+ return keys
158
+ .map(([key, action]) => `${theme.fg("muted", key)} ${theme.fg("dim", action)}`)
159
+ .join(" ");
160
+ }
161
+
162
+ export function pointer(theme: Theme, selected: boolean, active = true): string {
163
+ if (!selected) return " ";
164
+ return `${theme.fg(active ? "accent" : "dim", POINTER)} `;
165
+ }
166
+
167
+ interface Junction {
168
+ at: number;
169
+ char: string;
170
+ }
171
+
172
+ function junction(split: number | undefined, char: string): Junction | undefined {
173
+ return split === undefined ? undefined : { at: FRAME_PAD + split + 1, char };
174
+ }
175
+
176
+ function edge(
177
+ theme: Theme,
178
+ color: ThemeColor,
179
+ width: number,
180
+ left: string,
181
+ right: string,
182
+ label: string,
183
+ note: string,
184
+ cross?: Junction,
185
+ ): string {
186
+ const border = (text: string) => theme.fg(color, text);
187
+ const span = Math.max(0, width - 2);
188
+ const at = cross === undefined || cross.at - 1 >= span ? undefined : cross.at - 1;
189
+ const head = label === "" ? "" : ` ${label} `;
190
+ const before = at === undefined || visibleWidth(head) + 2 <= at;
191
+ const start = before ? 1 : (at ?? 0) + 2;
192
+ const end = before && at !== undefined && note === "" ? at : span - 1;
193
+ const fittedHead = truncateToWidth(head, Math.max(0, end - start), "... ");
194
+ const tail = note === "" ? "" : ` ${note} `;
195
+ const tailStart = span - 1 - visibleWidth(tail);
196
+ const tailFits =
197
+ tail !== "" &&
198
+ tailStart >= start + visibleWidth(fittedHead) &&
199
+ (at === undefined || tailStart > at);
200
+
201
+ const cells: string[] = Array.from({ length: span }, () => border(LINE.h));
202
+ if (at !== undefined) cells[at] = border(cross?.char ?? LINE.h);
203
+ const place = (text: string, from: number) => {
204
+ const size = visibleWidth(text);
205
+ if (size === 0) return;
206
+ cells[from] = text;
207
+ for (let index = from + 1; index < from + size; index++) cells[index] = "";
208
+ };
209
+ place(fittedHead, start);
210
+ if (tailFits) place(tail, tailStart);
211
+ return `${border(left)}${cells.join("")}${border(right)}`;
212
+ }