diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index af9d8209f5..ed2b870e27 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid" import { TextAttributes } from "@opentui/core" import { createMemo, createResource, createSignal, onMount, Show } from "solid-js" import path from "path" -import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" -import { useDialog } from "../ui/dialog" +import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select" +import { dialogWidth, useDialog } from "../ui/dialog" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" @@ -159,7 +159,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (b.location === b.root.directory) return 1 return a.location.localeCompare(b.location) }) - const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12) + const titleWidth = Math.max( + 1, + dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)), + ) return list.map((item) => { const title = abbreviateHome(item.location, paths.home) diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index d1c7603a7e..a1a6a1aa2d 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -2,8 +2,8 @@ import path from "path" import { createMemo, createResource, createSignal, onMount } from "solid-js" import type { SessionInfo } from "@opencode-ai/client" import { useTerminalDimensions } from "@opentui/solid" -import { useDialog } from "../ui/dialog" -import { DialogSelect } from "../ui/dialog-select" +import { dialogWidth, useDialog } from "../ui/dialog" +import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select" import { useRoute } from "../context/route" import { useData } from "../context/data" import { useClient } from "../context/client" @@ -105,8 +105,8 @@ export function DialogOpen() { .map((project) => { const title = project.name ?? path.basename(project.canonical) const footer = abbreviateHome(project.canonical, paths.home) - // Dialog padding, the gutter column, title padding, and the separating space use nine columns. - const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title) + const width = + dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title) return { title, footer: truncateFilePath(footer, width), diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index e3733fdece..969b3f7569 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -83,6 +83,11 @@ export interface DialogSelectOption { onSelect?: (ctx: DialogContext) => void } +export function dialogSelectContentWidth(dialogWidth: number) { + // Scroll padding, row padding, the gutter, title padding, and the separating gap. + return dialogWidth - 12 +} + export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 0c55bbae5b..6a89f79a17 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -8,9 +8,17 @@ import { useToast } from "./toast" import { useClipboard } from "../context/clipboard" import { useConfig } from "../config" +export type DialogSize = "medium" | "large" | "xlarge" + +export function dialogWidth(size: DialogSize) { + if (size === "xlarge") return 116 + if (size === "large") return 88 + return 60 +} + export function Dialog( props: ParentProps<{ - size?: "medium" | "large" | "xlarge" + size?: DialogSize centered?: boolean onClose: () => void }>, @@ -20,12 +28,6 @@ export function Dialog( const renderer = useRenderer() let dismiss = false - const width = () => { - if (props.size === "xlarge") return 116 - if (props.size === "large") return 88 - return 60 - } - return ( { @@ -57,7 +59,7 @@ export function Dialog( dismiss = false e.stopPropagation() }} - width={width()} + width={dialogWidth(props.size ?? "medium")} maxWidth={dimensions().width - 2} backgroundColor={theme.background.default} paddingTop={1} @@ -74,7 +76,7 @@ function init() { element: JSX.Element onClose?: () => void }[], - size: "medium" as "medium" | "large" | "xlarge", + size: "medium" as DialogSize, centered: false, }) diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index 228119875b..434f4d5dc6 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -5,7 +5,10 @@ import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { createSignal, onCleanup, onMount } from "solid-js" -import type { DialogSelectOption } from "../../../src/ui/dialog-select" +import { dialogWidth } from "../../../src/ui/dialog" +import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select" +import { truncateFilePath } from "../../../src/ui/file-path" +import { stringWidth } from "../../../src/util/string-width" import { tmpdir } from "../../fixture/fixture" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -147,6 +150,28 @@ async function mountSelect(root: string, initial: DialogSelectOption[], return { app, moved, replaceOptions, selected } } +test("budgets option content for constrained and full-width large dialogs", () => { + expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 62 - 2)) - 7).toBe(41) + expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69) +}) + +test("renders the complete truncated footer within the option row", async () => { + await using tmp = await tmpdir() + const title = "Project" + const footer = truncateFilePath( + "/tmp/opencode/projects/a-very-long-project-directory/distinctive-tail.tsx", + dialogSelectContentWidth(dialogWidth("medium")) - stringWidth(title), + ) + const select = await mountSelect(tmp.path, [{ title, footer, value: "project" }]) + + try { + await select.app.waitForFrame((frame) => frame.includes(footer)) + expect(select.app.captureCharFrame()).toContain(footer) + } finally { + select.app.renderer.destroy() + } +}) + test("renders actions with a current selection", async () => { await using tmp = await tmpdir() const app = await renderSelect(