fix(tui): fit project paths in open menu (#39887)

This commit is contained in:
Kit Langton 2026-07-31 11:07:12 -04:00 committed by GitHub
commit 7fd12c560c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 17 deletions

View file

@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js" import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path" import path from "path"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog" import { dialogWidth, useDialog } from "../ui/dialog"
import { useClient } from "../context/client" import { useClient } from "../context/client"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
@ -159,7 +159,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (b.location === b.root.directory) return 1 if (b.location === b.root.directory) return 1
return a.location.localeCompare(b.location) 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) => { return list.map((item) => {
const title = abbreviateHome(item.location, paths.home) const title = abbreviateHome(item.location, paths.home)

View file

@ -2,8 +2,8 @@ import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js" import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client" import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { useDialog } from "../ui/dialog" import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useData } from "../context/data" import { useData } from "../context/data"
import { useClient } from "../context/client" import { useClient } from "../context/client"
@ -105,8 +105,8 @@ export function DialogOpen() {
.map((project) => { .map((project) => {
const title = project.name ?? path.basename(project.canonical) const title = project.name ?? path.basename(project.canonical)
const footer = abbreviateHome(project.canonical, paths.home) const footer = abbreviateHome(project.canonical, paths.home)
// Dialog padding, the gutter column, title padding, and the separating space use nine columns. const width =
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title) dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
return { return {
title, title,
footer: truncateFilePath(footer, width), footer: truncateFilePath(footer, width),

View file

@ -83,6 +83,11 @@ export interface DialogSelectOption<T = any> {
onSelect?: (ctx: DialogContext) => void 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<T> = { export type DialogSelectRef<T> = {
filter: string filter: string
filtered: DialogSelectOption<T>[] filtered: DialogSelectOption<T>[]

View file

@ -8,9 +8,17 @@ import { useToast } from "./toast"
import { useClipboard } from "../context/clipboard" import { useClipboard } from "../context/clipboard"
import { useConfig } from "../config" 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( export function Dialog(
props: ParentProps<{ props: ParentProps<{
size?: "medium" | "large" | "xlarge" size?: DialogSize
centered?: boolean centered?: boolean
onClose: () => void onClose: () => void
}>, }>,
@ -20,12 +28,6 @@ export function Dialog(
const renderer = useRenderer() const renderer = useRenderer()
let dismiss = false let dismiss = false
const width = () => {
if (props.size === "xlarge") return 116
if (props.size === "large") return 88
return 60
}
return ( return (
<box <box
onMouseDown={() => { onMouseDown={() => {
@ -57,7 +59,7 @@ export function Dialog(
dismiss = false dismiss = false
e.stopPropagation() e.stopPropagation()
}} }}
width={width()} width={dialogWidth(props.size ?? "medium")}
maxWidth={dimensions().width - 2} maxWidth={dimensions().width - 2}
backgroundColor={theme.background.default} backgroundColor={theme.background.default}
paddingTop={1} paddingTop={1}
@ -74,7 +76,7 @@ function init() {
element: JSX.Element element: JSX.Element
onClose?: () => void onClose?: () => void
}[], }[],
size: "medium" as "medium" | "large" | "xlarge", size: "medium" as DialogSize,
centered: false, centered: false,
}) })

View file

@ -5,7 +5,10 @@ import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { createSignal, onCleanup, onMount } from "solid-js" 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 { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@ -147,6 +150,28 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[],
return { app, moved, replaceOptions, selected } 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 () => { test("renders actions with a current selection", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const app = await renderSelect( const app = await renderSelect(