Skip to content

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/core/slash-chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { findAgentByName } from "./subagent.js";
import { resolveModelSelection, validateModelThinking } from "./model-resolver.js";
import { preflightChainModels, validateChainAgents } from "./chain-preflight.js";
import { normalizeThinkingLevel, ChainThinkingLevelError } from "../shared/thinking.js";
import { DASHBOARD_OVERLAY_OPTIONS } from "../tui/dashboard-style.js";

export class SlashParseError extends Error {}

Expand Down Expand Up @@ -619,7 +620,7 @@ export async function executeSlashChain(
(tui, theme, _kb, done) => new ChainClarifyComponent(tui, theme, chain, done),
{
overlay: true,
overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" },
overlayOptions: DASHBOARD_OVERLAY_OPTIONS,
},
);
} catch (error) {
Expand Down
116 changes: 92 additions & 24 deletions src/tui/chain-clarify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import {
} from "@earendil-works/pi-tui";
import type { ChainStep, SequentialStep } from "../shared/types.js";
import type { Theme } from "./agent-widget.js";
import {
DASHBOARD_MAX_HEIGHT_RATIO,
MIN_DASHBOARD_FRAME_WIDTH,
dashboardContentWidth,
fitDashboardViewport,
renderDashboardFrame,
renderDashboardTooSmall,
} from "./dashboard-style.js";

const DASHBOARD_CHROME_ROWS = 8;
const MIN_CHAIN_DASHBOARD_ROWS = 11;

export interface ChainClarifyResult {
action: "run" | "cancel" | "bg";
Expand Down Expand Up @@ -41,6 +52,7 @@ export class ChainClarifyComponent implements Component, Focusable {
private mode: EditMode = "list";
private modelOverrides = new Map<number, string>();
private taskOverrides = new Map<number, string>();
private viewportOffset = 0;

constructor(
private tui: TUI,
Expand Down Expand Up @@ -113,37 +125,49 @@ export class ChainClarifyComponent implements Component, Focusable {
}

private renderListMode(width: number): string[] {
const th = this.theme;
const lines: string[] = [];
const header = `Chain Preview (${this.steps.length} step${this.steps.length === 1 ? "" : "s"})`;
const hint = "[Enter] Run [b] Background [Esc] Cancel";
lines.push(`${th.fg("accent", header)} ${th.fg("dim", hint)}`);
lines.push(th.fg("dim", "─".repeat(Math.min(width, 60))));
let selectedLine: number | undefined;

for (let i = 0; i < this.steps.length; i++) {
const step = this.steps[i]!;
const cursor = i === this.selectedIndex ? ">" : " ";
const isSeq = !("parallel" in step);

if (isSeq) {
const cursor = i === this.selectedIndex ? "▸" : " ";
if ("expand" in step) {
lines.push(`${cursor} [${i + 1}/${this.steps.length}] Dynamic parallel · ${step.parallel.agent}`);
if (i === this.selectedIndex) selectedLine = lines.length - 1;
} else if ("parallel" in step) {
lines.push(`${cursor} [${i + 1}/${this.steps.length}] Parallel · ${step.parallel.map((item) => item.agent).join(", ")}`);
if (i === this.selectedIndex) selectedLine = lines.length - 1;
} else {
const seq = step as SequentialStep;
const taskText = this.taskOverrides.get(i) ?? seq.task ?? "(no task)";
const modelText = this.modelOverrides.get(i) ?? seq.model ?? "(inherit)";
const taskMarker = this.taskOverrides.has(i) ? "* " : "";
const modelMarker = this.modelOverrides.has(i) ? "* " : "";
lines.push(
` ${cursor} [${i + 1}] ${th.fg("accent", seq.agent ?? "?")}`,
);
lines.push(` Task: ${taskMarker}${taskText}`);
lines.push(` Model: ${modelMarker}${modelText}`);
} else {
lines.push(` ${cursor} [${i + 1}] ${th.fg("dim", "(parallel step)")}`);
lines.push(`${cursor} [${i + 1}/${this.steps.length}] ${seq.agent ?? "?"}`);
lines.push(` Task ${taskMarker}${taskText}`);
lines.push(` Model ${modelMarker}${modelText}`);
if (i === this.selectedIndex) selectedLine = lines.length - 1;
}
if (i < this.steps.length - 1) lines.push("");
}

const selectedStep = this.steps[this.selectedIndex];
if (
selectedLine !== undefined &&
selectedStep &&
!("parallel" in selectedStep) &&
selectedLine - 2 <= this.viewportOffset
) {
this.viewportOffset = selectedLine - 2;
}

lines.push(th.fg("dim", "─".repeat(Math.min(width, 60))));
lines.push(th.fg("dim", "[e] Edit task [m] Model [j/k] Navigate"));
return lines;
return this.renderDashboard(
width,
lines,
selectedLine,
`Chain Preview · ${this.steps.length} steps`,
"↑/↓ Select • e Edit task • m Edit model • Enter Run • b Background • q/Esc Cancel",
);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -178,12 +202,56 @@ export class ChainClarifyComponent implements Component, Focusable {
}

private renderEditMode(width: number): string[] {
const th = this.theme;
const label = this.mode === "edit-task" ? "Task" : "Model";
return [
`${th.fg("accent", `Edit ${label}`)} ${th.fg("dim", "[Enter] Confirm [Esc] Cancel")}`,
...this.input.render(width),
];
return this.renderDashboard(
width,
this.input.render(dashboardContentWidth(width)),
undefined,
`Edit ${label}`,
"Enter Submit • Esc Cancel",
);
}

private renderDashboard(
width: number,
lines: string[],
selectedLine: number | undefined,
header: string,
footer: string,
): string[] {
const maxRows = Math.max(
1,
Math.floor(this.tui.terminal.rows * DASHBOARD_MAX_HEIGHT_RATIO),
);
const targetRows =
maxRows < MIN_CHAIN_DASHBOARD_ROWS
? maxRows
: Math.min(
maxRows,
DASHBOARD_CHROME_ROWS + Math.max(3, lines.length),
);
if (width < MIN_DASHBOARD_FRAME_WIDTH || targetRows < MIN_CHAIN_DASHBOARD_ROWS) {
return renderDashboardTooSmall(width, targetRows, this.theme);
}

const viewport = fitDashboardViewport(
lines,
selectedLine,
targetRows - DASHBOARD_CHROME_ROWS,
this.viewportOffset,
);
this.viewportOffset = viewport.offset;
return renderDashboardFrame(
[
this.theme.fg("accent", header),
"",
...viewport.lines,
"",
this.theme.fg("dim", footer),
],
width,
this.theme,
);
}

// ---------------------------------------------------------------------------
Expand Down
66 changes: 66 additions & 0 deletions src/tui/dashboard-style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { truncateToWidth, visibleWidth, type OverlayOptions } from "@earendil-works/pi-tui";
import type { Theme } from "./agent-widget.js";

const PADDING_X = 2;
const FRAME = { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" } as const;

export const DASHBOARD_MAX_HEIGHT_RATIO = 0.85;
export const MIN_DASHBOARD_FRAME_WIDTH = 7;
export const DASHBOARD_OVERLAY_OPTIONS: OverlayOptions = {
anchor: "center",
width: "92%",
maxHeight: "85%",
};

export function dashboardContentWidth(width: number): number {
return Math.max(1, Math.floor(width) - 6);
}

function fixedWidth(text: string, width: number): string {
const truncated = truncateToWidth(text.replace(/[\r\n]+/g, " "), width, "");
return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
}

export function renderDashboardFrame(lines: readonly string[], width: number, theme: Theme): string[] {
const frameWidth = Math.max(MIN_DASHBOARD_FRAME_WIDTH, Math.floor(width));
const contentWidth = dashboardContentWidth(frameWidth);
const border = (glyph: string) => theme.fg("border", glyph);
const row = (content: string) =>
fixedWidth(`${border(FRAME.v)}${" ".repeat(PADDING_X)}${fixedWidth(content, contentWidth)}${" ".repeat(PADDING_X)}${border(FRAME.v)}`, frameWidth);

return [
fixedWidth(`${border(FRAME.tl)}${border(FRAME.h.repeat(frameWidth - 2))}${border(FRAME.tr)}`, frameWidth),
row(""),
...lines.map(row),
row(""),
fixedWidth(`${border(FRAME.bl)}${border(FRAME.h.repeat(frameWidth - 2))}${border(FRAME.br)}`, frameWidth),
];
}

export function fitDashboardViewport(
lines: readonly string[],
selectedLine: number | undefined,
height: number,
offset: number,
): { lines: string[]; offset: number } {
const viewportHeight = Math.max(0, Math.floor(height));
if (viewportHeight === 0) return { lines: [], offset: 0 };

const maxOffset = Math.max(0, lines.length - viewportHeight);
let nextOffset = Math.max(0, Math.min(Math.floor(offset), maxOffset));
if (selectedLine !== undefined) {
if (selectedLine < nextOffset) nextOffset = selectedLine;
else if (selectedLine >= nextOffset + viewportHeight) nextOffset = selectedLine - viewportHeight + 1;
nextOffset = Math.max(0, Math.min(nextOffset, maxOffset));
}

const visibleLines = lines.slice(nextOffset, nextOffset + viewportHeight);
return { lines: [...visibleLines, ...Array(viewportHeight - visibleLines.length).fill("")], offset: nextOffset };
}

export function renderDashboardTooSmall(width: number, height: number, theme: Theme): string[] {
const safeWidth = Math.max(1, Math.floor(width));
const safeHeight = Math.max(1, Math.floor(height));
const message = theme.fg("accent", "Terminal too small · Esc");
return Array.from({ length: safeHeight }, (_, index) => fixedWidth(index === Math.floor(safeHeight / 2) ? message : "", safeWidth));
}
Loading
Loading