diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 47a45a770a..6ca718eb93 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -60,6 +60,15 @@ export const settings: Setting[] = [ labels: ["off", "on"], keywords: ["scroll bar"], }, + { + title: "Max width", + category: "Session", + path: ["session", "max_width"], + default: "auto", + values: ["auto", 80, 100, 120], + labels: ["auto", "80 columns", "100 columns", "120 columns"], + keywords: ["transcript", "composer", "centered", "reading width"], + }, { title: "Thinking", category: "Session", diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 8a44a505ba..24fa5534a0 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -125,6 +125,11 @@ export const Info = Schema.Struct({ description: "Session sidebar visibility; 'auto' shows it when space permits", }), scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }), + max_width: Schema.optional( + Schema.Union([Schema.Int.check(Schema.isGreaterThan(4)), Schema.Literal("auto")]), + ).annotate({ + description: "Session transcript and composer max width, or 'auto' to use the available width", + }), thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ description: "Show or hide model reasoning by default", }), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c4ee230737..c3623a2608 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -68,7 +68,7 @@ import { errorMessage } from "../../util/error" import { useToast } from "../../ui/toast" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" -import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout" +import { sessionContentWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout" import { projectedPromptInput } from "../../prompt/codec" import { deduplicateVisibleImages } from "../../prompt/attachment" import { useEpilogue } from "../../context/epilogue" @@ -225,6 +225,7 @@ export function Session() { const thinkingMode = createMemo(() => config.session?.thinking ?? "hide") const showThinking = createMemo(() => true) const showScrollbar = createMemo(() => config.session?.scrollbar ?? false) + const maxWidth = createMemo(() => config.session?.max_width ?? "auto") const markdownMode = createMemo(() => config.session?.markdown ?? "rendered") const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word") const groupExploration = createMemo(() => config.session?.grouping !== "none") @@ -243,7 +244,7 @@ export function Session() { if (sidebar() === "auto" && wide()) return true return false }) - const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4) + const contentWidth = createMemo(() => sessionContentWidth(availableWidth(), sidebarVisible(), maxWidth())) const models = createMemo(() => data.location.model.list(location()) ?? []) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) @@ -1037,103 +1038,107 @@ export function Session() { }} > - - - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.raise(theme.background.surface.offset), - foregroundColor: theme.border.default, - }, - }} - stickyScroll={!navigationMessage()} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {(row, index) => ( - data.session.message.get(route.sessionID, messageID)} - boundaryID={boundaries()[index() + hidden()]} + + + + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.raise(theme.background.surface.offset), + foregroundColor: theme.border.default, + }, + }} + stickyScroll={!navigationMessage()} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + > + + {(row, index) => ( + data.session.message.get(route.sessionID, messageID)} + boundaryID={boundaries()[index() + hidden()]} + /> + )} + + + + message.type === "user").length} + files={session()!.revert!.files ?? []} /> - )} - - - - message.type === "user").length} - files={session()!.revert!.files ?? []} + + + {(height) => } + + + + 0}> + + + + setComposer("open", false)} /> - - - {(height) => } - - - - 0}> - - - - setComposer("open", false)} - /> - - {null} - 0}> - - {(_) => { - const request = promptedPermissions()[0] - return request ? ( - - ) : null - }} - - - 0}> - - {(_) => { - const form = forms()[0] - return form ? : null - }} - - - - { - toBottom() - }} - onEmptySubmit={async () => { - const next = queuedPrompts()[0] - if (!next) return false - return mutatePending("steer", next.id) - }} - sessionID={route.sessionID} - /> - - - - + + {null} + 0}> + + {(_) => { + const request = promptedPermissions()[0] + return request ? ( + + ) : null + }} + + + 0}> + + {(_) => { + const form = forms()[0] + return form ? : null + }} + + + + { + toBottom() + }} + onEmptySubmit={async () => { + const next = queuedPrompts()[0] + if (!next) return false + return mutatePending("steer", next.id) + }} + sessionID={route.sessionID} + /> + + + + + diff --git a/packages/tui/src/ui/layout.ts b/packages/tui/src/ui/layout.ts index 2fa311619f..1ca6611662 100644 --- a/packages/tui/src/ui/layout.ts +++ b/packages/tui/src/ui/layout.ts @@ -1,6 +1,13 @@ export const SESSION_SIDEBAR_WIDTH = 42 const SESSION_CONTENT_MIN_WIDTH = 44 +const SESSION_CONTENT_PADDING = 4 export function sessionTabsFitVertically(total: number) { return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH } + +export function sessionContentWidth(total: number, sidebar: boolean, maxWidth: number | "auto" = "auto") { + const available = total - (sidebar ? SESSION_SIDEBAR_WIDTH : 0) - SESSION_CONTENT_PADDING + if (maxWidth === "auto") return available + return Math.max(1, Math.min(available, maxWidth - SESSION_CONTENT_PADDING)) +} diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 1e917f3a14..bbfc4d8082 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -27,6 +27,15 @@ test("validates the session tabs setting", () => { expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } }) }) +test("validates the session max width setting", () => { + const decode = Schema.decodeUnknownSync(Info) + + expect(decode({ session: { max_width: "auto" } })).toEqual({ session: { max_width: "auto" } }) + expect(decode({ session: { max_width: 100 } })).toEqual({ session: { max_width: 100 } }) + expect(() => decode({ session: { max_width: 4 } })).toThrow() + expect(() => decode({ session: { max_width: 100.5 } })).toThrow() +}) + test("resolves nested config and keybind defaults", () => { const config = resolve( { @@ -53,6 +62,13 @@ test("shows resolved tab defaults in settings", () => { expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal") }) +test("shows session max width presets in settings", () => { + const setting = settings.find((setting) => setting.path.join(".") === "session.max_width") + + expect(setting?.default).toBe("auto") + expect(setting?.values).toEqual(["auto", 80, 100, 120]) +}) + test("provides config and its host interface", async () => { const config = resolve({}, { terminalSuspend: true }) let current = {} diff --git a/packages/tui/test/ui/layout.test.ts b/packages/tui/test/ui/layout.test.ts index 53c9a82479..8ab0957d3d 100644 --- a/packages/tui/test/ui/layout.test.ts +++ b/packages/tui/test/ui/layout.test.ts @@ -1,8 +1,18 @@ import { expect, test } from "bun:test" -import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout" +import { sessionContentWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout" test("vertical tabs match the session sidebar and preserve compact content width", () => { expect(SESSION_SIDEBAR_WIDTH).toBe(42) expect(sessionTabsFitVertically(86)).toBe(true) expect(sessionTabsFitVertically(85)).toBe(false) }) + +test("session content uses available width by default", () => { + expect(sessionContentWidth(160, false)).toBe(156) + expect(sessionContentWidth(160, true)).toBe(114) +}) + +test("session content caps wide sessions and preserves narrow sessions", () => { + expect(sessionContentWidth(160, false, 100)).toBe(96) + expect(sessionContentWidth(80, false, 100)).toBe(76) +})