From ca1f041b02aed5ea01c0c37702b274f24dca4833 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 6 May 2026 11:24:48 +0200 Subject: [PATCH] tui: add preview margin to menu scrolling The footer menu now starts scrolling before the selection reaches the viewport edge, keeping surrounding items visible for context. Extract offset logic into pure helpers and add tests. Simplify theme primary color selection to use fixed ansi colors instead of a contrast/chroma heuristic, and suppress diff backgrounds in scrollback mode for cleaner rendering. --- .../opencode/src/cli/cmd/run/footer.menu.tsx | 160 ++++++++++++------ .../src/cli/cmd/run/scrollback.writer.tsx | 35 ++-- packages/opencode/src/cli/cmd/run/theme.ts | 64 ++----- packages/opencode/src/cli/cmd/run/types.ts | 1 + .../opencode/test/cli/run/footer.menu.test.ts | 43 +++++ 5 files changed, 188 insertions(+), 115 deletions(-) create mode 100644 packages/opencode/test/cli/run/footer.menu.test.ts diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/opencode/src/cli/cmd/run/footer.menu.tsx index 3fb02dac3e..db347df7fc 100644 --- a/packages/opencode/src/cli/cmd/run/footer.menu.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.menu.tsx @@ -16,6 +16,41 @@ type RunFooterMenuRow = | { type: "item"; item: RunFooterMenuItem; index: number } | { type: "spacer" } +function maxOffset(count: number, limit: number) { + return Math.max(0, count - limit) +} + +function previewMargin(limit: number) { + return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2))) +} + +function revealOffset(value: number, input: { count: number; limit: number; selected: number }) { + const max = maxOffset(input.count, input.limit) + if (input.selected < value) { + return Math.min(max, input.selected) + } + + if (input.selected >= value + input.limit) { + return Math.min(max, input.selected - input.limit + 1) + } + + return Math.min(max, value) +} + +function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) { + const max = maxOffset(input.count, input.limit) + const margin = previewMargin(input.limit) + if (input.dir < 0 && input.selected < value + margin) { + return Math.max(0, Math.min(max, input.selected - margin)) + } + + if (input.dir > 0 && input.selected > value + input.limit - margin - 1) { + return Math.min(max, input.selected - input.limit + margin + 1) + } + + return Math.min(max, value) +} + export function createFooterMenuState(input: { count: Accessor; limit?: number }) { const [selected, setSelected] = createSignal(0) const [offset, setOffset] = createSignal(0) @@ -32,18 +67,7 @@ export function createFooterMenuState(input: { count: Accessor; limit?: const next = Math.max(0, Math.min(count - 1, index)) setSelected(next) - setOffset((value) => { - const max = Math.max(0, count - limit()) - if (next < value) { - return Math.min(max, next) - } - - if (next >= value + limit()) { - return Math.min(max, next - limit() + 1) - } - - return Math.min(max, value) - }) + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next })) } const reset = () => { @@ -62,27 +86,28 @@ export function createFooterMenuState(input: { count: Accessor; limit?: setSelected(count - 1) } - setOffset((value) => { - const max = Math.max(0, count - limit()) - if (selected() < value) { - return Math.min(max, selected()) - } - - if (selected() >= value + limit()) { - return Math.min(max, selected() - limit() + 1) - } - - return Math.min(max, value) - }) + setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() })) }) + const move = (dir: -1 | 1) => { + const count = input.count() + if (count === 0) { + reset() + return + } + + const next = Math.max(0, Math.min(count - 1, selected() + dir)) + setSelected(next) + setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir })) + } + return { selected, offset, rows, reveal, reset, - move: (dir: -1 | 1) => reveal(selected() + dir), + move, } } @@ -102,15 +127,9 @@ export function RunFooterMenu(props: { }) { const limit = () => props.limit ?? FOOTER_MENU_ROWS const border = () => props.border ?? true - const rows = createMemo(() => { - if (!props.grouped) { - return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({ - type: "item", - item, - index: index + props.offset(), - })) - } - + const [groupOffset, setGroupOffset] = createSignal(0) + let previous = -1 + const groupedRows = createMemo(() => { const all: RunFooterMenuRow[] = [] let category = "" props.items().forEach((item, index) => { @@ -125,13 +144,45 @@ export function RunFooterMenu(props: { all.push({ type: "item", item, index }) }) + return all + }) - const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected()) - if (selected === -1) { - return all.slice(0, limit()) + createEffect(() => { + if (!props.grouped) { + return } - const start = Math.max(0, Math.min(selected - limit() + 1, all.length - limit())) + const all = groupedRows() + const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected()) + if (all.length === 0 || selected === -1) { + setGroupOffset(0) + previous = props.selected() + return + } + + const dir = + props.selected() === previous + 1 ? 1 + : props.selected() === previous - 1 ? -1 + : undefined + setGroupOffset((value) => + dir + ? moveOffset(value, { count: all.length, limit: limit(), selected, dir }) + : revealOffset(value, { count: all.length, limit: limit(), selected }), + ) + previous = props.selected() + }) + + const rows = createMemo(() => { + if (!props.grouped) { + return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({ + type: "item", + item, + index: index + props.offset(), + })) + } + + const all = groupedRows() + const start = Math.max(0, Math.min(groupOffset(), all.length - limit())) return all.slice(start, start + limit()) }) const descriptionColumn = createMemo(() => { @@ -189,6 +240,7 @@ export function RunFooterMenu(props: { } const active = () => row.index === props.selected() + const inset = () => (active() ? 1 : 0) return ( {border() ? ( @@ -199,19 +251,27 @@ export function RunFooterMenu(props: { - - {row.item.display} - {row.item.description ? ( - - {descriptionPad(row.item)} - {row.item.description} - - ) : undefined} - + + + {row.item.display} + {row.item.description ? ( + + {descriptionPad(row.item)} + {row.item.description} + + ) : undefined} + + ) diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index e40e91d357..3b225926a4 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -1,12 +1,12 @@ /** @jsxImportSource @opentui/solid */ import { createScrollbackWriter } from "@opentui/solid" -import { TextRenderable, type ScrollbackWriter } from "@opentui/core" +import { TextRenderable, type ColorInput, type ScrollbackWriter } from "@opentui/core" import { Match, Switch, createMemo } from "solid-js" import { entryBody, entryFlags } from "./entry.body" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool" -import { RUN_THEME_FALLBACK, type RunTheme } from "./theme" +import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme" import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types" function todoText(item: { status: string; content: string }): string { @@ -94,6 +94,10 @@ export function RunEntryContent(props: { const style = createMemo(() => entryLook(props.commit, theme().entry)) const syntax = createMemo(() => entrySyntax(props.commit, theme())) const color = createMemo(() => entryColor(props.commit, theme())) + const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true) + const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color) + const diffSign = (normal: ColorInput, highlight: ColorInput) => (suppressBackgrounds() ? normal : highlight) + const diffTitle = createMemo(() => (suppressBackgrounds() ? theme().block.text : theme().block.muted)) const streaming = createMemo(() => props.commit.phase === "progress") const width = createMemo(() => Math.max(1, Math.trunc(props.width ?? 80))) const view = createMemo(() => toolDiffView(width(), props.opts?.diffStyle)) @@ -184,7 +188,7 @@ export function RunEntryContent(props: { {snap().items.map((item) => ( - + {item.title} {item.diff.trim() ? ( @@ -198,15 +202,15 @@ export function RunEntryContent(props: { width="100%" wrapMode="word" fg={theme().block.text} - addedBg={theme().block.diffAddedBg} - removedBg={theme().block.diffRemovedBg} - contextBg={theme().block.diffContextBg} - addedSignColor={theme().block.diffHighlightAdded} - removedSignColor={theme().block.diffHighlightRemoved} + addedBg={diffBg(theme().block.diffAddedBg)} + removedBg={diffBg(theme().block.diffRemovedBg)} + contextBg={diffBg(theme().block.diffContextBg)} + addedSignColor={diffSign(theme().block.diffAdded, theme().block.diffHighlightAdded)} + removedSignColor={diffSign(theme().block.diffRemoved, theme().block.diffHighlightRemoved)} lineNumberFg={theme().block.diffLineNumber} - lineNumberBg={theme().block.diffContextBg} - addedLineNumberBg={theme().block.diffAddedLineNumberBg} - removedLineNumberBg={theme().block.diffRemovedLineNumberBg} + lineNumberBg={diffBg(theme().block.diffContextBg)} + addedLineNumberBg={diffBg(theme().block.diffAddedLineNumberBg)} + removedLineNumberBg={diffBg(theme().block.diffRemovedLineNumberBg)} /> ) : ( @@ -309,7 +313,14 @@ export function entryWriter(input: { opts?: ScrollbackOptions }): ScrollbackWriter { return createScrollbackWriter( - (ctx) => , + (ctx) => ( + + ), entryFlags(input.commit), ) } diff --git a/packages/opencode/src/cli/cmd/run/theme.ts b/packages/opencode/src/cli/cmd/run/theme.ts index d5894386e3..f160571e2a 100644 --- a/packages/opencode/src/cli/cmd/run/theme.ts +++ b/packages/opencode/src/cli/cmd/run/theme.ts @@ -176,10 +176,6 @@ function blend(color: RGBA, bg: RGBA): RGBA { ) } -function luminance(color: RGBA) { - return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b -} - function chroma(color: RGBA) { return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b) } @@ -289,24 +285,6 @@ export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiTheme } } -function pickPrimaryColor( - bg: RGBA, - candidates: Array<{ - key: string - color: RGBA | undefined - }>, -) { - return candidates - .flatMap((item) => { - if (!item.color) return [] - const contrast = Math.abs(luminance(item.color) - luminance(bg)) - const vivid = chroma(item.color) - if (contrast < 0.16 || vivid < 0.12) return [] - return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }] - }) - .sort((a, b) => b.score - a.score)[0] -} - function generateGrayScale(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): Record { const r = bg.r * 255 const g = bg.g * 255 @@ -389,34 +367,14 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): const diff_alpha = isDark ? 0.22 : 0.14 const diff_context_bg = grays[2] - const primary = - pickPrimaryColor(bg_snapshot, [ - { - key: "cursor", - color: colors.cursorColor ? nearest(RGBA.fromHex(colors.cursorColor)) : undefined, - }, - { - key: "selection", - color: colors.highlightBackground ? nearest(RGBA.fromHex(colors.highlightBackground)) : undefined, - }, - { - key: "blue", - color: ansi.blue, - }, - { - key: "magenta", - color: ansi.magenta, - }, - ]) ?? { - key: "blue", - color: ansi.blue, - } + const primary = ansi.cyan + const secondary = ansi.magenta return { theme: { - primary: primary.color, - secondary: primary.key === "magenta" ? ansi.blue : ansi.magenta, - accent: primary.color, + primary, + secondary, + accent: primary, error: ansi.red, warning: ansi.yellow, success: ansi.green, @@ -550,13 +508,13 @@ function map( } const seed = { - highlight: rgba("#38bdf8"), - muted: rgba("#64748b"), - text: rgba("#f8fafc"), + highlight: RGBA.fromIndex(6, rgba("#38bdf8")), + muted: RGBA.fromIndex(8, rgba("#64748b")), + text: RGBA.defaultForeground(rgba("#f8fafc")), panel: rgba("#0f172a"), - success: rgba("#22c55e"), - warning: rgba("#f59e0b"), - error: rgba("#ef4444"), + success: RGBA.fromIndex(2, rgba("#22c55e")), + warning: RGBA.fromIndex(3, rgba("#f59e0b")), + error: RGBA.fromIndex(1, rgba("#ef4444")), } function tone(body: ColorInput, start?: ColorInput): Tone { diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index 022e0db3d7..83086bfb87 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -89,6 +89,7 @@ export type RunDiffStyle = "auto" | "stacked" export type ScrollbackOptions = { diffStyle?: RunDiffStyle + suppressBackgrounds?: boolean } export type ToolCodeSnapshot = { diff --git a/packages/opencode/test/cli/run/footer.menu.test.ts b/packages/opencode/test/cli/run/footer.menu.test.ts new file mode 100644 index 0000000000..edfa59be88 --- /dev/null +++ b/packages/opencode/test/cli/run/footer.menu.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu" + +function mount(count: number, limit = FOOTER_MENU_ROWS) { + let dispose!: () => void + let menu!: ReturnType + + createRoot((nextDispose) => { + dispose = nextDispose + menu = createFooterMenuState({ count: () => count, limit }) + return null + }) + + return { menu, dispose } +} + +test("footer menu scrolls before the selected row hits the bottom edge", () => { + const state = mount(20) + + try { + Array.from({ length: 6 }).forEach(() => state.menu.move(1)) + + expect(state.menu.selected()).toBe(6) + expect(state.menu.offset()).toBe(1) + } finally { + state.dispose() + } +}) + +test("footer menu scrolls before the selected row hits the top edge", () => { + const state = mount(20) + + try { + Array.from({ length: 13 }).forEach(() => state.menu.move(1)) + Array.from({ length: 4 }).forEach(() => state.menu.move(-1)) + + expect(state.menu.selected()).toBe(9) + expect(state.menu.offset()).toBe(7) + } finally { + state.dispose() + } +})