Files
harness-tools/extensions/workflow/tui.ts
T

703 lines
20 KiB
TypeScript

import type {
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import {
Editor,
type EditorTheme,
Input,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Component,
type Focusable,
type TUI,
} from "@earendil-works/pi-tui";
export interface OverlayChoice<T> {
value: T;
label: string;
description?: string;
}
function sanitizeLine(value: string) {
return value
.replace(
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?|[PX^_][\s\S]*?\x1b\\|.)/g,
"",
)
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
.replaceAll("\t", " ");
}
export function sanitizeTerminalText(value: string) {
return value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").map(sanitizeLine).join("\n");
}
function fit(value: string, width: number) {
const fitted = truncateToWidth(value, Math.max(1, width), "");
return value.includes("\x1b") ? fitted : sanitizeLine(fitted);
}
function pad(value: string, width: number) {
const bounded = fit(value, width);
return `${bounded}${" ".repeat(Math.max(0, width - visibleWidth(bounded)))}`;
}
function card(
theme: Theme,
title: string,
contents: string[],
width: number,
) {
const innerWidth = Math.max(1, width - 2);
const safeTitle = fit(` ${sanitizeLine(title)} `, innerWidth);
const titleWidth = visibleWidth(safeTitle);
const trailing = Math.max(0, innerWidth - titleWidth);
return [
`${theme.fg("border", "╭")}${theme.fg("accent", safeTitle)}${theme.fg("border", `${"─".repeat(trailing)}╮`)}`,
...contents.map(
(line) => `${theme.fg("border", "│")}${pad(line, innerWidth)}${theme.fg("border", "│")}`,
),
theme.fg("border", `╰${"─".repeat(innerWidth)}╯`),
].map((line) => fit(line, width));
}
export class WorkflowSelectionOverlay<T> implements Component {
private selected = 0;
private readonly title: string;
private readonly choices: OverlayChoice<T>[];
private readonly theme: Theme;
private readonly done: (value: T | undefined) => void;
private readonly maxRows: number;
constructor(
title: string,
choices: OverlayChoice<T>[],
theme: Theme,
done: (value: T | undefined) => void,
maxRows = 24,
) {
this.title = title;
this.choices = choices;
this.theme = theme;
this.done = done;
this.maxRows = Math.max(4, Math.floor(maxRows));
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
this.done(undefined);
} else if (matchesKey(data, Key.up)) {
this.selected = Math.max(0, this.selected - 1);
} else if (matchesKey(data, Key.down)) {
this.selected = Math.min(this.choices.length - 1, this.selected + 1);
} else if (matchesKey(data, Key.home)) {
this.selected = 0;
} else if (matchesKey(data, Key.end)) {
this.selected = Math.max(0, this.choices.length - 1);
} else if (matchesKey(data, Key.enter)) {
const choice = this.choices[this.selected];
if (choice) this.done(choice.value);
}
}
render(width: number) {
const bodyRows = Math.max(2, this.maxRows - 2);
const showLeadingSpace = bodyRows >= 5;
const showPagination = this.choices.length > 1 && bodyRows >= 3;
const selectedChoice = this.choices[this.selected];
const descriptionWidth = Math.max(1, width - 8);
const selectedDescription = selectedChoice?.description
? sanitizeTerminalText(selectedChoice.description)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, descriptionWidth);
return wrapped.length > 0 ? wrapped : [""];
})
: [];
const fixedRows = 1 + Number(showLeadingSpace) + Number(showPagination);
const choiceRows = Math.max(1, bodyRows - fixedRows);
const descriptionRows = Math.min(
selectedDescription.length,
Math.max(0, Math.floor((choiceRows - 1) / 2)),
);
const maxVisible = Math.max(
1,
Math.min(12, this.choices.length || 1, choiceRows - descriptionRows),
);
const start = Math.max(
0,
Math.min(
this.selected - Math.floor(maxVisible / 2),
Math.max(0, this.choices.length - maxVisible),
),
);
const body: string[] = showLeadingSpace ? [""] : [];
for (const [offset, choice] of this.choices.slice(start, start + maxVisible).entries()) {
const index = start + offset;
const selected = index === this.selected;
const text = `${selected ? "> " : " "}${sanitizeLine(choice.label)}`;
body.push(
selected
? this.theme.bg("selectedBg", this.theme.fg("text", text))
: this.theme.fg("text", text),
);
if (selected) {
for (const line of selectedDescription.slice(0, descriptionRows)) {
body.push(this.theme.fg("muted", ` ${line}`));
}
}
}
if (this.choices.length === 0) {
body.push(this.theme.fg("muted", " No choices available"));
}
if (showPagination && this.choices.length > maxVisible) {
body.push(
this.theme.fg(
"dim",
` ${start + 1}-${Math.min(this.choices.length, start + maxVisible)} of ${this.choices.length}`,
),
);
}
body.push(this.theme.fg("dim", " ↑↓ navigate enter select esc back"));
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
}
invalidate() {}
}
abstract class FocusedTextOverlay implements Component, Focusable {
private _focused = false;
abstract readonly child: Input | Editor;
get focused() {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.child.focused = value;
}
abstract render(width: number): string[];
abstract handleInput(data: string): void;
abstract invalidate(): void;
}
class WorkflowInputOverlay extends FocusedTextOverlay {
readonly child = new Input();
private readonly title: string;
private readonly prompt: string;
private readonly theme: Theme;
private readonly maxRows: number;
constructor(
title: string,
prompt: string,
theme: Theme,
done: (value: string | undefined) => void,
maxRows: number,
) {
super();
this.title = title;
this.prompt = prompt;
this.theme = theme;
this.maxRows = Math.max(4, Math.floor(maxRows));
this.child.onSubmit = (value) => done(value);
this.child.onEscape = () => done(undefined);
}
handleInput(data: string) {
this.child.handleInput(data);
}
render(width: number) {
const innerWidth = Math.max(1, width - 4);
const wrappedPrompt = sanitizeTerminalText(this.prompt)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, innerWidth);
return wrapped.length > 0 ? wrapped : [""];
});
const bodyRows = this.maxRows - 2;
const showLeadingSpace = bodyRows >= 5;
const promptRows = Math.max(0, bodyRows - 2 - Number(showLeadingSpace));
const prompt = wrappedPrompt.slice(0, promptRows);
if (wrappedPrompt.length > promptRows && prompt.length > 0) {
const last = prompt.length - 1;
prompt[last] = fit(
`${truncateToWidth(prompt[last] ?? "", Math.max(1, innerWidth - 1), "")}…`,
innerWidth,
);
}
const body = [
...(showLeadingSpace ? [""] : []),
...prompt.map((line) => ` ${line}`),
` ${this.child.render(innerWidth)[0] ?? ""}`,
this.theme.fg("dim", " enter submit esc back"),
];
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
}
invalidate() {
this.child.invalidate();
}
}
class WorkflowEditorOverlay extends FocusedTextOverlay {
readonly child: Editor;
private readonly title: string;
private readonly prompt: string;
private readonly theme: Theme;
constructor(
tui: TUI,
title: string,
prompt: string,
initial: string,
theme: Theme,
done: (value: string | undefined) => void,
) {
super();
this.title = title;
this.prompt = prompt;
this.theme = theme;
const editorTheme: EditorTheme = {
borderColor: (value) => theme.fg("borderAccent", value),
selectList: {
selectedPrefix: (value) => theme.fg("accent", value),
selectedText: (value) => theme.fg("accent", value),
description: (value) => theme.fg("muted", value),
scrollInfo: (value) => theme.fg("dim", value),
noMatch: (value) => theme.fg("warning", value),
},
};
this.child = new Editor(tui, editorTheme, { paddingX: 0 });
this.child.setText(initial);
this.child.onSubmit = (value) => done(value);
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) return;
this.child.handleInput(data);
}
render(width: number) {
const innerWidth = Math.max(1, width - 4);
const prompt = wrapTextWithAnsi(sanitizeLine(this.prompt), innerWidth);
return card(
this.theme,
this.title,
["", ...prompt.map((line) => ` ${line}`), "", ...this.child.render(innerWidth).map((line) => ` ${line}`), "", this.theme.fg("dim", " enter submit shift+enter newline esc back")],
width,
);
}
invalidate() {
this.child.invalidate();
}
}
function assertTui(ctx: ExtensionContext) {
if (ctx.mode !== "tui") {
throw new Error("workflow overlays require TUI mode");
}
}
export async function workflowSelect<T>(
ctx: ExtensionContext,
title: string,
choices: OverlayChoice<T>[],
) {
assertTui(ctx);
return ctx.ui.custom<T | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowSelectionOverlay(
title,
choices,
theme,
done,
Math.floor(tui.terminal.rows * 0.8),
);
return {
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: {
anchor: "center",
width: "70%",
minWidth: 36,
maxHeight: "80%",
margin: 1,
},
},
);
}
export function workflowConfirm(
ctx: ExtensionContext,
title: string,
message: string,
) {
return workflowSelect(ctx, title, [
{ value: true, label: "Confirm", description: message },
{ value: false, label: "Cancel" },
]).then((value) => value === true);
}
export async function workflowInput(
ctx: ExtensionContext,
title: string,
prompt: string,
) {
assertTui(ctx);
return ctx.ui.custom<string | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowInputOverlay(
title,
prompt,
theme,
done,
Math.max(4, Math.floor(tui.terminal.rows * 0.7)),
);
return {
get focused() {
return overlay.focused;
},
set focused(value: boolean) {
overlay.focused = value;
},
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "70%", minWidth: 36, maxHeight: "70%", margin: 1 },
},
);
}
export async function workflowEditor(
ctx: ExtensionContext,
title: string,
prompt: string,
initial = "",
) {
assertTui(ctx);
return ctx.ui.custom<string | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowEditorOverlay(tui, title, prompt, initial, theme, done);
return {
get focused() {
return overlay.focused;
},
set focused(value: boolean) {
overlay.focused = value;
},
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.escape)) done(undefined);
else overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 },
},
);
}
export interface PlanHeading {
level: 1 | 2 | 3;
text: string;
line: number;
}
export interface PlanReaderModel {
title: string;
lines: string[];
headings: PlanHeading[];
}
export interface PlanReaderState {
topRow: number;
}
export type PlanReaderAction =
| "up"
| "down"
| "page-up"
| "page-down"
| "home"
| "end";
interface PlanDisplayRow {
text: string;
sourceLine: number;
}
export function createPlanReaderModel(title: string, contents: string): PlanReaderModel {
const lines = sanitizeTerminalText(contents).split("\n");
const headings = lines.flatMap((line, index): PlanHeading[] => {
const match = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
return match
? [{ level: match[1]!.length as 1 | 2 | 3, text: match[2]!, line: index }]
: [];
});
return { title: sanitizeLine(title), lines, headings };
}
export function createPlanReaderState(): PlanReaderState {
return { topRow: 0 };
}
function readerBodyHeight(height: number) {
return Math.max(1, height - 4);
}
function readerOutlineWidth(width: number) {
return Math.max(24, Math.min(32, Math.floor(width * 0.34)));
}
function readerDocumentWidth(width: number) {
return width < 80
? Math.max(1, width - 2)
: Math.max(1, width - readerOutlineWidth(width) - 3);
}
function displayRows(model: PlanReaderModel, width: number) {
return model.lines.flatMap((line, sourceLine): PlanDisplayRow[] => {
const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
return (wrapped.length > 0 ? wrapped : [""]).map((text) => ({
text,
sourceLine,
}));
});
}
export function reducePlanReaderState(
model: PlanReaderModel,
state: PlanReaderState,
action: PlanReaderAction,
dimensions: { width: number; height: number },
): PlanReaderState {
const rows = displayRows(model, readerDocumentWidth(dimensions.width));
const last = Math.max(0, rows.length - 1);
const page = Math.max(1, readerBodyHeight(dimensions.height) - 1);
const delta =
action === "up"
? -1
: action === "down"
? 1
: action === "page-up"
? -page
: action === "page-down"
? page
: 0;
const topRow =
action === "home"
? 0
: action === "end"
? last
: Math.max(0, Math.min(last, state.topRow + delta));
return { topRow };
}
function currentHeading(model: PlanReaderModel, sourceLine: number) {
return [...model.headings].reverse().find((heading) => heading.line <= sourceLine);
}
function viewportRows(
rows: PlanDisplayRow[],
topRow: number,
height: number,
) {
return rows.slice(topRow, topRow + readerBodyHeight(height));
}
export function renderPlanReader(
model: PlanReaderModel,
state: PlanReaderState,
dimensions: { width: number; height: number },
theme?: Theme,
) {
const { width, height } = dimensions;
if (width < 48 || height < 12) {
const lines = [
"",
"terminal too small",
"",
"workflow plan needs 48x12",
`current terminal is ${width}x${height}`,
"",
"resize the terminal or press esc",
];
const leftPad = Math.max(0, Math.floor((width - Math.max(...lines.map(visibleWidth))) / 2));
return lines.map((line) => fit(`${" ".repeat(leftPad)}${line}`, width)).slice(0, height);
}
const documentWidth = readerDocumentWidth(width);
const rows = displayRows(model, documentWidth);
const topRow = Math.max(0, Math.min(rows.length - 1, state.topRow));
const sourceLine = rows[topRow]?.sourceLine ?? 0;
const heading = currentHeading(model, sourceLine);
const position = `row ${Math.min(rows.length, topRow + 1)}/${rows.length}`;
const headingLabel = width < 80 && heading ? ` · ${heading.text}` : "";
const header = fit(` workflow plan · ${model.title}${headingLabel} · ${position}`, width);
const footer = fit(" ↑↓ scroll pgup/pgdn page home/end document esc back", width);
const bodyHeight = readerBodyHeight(height);
const paint = (token: "accent" | "dim" | "mdHeading" | "muted" | "text", value: string) =>
theme ? theme.fg(token, value) : value;
const viewport = viewportRows(rows, topRow, height);
if (width < 80) {
const body = viewport.map((row) => {
const token = /^#{1,3}\s/.test(row.text) ? "mdHeading" as const : "text" as const;
return fit(` ${paint(token, row.text)}`, width);
});
while (body.length < bodyHeight) body.push("");
return [paint("accent", header), "", ...body.slice(0, bodyHeight), paint("dim", footer)].slice(0, height);
}
const outlineWidth = readerOutlineWidth(width);
const gap = 3;
const body = viewport.map((row) => row.text);
const selectedHeading = heading?.line;
const selectedHeadingIndex = Math.max(
0,
model.headings.findIndex((entry) => entry.line === selectedHeading),
);
const outlineStart = Math.max(
0,
Math.min(
selectedHeadingIndex - Math.floor(bodyHeight / 2),
Math.max(0, model.headings.length - bodyHeight),
),
);
const outline = model.headings
.slice(outlineStart, outlineStart + bodyHeight)
.map((entry) => {
const prefix = entry.line === selectedHeading ? "> " : " ";
const indent = " ".repeat(entry.level - 1);
const text = `${prefix}${indent}${entry.text}`;
return entry.line === selectedHeading
? paint("accent", text)
: paint(entry.level === 1 ? "text" : "muted", text);
});
const renderedRows = Array.from({ length: bodyHeight }, (_, index) => {
const left = pad(outline[index] ?? "", outlineWidth);
const right = fit(body[index] ?? "", documentWidth);
return fit(`${left}${" ".repeat(gap)}${right}`, width);
});
return [paint("accent", header), "", ...renderedRows, paint("dim", footer)].slice(0, height);
}
class PlanReaderComponent implements Component {
private state = createPlanReaderState();
private width: number;
private readonly tui: TUI;
private readonly model: PlanReaderModel;
private readonly theme: Theme;
private readonly done: () => void;
constructor(
tui: TUI,
model: PlanReaderModel,
theme: Theme,
done: () => void,
) {
this.tui = tui;
this.width = tui.terminal.columns;
this.model = model;
this.theme = theme;
this.done = done;
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
this.done();
return;
}
const action = matchesKey(data, Key.up)
? "up"
: matchesKey(data, Key.down)
? "down"
: matchesKey(data, Key.pageUp)
? "page-up"
: matchesKey(data, Key.pageDown)
? "page-down"
: matchesKey(data, Key.home)
? "home"
: matchesKey(data, Key.end)
? "end"
: undefined;
if (action) {
this.state = reducePlanReaderState(
this.model,
this.state,
action,
{ width: this.width, height: this.tui.terminal.rows },
);
}
}
render(width: number) {
this.width = width;
return renderPlanReader(
this.model,
this.state,
{ width, height: this.tui.terminal.rows },
this.theme,
);
}
invalidate() {}
}
export async function workflowPlanReader(
ctx: ExtensionContext,
title: string,
contents: string,
) {
assertTui(ctx);
const model = createPlanReaderModel(title, contents);
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
const reader = new PlanReaderComponent(tui, model, theme, done);
return {
render: (width) => reader.render(width),
invalidate: () => reader.invalidate(),
handleInput: (data) => {
reader.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: {
anchor: "top-left",
width: "100%",
maxHeight: "100%",
margin: 0,
},
},
);
}