@shanepadgett/tau-tui 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ # @shanepadgett/tau-tui
2
+
3
+ Shared terminal UI components and helpers for Tau Agent extensions.
4
+
5
+ Use these when Pi built-ins (`ctx.ui.select`, `SelectList`, …) are not enough and you need custom panels, lists, tabs, markers, or key-hint footers that match Tau’s look.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @shanepadgett/tau-tui
11
+ ```
12
+
13
+ Peer dependencies: `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { ToolPanel, SelectableList, bindingHint } from "@shanepadgett/tau-tui";
19
+ ```
20
+
21
+ Authoring guidance for Tau Agent lives in the agent package docs (`docs/tui.md`).
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@shanepadgett/tau-tui",
3
+ "version": "0.1.0",
4
+ "description": "Shared TUI components and helpers for Tau Agent",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "files": [
13
+ "src/**/*",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "tau",
18
+ "tui",
19
+ "terminal",
20
+ "pi"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/shanepadgett/tau-agent.git",
25
+ "directory": "packages/tui"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.19.0"
36
+ }
37
+ }
@@ -0,0 +1,15 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { EditorTheme } from "@earendil-works/pi-tui";
3
+
4
+ export function editorTheme(theme: Theme): EditorTheme {
5
+ return {
6
+ borderColor: (text) => theme.fg("accent", text),
7
+ selectList: {
8
+ selectedPrefix: (text) => theme.fg("accent", text),
9
+ selectedText: (text) => theme.fg("accent", text),
10
+ description: (text) => theme.fg("muted", text),
11
+ scrollInfo: (text) => theme.fg("dim", text),
12
+ noMatch: (text) => theme.fg("warning", text),
13
+ },
14
+ };
15
+ }
@@ -0,0 +1,11 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Input, truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ export function renderFilterRow(theme: Theme, input: Input, width: number, active: boolean): string {
5
+ if (!active) {
6
+ const value = input.getValue();
7
+ return truncateToWidth(theme.fg("muted", value ? `> ${value}` : ">"), width, "");
8
+ }
9
+ const body = input.render(Math.max(1, width))[0] ?? "";
10
+ return truncateToWidth(body, width, "");
11
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { editorTheme } from "./editor-theme.ts";
2
+ export { bindingHint, bindingsHint, rawHint, type ToolKeyHint } from "./key-hints.ts";
3
+ export { Marker, type MarkerState } from "./marker.ts";
4
+ export {
5
+ SelectableList,
6
+ type SelectableListActionResult,
7
+ type SelectableListItem,
8
+ type SelectableListResult,
9
+ } from "./selectable-list.ts";
10
+ export { Tabs, type TabItem } from "./tabs.ts";
11
+ export {
12
+ createTextRecordSelectPanel,
13
+ type TextRecordSelectPanelConfig,
14
+ type TextRecordSelectResult,
15
+ } from "./text-record-select-panel.ts";
16
+ export { formatAge, preview } from "./text.ts";
17
+ export { ToolPanel, type ToolPanelConfig, type ToolPanelFooter } from "./tool-panel.ts";
18
+ export { visibleWindow } from "./viewport.ts";
@@ -0,0 +1,68 @@
1
+ import { keyHint, rawKeyHint, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getKeybindings, type Keybinding } from "@earendil-works/pi-tui";
3
+
4
+ export type ToolKeyHint =
5
+ | { kind: "binding"; binding: Keybinding; label: string }
6
+ | { kind: "bindings"; bindings: readonly Keybinding[]; label: string }
7
+ | { kind: "raw"; key: string; label: string };
8
+
9
+ export function bindingHint(binding: Keybinding, label: string): ToolKeyHint {
10
+ return { kind: "binding", binding, label };
11
+ }
12
+
13
+ export function bindingsHint(bindings: readonly Keybinding[], label: string): ToolKeyHint {
14
+ return { kind: "bindings", bindings, label };
15
+ }
16
+
17
+ export function rawHint(key: string, label: string): ToolKeyHint {
18
+ return { kind: "raw", key, label };
19
+ }
20
+
21
+ export function renderToolKeyHints(theme: Theme, hints: readonly ToolKeyHint[]): string {
22
+ const separator = theme.fg("dim", " · ");
23
+ return hints.map(renderToolKeyHint).join(separator);
24
+ }
25
+
26
+ function renderToolKeyHint(hint: ToolKeyHint): string {
27
+ switch (hint.kind) {
28
+ case "binding":
29
+ return keyHint(hint.binding, hint.label);
30
+ case "bindings":
31
+ return rawKeyHint(formatBindingGroup(hint.bindings), hint.label);
32
+ case "raw":
33
+ return rawKeyHint(hint.key, hint.label);
34
+ }
35
+ }
36
+
37
+ function formatBindingGroup(bindings: readonly Keybinding[]): string {
38
+ const keys = bindings
39
+ .map((binding) => getKeybindings().getKeys(binding)[0])
40
+ .filter((key) => key !== undefined)
41
+ .map(formatGroupedKey);
42
+ return compactRawKeys(keys);
43
+ }
44
+
45
+ function formatGroupedKey(key: string): string {
46
+ return key
47
+ .replace(/\bpageUp\b/g, "pgup")
48
+ .replace(/\bpageDown\b/g, "pgdn")
49
+ .replace(/\bup\b/g, "↑")
50
+ .replace(/\bdown\b/g, "↓")
51
+ .replace(/\bleft\b/g, "←")
52
+ .replace(/\bright\b/g, "→");
53
+ }
54
+
55
+ function compactRawKeys(keys: readonly string[]): string {
56
+ if (keys.length === 1) return keys[0] ?? "";
57
+
58
+ const parts = keys.map((key) => {
59
+ const separatorIndex = key.lastIndexOf("+");
60
+ return separatorIndex === -1
61
+ ? { prefix: "", suffix: key }
62
+ : { prefix: key.slice(0, separatorIndex + 1), suffix: key.slice(separatorIndex + 1) };
63
+ });
64
+ const prefix = parts[0]?.prefix ?? "";
65
+ return prefix && parts.every((part) => part.prefix === prefix)
66
+ ? `${prefix}${parts.map((part) => part.suffix).join("/")}`
67
+ : keys.join("/");
68
+ }
@@ -0,0 +1,61 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { visibleWindow } from "./viewport.ts";
4
+
5
+ export function renderWindowedList<T>(
6
+ theme: Theme,
7
+ items: readonly T[],
8
+ cursor: number,
9
+ maxVisible: number,
10
+ emptyMessage: string,
11
+ width: number,
12
+ renderHeader: (width: number) => readonly string[],
13
+ renderRow: (item: T, index: number, width: number) => string[],
14
+ ): string[] {
15
+ const renderWidth = Math.max(1, width);
16
+ return [
17
+ ...renderHeader(renderWidth),
18
+ ...renderWindowedRows(theme, items, cursor, maxVisible, emptyMessage, renderWidth, (item, index) =>
19
+ renderRow(item, index, renderWidth),
20
+ ),
21
+ ];
22
+ }
23
+
24
+ export function renderPrefixedRow<T, S>(
25
+ item: T,
26
+ state: S,
27
+ width: number,
28
+ prefix: string,
29
+ prefixWidthText: string,
30
+ renderItem: (item: T, state: S, width: number) => string[],
31
+ ): string[] {
32
+ const prefixWidth = visibleWidth(prefixWidthText);
33
+ const content = renderItem(item, state, Math.max(1, width - prefixWidth));
34
+ return renderPrefixedLines(prefix, prefixWidth, content, width);
35
+ }
36
+
37
+ function renderWindowedRows<T>(
38
+ theme: Theme,
39
+ items: readonly T[],
40
+ cursor: number,
41
+ maxVisible: number,
42
+ emptyMessage: string,
43
+ width: number,
44
+ renderRow: (item: T, index: number) => string[],
45
+ ): string[] {
46
+ if (items.length === 0) return wrapTextWithAnsi(theme.fg("muted", emptyMessage), width);
47
+
48
+ const lines: string[] = [];
49
+ const { start, end } = visibleWindow(cursor, items.length, maxVisible);
50
+ for (let index = start; index < end; index++) {
51
+ const item = items[index];
52
+ if (item !== undefined) lines.push(...renderRow(item, index));
53
+ }
54
+ if (start > 0 || end < items.length) lines.push(theme.fg("dim", ` (${cursor + 1}/${items.length})`));
55
+ return lines;
56
+ }
57
+
58
+ function renderPrefixedLines(prefix: string, prefixWidth: number, content: readonly string[], width: number): string[] {
59
+ const indent = " ".repeat(prefixWidth);
60
+ return content.map((line, index) => truncateToWidth(`${index === 0 ? prefix : indent}${line}`, width, ""));
61
+ }
package/src/marker.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ type ThemeColor = Parameters<Theme["fg"]>[0];
5
+
6
+ export type MarkerState = "busy" | "complete" | "muted" | "warning" | "error";
7
+
8
+ export interface MarkerOptions {
9
+ theme: Theme;
10
+ state: MarkerState;
11
+ label: string;
12
+ parts: readonly string[];
13
+ }
14
+
15
+ export class Marker implements Component {
16
+ private readonly theme: Theme;
17
+ private readonly state: MarkerState;
18
+ private readonly label: string;
19
+ private readonly parts: readonly string[];
20
+
21
+ constructor(options: MarkerOptions) {
22
+ this.theme = options.theme;
23
+ this.state = options.state;
24
+ this.label = options.label;
25
+ this.parts = options.parts;
26
+ }
27
+
28
+ render(width: number): string[] {
29
+ const color = colorForState(this.state);
30
+ const dot = this.state === "busy" && Math.floor(Date.now() / 500) % 2 === 1 ? " " : "●";
31
+ const text = [
32
+ ` ${this.theme.fg(color, dot)}`,
33
+ this.theme.fg("text", this.theme.bold(this.label)),
34
+ ...this.parts.map((part) => this.theme.fg("muted", part)),
35
+ ]
36
+ .filter(Boolean)
37
+ .join(" ");
38
+
39
+ return [truncateToWidth(text, width)];
40
+ }
41
+
42
+ invalidate(): void {}
43
+ }
44
+
45
+ function colorForState(state: MarkerState): ThemeColor {
46
+ if (state === "warning") return "warning";
47
+ if (state === "error") return "error";
48
+ return "dim";
49
+ }
@@ -0,0 +1,420 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ type Focusable,
5
+ fuzzyFilter,
6
+ getKeybindings,
7
+ Input,
8
+ Key,
9
+ type KeyId,
10
+ matchesKey,
11
+ } from "@earendil-works/pi-tui";
12
+ import { renderFilterRow } from "./filter-row.ts";
13
+ import { bindingHint, bindingsHint, rawHint, type ToolKeyHint } from "./key-hints.ts";
14
+ import { renderPrefixedRow, renderWindowedList } from "./list-render.ts";
15
+ import { clampIndex } from "./viewport.ts";
16
+
17
+ const NON_PRINTABLE_KEYS = new Set<string>([
18
+ "escape",
19
+ "esc",
20
+ "enter",
21
+ "return",
22
+ "tab",
23
+ "backspace",
24
+ "delete",
25
+ "insert",
26
+ "clear",
27
+ "home",
28
+ "end",
29
+ "pageUp",
30
+ "pageDown",
31
+ "up",
32
+ "down",
33
+ "left",
34
+ "right",
35
+ "f1",
36
+ "f2",
37
+ "f3",
38
+ "f4",
39
+ "f5",
40
+ "f6",
41
+ "f7",
42
+ "f8",
43
+ "f9",
44
+ "f10",
45
+ "f11",
46
+ "f12",
47
+ ]);
48
+
49
+ export interface SelectableListItem {
50
+ id: string;
51
+ }
52
+
53
+ export type SelectableListActionTarget = "current" | "currentOrSelection" | "visible" | "olderThanCursor";
54
+ export type SelectableListResolvedTarget = "cursor" | "selection" | "visible" | "olderThanCursor";
55
+
56
+ export interface SelectableListAction {
57
+ id: string;
58
+ key: KeyId;
59
+ hint: ToolKeyHint;
60
+ target?: SelectableListActionTarget;
61
+ }
62
+
63
+ export interface SelectableListActionResult<T extends SelectableListItem> {
64
+ actionId: string;
65
+ items: readonly T[];
66
+ target: SelectableListResolvedTarget;
67
+ }
68
+
69
+ export type SelectableListResult<T extends SelectableListItem> =
70
+ | { kind: "cancel" }
71
+ | { kind: "primary"; items: readonly T[]; target: SelectableListResolvedTarget }
72
+ | ({ kind: "action" } & SelectableListActionResult<T>);
73
+
74
+ export interface SelectableListRowState {
75
+ active: boolean;
76
+ selected: boolean;
77
+ index: number;
78
+ }
79
+
80
+ export type SelectableListSelection =
81
+ | { kind: "single"; primaryLabel: string }
82
+ | { kind: "multi"; primaryLabel?: string };
83
+
84
+ export interface SelectableListFilter<T extends SelectableListItem> {
85
+ searchText(item: T): string;
86
+ }
87
+
88
+ export interface SelectableListConfig<T extends SelectableListItem> {
89
+ items: readonly T[];
90
+ emptyMessage: string;
91
+ selection: SelectableListSelection;
92
+ filter?: SelectableListFilter<T>;
93
+ actions: readonly SelectableListAction[];
94
+ cancelLabel?: string;
95
+ maxVisible: number;
96
+ // Render row content only. SelectableList owns cursor, selection, and filter chrome.
97
+ renderItem(item: T, state: SelectableListRowState, width: number): string[];
98
+ onResult(result: SelectableListResult<T>): void;
99
+ onSelectionChange?: (items: readonly T[]) => void;
100
+ }
101
+
102
+ export class SelectableList<T extends SelectableListItem> implements Component, Focusable {
103
+ private readonly theme: Theme;
104
+ private readonly config: SelectableListConfig<T>;
105
+ private readonly filterInput = new Input();
106
+ private readonly selected = new Set<string>();
107
+ private items: readonly T[];
108
+ private cursor = 0;
109
+ private filterActive: boolean;
110
+ private _focused = false;
111
+
112
+ constructor(theme: Theme, config: SelectableListConfig<T>) {
113
+ this.theme = theme;
114
+ this.config = config;
115
+ this.items = config.items;
116
+ this.filterActive = config.filter !== undefined && config.selection.kind === "single";
117
+ this.filterInput.focused = this.filterActive;
118
+ assertFilterSafeActions(config.actions, config.filter !== undefined && config.selection.kind === "single");
119
+ }
120
+
121
+ get focused(): boolean {
122
+ return this._focused;
123
+ }
124
+
125
+ set focused(value: boolean) {
126
+ this._focused = value;
127
+ this.filterInput.focused = value && this.filterActive;
128
+ }
129
+
130
+ setItems(items: readonly T[], activeId?: string): void {
131
+ this.items = items;
132
+ const ids = new Set(items.map((item) => item.id));
133
+ for (const id of this.selected) {
134
+ if (!ids.has(id)) this.selected.delete(id);
135
+ }
136
+ if (activeId !== undefined) {
137
+ const index = this.filteredItems().findIndex((item) => item.id === activeId);
138
+ if (index >= 0) this.cursor = index;
139
+ }
140
+ this.clampCursor();
141
+ this.emitSelectionChange();
142
+ }
143
+
144
+ clearSelection(): void {
145
+ this.selected.clear();
146
+ this.emitSelectionChange();
147
+ }
148
+
149
+ setSelectedIds(ids: readonly string[]): void {
150
+ const validIds = new Set(this.items.map((item) => item.id));
151
+ this.selected.clear();
152
+ for (const id of ids) {
153
+ if (validIds.has(id)) this.selected.add(id);
154
+ }
155
+ this.emitSelectionChange();
156
+ }
157
+
158
+ getCurrentItem(): T | undefined {
159
+ return this.filteredItems()[this.cursor];
160
+ }
161
+
162
+ isFilterFocused(): boolean {
163
+ return this.config.selection.kind === "multi" && this.filterActive;
164
+ }
165
+
166
+ getKeyHints(): ToolKeyHint[] {
167
+ if (this.isFilterFocused()) {
168
+ return [
169
+ bindingsHint(["tui.select.up", "tui.select.down"], "move"),
170
+ rawHint("Enter", "apply filter"),
171
+ rawHint("Esc", "cancel filter"),
172
+ rawHint("ctrl+c", "clear filter"),
173
+ ];
174
+ }
175
+
176
+ const primaryLabel = this.config.selection.primaryLabel;
177
+ return [
178
+ bindingsHint(["tui.select.up", "tui.select.down"], "move"),
179
+ ...(primaryLabel ? [bindingHint("tui.select.confirm", primaryLabel)] : []),
180
+ ...(this.config.selection.kind === "multi" ? this.multiSelectHints() : []),
181
+ ...this.config.actions.map((action) => action.hint),
182
+ ...(this.config.cancelLabel ? [bindingHint("tui.select.cancel", this.config.cancelLabel)] : []),
183
+ ];
184
+ }
185
+
186
+ handleInput(data: string): void {
187
+ const keybindings = getKeybindings();
188
+ if (this.config.selection.kind === "multi" && this.filterActive && matchesKey(data, Key.ctrl("c"))) {
189
+ this.clearFilter();
190
+ return;
191
+ }
192
+ if (
193
+ this.config.selection.kind === "multi" &&
194
+ this.filterActive &&
195
+ keybindings.matches(data, "tui.select.cancel")
196
+ ) {
197
+ this.clearFilterFocus();
198
+ return;
199
+ }
200
+ if (
201
+ this.config.selection.kind === "multi" &&
202
+ this.filterActive &&
203
+ keybindings.matches(data, "tui.select.confirm")
204
+ ) {
205
+ this.blurFilter();
206
+ return;
207
+ }
208
+ if (keybindings.matches(data, "tui.select.cancel")) {
209
+ this.config.onResult({ kind: "cancel" });
210
+ return;
211
+ }
212
+
213
+ if (keybindings.matches(data, "tui.select.confirm")) {
214
+ this.primary();
215
+ return;
216
+ }
217
+
218
+ if (this.handleListKey(data)) return;
219
+ if (this.config.selection.kind === "multi" && this.handleMultiSelectKey(data)) return;
220
+ if (
221
+ this.config.selection.kind === "multi" &&
222
+ this.config.filter &&
223
+ !this.filterActive &&
224
+ matchesKey(data, Key.slash)
225
+ ) {
226
+ this.activateFilter();
227
+ return;
228
+ }
229
+ if (this.config.selection.kind === "multi" && this.filterActive) {
230
+ this.handleFilterInput(data);
231
+ return;
232
+ }
233
+
234
+ for (const action of this.config.actions) {
235
+ if (matchesKey(data, action.key)) {
236
+ this.runAction(action);
237
+ return;
238
+ }
239
+ }
240
+
241
+ if (this.config.filter && this.config.selection.kind === "single") this.handleFilterInput(data);
242
+ }
243
+
244
+ render(width: number): string[] {
245
+ return renderWindowedList(
246
+ this.theme,
247
+ this.filteredItems(),
248
+ this.cursor,
249
+ this.config.maxVisible,
250
+ this.config.emptyMessage,
251
+ width,
252
+ (renderWidth) =>
253
+ this.config.filter
254
+ ? [renderFilterRow(this.theme, this.filterInput, renderWidth, this.filterActive), ""]
255
+ : [],
256
+ (item, index, renderWidth) => this.renderRow(item, index, renderWidth),
257
+ );
258
+ }
259
+
260
+ invalidate(): void {}
261
+
262
+ private multiSelectHints(): ToolKeyHint[] {
263
+ if (!this.config.filter) return [rawHint("Space", "select"), rawHint("c", "clear")];
264
+ return this.filterActive
265
+ ? [rawHint("Enter", "apply filter"), rawHint("Esc", "cancel filter"), rawHint("ctrl+c", "clear filter")]
266
+ : [rawHint("Space", "select"), rawHint("c", "clear"), rawHint("/", "filter")];
267
+ }
268
+
269
+ private primary(): void {
270
+ const filtered = this.filteredItems();
271
+ if (this.config.selection.kind === "multi") {
272
+ this.config.onResult({
273
+ kind: "primary",
274
+ items: this.items.filter((item) => this.selected.has(item.id)),
275
+ target: "selection",
276
+ });
277
+ return;
278
+ }
279
+
280
+ const current = filtered[this.cursor];
281
+ this.config.onResult({ kind: "primary", items: current ? [current] : [], target: "cursor" });
282
+ }
283
+
284
+ private handleListKey(data: string): boolean {
285
+ const keybindings = getKeybindings();
286
+ if (keybindings.matches(data, "tui.select.up")) {
287
+ const filtered = this.filteredItems();
288
+ this.cursor = filtered.length === 0 ? 0 : Math.max(0, this.cursor - 1);
289
+ return true;
290
+ }
291
+ if (keybindings.matches(data, "tui.select.down")) {
292
+ const filtered = this.filteredItems();
293
+ this.cursor = filtered.length === 0 ? 0 : Math.min(this.cursor + 1, filtered.length - 1);
294
+ return true;
295
+ }
296
+ return false;
297
+ }
298
+
299
+ private handleMultiSelectKey(data: string): boolean {
300
+ const filterCapturesText = this.config.filter && this.filterActive;
301
+ const toggleKey = Key.space;
302
+ const clearKey = filterCapturesText ? Key.ctrl("c") : "c";
303
+ if (filterCapturesText && matchesKey(data, clearKey)) {
304
+ this.clearFilter();
305
+ return true;
306
+ }
307
+ if (filterCapturesText) return false;
308
+ if (matchesKey(data, toggleKey) || (!filterCapturesText && data === " ")) {
309
+ const item = this.filteredItems()[this.cursor];
310
+ if (item && this.selected.has(item.id)) this.selected.delete(item.id);
311
+ else if (item) this.selected.add(item.id);
312
+ this.emitSelectionChange();
313
+ return true;
314
+ }
315
+ if (matchesKey(data, clearKey)) {
316
+ this.selected.clear();
317
+ this.emitSelectionChange();
318
+ return true;
319
+ }
320
+ return false;
321
+ }
322
+
323
+ private handleFilterInput(data: string): void {
324
+ const previous = this.filterInput.getValue();
325
+ this.filterInput.handleInput(data);
326
+ if (this.filterInput.getValue() !== previous) this.cursor = 0;
327
+ this.clampCursor();
328
+ }
329
+
330
+ private activateFilter(): void {
331
+ this.filterActive = true;
332
+ this.filterInput.focused = this._focused;
333
+ this.clampCursor();
334
+ }
335
+
336
+ private blurFilter(): void {
337
+ this.filterActive = false;
338
+ this.filterInput.focused = false;
339
+ this.clampCursor();
340
+ }
341
+
342
+ private clearFilter(): void {
343
+ this.filterInput.setValue("");
344
+ this.cursor = 0;
345
+ this.clampCursor();
346
+ }
347
+
348
+ private clearFilterFocus(): void {
349
+ this.clearFilter();
350
+ this.blurFilter();
351
+ }
352
+
353
+ private renderRow(item: T, index: number, width: number): string[] {
354
+ const state = { active: index === this.cursor, selected: this.selected.has(item.id), index };
355
+ const prefix = this.config.selection.kind === "multi" ? this.multiPrefix(state) : this.singlePrefix(state);
356
+ const placeholder = this.config.selection.kind === "multi" ? "› [ ] " : "› ";
357
+ return renderPrefixedRow(item, state, width, prefix, placeholder, this.config.renderItem);
358
+ }
359
+
360
+ private singlePrefix(state: SelectableListRowState): string {
361
+ return state.active ? this.theme.fg("accent", "› ") : " ";
362
+ }
363
+
364
+ private multiPrefix(state: SelectableListRowState): string {
365
+ const marker = state.active ? this.theme.fg("accent", "› ") : " ";
366
+ const box = state.selected ? this.theme.fg("success", "[x]") : this.theme.fg("dim", "[ ]");
367
+ return `${marker}${box} `;
368
+ }
369
+
370
+ private filteredItems(): readonly T[] {
371
+ const filter = this.config.filter;
372
+ if (!filter) return this.items;
373
+ const query = this.filterInput.getValue().trim();
374
+ if (!query) return this.items;
375
+ return fuzzyFilter([...this.items], query, filter.searchText);
376
+ }
377
+
378
+ private runAction(action: SelectableListAction): void {
379
+ const resolved = this.resolveTarget(action.target ?? "current");
380
+ this.config.onResult({ kind: "action", actionId: action.id, items: resolved.items, target: resolved.target });
381
+ }
382
+
383
+ private resolveTarget(target: SelectableListActionTarget): {
384
+ items: readonly T[];
385
+ target: SelectableListResolvedTarget;
386
+ } {
387
+ const filtered = this.filteredItems();
388
+ if (target === "visible") return { items: filtered, target: "visible" };
389
+ if (target === "olderThanCursor") return { items: filtered.slice(this.cursor + 1), target: "olderThanCursor" };
390
+ if (target === "currentOrSelection" && this.selected.size > 0) {
391
+ return { items: this.items.filter((item) => this.selected.has(item.id)), target: "selection" };
392
+ }
393
+ const current = filtered[this.cursor];
394
+ return { items: current ? [current] : [], target: "cursor" };
395
+ }
396
+
397
+ private clampCursor(): void {
398
+ this.cursor = clampIndex(this.cursor, this.filteredItems().length);
399
+ }
400
+
401
+ private emitSelectionChange(): void {
402
+ this.config.onSelectionChange?.(this.items.filter((item) => this.selected.has(item.id)));
403
+ }
404
+ }
405
+
406
+ function assertFilterSafeActions(actions: readonly SelectableListAction[], filtered: boolean): void {
407
+ if (!filtered) return;
408
+ for (const action of actions) assertFilterSafeKey(action.key, action.id);
409
+ }
410
+
411
+ function assertFilterSafeKey(key: KeyId, label: string): void {
412
+ if (isFilterSafeKey(key)) return;
413
+ throw new Error(`Filtered list action ${label} uses text key ${key}. Use ctrl, alt, super, or a non-printable key.`);
414
+ }
415
+
416
+ function isFilterSafeKey(key: KeyId): boolean {
417
+ if (key.includes("ctrl+") || key.includes("alt+") || key.includes("super+")) return true;
418
+ const base = key.split("+").at(-1) ?? key;
419
+ return NON_PRINTABLE_KEYS.has(base) && base !== "space";
420
+ }
package/src/tabs.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { rawHint, type ToolKeyHint } from "./key-hints.ts";
4
+
5
+ export interface TabItem {
6
+ id: string;
7
+ label: string;
8
+ count?: number;
9
+ body: Component;
10
+ getKeyHints?: () => readonly ToolKeyHint[];
11
+ }
12
+
13
+ export class Tabs implements Component {
14
+ private readonly theme: Theme;
15
+ private tabs: readonly TabItem[];
16
+ private activeId: string;
17
+
18
+ constructor(theme: Theme, tabs: readonly TabItem[], activeId: string) {
19
+ this.theme = theme;
20
+ this.tabs = tabs;
21
+ this.activeId = tabs.some((tab) => tab.id === activeId) ? activeId : (tabs[0]?.id ?? "");
22
+ }
23
+
24
+ getActiveId(): string {
25
+ return this.activeId;
26
+ }
27
+
28
+ setActiveId(id: string): void {
29
+ if (this.tabs.some((tab) => tab.id === id)) this.activeId = id;
30
+ }
31
+
32
+ setTabs(tabs: readonly TabItem[]): void {
33
+ this.tabs = tabs;
34
+ if (!tabs.some((tab) => tab.id === this.activeId)) this.activeId = tabs[0]?.id ?? "";
35
+ }
36
+
37
+ handleKey(data: string): boolean {
38
+ const direction =
39
+ matchesKey(data, Key.tab) || matchesKey(data, Key.right)
40
+ ? 1
41
+ : matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)
42
+ ? -1
43
+ : 0;
44
+ if (direction === 0) return false;
45
+ if (this.tabs.length === 0) return true;
46
+ const currentIndex = this.tabs.findIndex((tab) => tab.id === this.activeId);
47
+ const start = currentIndex >= 0 ? currentIndex : 0;
48
+ const next = this.tabs[(start + direction + this.tabs.length) % this.tabs.length];
49
+ if (next) this.activeId = next.id;
50
+ return true;
51
+ }
52
+
53
+ handleInput(data: string): void {
54
+ if (this.handleKey(data)) return;
55
+ this.activeBody()?.handleInput?.(data);
56
+ }
57
+
58
+ getKeyHints(): ToolKeyHint[] {
59
+ return [rawHint("Tab/←/→", "switch tab"), ...(this.activeTab()?.getKeyHints?.() ?? [])];
60
+ }
61
+
62
+ render(width: number): string[] {
63
+ const renderWidth = Math.max(1, width);
64
+ const parts = this.tabs.map((tab) => {
65
+ const count = tab.count === undefined ? "" : ` ${this.theme.fg("dim", String(tab.count))}`;
66
+ const text = ` ${tab.label}${count} `;
67
+ return tab.id === this.activeId
68
+ ? this.theme.bg("selectedBg", this.theme.fg("text", this.theme.bold(text)))
69
+ : this.theme.fg("muted", text);
70
+ });
71
+ const lines = wrapTextWithAnsi(parts.join(" "), renderWidth).map((line) =>
72
+ truncateToWidth(line, renderWidth, ""),
73
+ );
74
+ const activeBody = this.activeBody()?.render(renderWidth) ?? [];
75
+ if (activeBody.length > 0) lines.push("");
76
+ lines.push(...activeBody.map((line) => truncateToWidth(line, renderWidth, "")));
77
+ return lines;
78
+ }
79
+
80
+ invalidate(): void {
81
+ for (const tab of this.tabs) tab.body.invalidate();
82
+ }
83
+
84
+ private activeBody(): Component | undefined {
85
+ return this.activeTab()?.body;
86
+ }
87
+
88
+ private activeTab(): TabItem | undefined {
89
+ return this.tabs.find((tab) => tab.id === this.activeId);
90
+ }
91
+ }
@@ -0,0 +1,92 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { formatAge, preview } from "./text.ts";
4
+ import {
5
+ SelectableList,
6
+ type SelectableListAction,
7
+ type SelectableListItem,
8
+ type SelectableListRowState,
9
+ } from "./selectable-list.ts";
10
+ import { ToolPanel } from "./tool-panel.ts";
11
+
12
+ export interface TextRecordSelectItem extends SelectableListItem {
13
+ text: string;
14
+ createdAt?: number;
15
+ }
16
+
17
+ export type TextRecordSelectResult<T extends TextRecordSelectItem> =
18
+ | { kind: "cancel" }
19
+ | { kind: "primary"; item: T }
20
+ | { kind: "action"; actionId: string; item: T };
21
+
22
+ export interface TextRecordSelectPanelConfig {
23
+ title: string;
24
+ path: string;
25
+ emptyMessage: string;
26
+ primaryLabel: string;
27
+ actions: readonly SelectableListAction[];
28
+ expandActiveItem: boolean;
29
+ }
30
+
31
+ export function createTextRecordSelectPanel<T extends TextRecordSelectItem>(
32
+ theme: Theme,
33
+ items: readonly T[],
34
+ config: TextRecordSelectPanelConfig,
35
+ done: (result: TextRecordSelectResult<T>) => void,
36
+ ): Component {
37
+ const list = new SelectableList(theme, {
38
+ items,
39
+ emptyMessage: config.emptyMessage,
40
+ selection: { kind: "single", primaryLabel: config.primaryLabel },
41
+ filter: { searchText: (item) => item.text },
42
+ actions: config.actions,
43
+ cancelLabel: "cancel",
44
+ maxVisible: 12,
45
+ renderItem: (item, state, width) => renderTextRecord(theme, item, state, config.expandActiveItem, width),
46
+ onResult: (result) => {
47
+ if (result.kind === "cancel") {
48
+ done(result);
49
+ return;
50
+ }
51
+ const item = result.items[0];
52
+ if (!item) return;
53
+ done(
54
+ result.kind === "primary" ? { kind: "primary", item } : { kind: "action", actionId: result.actionId, item },
55
+ );
56
+ },
57
+ });
58
+ const panel = new ToolPanel(theme, {
59
+ title: config.title,
60
+ secondary: `${items.length} total · ${config.path}`,
61
+ body: list,
62
+ footer: { kind: "hints", hints: list.getKeyHints() },
63
+ });
64
+
65
+ return {
66
+ handleInput: (data) => list.handleInput(data),
67
+ render: (width) => panel.render(width),
68
+ invalidate: () => panel.invalidate(),
69
+ };
70
+ }
71
+
72
+ function renderTextRecord(
73
+ theme: Theme,
74
+ item: TextRecordSelectItem,
75
+ state: SelectableListRowState,
76
+ expandActiveItem: boolean,
77
+ width: number,
78
+ ): string[] {
79
+ const age = item.createdAt === undefined ? "" : theme.fg("dim", ` ${formatAge(item.createdAt)}`);
80
+ if (!state.active || !expandActiveItem) {
81
+ const label = theme.fg(state.active ? "accent" : "text", preview(item.text));
82
+ return [truncateToWidth(`${label}${age}`, width, "")];
83
+ }
84
+
85
+ const body = wrapTextWithAnsi(theme.fg("accent", item.text), width);
86
+ const lines = body.map((line) => truncateToWidth(line, width, ""));
87
+ const lastIndex = lines.length - 1;
88
+ if (age && lastIndex >= 0 && visibleWidth(lines[lastIndex] ?? "") + visibleWidth(age) <= width) {
89
+ lines[lastIndex] = `${lines[lastIndex]}${age}`;
90
+ }
91
+ return lines;
92
+ }
package/src/text.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Display text helpers for TUI components.
2
+
3
+ export function preview(text: string, max = 80): string {
4
+ const oneLine = text.replace(/\s+/g, " ").trim();
5
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
6
+ }
7
+
8
+ export function formatAge(createdAt: number): string {
9
+ const seconds = Math.max(0, Math.round((Date.now() - createdAt) / 1000));
10
+ if (seconds < 60) return "just now";
11
+ const minutes = Math.round(seconds / 60);
12
+ if (minutes < 60) return `${minutes}m ago`;
13
+ const hours = Math.round(minutes / 60);
14
+ if (hours < 48) return `${hours}h ago`;
15
+ const days = Math.round(hours / 24);
16
+ if (days < 30) return `${days}d ago`;
17
+ return new Date(createdAt).toISOString().slice(0, 10);
18
+ }
@@ -0,0 +1,90 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { renderToolKeyHints, type ToolKeyHint } from "./key-hints.ts";
4
+
5
+ export type ToolPanelFooter =
6
+ | { kind: "hints"; hints: readonly ToolKeyHint[] }
7
+ | { kind: "destructiveAck"; message: string; hints: readonly ToolKeyHint[] }
8
+ | { kind: "infoAck"; message: string; hints: readonly ToolKeyHint[] };
9
+
10
+ export interface ToolPanelConfig {
11
+ title: string;
12
+ secondary?: string;
13
+ header?: Component | readonly string[];
14
+ body: Component;
15
+ footer: ToolPanelFooter;
16
+ }
17
+
18
+ export class ToolPanel implements Component {
19
+ private readonly theme: Theme;
20
+ private config: ToolPanelConfig;
21
+
22
+ constructor(theme: Theme, config: ToolPanelConfig) {
23
+ this.theme = theme;
24
+ this.config = config;
25
+ }
26
+
27
+ render(width: number): string[] {
28
+ const renderWidth = Math.max(1, width);
29
+ const body = this.config.body.render(renderWidth).map((line) => truncateToWidth(line, renderWidth, ""));
30
+ const footer = this.renderFooter(renderWidth);
31
+ const lines: string[] = [];
32
+
33
+ lines.push(this.theme.fg("border", "─".repeat(renderWidth)));
34
+ lines.push(...this.renderTitle(renderWidth));
35
+ const header = this.renderHeader(renderWidth);
36
+ if (header.length > 0) lines.push("");
37
+ lines.push(...header);
38
+ if (body.length > 0) lines.push("");
39
+ lines.push(...body);
40
+ if (footer.length > 0) lines.push("");
41
+ lines.push(...footer);
42
+ lines.push(this.theme.fg("border", "─".repeat(renderWidth)));
43
+
44
+ return lines;
45
+ }
46
+
47
+ invalidate(): void {
48
+ const header = this.config.header;
49
+ if (header && "render" in header) header.invalidate();
50
+ this.config.body.invalidate();
51
+ }
52
+
53
+ private renderTitle(width: number): string[] {
54
+ const title = this.theme.fg("accent", this.theme.bold(this.config.title));
55
+ const lines = [truncateToWidth(title, width, "")];
56
+ if (this.config.secondary) {
57
+ lines.push(
58
+ ...wrapTextWithAnsi(this.theme.fg("dim", this.config.secondary), width).map((line) =>
59
+ truncateToWidth(line, width, ""),
60
+ ),
61
+ );
62
+ }
63
+ return lines;
64
+ }
65
+
66
+ private renderHeader(width: number): string[] {
67
+ const header = this.config.header;
68
+ if (!header) return [];
69
+
70
+ if ("render" in header) {
71
+ return header.render(width).map((line) => truncateToWidth(line, width, ""));
72
+ }
73
+
74
+ return header.flatMap((line) =>
75
+ wrapTextWithAnsi(line, width).map((wrapped) => truncateToWidth(wrapped, width, "")),
76
+ );
77
+ }
78
+
79
+ private renderFooter(width: number): string[] {
80
+ const footer = this.config.footer;
81
+ const hintText = renderToolKeyHints(this.theme, footer.hints);
82
+ const text =
83
+ footer.kind === "hints"
84
+ ? hintText
85
+ : `${this.theme.fg(footer.kind === "destructiveAck" ? "error" : "accent", footer.message)} ${hintText}`;
86
+
87
+ if (!text) return [];
88
+ return wrapTextWithAnsi(text, width).map((line) => truncateToWidth(line, width, ""));
89
+ }
90
+ }
@@ -0,0 +1,14 @@
1
+ export interface VisibleWindow {
2
+ start: number;
3
+ end: number;
4
+ }
5
+
6
+ export function clampIndex(index: number, length: number): number {
7
+ return Math.min(Math.max(0, index), Math.max(0, length - 1));
8
+ }
9
+
10
+ export function visibleWindow(cursor: number, length: number, maxVisible: number): VisibleWindow {
11
+ const visible = Math.max(1, maxVisible);
12
+ const start = Math.max(0, Math.min(cursor - Math.floor(visible / 2), length - visible));
13
+ return { start, end: Math.min(length, start + visible) };
14
+ }