diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx index e801a8093a6..4f46db32535 100644 --- a/packages/tui/src/component/command-palette.tsx +++ b/packages/tui/src/component/command-palette.tsx @@ -61,6 +61,14 @@ export function CommandPaletteDialog() { } return ( - (ref = value)} title="Commands" options={list()} flat={true} filterThreshold={0.7} /> + (ref = value)} + title="Commands" + searchInTitle + titlePlaceholder="Search commands" + options={list()} + flat={true} + filterThreshold={0.7} + /> ) } diff --git a/packages/tui/src/component/dialog-agent.tsx b/packages/tui/src/component/dialog-agent.tsx index f586cb07806..34d60717a32 100644 --- a/packages/tui/src/component/dialog-agent.tsx +++ b/packages/tui/src/component/dialog-agent.tsx @@ -20,6 +20,8 @@ export function DialogAgent() { return ( { diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 94fc435b9b0..f2e3f28b837 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -1,5 +1,5 @@ import { createMemo, createSignal } from "solid-js" -import { useConfig } from "../config" +import { useConfig, useNewDesigns } from "../config" import { useThemes } from "../context/theme" import { DialogSelect } from "../ui/dialog-select" import { useToast } from "../ui/toast" @@ -332,6 +332,7 @@ export function settingID(setting: Setting) { export function DialogConfig(props: { current?: string }) { const config = useConfig() + const newDesigns = useNewDesigns() const toast = useToast() const themes = useThemes() const current = Math.max( @@ -394,12 +395,14 @@ export function DialogConfig(props: { current?: string }) { return ( setSelected(option.value)} onSelect={(option) => void change(1, option.value)} - footerHints={[{ title: "change", label: "←/→" }]} + footerHints={newDesigns() ? [] : [{ title: "change", label: "←/→" }]} bindings={[ { bind: "left", diff --git a/packages/tui/src/component/dialog-debug.tsx b/packages/tui/src/component/dialog-debug.tsx index 9eb89f76ad2..546f47c56c6 100644 --- a/packages/tui/src/component/dialog-debug.tsx +++ b/packages/tui/src/component/dialog-debug.tsx @@ -1,13 +1,14 @@ import { createMemo, createSignal, For } from "solid-js" import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" -import { DialogFooter, DialogHeader, DialogLayout, useDialog } from "../ui/dialog" +import { DialogHeader, DialogLayout, useDialog } from "../ui/dialog" import { useRoute } from "../context/route" import { useLocal } from "../context/local" import { useClipboard } from "../context/clipboard" import { useToast } from "../ui/toast" import { describeOS, describeTerminal } from "../util/system" import { useTuiApp } from "../context/runtime" +import { useNewDesigns } from "../config" export function DialogDebug() { const theme = useTheme() @@ -17,6 +18,7 @@ export function DialogDebug() { const clipboard = useClipboard() const toast = useToast() const app = useTuiApp() + const newDesigns = useNewDesigns() const [copied, setCopied] = createSignal(false) dialog.setSize("large") @@ -48,27 +50,26 @@ export function DialogDebug() { Keymap.createLayer(() => ({ mode: "modal", - commands: [{ bind: "return", title: "Copy debug info", group: "Dialog", run: copy }], + commands: [{ bind: newDesigns() ? "c" : "return", title: "Copy debug info", group: "Dialog", run: copy }], })) + const copyAction = () => ({ + title: copied() ? "✓ copied" : "copy", + key: newDesigns() ? "c" : "enter", + color: copied() ? theme.text.feedback.success.base : undefined, + onTrigger: copy, + }) + return ( } - footer={ - - Share this when reporting an issue. - - } + header={} + footer={{ + actions: { primary: [copyAction()] }, + get children() { + if (newDesigns()) return undefined + return Share this when reporting an issue. + }, + }} > {/* No click-to-copy here: releasing a mouse selection must trigger the global copy-on-select so users can copy a single value, e.g. the session id. */} diff --git a/packages/tui/src/component/dialog-error-details.tsx b/packages/tui/src/component/dialog-error-details.tsx index 094461bce0e..fe8f122903e 100644 --- a/packages/tui/src/component/dialog-error-details.tsx +++ b/packages/tui/src/component/dialog-error-details.tsx @@ -61,6 +61,13 @@ export function DialogErrorDetails(props: { ], })) + const actions = () => [ + { title: "investigate", key: "i", onTrigger: investigate }, + copied() + ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy } + : { title: "copy details", key: "c", onTrigger: copy }, + ] + useKeyboard((event) => { if (event.name === "up") return scroll?.scrollBy(-1) if (event.name === "down") return scroll?.scrollBy(1) @@ -101,17 +108,8 @@ export function DialogErrorDetails(props: { } - footer={ - - } + footer={{ actions: { primary: actions() } }} + legacyFooter={} > (scroll = element)} diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 35442d27b74..a8830000f88 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -1,5 +1,5 @@ import { createMemo, createSignal } from "solid-js" -import { useConfig } from "../config" +import { useConfig, useNewDesigns } from "../config" import { DIALOG_LIST_INSET } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useTheme } from "../context/theme" @@ -24,6 +24,7 @@ export const experiments: Experiment[] = [ export function DialogExperiments() { const config = useConfig() + const newDesigns = useNewDesigns() const theme = useTheme() const toast = useToast() const [selected, setSelected] = createSignal() @@ -67,7 +68,7 @@ export function DialogExperiments() { No experiments available } - footerHints={experiments.length > 0 ? [{ title: "change", label: "←/→" }] : []} + footerHints={experiments.length > 0 && !newDesigns() ? [{ title: "change", label: "←/→" }] : []} bindings={ experiments.length > 0 ? [ diff --git a/packages/tui/src/component/dialog-highlights.tsx b/packages/tui/src/component/dialog-highlights.tsx index c0ae0717981..d11506eae7a 100644 --- a/packages/tui/src/component/dialog-highlights.tsx +++ b/packages/tui/src/component/dialog-highlights.tsx @@ -35,9 +35,10 @@ export function DialogHighlights(props: { version: string; items: ReadonlyArray< return ( } - footer={ + footer={{ actions: { primary: [{ title: "ok", key: "enter", onTrigger: () => dialog.clear() }] } }} + legacyFooter={ - + } - footer={ + footer={{ + actions: { + primary: + props.images.length > 1 + ? [ + { title: "previous", key: "←", onTrigger: () => move(-1) }, + { title: "next", key: "→", onTrigger: () => move(1) }, + ] + : [], + }, + get children() { + return ( + + {failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)} + + ) + }, + }} + legacyFooter={ move(-1)}> {props.images.length > 1 ? "← previous" : ""} diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 0a92aedf4fa..183e8df3af9 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -15,6 +15,7 @@ import type { import { openUrl } from "@opencode/util/open" import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" +import { useNewDesigns } from "../config" import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" @@ -62,6 +63,8 @@ const CANCELLED = Symbol("cancelled") const CUSTOM = Symbol("custom") const OPEN = Symbol("open") const SUBMIT = Symbol("submit") +// Device-flow codes such as "R283-GOUEO"; when present they are copied instead of the URL. +const OAUTH_CODE = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/ export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -179,6 +182,7 @@ function manageConnections( const toast = useToast() const theme = useTheme().surface("dialog") const shortcuts = Keymap.useShortcuts() + const newDesigns = useNewDesigns() const [deleting, setDeleting] = createSignal() const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id) const current = createMemo(() => @@ -257,7 +261,7 @@ function manageConnections( }, { command: "dialog.integration.delete", - title: "delete", + title: newDesigns() ? "remove account" : "delete", hidden: selected() === "add", disabled: (option) => !option || option.value === "add", onTrigger: (option) => { @@ -291,16 +295,20 @@ function selectMethod( onConnected?: OnIntegrationConnected, ) { if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected) - dialog.replace(() => ( - ({ - title: method.type === "key" ? (method.label ?? "API key") : method.label, - value: method.type === "key" ? "key" : method.id, - onSelect: () => openMethod(integration, method, location, dialog, onConnected), - }))} - /> - )) + dialog.replace(() => { + const newDesigns = useNewDesigns() + return ( + ({ + title: method.type === "key" ? (method.label ?? "API key") : method.label, + value: method.type === "key" ? "key" : method.id, + onSelect: () => openMethod(integration, method, location, dialog, onConnected), + }))} + /> + ) + }) } function openMethod( @@ -656,37 +664,32 @@ function OAuthAuto(props: { const client = useClient() const toast = useToast() const clipboard = useClipboard() + const newDesigns = useNewDesigns() let timer: ReturnType | undefined let settled = false + const open = () => { + openUrl(props.attempt.url).catch(() => + toast.show({ + message: "Could not open the browser. Copy the URL and continue manually.", + variant: "error", + }), + ) + } + + const copy = () => { + const value = props.attempt.instructions.match(OAUTH_CODE)?.[0] ?? props.attempt.url + clipboard + .write(value) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + } + Keymap.createLayer(() => ({ mode: "modal", commands: [ - { - bind: "o", - title: "Open authorization URL", - group: "Dialog", - run: () => { - openUrl(props.attempt.url).catch(() => - toast.show({ - message: "Could not open the browser. Copy the URL and continue manually.", - variant: "error", - }), - ) - }, - }, - { - bind: "c", - title: "Copy authorization details", - group: "Dialog", - run: () => { - const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url - clipboard - .write(value) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - }, - }, + { bind: newDesigns() ? "return" : "o", title: "Open authorization URL", group: "Dialog", run: open }, + { bind: "c", title: "Copy authorization details", group: "Dialog", run: copy }, ], })) @@ -735,8 +738,8 @@ function OAuthAuto(props: { url={props.attempt.url} instructions={props.attempt.instructions} message="Waiting for authorization…" - copy - open + onCopy={copy} + onOpen={open} /> ) } @@ -753,6 +756,7 @@ function OAuthCode(props: { const client = useClient() const toast = useToast() const theme = useTheme().surface("dialog") + const newDesigns = useNewDesigns() const [error, setError] = createSignal() let settled = false @@ -768,6 +772,7 @@ function OAuthCode(props: { return ( { if (!code) return @@ -786,7 +791,9 @@ function OAuthCode(props: { }} description={() => ( - {props.attempt.instructions} + + {props.attempt.instructions} + {(value) => {value()}} @@ -800,19 +807,37 @@ function OAuthView(props: { url?: string instructions?: string message: string - copy?: boolean - open?: boolean + onCopy?: () => void + onOpen?: () => void }) { const theme = useTheme().surface("dialog") return ( } - footer={ + footer={{ + actions: { + primary: [ + ...(props.onCopy + ? [ + { + title: props.instructions?.match(OAUTH_CODE) ? "copy code" : "copy link", + key: "c", + onTrigger: props.onCopy, + }, + ] + : []), + ...(props.onOpen ? [{ title: "open link", key: "enter", onTrigger: props.onOpen }] : []), + ], + }, + }} + legacyFooter={ } > @@ -980,22 +1005,20 @@ function StringChoiceField(props: { return ( (store.editing ? back() : dialog.clear())} />} - footer={ - submit(textarea()?.plainText.trim() || undefined) }, - { title: "back", key: "esc", onTrigger: back }, - ] - : [ - { title: "select", key: "↑/↓" }, - { title: "confirm", key: "enter", onTrigger: () => select(store.selected) }, - { title: "cancel", key: "esc", onTrigger: () => dialog.clear() }, - ] - } - /> - } + footer={{ + actions: { + secondary: store.editing + ? [ + { title: "submit", key: "enter", onTrigger: () => submit(textarea()?.plainText.trim() || undefined) }, + { title: "back", key: "esc", onTrigger: back }, + ] + : [ + { title: "select", key: "↑/↓" }, + { title: "confirm", key: "enter", onTrigger: () => select(store.selected) }, + { title: "cancel", key: "esc", onTrigger: () => dialog.clear() }, + ], + }, + }} > diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index 43d9161be90..68e3778b42f 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -138,6 +138,8 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } = fallback={ setFocused(option.value as string)} @@ -152,7 +154,7 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } = }, }, ]} - footerHints={focusedError() ? [{ title: "view error", label: "enter" }] : []} + footerHints={focusedError() ? [{ title: "view error", label: "enter", group: "primary" }] : []} /> } > diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index a181fddd8cc..344d9fcf041 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -9,9 +9,11 @@ import { useConnected } from "./use-connected" import { useData } from "../context/data" import { modelPreferenceKey } from "../model-preference" import { useLocation } from "../context/location" +import { useNewDesigns } from "../config" export function DialogModel(props: { providerID?: string }) { const local = useLocal() + const newDesigns = useNewDesigns() const data = useData() const dialog = useDialog() const location = useLocation() @@ -140,11 +142,19 @@ export function DialogModel(props: { providerID?: string }) { return ( [number]["value"]> + searchInTitle + titlePlaceholder="Search models" options={options()} actions={[ { command: "model.dialog.provider", - title: connected() ? "Connect an integration" : "View all integrations", + title: newDesigns() + ? connected() + ? "connect integration" + : "view all integrations" + : connected() + ? "Connect an integration" + : "View all integrations", selection: "none", onTrigger() { dialog.replace(() => ( @@ -156,7 +166,7 @@ export function DialogModel(props: { providerID?: string }) { }, { command: "model.dialog.favorite", - title: "Favorite", + title: newDesigns() ? "favorite" : "Favorite", hidden: !connected(), onTrigger: (option) => { local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 1b33e5caf91..d5fb5ac4f9b 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -19,7 +19,7 @@ import { Spinner } from "./spinner" import { errorMessage } from "../util/error" import { useSessionTabs } from "../context/session-tabs" import { useStorage } from "../context/storage" -import { useConfig } from "../config" +import { useConfig, useNewDesigns } from "../config" import { withTimestampedFallback } from "@opencode/util/session-title-fallback" import { projectName } from "../util/project" import { useLocation } from "../context/location" @@ -33,6 +33,7 @@ export function DialogSessionList() { const local = useLocal() const sessionTabs = useSessionTabs() const config = useConfig().data + const newDesigns = useNewDesigns() const toast = useToast() const activeLocation = useLocation() const [filter, setFilter] = createSignal("") @@ -206,6 +207,10 @@ export function DialogSessionList() { } + searchInTitle + titlePlaceholder={ + !allProjects() && currentProjectName() ? `Search sessions in ${currentProjectName()}` : "Search sessions" + } options={options()} skipFilter={true} current={currentSessionID()} @@ -286,7 +291,7 @@ export function DialogSessionList() { }, ]} footerHints={[ - ...quickSwitchFooterHints(), + ...(newDesigns() ? [] : quickSwitchFooterHints()), { title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" }, ]} /> diff --git a/packages/tui/src/component/dialog-shell-output.tsx b/packages/tui/src/component/dialog-shell-output.tsx index 4616108202d..42f0c98d1b0 100644 --- a/packages/tui/src/component/dialog-shell-output.tsx +++ b/packages/tui/src/component/dialog-shell-output.tsx @@ -113,13 +113,15 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR {status()} } - footer={ + legacyFooter={ } > diff --git a/packages/tui/src/component/dialog-theme-list.tsx b/packages/tui/src/component/dialog-theme-list.tsx index f1a8cda376a..382373fdc58 100644 --- a/packages/tui/src/component/dialog-theme-list.tsx +++ b/packages/tui/src/component/dialog-theme-list.tsx @@ -23,6 +23,8 @@ export function DialogThemeList() { return ( { diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx index 81a129dfccc..9dcf07b2140 100644 --- a/packages/tui/src/component/dialog-update.tsx +++ b/packages/tui/src/component/dialog-update.tsx @@ -1,5 +1,6 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { Keymap } from "../context/keymap" +import { useNewDesigns } from "../config" import { useTheme } from "../context/theme" import type { UpdateState } from "../context/update-notification" import { DialogHeader, DialogLayout, useDialog } from "../ui/dialog" @@ -15,6 +16,7 @@ export function DialogUpdate(props: { }) { const dialog = useDialog() const theme = useTheme().surface("dialog") + const newDesigns = useNewDesigns() const [error, setError] = createSignal() const [active, setActive] = createSignal(0) const controller = new AbortController() @@ -70,9 +72,9 @@ export function DialogUpdate(props: { bind: "return", title: "Confirm update action", group: "Dialog", - run: () => void buttons()[active()]?.run(), + run: () => void buttons()[newDesigns() ? buttons().length - 1 : active()]?.run(), }, - ...["left", "right", "tab", "shift+tab"].map((bind) => ({ + ...(newDesigns() ? [] : ["left", "right", "tab", "shift+tab"]).map((bind) => ({ bind, title: bind === "left" || bind === "shift+tab" ? "Previous update action" : "Next update action", group: "Dialog", @@ -97,7 +99,16 @@ export function DialogUpdate(props: { } /> } - footer={ + footer={{ + actions: { + primary: buttons().map((button, index) => ({ + title: button.label.toLowerCase(), + key: index === buttons().length - 1 ? "enter" : undefined, + onTrigger: () => void button.run(), + })), + }, + }} + legacyFooter={ 0}> diff --git a/packages/tui/src/component/dialog-variant.tsx b/packages/tui/src/component/dialog-variant.tsx index cfb703f6600..b2811fb6773 100644 --- a/packages/tui/src/component/dialog-variant.tsx +++ b/packages/tui/src/component/dialog-variant.tsx @@ -2,10 +2,12 @@ import { createMemo } from "solid-js" import { useLocal } from "../context/local" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" +import { useNewDesigns } from "../config" export function DialogVariant() { const local = useLocal() const dialog = useDialog() + const newDesigns = useNewDesigns() const options = createMemo(() => [ { @@ -33,6 +35,7 @@ export function DialogVariant() { options={options()} title={"Select variant"} + renderFilter={!newDesigns()} current={local.model.variant.current() ?? "default"} flat={true} /> diff --git a/packages/tui/src/component/dialog-workspace-file-changes.tsx b/packages/tui/src/component/dialog-workspace-file-changes.tsx index 0c0d1a79334..232783d1116 100644 --- a/packages/tui/src/component/dialog-workspace-file-changes.tsx +++ b/packages/tui/src/component/dialog-workspace-file-changes.tsx @@ -4,7 +4,7 @@ import { createMemo, For } from "solid-js" import { createStore } from "solid-js/store" import { FilePath } from "../ui/file-path" import { useTheme } from "../context/theme" -import { useConfig } from "../config" +import { useConfig, useNewDesigns } from "../config" import { DIALOG_INSET, DialogBleed, @@ -36,6 +36,8 @@ export function DialogWorkspaceFileChanges(props: { onSelect: (choice: WorkspaceFileChangesChoice) => void title?: string message?: string + /** Footer action for the "yes" choice under the "new-designs" experiment, e.g. `{ title: "delete", key: "d" }`. */ + confirm?: { title: string; key: string } }) { const dialog = useDialog() const theme = useTheme().surface("dialog") @@ -43,6 +45,7 @@ export function DialogWorkspaceFileChanges(props: { const config = useConfig().data const dimensions = useTerminalDimensions() const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) + const newDesigns = useNewDesigns() const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice }) const height = createMemo(() => Math.min(props.files.length, 8)) const fileNameWidth = createMemo(() => @@ -55,16 +58,26 @@ export function DialogWorkspaceFileChanges(props: { ), ) - function confirm() { - props.onSelect(store.active) + function choose(choice: WorkspaceFileChangesChoice) { + props.onSelect(choice) dialog.clear() } + const confirmAction = () => props.confirm ?? { title: "confirm", key: "enter" } + useKeyboard((evt) => { + // Escape needs no handling here: the dialog closes on escape, which resolves as a cancel. + if (newDesigns()) { + if (evt.name !== (confirmAction().key === "enter" ? "return" : confirmAction().key)) return + evt.preventDefault() + evt.stopPropagation() + choose("yes") + return + } if (evt.name === "return") { evt.preventDefault() evt.stopPropagation() - confirm() + choose(store.active) return } if (evt.name === "left") { @@ -85,7 +98,15 @@ export function DialogWorkspaceFileChanges(props: { return ( } - footer={ + footer={{ + actions: { + primary: [ + { title: "cancel", key: "esc", onTrigger: () => choose("no") }, + { title: confirmAction().title, key: confirmAction().key, onTrigger: () => choose("yes") }, + ], + }, + }} + legacyFooter={ {(item) => ( @@ -155,7 +176,7 @@ export function DialogWorkspaceFileChanges(props: { DialogWorkspaceFileChanges.show = ( dialog: DialogContext, files: VcsFileStatus[], - options?: { title?: string; message?: string }, + options?: { title?: string; message?: string; confirm?: { title: string; key: string } }, ) => { return new Promise((resolve) => { dialog.replace( diff --git a/packages/tui/src/component/dialog-workspaces.tsx b/packages/tui/src/component/dialog-workspaces.tsx index 58cffae6eae..70aaa8ff4bf 100644 --- a/packages/tui/src/component/dialog-workspaces.tsx +++ b/packages/tui/src/component/dialog-workspaces.tsx @@ -260,6 +260,7 @@ export function DialogWorkspaces(props: DialogWorkspacesProps) { const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], { title: "Delete worktree?", message: "This worktree has file changes. Do you want to delete it anyway?", + confirm: { title: "delete", key: "d" }, }) if (choice !== "yes") { reopen() diff --git a/packages/tui/src/component/dialog-worktree-name.tsx b/packages/tui/src/component/dialog-worktree-name.tsx index 889d2508e89..48f70f523a2 100644 --- a/packages/tui/src/component/dialog-worktree-name.tsx +++ b/packages/tui/src/component/dialog-worktree-name.tsx @@ -1,8 +1,10 @@ import { Slug } from "@opencode/core/util/slug" +import { useNewDesigns } from "../config" import { type DialogContext } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" export function DialogWorktreeName(props: { onConfirm: (name: string) => void }) { + const newDesigns = useNewDesigns() return ( void }) onConfirm={(value) => props.onConfirm(slugify(value) || Slug.create())} actions={[ { - title: "generate one", + title: newDesigns() ? "generate name" : "generate one", command: "dialog.worktree.generate", commandTitle: "Generate worktree name", onTrigger: (prompt) => prompt.setValue(Slug.create()), diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 43efe418270..953d5977d00 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -379,3 +379,9 @@ export function useConfig() { if (!value) throw new Error("ConfigProvider is missing") return value } + +/** Reactive accessor for the temporary "new-designs" experiment. */ +export function useNewDesigns() { + const config = useConfig() + return () => config.data.experimental?.["new-designs"] === true +} diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 54eabb6e1d5..d3304eb3390 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -19,6 +19,7 @@ import { type ModelPreferenceModel, } from "../model-preference" import { useTheme } from "./theme" +import { useNewDesigns } from "../config" import { useToast } from "../ui/toast" import { useRoute } from "./route" import { useData } from "./data" @@ -32,6 +33,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const data = useData() const toast = useToast() const theme = useTheme() + const newDesigns = useNewDesigns() const route = useRoute() const paths = useTuiPaths() const args = useArgs() @@ -73,7 +75,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }), ) const colors = createMemo(() => { - const step = 200 + // The new designs palette keeps its vivid accent at 400; 200 there is a muted tint. + const step = newDesigns() ? 400 : 200 return dedupeWith( theme.categorical.map((scale) => scale[step]), (first, second) => first.equals(second), diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index f84deccccb9..ed568e695c9 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -23,7 +23,7 @@ import { createComponentTheme, type ComponentTheme } from "../theme/component" import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" -import { useConfig } from "../config" +import { useConfig, useNewDesigns } from "../config" import { useStorageOptional } from "./storage" import { DevTools } from "../devtools" import { configDirectories } from "../util/config-directories" @@ -125,6 +125,7 @@ const themeContext = createSimpleContext({ init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => { const renderer = useRenderer() const configState = useConfig() + const newDesigns = useNewDesigns() const config = configState.data const themes = props.source const cache = useStorageOptional()?.store<{ colors?: TerminalColors }>("system-theme", { initial: {} }) @@ -287,10 +288,9 @@ const themeContext = createSimpleContext({ themeRefreshTimeouts.length = 0 }) - const available = createMemo((): Record => - config.experimental?.["new-designs"] === true - ? { opencode2: getOpenCode2Theme(), ...themeSources() } - : themeSources(), + const available = createMemo( + (): Record => + newDesigns() ? { opencode2: getOpenCode2Theme(), ...themeSources() } : themeSources(), ) const has = (name: string) => Boolean(name) && available()[name] !== undefined diff --git a/packages/tui/src/feature-plugins/prompt/btw.tsx b/packages/tui/src/feature-plugins/prompt/btw.tsx index 5cf4c20ffe0..6aca5607022 100644 --- a/packages/tui/src/feature-plugins/prompt/btw.tsx +++ b/packages/tui/src/feature-plugins/prompt/btw.tsx @@ -139,14 +139,25 @@ export function Answer(props: { } - footer={ - } > diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index c1fcdf31871..e926e1647c5 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -197,7 +197,9 @@ export function PluginsDialog(props: { context: Plugin.Context; plugins: ReturnT }, ]} footerHints={[ - ...(pluginError(focusedEntry()) && !focusedTui() ? [{ title: "view error", label: "enter" }] : []), + ...(pluginError(focusedEntry()) && !focusedTui() + ? [{ title: "view error", label: "enter", group: "primary" as const }] + : []), { title: `${showInternal() ? "hide" : "show"} internal`, label: "ctrl+a" }, ]} onMove={(option) => setFocused(option.value)} diff --git a/packages/tui/src/routes/session/dialog-execute.tsx b/packages/tui/src/routes/session/dialog-execute.tsx index 00a0123eeb9..86f9c8f1c52 100644 --- a/packages/tui/src/routes/session/dialog-execute.tsx +++ b/packages/tui/src/routes/session/dialog-execute.tsx @@ -99,6 +99,15 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) { ], })) + const copyActions = () => [ + copied() === "code" + ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: () => copy("code") } + : { title: "copy code", key: "c", onTrigger: () => copy("code") }, + copied() === "output" + ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: () => copy("output") } + : { title: "copy output", key: "o", onTrigger: () => copy("output") }, + ] + return ( {status()} } - footer={ + footer={{ actions: { primary: copyActions() } }} + legacyFooter={ copy("code") } - : { title: "copy code", key: "c", onTrigger: () => copy("code") }, - copied() === "output" - ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: () => copy("output") } - : { title: "copy output", key: "o", onTrigger: () => copy("output") }, - { title: "back", key: "esc", onTrigger: () => dialog.clear() }, - ]} + actions={{ + secondary: [ + { title: "scroll", key: "↑/↓" }, + ...copyActions(), + { title: "back", key: "esc", onTrigger: () => dialog.clear() }, + ], + }} /> } > diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 117491cc7d8..bf706ddb566 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -8,6 +8,7 @@ import { errorMessage } from "../../util/error" import { DialogFork } from "./dialog-fork" import type { PromptInfo } from "../../prompt/history" import { projectedPromptInput } from "../../prompt/codec" +import { useNewDesigns } from "../../config" export function DialogMessage(props: { messageID: string @@ -18,11 +19,13 @@ export function DialogMessage(props: { const clipboard = useClipboard() const toast = useToast() const client = useClient() + const newDesigns = useNewDesigns() const message = createMemo(() => data.session.message.get(props.sessionID, props.messageID)) return ( props.onMove(option.value)} title="Timeline" options={options()} /> + return ( + props.onMove(option.value)} + title="Timeline" + searchInTitle + titlePlaceholder="Search timeline" + options={options()} + /> + ) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ab8334f332d..e227d90e696 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -86,7 +86,7 @@ import { FormPrompt } from "./form" import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportResult } from "../../ui/dialog-export-result" import { sessionEpilogue } from "../../util/presentation" -import { useConfig } from "../../config" +import { useConfig, useNewDesigns } from "../../config" import { useClipboard } from "../../context/clipboard" import { nextThinkingMode, type ThinkingMode } from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" @@ -647,6 +647,8 @@ export function Session(props: { dialog.replace(() => ( ({ title: prompt.text, value: prompt.id, @@ -689,7 +691,7 @@ export function Session(props: { }, }, ]} - footerHints={[{ title: "steer", label: "enter" }]} + footerHints={[{ title: "steer", label: "enter", group: "primary" }]} /> )) const alignMessage = (messageID: string, top: number) => { @@ -2213,6 +2215,7 @@ function ShellMessage(props: { message: Extract deduplicateVisibleImages(props.message.files ?? [])) @@ -2258,6 +2261,7 @@ function UserMessage(props: { message: SessionMessageUser }) { dialog.replace(() => ( { + props.onConfirm?.() + dialog.clear() + } + Keymap.createLayer(() => ({ mode: "modal", commands: [ @@ -19,26 +24,21 @@ export function DialogAlert(props: DialogAlertProps) { bind: "return", title: "Confirm alert", group: "Dialog", - run: () => { - props.onConfirm?.() - dialog.clear() - }, + run: confirm, }, ], })) return ( } - footer={ + footer={{ actions: { primary: [{ title: "ok", key: "enter", onTrigger: confirm }] } }} + legacyFooter={ { - props.onConfirm?.() - dialog.clear() - }} + onMouseUp={confirm} > ok diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index 3a063555a83..777a2aaf6f8 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -1,5 +1,6 @@ import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" +import { useNewDesigns } from "../config" import { DialogHeader, DialogLayout, useDialog } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" @@ -19,45 +20,66 @@ export type DialogConfirmProps = { export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() const theme = useTheme().surface("dialog") + const newDesigns = useNewDesigns() const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) + const choose = (key: "confirm" | "cancel") => { + if (key === "confirm") props.onConfirm?.() + if (key === "cancel") props.onCancel?.() + dialog.clear() + } + Keymap.createLayer(() => ({ mode: "modal", - commands: [ - { - bind: "return", - title: "Confirm dialog selection", - group: "Dialog", - run: () => { - if (store.active === "confirm") props.onConfirm?.() - if (store.active === "cancel") props.onCancel?.() - dialog.clear() - }, - }, - { - bind: "left", - title: "Previous dialog option", - group: "Dialog", - run: () => { - setStore("active", store.active === "confirm" ? "cancel" : "confirm") - }, - }, - { - bind: "right", - title: "Next dialog option", - group: "Dialog", - run: () => { - setStore("active", store.active === "confirm" ? "cancel" : "confirm") - }, - }, - ], + commands: newDesigns() + ? [ + { bind: "return", title: "Confirm", group: "Dialog", run: () => choose("confirm") }, + { bind: "escape", title: "Cancel", group: "Dialog", run: () => choose("cancel") }, + ] + : [ + { + bind: "return", + title: "Confirm dialog selection", + group: "Dialog", + run: () => choose(store.active), + }, + { + bind: "left", + title: "Previous dialog option", + group: "Dialog", + run: () => { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + }, + }, + { + bind: "right", + title: "Next dialog option", + group: "Dialog", + run: () => { + setStore("active", store.active === "confirm" ? "cancel" : "confirm") + }, + }, + ], })) return ( } - footer={ + footer={{ + actions: { + primary: [ + // Footer labels are lowercase; the legacy buttons title-case the same labels. + { title: (props.label?.cancel ?? "cancel").toLowerCase(), key: "esc", onTrigger: () => choose("cancel") }, + { + title: (props.label?.confirm ?? "confirm").toLowerCase(), + key: "enter", + onTrigger: () => choose("confirm"), + }, + ], + }, + }} + legacyFooter={ {(key) => ( @@ -65,11 +87,7 @@ export function DialogConfirm(props: DialogConfirmProps) { paddingLeft={1} paddingRight={1} backgroundColor={key === store.active ? theme.background.action.primary.focused : undefined} - onMouseUp={() => { - if (key === "confirm") props.onConfirm?.() - if (key === "cancel") props.onCancel?.() - dialog.clear() - }} + onMouseUp={() => choose(key)} > {Locale.titlecase(props.label?.[key] ?? key)} diff --git a/packages/tui/src/ui/dialog-export-options.tsx b/packages/tui/src/ui/dialog-export-options.tsx index 77b35bdf7ff..2a38f629227 100644 --- a/packages/tui/src/ui/dialog-export-options.tsx +++ b/packages/tui/src/ui/dialog-export-options.tsx @@ -1,4 +1,5 @@ import { Keymap } from "../context/keymap" +import { useNewDesigns } from "../config" import { useTheme } from "../context/theme" import { DialogHeader, DialogLayout, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" @@ -23,6 +24,7 @@ type Active = ExportFormat | "thinking" | "tools" | "sanitize" | "copy" | "expor export function DialogExportOptions(props: DialogExportOptionsProps) { const theme = useTheme().surface("dialog") const overlayTheme = useTheme() + const newDesigns = useNewDesigns() const [store, setStore] = createStore({ format: "markdown" as ExportFormat, thinking: props.defaultThinking, @@ -59,19 +61,27 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { title: "Next export option", group: "Dialog", run: () => { - const order: Active[] = - store.format === "markdown" - ? ["markdown", "json", "thinking", "tools", "copy", "export"] - : ["markdown", "json", "sanitize", "copy", "export"] + const options: Active[] = + store.format === "markdown" ? ["markdown", "json", "thinking", "tools"] : ["markdown", "json", "sanitize"] + const order: Active[] = newDesigns() ? options : [...options, "copy", "export"] setStore("active", order[(order.indexOf(store.active) + 1) % order.length]) }, }, - { - bind: "return", - title: "Select export option", - group: "Dialog", - run: activate, - }, + ...(newDesigns() + ? [ + // The footer owns enter (save) and c (copy), so space toggles the focused option instead. + { bind: "return", title: "Save to file", group: "Dialog", run: () => confirm("export") }, + { bind: "c", title: "Copy export", group: "Dialog", run: () => confirm("copy") }, + { bind: "space", title: "Toggle export option", group: "Dialog", run: activate }, + ] + : [ + { + bind: "return", + title: "Select export option", + group: "Dialog", + run: activate, + }, + ]), ], })) @@ -83,7 +93,15 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { return ( } - footer={ + footer={{ + actions: { + primary: [ + { title: "copy", key: "c", onTrigger: () => confirm("copy") }, + { title: "save to file", key: "enter", onTrigger: () => confirm("export") }, + ], + }, + }} + legacyFooter={ void }) { const dialog = useDialog() const theme = useTheme().surface("dialog") + const clipboard = useClipboard() + const toast = useToast() + const newDesigns = useNewDesigns() + const [copied, setCopied] = createSignal(false) const close = () => { props.onClose?.() dialog.clear() } + const copy = () => { + void clipboard + .write(props.path) + .then(() => setCopied(true)) + .catch(toast.error) + } + Keymap.createLayer(() => ({ mode: "modal", commands: [ @@ -23,10 +38,25 @@ export function DialogExportResult(props: { path: string; onClose?: () => void } ], })) + Keymap.createLayer(() => ({ + mode: "modal", + enabled: newDesigns(), + commands: [{ bind: "c", title: "Copy export path", group: "Dialog", run: copy }], + })) + return ( } - footer={ + footer={{ + actions: { + primary: [ + copied() + ? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy } + : { title: "copy path", key: "c", onTrigger: copy }, + ], + }, + }} + legacyFooter={ } - footer={ + footer={{ actions: { primary: [{ title: "ok", key: "enter", onTrigger: () => dialog.clear() }] } }} + legacyFooter={ JSX.Element placeholder?: string value?: string @@ -34,6 +36,7 @@ export function DialogPrompt(props: DialogPromptProps) { const renderer = useRenderer() const theme = useTheme().surface("dialog") const config = useConfig().data + const newDesigns = useNewDesigns() const [textareaTarget, setTextareaTarget] = createSignal() let textarea: TextareaRenderable @@ -49,6 +52,14 @@ export function DialogPrompt(props: DialogPromptProps) { }, } + const extraActions = () => + (props.actions ?? []).map((action) => ({ + title: action.title, + command: action.command, + disabled: props.busy, + onTrigger: () => action.onTrigger(handle), + })) + Keymap.createLayer(() => ({ mode: "modal", target: textareaTarget, @@ -122,22 +133,26 @@ export function DialogPrompt(props: DialogPromptProps) { header={ { if (!props.busy) (props.onCancel ?? dialog.clear)() }} /> } - footer={ + footer={{ + actions: { + primary: [ + ...extraActions(), + { title: "submit", command: "dialog.prompt.submit", disabled: props.busy, onTrigger: confirm }, + ], + }, + }} + legacyFooter={ processing…}> ({ - title: action.title, - command: action.command, - onTrigger: () => action.onTrigger(handle), - })), - ]} + actions={{ + secondary: [{ title: "submit", command: "dialog.prompt.submit", onTrigger: confirm }, ...extraActions()], + }} /> } @@ -153,7 +168,7 @@ export function DialogPrompt(props: DialogPromptProps) { }} initialValue={props.value} placeholder={props.placeholder ?? "Enter text"} - placeholderColor={theme.text.muted} + placeholderColor={newDesigns() ? theme.text.faint : theme.text.muted} textColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.base} focusedTextColor={props.busy ? theme.text.formfield.disabled : theme.text.formfield.base} cursorColor={props.busy ? theme.background.formfield.disabled : theme.text.base} diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index f9eaf0cca6d..f86d8b507f7 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -2,7 +2,7 @@ import { CliRenderEvents, InputRenderable, RGBA, ScrollBoxRenderable, TextAttrib import { Keymap, type KeymapCommand } from "../context/keymap" import { useTheme } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" -import { batch, createEffect, createMemo, For, Show, type JSX, on, onCleanup, onMount } from "solid-js" +import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { useRenderer, useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" @@ -20,7 +20,7 @@ import { } from "./dialog" import { Locale } from "../util/locale" import { getScrollAcceleration } from "../util/scroll" -import { useConfig } from "../config" +import { useConfig, useNewDesigns } from "../config" import { moveSelection, reconcileSelection } from "./select-controller" export interface DialogSelectProps { @@ -47,20 +47,33 @@ export interface DialogSelectProps { footerHints?: { title: string label: string + /** Footer placement while the "new-designs" experiment is off. */ side?: "left" | "right" + /** Footer group under the "new-designs" experiment. Defaults to `secondary`. */ + group?: "primary" | "secondary" }[] bindings?: readonly KeymapCommand[] current?: T focusTarget?: T focusCurrent?: boolean sectionNavigation?: boolean + /** Under the "new-designs" experiment, puts the search input in the header row in place of the title. */ + searchInTitle?: boolean + /** Placeholder for the search input while it replaces the title, e.g. "Search models". */ + titlePlaceholder?: string } type DialogSelectActionBase = { command: string title: string bind?: string + /** Footer placement while the "new-designs" experiment is off. */ side?: "left" | "right" + /** + * Footer group under the "new-designs" experiment. Defaults to `primary` for actions on the selected + * option and `secondary` for dialog-level actions (`selection: "none"`). + */ + group?: "primary" | "secondary" hidden?: boolean disabled?: boolean | ((option: DialogSelectOption | undefined) => boolean) } @@ -117,6 +130,7 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const theme = useTheme().surface("dialog") const config = useConfig().data + const newDesigns = useNewDesigns() const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const renderer = useRenderer() @@ -232,7 +246,15 @@ export function DialogSelect(props: DialogSelectProps) { }) const dimensions = useTerminalDimensions() - const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6)) + const limit = createMemo(() => Math.floor(dimensions().height / 2) - 6) + // Under new designs an overflowing list gives its last row to a "…" indicator. + const overflows = createMemo(() => newDesigns() && rows() > limit()) + const height = createMemo(() => Math.min(rows(), limit()) - (overflows() ? 1 : 0)) + const [moreBelow, setMoreBelow] = createSignal(false) + function updateMoreBelow() { + if (!scroll || scroll.isDestroyed) return + setMoreBelow(scroll.scrollTop + scroll.viewport.height < scroll.scrollHeight) + } const selected = createMemo(() => flat()[store.selected]) @@ -556,16 +578,24 @@ export function DialogSelect(props: DialogSelectProps) { const footerActions = createMemo(() => visibleActions().map((item) => { - if (!isActionItem(item)) return { title: item.title, key: item.label, side: item.side } + if (!isActionItem(item)) + return { side: item.side, group: item.group ?? "secondary", action: { title: item.title, key: item.label } } return { - title: item.title, - key: item.label, side: item.side, - disabled: isActionDisabled(item), - onTrigger: () => trigger(item), + group: item.group ?? (item.selection === "none" ? "secondary" : "primary"), + action: { + title: item.title, + key: item.label, + disabled: isActionDisabled(item), + onTrigger: () => trigger(item), + }, } }), ) + const footerGroup = (match: (item: ReturnType[number]) => boolean) => + footerActions() + .filter(match) + .map((item) => item.action) function trigger(item: Action | undefined) { if (props.locked || !item || isActionDisabled(item)) return @@ -588,45 +618,75 @@ export function DialogSelect(props: DialogSelectProps) { return typeof item.disabled === "function" ? item.disabled(option) : item.disabled } + const searchInTitle = () => newDesigns() && props.searchInTitle === true && props.renderFilter !== false + + const searchInput = () => ( + { + if (props.locked) return + batch(() => { + setStore("filter", e) + props.onFilter?.(e) + }) + }} + focusedBackgroundColor={theme.background.formfield.focused} + cursorColor={newDesigns() ? theme.text.base : theme.text.formfield.focused} + cursorStyle={config.cursor} + focusedTextColor={theme.text.formfield.focused} + ref={(r) => { + input = r + input.traits = { status: "FILTER" } + setTimeout(() => { + if (!input) return + if (r.isDestroyed) return + r.focus() + }, 1) + }} + placeholder={(searchInTitle() ? props.titlePlaceholder : undefined) ?? props.placeholder ?? "Search"} + placeholderColor={newDesigns() ? theme.text.faint : theme.text.muted} + /> + ) + return ( - - - - { - if (props.locked) return - batch(() => { - setStore("filter", e) - props.onFilter?.(e) - }) - }} - focusedBackgroundColor={theme.background.formfield.focused} - cursorColor={theme.text.formfield.focused} - cursorStyle={config.cursor} - focusedTextColor={theme.text.formfield.focused} - ref={(r) => { - input = r - input.traits = { status: "FILTER" } - setTimeout(() => { - if (!input) return - if (r.isDestroyed) return - r.focus() - }, 1) - }} - placeholder={props.placeholder ?? "Search"} - placeholderColor={theme.text.muted} - /> - + + + + {searchInput()} + + + } + > + } - footer={ + footer={{ + inset: DIALOG_LIST_INSET - DIALOG_INSET, + actions: { + secondary: footerGroup((item) => item.group === "secondary"), + primary: footerGroup((item) => item.group === "primary"), + }, + get children() { + return props.footer + }, + }} + legacyFooter={ }> - + item.side !== "right"), + primary: footerGroup((item) => item.side === "right"), + }} + > {props.footer} @@ -656,11 +716,17 @@ export function DialogSelect(props: DialogSelectProps) { > {/* The 1-cell inset keeps the row highlight off the panel edge; rows add the rest of DIALOG_LIST_INSET. */} (scroll = r)} + ref={(r: ScrollBoxRenderable) => { + scroll = r + r.verticalScrollBar.on("change", updateMoreBelow) + r.content.on("resize", updateMoreBelow) + r.viewport.on("resize", updateMoreBelow) + }} maxHeight={height()} > @@ -721,6 +787,11 @@ export function DialogSelect(props: DialogSelectProps) { )} + + + {moreBelow() ? "…" : " "} + + @@ -729,9 +800,10 @@ export function DialogSelect(props: DialogSelectProps) { function DialogSelectCategory(props: { title: string; first: boolean }) { const theme = useTheme().surface("dialog") + const newDesigns = useNewDesigns() return ( - - + + {props.title} @@ -754,19 +826,23 @@ function DialogSelectRow(props: { activeColor?: RGBA }) { const theme = useTheme().surface("dialog") + const newDesigns = useNewDesigns() const text = createMemo(() => { if (props.active) return props.activeColor ?? theme.text.action.primary.focused - if (props.current) return theme.text.formfield.selected + if (props.current && !newDesigns()) return theme.text.formfield.selected return theme.text.base }) + // Secondary text (description and footer) on the highlighted row. + const secondary = () => (newDesigns() ? theme.text.action.secondary.focused : text()) - return ( + const row = () => ( // A marker or gutter takes its cell plus the row gap out of the left padding, // so titles line up with unmarked rows: scroll pad (1) + marker (1) + gap (1). - - ● + + {newDesigns() ? "▪" : "●"} @@ -788,7 +864,7 @@ function DialogSelectRow(props: { - {" " + props.description} + {" " + props.description} - {props.footer} + {props.footer} ) + + return ( + + + + {props.active ? "┃" : " "} + + {row()} + + + ) } function DialogSelectDetails(props: { details?: string[] }) { @@ -817,10 +904,14 @@ function DialogSelectDetails(props: { details?: string[] }) { const dialog = useDialog() const dimensions = useTerminalDimensions() const width = createMemo(() => dialogSelectContentWidth(Math.min(dialogWidth(dialog.size), dimensions().width))) + const newDesigns = useNewDesigns() return ( {(detail) => ( - + {Locale.truncateMiddle(detail, Math.max(1, width()))} diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index b9a4eb3ccd9..50505f6eaf6 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -1,8 +1,10 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, + children, createContext, createEffect, + createSignal, For, onCleanup, Show, @@ -16,7 +18,7 @@ import { InputRenderable, MouseButton, Renderable, RGBA, TextAttributes } from " import { createStore } from "solid-js/store" import { useToast } from "./toast" import { useClipboard } from "../context/clipboard" -import { useConfig } from "../config" +import { useConfig, useNewDesigns } from "../config" import { copy, copyOnSelectRelease } from "../util/selection" export type DialogSize = "medium" | "large" | "xlarge" @@ -94,23 +96,40 @@ export function DialogHeader( closeLabel?: string onClose?: () => void gap?: number + /** Faint line below the title. Shown only under the "new-designs" experiment. */ + description?: string }>, ) { const dialog = useDialog() const theme = useTheme().surface("dialog") - return ( + const newDesigns = useNewDesigns() + const row = () => ( {props.titleView ?? ( - + {props.title} )} {props.children} - (props.onClose ?? dialog.clear)()}> + (props.onClose ?? dialog.clear)()} + > {props.closeLabel ?? "esc"} ) + return ( + + + {row()} + + {props.description} + + + + ) } /** @@ -122,18 +141,29 @@ export function DialogLayout( id?: string /** Usually a `DialogHeader`. */ header: JSX.Element - /** Usually a `DialogFooter` or a row of buttons. */ - footer?: JSX.Element + /** Rendered as a `DialogFooter`. */ + footer?: DialogFooterProps + /** Footer shown while the "new-designs" experiment is off. Remove when the experiment graduates. */ + legacyFooter?: JSX.Element /** Side padding in cells. Defaults to DIALOG_INSET. */ inset?: number }>, ) { const inset = () => props.inset ?? DIALOG_INSET + const newDesigns = useNewDesigns() return ( {props.header} {props.children} - {props.footer} + + + {(footer) => ( + + {footer().children} + + )} + + ) } @@ -154,58 +184,90 @@ export type DialogFooterAction = { /** Keymap command id whose first shortcut is shown when `key` is not set. The action hides when it has no shortcut. */ command?: string onTrigger?: () => void - side?: "left" | "right" disabled?: boolean /** Overrides the title color, e.g. for a "✓ copied" confirmation. */ color?: RGBA } -export function DialogFooter( - props: ParentProps<{ - actions?: DialogFooterAction[] - /** Side padding in cells, e.g. DIALOG_INSET or DIALOG_LIST_INSET. Omit when the parent already pads. */ - inset?: number - }>, -) { +/** + * Alignment separates scope: `secondary` holds dialog-level actions on the left, `primary` holds actions + * on the selected item (or the dialog's main action) on the right. + */ +export type DialogFooterActions = { + secondary?: DialogFooterAction[] + primary?: DialogFooterAction[] +} + +export type DialogFooterProps = ParentProps<{ + actions?: DialogFooterActions + /** Side padding in cells, e.g. DIALOG_INSET or DIALOG_LIST_INSET. Omit when the parent already pads. */ + inset?: number +}> + +export function DialogFooter(props: DialogFooterProps) { const dimensions = useTerminalDimensions() const shortcuts = Keymap.useShortcuts() + const theme = useTheme().surface("dialog") + const newDesigns = useNewDesigns() const narrow = () => dimensions().width < 60 - const actions = () => - (props.actions ?? []).flatMap((action) => { + const resolve = (actions: DialogFooterAction[] = []) => + actions.flatMap((action) => { if (action.key !== undefined || action.command === undefined) return [action] const key = shortcuts.get(action.command) return key ? [{ ...action, key }] : [] }) - const left = () => actions().filter((action) => action.side !== "right") - const right = () => actions().filter((action) => action.side === "right") + const secondary = () => resolve(props.actions?.secondary) + const primary = () => resolve(props.actions?.primary) + const content = children(() => props.children) + // The raised band bleeds across the parent's DIALOG_INSET padding and the layout's bottom row, + // so it spans the dialog edge to edge and sits flush with its bottom. + const band = () => newDesigns() && (secondary().length > 0 || primary().length > 0 || content.toArray().length > 0) + // Hoverable actions carry one cell of padding on each side; the band gives that cell back so labels keep their column. + const bandInset = () => DIALOG_INSET + (props.inset ?? 0) - 1 return ( - - {props.children} - {(action) => } + + {content()} + {(action) => } - - {(action) => } + + {(action) => } ) } -function DialogFooterItem(props: { action: DialogFooterAction }) { +function DialogFooterItem(props: { action: DialogFooterAction; variant: "primary" | "secondary" }) { const theme = useTheme().surface("dialog") - return ( - { - if (props.action.disabled) return - props.action.onTrigger?.() - }} - > + const newDesigns = useNewDesigns() + const [hovered, setHovered] = createSignal(false) + const trigger = () => { + if (props.action.disabled) return + props.action.onTrigger?.() + } + const label = (key: RGBA) => ( + <> - + {" " + props.action.key} - + + ) + return ( + {label(theme.text.muted)}}> + setHovered(true)} + onMouseOut={() => setHovered(false)} + onMouseUp={trigger} + > + {label(theme.text.faint)} + + ) }