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() + } +})