703 lines
20 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
);
|
|
}
|