From 849b27a910bf5a64573073aa94198831d7f1ad96 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 19:01:20 -0700 Subject: [PATCH 1/2] Make settings and agent forms reflow on narrow terminals Extract shared form-reflow helpers and wire Settings + Agent Configuration so labels stack, long values keep the caret on-screen, and help footers wrap at ~40-60 columns. Closes CL-5341 --- src/tui/components/agent-modal.tsx | 283 ++++++++++++++++-------- src/tui/components/form-reflow.test.ts | 81 +++++++ src/tui/components/form-reflow.ts | 45 ++++ src/tui/components/settings-overlay.tsx | 64 ++++-- 4 files changed, 363 insertions(+), 110 deletions(-) create mode 100644 src/tui/components/form-reflow.test.ts create mode 100644 src/tui/components/form-reflow.ts diff --git a/src/tui/components/agent-modal.tsx b/src/tui/components/agent-modal.tsx index 9ed0f0632..1e52e8b73 100644 --- a/src/tui/components/agent-modal.tsx +++ b/src/tui/components/agent-modal.tsx @@ -7,6 +7,13 @@ import { supportedEfforts, type ReasoningEffort } from "../../provider/reasoning import { PROVIDER_TIERS, type ProviderTier, type TierConfig } from "../../config/settings.js"; import { formatTierChain, normalizeTierDefinition } from "../../config/inference-sources.js"; import type { AgentProfile } from "../../agent/profiles.js"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { + STACK_FORM_COLUMNS, + fitTrailingText, + formContentWidth, + wrapHelpSegments, +} from "./form-reflow.js"; // Effort display: undefined means "no override" (field omitted); "none" is // OpenAI's explicit disable-reasoning value. Both read as "off". @@ -249,6 +256,15 @@ export function AgentModal({ unauthedProviders, onRequestLogin, }: AgentModalProps): ReactNode { + const { columns } = useTerminalSize(); + const stackFields = columns < STACK_FORM_COLUMNS; + const contentWidth = formContentWidth(columns, stackFields); + // Label column widths used in row layout; stacked layout uses full content width for values. + const providerLabelWidth = 16; + const profileLabelWidth = 14; + const valueWidth = stackFields + ? contentWidth + : Math.max(8, contentWidth - Math.max(providerLabelWidth, profileLabelWidth) - 1); const initialProvider = Math.max( 0, providers.findIndex((p) => p.name === activeProvider), @@ -773,13 +789,40 @@ export function AgentModal({ setFormError(null); }); + const helpText = ((): string | null => { + switch (step) { + case "provider": + return "Up/Down navigate · Enter models · a add · e edit · x remove · t tiers · p profiles · Esc close"; + case "tiers": + return "Up/Down navigate · Enter add · e edit chain · m mode · c clear · Esc back"; + case "tier-chain": + return "Up/Down leg · a/Enter add · x remove · u/d reorder · m mode · Esc back"; + case "profiles": + return "Up/Down navigate · a add · e edit · x remove · Esc back"; + case "profile-form": + return "Up/Down fields · Left/Right for tier · Enter next/save · Esc cancel"; + case "profile-delete": + return "y remove · n cancel · Esc back"; + case "model": + return "Up/Down navigate · Enter effort · Esc back"; + case "effort": + return "Up/Down navigate · Enter use now · d set as default · Esc back"; + case "form": + return "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel"; + case "delete": + return "y remove · n cancel · Esc back"; + } + })(); + const helpLines = helpText !== null ? wrapHelpSegments(helpText.split(" · "), contentWidth) : []; + return ( Agent Configuration @@ -859,16 +902,18 @@ export function AgentModal({ const assignment = tiers[tier]; const isCursor = i === tierIndex; const assignmentLabel = formatTierChain(assignment); + const rowDir = stackFields ? "column" : "row"; return ( - - - {isCursor ? ">" : " "} - - + + + + {isCursor ? ">" : " "} + {tier} - {assignmentLabel} + {stackFields ? " " : ""} + {fitTrailingText(assignmentLabel, stackFields ? contentWidth - 2 : Math.max(8, contentWidth - 12))} ); @@ -886,17 +931,27 @@ export function AgentModal({ {models.map((m, i) => { const isActive = selectedProvider?.name === activeProvider && m === activeModel; const isCursor = i === modelIndex; + const desc = MODEL_DESCRIPTIONS[m]; + const namePart = `${isActive ? "* " : " "}${m}`; + const showDescInline = desc !== undefined && !stackFields && namePart.length + desc.length + 4 < contentWidth; return ( - - - {isCursor ? ">" : " "} - - - {isActive ? "* " : " "} - {m} - - {MODEL_DESCRIPTIONS[m] !== undefined && ( - — {MODEL_DESCRIPTIONS[m]} + + + + {isCursor ? ">" : " "} + + + {fitTrailingText(namePart, contentWidth - 2)} + + {showDescInline && ( + — {desc} + )} + + {desc !== undefined && !showDescInline && ( + + {" "} + {fitTrailingText(desc, contentWidth - 2)} + )} ); @@ -915,17 +970,27 @@ export function AgentModal({ {efforts.map((e, i) => { const isActive = e === activeEffort; const isCursor = i === effortIndex; + const desc = EFFORT_DESCRIPTIONS[e]; + const namePart = `${isActive ? "* " : " "}${effortLabel(e)}`; + const showDescInline = desc !== undefined && !stackFields && namePart.length + desc.length + 4 < contentWidth; return ( - - - {isCursor ? ">" : " "} - - - {isActive ? "* " : " "} - {effortLabel(e)} - - {EFFORT_DESCRIPTIONS[e] !== undefined && ( - — {EFFORT_DESCRIPTIONS[e]} + + + + {isCursor ? ">" : " "} + + + {fitTrailingText(namePart, contentWidth - 2)} + + {showDescInline && ( + — {desc} + )} + + {desc !== undefined && !showDescInline && ( + + {" "} + {fitTrailingText(desc, contentWidth - 2)} + )} ); @@ -949,45 +1014,67 @@ export function AgentModal({ const isCursor = i === formIndex; const value = formValues[field]; const isKeyless = formValues.keyless === "yes"; - // gap only between label and value — never between value and caret, +// gap only between label and value — never between value and caret, // or the caret sits after a phantom space the user did not type. const showCaret = isCursor && field !== "keyless" && !(field === "apiKey" && isKeyless); + const rawDisplay = + field === "keyless" + ? null + : field === "apiKey" && isKeyless + ? "(disabled — keyless provider)" + : value.length > 0 + ? maskInput(field, value) + : field === "apiKey" && editingProvider !== undefined + ? "leave blank to keep existing" + : FIELD_HINTS[field]; + // Reserve one cell for the caret so long values do not push it off-screen. + const fitted = + rawDisplay === null + ? null + : fitTrailingText(rawDisplay, showCaret ? Math.max(1, valueWidth - 1) : valueWidth); return ( - - + + {FIELD_LABELS[field]} - {field === "keyless" ? ( - - {isCursor ? "< " : " "} - {value === "yes" ? "yes" : "no"} - {isCursor ? " >" : ""} - - ) : field === "apiKey" && isKeyless ? ( - (disabled — keyless provider) - ) : ( - - 0 ? color("text") : color("muted")}> - {value.length > 0 - ? maskInput(field, value) - : field === "apiKey" && editingProvider !== undefined - ? "leave blank to keep existing" - : FIELD_HINTS[field]} + + {field === "keyless" ? ( + + {isCursor ? "< " : " "} + {value === "yes" ? "yes" : "no"} + {isCursor ? " >" : ""} - {showCaret && |} - - )} + ) : ( + 0 + ? color("text") + : color("muted") + } + > + {fitted} + + )} + {showCaret && |} + ); })} {formError !== null && ( - {formError} + {fitTrailingText(formError, contentWidth)} )} @@ -1000,18 +1087,23 @@ export function AgentModal({ )} {profiles.map((p, i) => { const isCursor = i === profileIndex; + const meta = `${p.tier !== undefined ? `[${p.tier}]` : ""}${p.description !== undefined ? ` ${p.description}` : ""}`.trim(); return ( - - - {isCursor ? ">" : " "} - - - {p.id} + + + + {isCursor ? ">" : " "} + + + {fitTrailingText(p.id, stackFields ? contentWidth - 2 : 20)} + - - {p.tier !== undefined ? `[${p.tier}]` : ""} - {p.description !== undefined ? ` ${p.description}` : ""} - + {meta.length > 0 && ( + + {stackFields ? " " : ""} + {fitTrailingText(meta, stackFields ? contentWidth - 2 : Math.max(8, contentWidth - 24))} + + )} ); })} @@ -1032,53 +1124,62 @@ export function AgentModal({ {PROFILE_FORM_FIELDS.map((field, i) => { const isCursor = i === profileFormIndex; + const showCaret = isCursor && field !== "tier"; + const raw = + field === "tier" + ? null + : profileFormValues[field].length > 0 + ? profileFormValues[field] + : PROFILE_FIELD_HINTS[field]; + const fitted = + raw === null + ? null + : fitTrailingText(raw, showCaret ? Math.max(1, valueWidth - 1) : valueWidth); return ( - - + + {PROFILE_FIELD_LABELS[field]} - {field === "tier" ? ( - 0 ? color("text") : color("muted")}> - {isCursor ? "< " : " "} - {profileFormValues.tier.length > 0 ? profileFormValues.tier : "none"} - {isCursor ? " >" : ""} - - ) : ( - - 0 ? color("text") : color("muted")}> - {profileFormValues[field].length > 0 ? profileFormValues[field] : PROFILE_FIELD_HINTS[field]} + + {field === "tier" ? ( + 0 ? color("text") : color("muted")}> + {isCursor ? "< " : " "} + {profileFormValues.tier.length > 0 ? profileFormValues.tier : "none"} + {isCursor ? " >" : ""} - {isCursor && |} - - )} + ) : ( + 0 ? color("text") : color("muted")} + > + {fitted} + + )} + {showCaret && |} + ); })} {profileFormError !== null && ( - {profileFormError} + {fitTrailingText(profileFormError, contentWidth)} )} )} - - - {step === "provider" && "Up/Down navigate · Enter models · a add · e edit · x remove · t tiers · p profiles · Esc close"} - {step === "tiers" && - "Up/Down navigate · Enter add · e edit chain · m mode · c clear · Esc back"} - {step === "tier-chain" && - "Up/Down leg · a/Enter add · x remove · u/d reorder · m mode · Esc back"} - {step === "profiles" && "Up/Down navigate · a add · e edit · x remove · Esc back"} - {step === "profile-form" && "Up/Down fields · Left/Right for tier · Enter next/save · Esc cancel"} - {step === "profile-delete" && "y remove · n cancel · Esc back"} - {step === "model" && "Up/Down navigate · Enter effort · Esc back"} - {step === "effort" && "Up/Down navigate · Enter use now · d set as default · Esc back"} - {step === "form" && "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel"} - {step === "delete" && "y remove · n cancel · Esc back"} - + + {helpLines.map((line, i) => ( + + {line} + + ))} ); diff --git a/src/tui/components/form-reflow.test.ts b/src/tui/components/form-reflow.test.ts new file mode 100644 index 000000000..477bb7e4a --- /dev/null +++ b/src/tui/components/form-reflow.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect } from "bun:test"; +import { + STACK_FORM_COLUMNS, + fitTrailingText, + formContentWidth, + wrapHelpSegments, +} from "./form-reflow.js"; + +describe("fitTrailingText", () => { + test("returns the full string when it fits", () => { + expect(fitTrailingText("hello", 10)).toBe("hello"); + }); + + test("keeps the trailing slice with an ellipsis when truncated", () => { + expect(fitTrailingText("abcdefghijklmnopqrstuvwxyz", 10)).toBe("…rstuvwxyz"); + }); + + test("handles a one-cell budget", () => { + expect(fitTrailingText("long-value", 1)).toBe("…"); + }); + + test("returns empty for non-positive budgets", () => { + expect(fitTrailingText("x", 0)).toBe(""); + expect(fitTrailingText("x", -3)).toBe(""); + }); +}); + +describe("wrapHelpSegments", () => { + const formHelp = [ + "Up/Down fields", + "Left/Right toggle keyless", + "Enter next/save", + "Esc cancel", + ]; + + test("keeps a short help line as a single row", () => { + expect(wrapHelpSegments(formHelp, 80)).toEqual([ + "Up/Down fields · Left/Right toggle keyless · Enter next/save · Esc cancel", + ]); + }); + + test("splits help into multiple rows at ~40 columns", () => { + const lines = wrapHelpSegments(formHelp, 40); + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(40); + } + expect(lines.join(" · ")).toContain("Up/Down fields"); + expect(lines.join(" · ")).toContain("Esc cancel"); + }); + + test("fits each segment at ~40–60 columns used by split panes", () => { + for (const width of [40, 48, 56, 60]) { + const lines = wrapHelpSegments(formHelp, width); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(Math.max(8, width)); + } + } + }); +}); + +describe("formContentWidth", () => { + test("accounts for margin and padding chrome", () => { + // wide: margin 2 + padding 4 = 6 + expect(formContentWidth(80, false)).toBe(74); + // narrow: margin 2 + padding 2 = 4 + expect(formContentWidth(40, true)).toBe(36); + }); + + test("never drops below a usable minimum", () => { + expect(formContentWidth(8, true)).toBe(12); + }); +}); + +describe("STACK_FORM_COLUMNS", () => { + test("threshold sits inside the 40–60 column manual-check band", () => { + expect(STACK_FORM_COLUMNS).toBeGreaterThanOrEqual(40); + expect(STACK_FORM_COLUMNS).toBeLessThanOrEqual(60); + }); +}); diff --git a/src/tui/components/form-reflow.ts b/src/tui/components/form-reflow.ts new file mode 100644 index 000000000..7a76cd275 --- /dev/null +++ b/src/tui/components/form-reflow.ts @@ -0,0 +1,45 @@ +// Shared layout helpers for settings / agent forms on narrow terminals. + +/** Stack labels above values below this terminal width. */ +export const STACK_FORM_COLUMNS = 56; + +/** Outer chrome: marginX(1)*2 + paddingX (2 wide / 1 narrow)*2. */ +export function formContentWidth(columns: number, narrow: boolean): number { + const padX = narrow ? 1 : 2; + return Math.max(12, columns - 2 - padX * 2); +} + +/** + * Caret sits at the end of append-only fields; keep the trailing slice so the + * insertion point stays on-screen when the value is longer than the pane. + */ +export function fitTrailingText(text: string, maxWidth: number): string { + if (maxWidth <= 0) return ""; + if (text.length <= maxWidth) return text; + if (maxWidth === 1) return "…"; + return `…${text.slice(-(maxWidth - 1))}`; +} + +/** Pack " · "-separated help segments into lines that fit the pane. */ +export function wrapHelpSegments(segments: readonly string[], maxWidth: number): string[] { + if (segments.length === 0) return []; + const width = Math.max(8, maxWidth); + const lines: string[] = []; + let current = ""; + for (const segment of segments) { + if (segment.length === 0) continue; + if (current.length === 0) { + current = segment.length > width ? fitTrailingText(segment, width) : segment; + continue; + } + const candidate = `${current} · ${segment}`; + if (candidate.length <= width) { + current = candidate; + continue; + } + lines.push(current); + current = segment.length > width ? fitTrailingText(segment, width) : segment; + } + if (current.length > 0) lines.push(current); + return lines; +} diff --git a/src/tui/components/settings-overlay.tsx b/src/tui/components/settings-overlay.tsx index c31b5cc57..455ac0f85 100644 --- a/src/tui/components/settings-overlay.tsx +++ b/src/tui/components/settings-overlay.tsx @@ -6,6 +6,8 @@ import type { ScopedApproval } from "../../permission/admin.js"; import type { SessionMode } from "../../config/session-mode.js"; import { SESSION_MODES } from "../../config/session-mode.js"; import { color } from "../theme.js"; +import { useTerminalSize } from "../hooks/use-terminal-size.js"; +import { STACK_FORM_COLUMNS, fitTrailingText, formContentWidth } from "./form-reflow.js"; export type CompactionMode = "llm" | "pruning"; @@ -72,23 +74,38 @@ function entryLabel(entry: ScopedApproval): string { return `${entry.tool} ${entry.pattern}${suffix}`; } -function TabBar({ activeTab, onSwitch }: { activeTab: Tab; onSwitch: (t: Tab) => void }): ReactNode { +function TabBar({ + activeTab, + onSwitch, + stack, +}: { + activeTab: Tab; + onSwitch: (t: Tab) => void; + stack: boolean; +}): ReactNode { return ( - - {TABS.map((tab) => { - const isActive = tab === activeTab; - return ( - - {tab} - - ); - })} - Tab to switch sections + + + {TABS.map((tab) => { + const isActive = tab === activeTab; + return ( + + {tab} + + ); + })} + + {stack ? "Tab to switch" : " Tab to switch sections"} ); } @@ -97,10 +114,12 @@ function PermissionsTab({ entries, onRevoke, maxRows, + contentWidth, }: { entries: ScopedApproval[]; onRevoke: (entry: ScopedApproval) => void; maxRows?: number | undefined; + contentWidth: number; }): ReactNode { const ordered = orderEntries(entries); const [selected, setSelected] = useState(0); @@ -151,7 +170,9 @@ function PermissionsTab({ {isActive ? "› " : " "} - {entryLabel(entry)} + + {fitTrailingText(entryLabel(entry), Math.max(8, contentWidth - 4))} + ); })} @@ -499,6 +520,9 @@ export function SettingsOverlay({ maxHeight, }: SettingsOverlayProps): ReactNode { const [activeTab, setActiveTab] = useState("Permissions"); + const { columns } = useTerminalSize(); + const stack = columns < STACK_FORM_COLUMNS; + const contentWidth = formContentWidth(columns, stack); const contentRows = maxHeight !== undefined ? Math.max(4, maxHeight - FIXED_CHROME) : undefined; @@ -518,18 +542,20 @@ export function SettingsOverlay({ return ( Settings - + {activeTab === "Permissions" && ( )} {activeTab === "Compaction" && ( From d819098c5dc8ad3ff139a487f93388fe6486cd66 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 4 Aug 2026 22:47:23 -0700 Subject: [PATCH 2/2] Truncate form text by display width and keep box width inside the terminal fitTrailingText was using string length, so CJK and emoji overran the pane. Also drop the forced 16-column box floor so margin plus width cannot exceed tiny terminals. --- src/tui/components/agent-modal.tsx | 2 +- src/tui/components/form-reflow.test.ts | 25 +++++++++++++++++++++-- src/tui/components/form-reflow.ts | 27 ++++++++++++++++++++----- src/tui/components/settings-overlay.tsx | 2 +- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/tui/components/agent-modal.tsx b/src/tui/components/agent-modal.tsx index 1e52e8b73..ef38ea86d 100644 --- a/src/tui/components/agent-modal.tsx +++ b/src/tui/components/agent-modal.tsx @@ -822,7 +822,7 @@ export function AgentModal({ paddingY={1} marginX={1} marginY={1} - width={Math.max(16, columns - 2)} + width={Math.max(1, columns - 2)} > Agent Configuration diff --git a/src/tui/components/form-reflow.test.ts b/src/tui/components/form-reflow.test.ts index 477bb7e4a..929701600 100644 --- a/src/tui/components/form-reflow.test.ts +++ b/src/tui/components/form-reflow.test.ts @@ -23,6 +23,27 @@ describe("fitTrailingText", () => { expect(fitTrailingText("x", 0)).toBe(""); expect(fitTrailingText("x", -3)).toBe(""); }); + + test("truncates by display width for CJK (two cells each)", () => { + // "你好世界" is 8 cells; budget 5 → "…" (1) + last two chars (4) = 5 + expect(fitTrailingText("你好世界", 5)).toBe("…世界"); + }); + + test("truncates by display width for emoji", () => { + // each rocket is 2 cells; budget 5 → "…" (1) + two rockets (4) = 5 + expect(fitTrailingText("🚀🚀🚀🚀", 5)).toBe("…🚀🚀"); + }); + + test("never exceeds the column budget after truncation", () => { + const samples = ["hello world", "你好世界测试", "a🚀b🚀c🚀d", "abcdefghijklmnopqrstuvwxyz"]; + for (const text of samples) { + for (const width of [1, 2, 3, 5, 8, 10]) { + const out = fitTrailingText(text, width); + // stringWidth is exercised via the public API; Bun.stringWidth matches. + expect(Bun.stringWidth(out)).toBeLessThanOrEqual(Math.max(0, width)); + } + } + }); }); describe("wrapHelpSegments", () => { @@ -43,7 +64,7 @@ describe("wrapHelpSegments", () => { const lines = wrapHelpSegments(formHelp, 40); expect(lines.length).toBeGreaterThan(1); for (const line of lines) { - expect(line.length).toBeLessThanOrEqual(40); + expect(Bun.stringWidth(line)).toBeLessThanOrEqual(40); } expect(lines.join(" · ")).toContain("Up/Down fields"); expect(lines.join(" · ")).toContain("Esc cancel"); @@ -54,7 +75,7 @@ describe("wrapHelpSegments", () => { const lines = wrapHelpSegments(formHelp, width); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { - expect(line.length).toBeLessThanOrEqual(Math.max(8, width)); + expect(Bun.stringWidth(line)).toBeLessThanOrEqual(Math.max(8, width)); } } }); diff --git a/src/tui/components/form-reflow.ts b/src/tui/components/form-reflow.ts index 7a76cd275..0c7efed58 100644 --- a/src/tui/components/form-reflow.ts +++ b/src/tui/components/form-reflow.ts @@ -1,5 +1,7 @@ // Shared layout helpers for settings / agent forms on narrow terminals. +import { stringWidth } from "../view/height.js"; + /** Stack labels above values below this terminal width. */ export const STACK_FORM_COLUMNS = 56; @@ -12,12 +14,27 @@ export function formContentWidth(columns: number, narrow: boolean): number { /** * Caret sits at the end of append-only fields; keep the trailing slice so the * insertion point stays on-screen when the value is longer than the pane. + * Budget is terminal columns (display width), not UTF-16 length — CJK and + * emoji are two cells each. */ export function fitTrailingText(text: string, maxWidth: number): string { if (maxWidth <= 0) return ""; - if (text.length <= maxWidth) return text; + if (stringWidth(text) <= maxWidth) return text; if (maxWidth === 1) return "…"; - return `…${text.slice(-(maxWidth - 1))}`; + + // Walk code points from the end until the trailing slice fills maxWidth - 1 + // (one cell reserved for the leading ellipsis). + const budget = maxWidth - 1; + const units = Array.from(text); + let used = 0; + let start = units.length; + for (let i = units.length - 1; i >= 0; i--) { + const cw = stringWidth(units[i]!); + if (used + cw > budget) break; + used += cw; + start = i; + } + return `…${units.slice(start).join("")}`; } /** Pack " · "-separated help segments into lines that fit the pane. */ @@ -29,16 +46,16 @@ export function wrapHelpSegments(segments: readonly string[], maxWidth: number): for (const segment of segments) { if (segment.length === 0) continue; if (current.length === 0) { - current = segment.length > width ? fitTrailingText(segment, width) : segment; + current = stringWidth(segment) > width ? fitTrailingText(segment, width) : segment; continue; } const candidate = `${current} · ${segment}`; - if (candidate.length <= width) { + if (stringWidth(candidate) <= width) { current = candidate; continue; } lines.push(current); - current = segment.length > width ? fitTrailingText(segment, width) : segment; + current = stringWidth(segment) > width ? fitTrailingText(segment, width) : segment; } if (current.length > 0) lines.push(current); return lines; diff --git a/src/tui/components/settings-overlay.tsx b/src/tui/components/settings-overlay.tsx index 455ac0f85..1592fe07f 100644 --- a/src/tui/components/settings-overlay.tsx +++ b/src/tui/components/settings-overlay.tsx @@ -546,7 +546,7 @@ export function SettingsOverlay({ paddingY={1} marginX={1} marginY={1} - width={Math.max(16, columns - 2)} + width={Math.max(1, columns - 2)} > Settings