Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,7 @@ export function App({
onResolvePermission={gates.resolvePermission}

width={columns}
{...(rows !== undefined ? { terminalRows: rows } : {})}
/>
</Box>
<OverlayStack
Expand Down
6 changes: 6 additions & 0 deletions src/tui/components/modal-stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export type ModalStackProps = {
permissionQueueDepth?: number;
queuedApprovals?: readonly QueuedApprovalSummary[];
onResolvePermission: (id: number, outcome: ApprovalOutcome) => void;
/** Terminal height, so approval bodies taller than it scroll instead of
* pushing the choices off screen. */
terminalRows?: number;


width?: number;
Expand Down Expand Up @@ -120,6 +123,7 @@ export function ModalStack({
queuedApprovals,
onResolvePermission,
width,
terminalRows,
}: ModalStackProps): ReactNode {

return (
Expand Down Expand Up @@ -167,6 +171,7 @@ export function ModalStack({
options={activeApproval.options}
onSelect={(result) => onSelectOperator(activeApproval.id, result)}
{...(width !== undefined ? { width } : {})}
{...(terminalRows !== undefined ? { terminalRows } : {})}
/>
)}
{activeApproval?.kind === "permission" && (
Expand All @@ -175,6 +180,7 @@ export function ModalStack({
request={activeApproval.request}
{...(permissionQueueDepth !== undefined ? { permissionQueueDepth } : {})}
{...(queuedApprovals !== undefined ? { queuedApprovals } : {})}
{...(terminalRows !== undefined ? { terminalRows } : {})}
{...(activeApproval.timeoutMs !== null ? { goalTimeoutMs: activeApproval.timeoutMs } : {})}
onResolve={(outcome) => onResolvePermission(activeApproval.id, outcome)}
{...(width !== undefined ? { width } : {})}
Expand Down
130 changes: 120 additions & 10 deletions src/tui/components/operator-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ import { createMemoizedParseMarkdown } from "../markdown-parser.js";
import type { StyledSegment } from "../markdown-parser.js";
import { color } from "../theme.js";
import { inkPropsForSegment } from "../styled-segment-props.js";
import { FALLBACK_TERMINAL_ROWS, useScrollWindow } from "../hooks/use-scroll-window.js";

export type OperatorModalProps = {
question: string;
options: string[];
onSelect: (result: OperatorResult) => void;
width?: number;
/** Terminal height, so a long question or option list scrolls/pages instead
* of pushing the selection out of view. Defaults to a conservative fallback. */
terminalRows?: number;
};

const MIN_QUESTION_ROWS = 2;
const MIN_OPTION_ROWS = 3;

function segmentProps(seg: StyledSegment): Record<string, unknown> {
return inkPropsForSegment(seg);
}
Expand All @@ -23,8 +30,7 @@ function segmentProps(seg: StyledSegment): Record<string, unknown> {
// re-render is pure waste. A small bounded cache turns that into a cache hit.
const memoizedParseMarkdown = createMemoizedParseMarkdown();

function MarkdownText({ text, width }: { text: string; width: number }): ReactNode {
const lines = memoizedParseMarkdown(text, width);
function renderMarkdownLines(lines: readonly StyledSegment[][]): ReactNode {
return (
<Box flexDirection="column">
{lines.map((line, li) => (
Expand All @@ -38,6 +44,7 @@ function MarkdownText({ text, width }: { text: string; width: number }): ReactNo
);
}


// Two-column layout when all options are short enough to fit side by side.
// Each column gets half the inner width minus a small gap for the number prefix.
function renderOptionsGrid(options: string[], selected: number, innerWidth: number): ReactNode {
Expand Down Expand Up @@ -80,15 +87,19 @@ function renderOptionsGrid(options: string[], selected: number, innerWidth: numb
return <Box flexDirection="column">{rows}</Box>;
}

function renderOptionsList(options: string[], selected: number): ReactNode {
// `startIndex` lets a windowed slice of `options` keep its true 1-based
// number and highlight against the real `selected` index, not its position
// within the slice.
function renderOptionsList(options: string[], selected: number, startIndex = 0): ReactNode {
return (
<Box flexDirection="column">
{options.map((opt, i) => {
const active = i === selected;
const realIndex = startIndex + i;
const active = realIndex === selected;
return (
<Text key={i} wrap="wrap">
<Text key={realIndex} wrap="wrap">
<Text color={active ? color("brand") : color("muted")} bold={active}>{active ? "› " : " "}</Text>
<Text color={color("muted")}>{`${i + 1}. `}</Text>
<Text color={color("muted")}>{`${realIndex + 1}. `}</Text>
<Text color={active ? color("text") : color("muted")} bold={active}>{opt}</Text>
</Text>
);
Expand All @@ -97,7 +108,13 @@ function renderOptionsList(options: string[], selected: number): ReactNode {
);
}

export function OperatorModal({ question, options, onSelect, width = 80 }: OperatorModalProps): ReactNode {
export function OperatorModal({
question,
options,
onSelect,
width = 80,
terminalRows = FALLBACK_TERMINAL_ROWS,
}: OperatorModalProps): ReactNode {
const [selected, setSelected] = useState(0);
const [draft, setDraft] = useState("");
const typing = draft.length > 0;
Expand All @@ -109,6 +126,66 @@ export function OperatorModal({ question, options, onSelect, width = 80 }: Opera
const maxOptLen = options.reduce((n, o) => Math.max(n, o.length), 0);
const useGrid = options.length >= 2 && options.length <= 4 && maxOptLen <= colWidth - 5;

const questionLines = memoizedParseMarkdown(question, innerWidth);
const optionsRowsNeeded = useGrid ? Math.ceil(options.length / 2) : options.length;
// border(2) + paddingY(2) + marginBottom after the question(1) + marginTop
// before the footer(1) + footer line(1).
const reservedChrome = 7;
const baseAvailable = Math.max(
MIN_QUESTION_ROWS + MIN_OPTION_ROWS,
terminalRows - reservedChrome,
);

const layout = (available: number): { questionRows: number; optionsRows: number } => {
if (questionLines.length + optionsRowsNeeded <= available) {
return { questionRows: questionLines.length, optionsRows: optionsRowsNeeded };
}
// The selection must stay reachable, so the option list gets priority;
// whatever's left goes to the question.
const optionsRows = Math.min(
optionsRowsNeeded,
Math.max(MIN_OPTION_ROWS, available - MIN_QUESTION_ROWS),
);
const questionRows = Math.max(MIN_QUESTION_ROWS, available - optionsRows);
return { questionRows, optionsRows };
};

let { questionRows, optionsRows } = layout(baseAvailable);
const questionScrollableTentative = questionLines.length > questionRows;
const optionsScrollableTentative = !useGrid && options.length > optionsRows;
const indicatorRows =
(questionScrollableTentative ? 1 : 0) + (optionsScrollableTentative ? 1 : 0);
if (indicatorRows > 0) {
({ questionRows, optionsRows } = layout(baseAvailable - indicatorRows));
}

const questionScrollable = questionLines.length > questionRows;
const questionScroll = useScrollWindow(questionLines.length, questionRows);
const clampedQuestionOffset = questionScroll.offset;
const visibleQuestionLines = questionScrollable
? questionLines.slice(clampedQuestionOffset, clampedQuestionOffset + questionRows)
: questionLines;
const questionLinesAbove = questionScroll.above;
const questionLinesBelow = questionScroll.below;

// Only the plain list windows around the selection — grid mode is capped at
// 4 options (2 rows), which always fits.
const optionsScrollable = !useGrid && options.length > optionsRows;
let optionsWindowStart = 0;
if (optionsScrollable) {
optionsWindowStart = Math.max(
0,
Math.min(selected - Math.floor(optionsRows / 2), options.length - optionsRows),
);
}
const visibleOptions = optionsScrollable
? options.slice(optionsWindowStart, optionsWindowStart + optionsRows)
: options;
const optionsAbove = optionsWindowStart;
const optionsBelow = optionsScrollable
? options.length - optionsWindowStart - visibleOptions.length
: 0;

useInput((input, key) => {
if (typing) {
if (key.escape) {
Expand All @@ -124,6 +201,16 @@ export function OperatorModal({ question, options, onSelect, width = 80 }: Opera
setDraft((d) => d.slice(0, -1));
return;
}
// Page keys still scroll a long question while the operator is typing a
// custom answer — the draft is unrelated to the question viewport.
if (questionScrollable && key.pageUp) {
questionScroll.pageUp();
return;
}
if (questionScrollable && key.pageDown) {
questionScroll.pageDown();
return;
}
if (!key.ctrl && !key.meta && input.length > 0 && /^[\x20-\x7E -￿]+$/.test(input)) {
setDraft((d) => d + input);
}
Expand All @@ -134,6 +221,14 @@ export function OperatorModal({ question, options, onSelect, width = 80 }: Opera
onSelect({ kind: "cancel" });
return;
}
if (questionScrollable && key.pageUp) {
questionScroll.pageUp();
return;
}
if (questionScrollable && key.pageDown) {
questionScroll.pageDown();
return;
}
if (key.ctrl && (key.upArrow || key.downArrow)) return;
if (key.upArrow || input === "\x1B[A" || input === "[A") {
setSelected((s) => (s > 0 ? s - 1 : options.length - 1));
Expand Down Expand Up @@ -170,8 +265,16 @@ export function OperatorModal({ question, options, onSelect, width = 80 }: Opera
marginX={1}
width={Math.max(24, width - 2)}
>
<Box marginBottom={1}>
<MarkdownText text={question} width={innerWidth} />
<Box marginBottom={1} flexDirection="column">
{renderMarkdownLines(visibleQuestionLines)}
{questionScrollable && (
<Text color={color("muted")}>
{questionLinesAbove > 0 ? `↑ ${questionLinesAbove} more above` : ""}
{questionLinesAbove > 0 && questionLinesBelow > 0 ? " · " : ""}
{questionLinesBelow > 0 ? `↓ ${questionLinesBelow} more below` : ""}
{" · PageUp/PageDown to scroll"}
</Text>
)}
</Box>
{typing ? (
<Box flexDirection="column">
Expand All @@ -188,7 +291,14 @@ export function OperatorModal({ question, options, onSelect, width = 80 }: Opera
<Box flexDirection="column">
{useGrid
? renderOptionsGrid(options, selected, innerWidth)
: renderOptionsList(options, selected)}
: renderOptionsList(visibleOptions, selected, optionsWindowStart)}
{optionsScrollable && (optionsAbove > 0 || optionsBelow > 0) && (
<Text color={color("muted")}>
{optionsAbove > 0 ? `↑ ${optionsAbove} more above` : ""}
{optionsAbove > 0 && optionsBelow > 0 ? " · " : ""}
{optionsBelow > 0 ? `↓ ${optionsBelow} more below` : ""}
</Text>
)}
<Box marginTop={1}>
<Text color={color("dim")} wrap="truncate-end">
{`1-${options.length} select · ↑↓ navigate · Enter choose · type to respond · Esc dismiss`}
Expand Down
66 changes: 66 additions & 0 deletions src/tui/components/permission-modal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { render } from "ink-testing-library";
import type { PermissionRequest } from "../../permission/types.js";
import { PermissionModal } from "./permission-modal.js";

function longChainRequest(segmentCount: number): PermissionRequest {
const subject = Array.from({ length: segmentCount }, (_, i) => `echo line-${i}`).join(" && ");
return { tool: "run_shell", action: "Run", subject, scopes: [] };
}

describe("PermissionModal scroll indicator", () => {
test("shows a 'more below' indicator when the body overflows and is scrolled to the top", () => {
const { lastFrame, unmount } = render(
createElement(PermissionModal, {
request: longChainRequest(30),
onResolve: () => {},
terminalRows: 15,
}),
);

const frame = lastFrame() ?? "";
expect(frame).toContain("more below");
expect(frame).not.toContain("more above");

unmount();
});

test("swaps to a 'more above' indicator, with no 'more below', once scrolled to the end", async () => {
const { lastFrame, stdin, unmount } = render(
createElement(PermissionModal, {
request: longChainRequest(30),
onResolve: () => {},
terminalRows: 15,
}),
);

// Page down repeatedly past the end of the body; offset clamps at max.
for (let i = 0; i < 10; i++) {
stdin.write("\x1b[6~");
await new Promise((r) => setTimeout(r, 0));
}

const frame = lastFrame() ?? "";
expect(frame).toContain("more above");
expect(frame).not.toContain("more below");

unmount();
});

test("shows no scroll indicator when the body fits without overflow", () => {
const { lastFrame, unmount } = render(
createElement(PermissionModal, {
request: longChainRequest(2),
onResolve: () => {},
terminalRows: 40,
}),
);

const frame = lastFrame() ?? "";
expect(frame).not.toContain("more below");
expect(frame).not.toContain("more above");

unmount();
});
});
Loading
Loading