feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,702 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user