feat(tui): new dialog designs behind new designs experiment (#55810)

Lands the rest of the dialog redesign stack, all behind the new-designs experiment:

- put dialog search in the title row (#55800)
- hide dialog search on short lists (#55801)
- read the experiment through useNewDesigns (#55802)
- raise dialog footers (#55805)
- standardize dialog footer actions (#55806)
- style selected dialog list rows (#55807)
- show list overflow indicator (#55808)
- restyle dialog headers (#55809)
- use vivid agent colors (#55810)
This commit is contained in:
Aarav Sareen
2026-10-16 15:15:15 +05:30
committed by GitHub
Unverified
parent 50d4280dbd
commit 00e7472faa
37 changed files with 734 additions and 304 deletions
@@ -61,6 +61,14 @@ export function CommandPaletteDialog() {
}
return (
<DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} flat={true} filterThreshold={0.7} />
<DialogSelect
ref={(value) => (ref = value)}
title="Commands"
searchInTitle
titlePlaceholder="Search commands"
options={list()}
flat={true}
filterThreshold={0.7}
/>
)
}
@@ -20,6 +20,8 @@ export function DialogAgent() {
return (
<DialogSelect
title="Select agent"
searchInTitle
titlePlaceholder="Search agents"
current={local.agent.current()?.id}
options={options()}
onSelect={(option) => {
+5 -2
View File
@@ -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 (
<DialogSelect
title="Settings"
searchInTitle
titlePlaceholder="Search settings"
options={options()}
current={current}
filterThreshold={0.7}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(1, option.value)}
footerHints={[{ title: "change", label: "←/→" }]}
footerHints={newDesigns() ? [] : [{ title: "change", label: "←/→" }]}
bindings={[
{
bind: "left",
+19 -18
View File
@@ -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 (
<DialogLayout
header={<DialogHeader title="Debug" />}
footer={
<DialogFooter
actions={[
{
title: copied() ? "✓ copied" : "copy",
key: "enter",
side: "right",
color: copied() ? theme.text.feedback.success.base : undefined,
onTrigger: copy,
},
]}
>
<text fg={theme.text.muted}>Share this when reporting an issue.</text>
</DialogFooter>
}
header={<DialogHeader title="Debug" description="Share this when reporting an issue" />}
footer={{
actions: { primary: [copyAction()] },
get children() {
if (newDesigns()) return undefined
return <text fg={theme.text.muted}>Share this when reporting an issue.</text>
},
}}
>
{/* 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. */}
@@ -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: {
</Show>
</box>
}
footer={
<DialogFooter
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 },
{ title: "scroll", key: "↑/↓", side: "right" },
]}
/>
}
footer={{ actions: { primary: actions() } }}
legacyFooter={<DialogFooter actions={{ secondary: actions(), primary: [{ title: "scroll", key: "↑/↓" }] }} />}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
@@ -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<Experiment>()
@@ -67,7 +68,7 @@ export function DialogExperiments() {
<text fg={theme.text.muted}>No experiments available</text>
</box>
}
footerHints={experiments.length > 0 ? [{ title: "change", label: "←/→" }] : []}
footerHints={experiments.length > 0 && !newDesigns() ? [{ title: "change", label: "←/→" }] : []}
bindings={
experiments.length > 0
? [
@@ -35,9 +35,10 @@ export function DialogHighlights(props: { version: string; items: ReadonlyArray<
return (
<DialogLayout
header={<DialogHeader title={`What's new in ${props.version}`} />}
footer={
footer={{ actions: { primary: [{ title: "ok", key: "enter", onTrigger: () => dialog.clear() }] } }}
legacyFooter={
<box flexDirection="row" justifyContent="space-between" alignItems="center">
<DialogFooter actions={[{ title: "scroll", key: "↑/↓" }]} />
<DialogFooter actions={{ secondary: [{ title: "scroll", key: "↑/↓" }] }} />
<box
paddingLeft={3}
paddingRight={3}
@@ -39,7 +39,25 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[];
<DialogLayout
id="prompt-image-viewer"
header={<DialogHeader title={`Image ${index() + 1} of ${props.images.length}`} />}
footer={
footer={{
actions: {
primary:
props.images.length > 1
? [
{ title: "previous", key: "←", onTrigger: () => move(-1) },
{ title: "next", key: "→", onTrigger: () => move(1) },
]
: [],
},
get children() {
return (
<text fg={failed() ? theme.text.feedback.error.base : theme.text.muted} wrapMode="none" truncate>
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
</text>
)
},
}}
legacyFooter={
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.muted} onMouseUp={() => move(-1)}>
{props.images.length > 1 ? "← previous" : ""}
@@ -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<string>()
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(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
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 (
<DialogSelect
title={`Connect ${integration.name}`}
renderFilter={!newDesigns()}
options={methods.map((method) => ({
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<typeof setTimeout> | 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<string>()
let settled = false
@@ -768,6 +772,7 @@ function OAuthCode(props: {
return (
<DialogPrompt
title={props.title}
subtitle={props.attempt.instructions}
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
@@ -786,7 +791,9 @@ function OAuthCode(props: {
}}
description={() => (
<box gap={1}>
<text fg={theme.text.muted}>{props.attempt.instructions}</text>
<Show when={!newDesigns()}>
<text fg={theme.text.muted}>{props.attempt.instructions}</text>
</Show>
<Link href={props.attempt.url} fg={theme.markdown.link} />
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.base}>{value()}</text>}</Show>
</box>
@@ -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 (
<DialogLayout
header={<DialogHeader title={props.title} />}
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={
<DialogFooter
actions={[
...(props.open ? [{ title: "open", key: "o" }] : []),
...(props.copy ? [{ title: "copy", key: "c" }] : []),
]}
actions={{
secondary: [
...(props.onOpen ? [{ title: "open", key: "o" }] : []),
...(props.onCopy ? [{ title: "copy", key: "c" }] : []),
],
}}
/>
}
>
@@ -980,22 +1005,20 @@ function StringChoiceField(props: {
return (
<DialogLayout
header={<DialogHeader title={formLabel(props.field)} onClose={() => (store.editing ? back() : dialog.clear())} />}
footer={
<DialogFooter
actions={
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() },
]
}
/>
}
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() },
],
},
}}
>
<box gap={1}>
<Show when={props.field.description}>
+3 -1
View File
@@ -138,6 +138,8 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
fallback={
<DialogSelect
title="MCP servers"
searchInTitle
titlePlaceholder="Search MCP servers"
options={options()}
preserveSelection
onMove={(option) => 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" }] : []}
/>
}
>
+12 -2
View File
@@ -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 (
<DialogSelect<ReturnType<typeof options>[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 })
@@ -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() {
</Show>
</box>
}
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" },
]}
/>
@@ -113,13 +113,15 @@ export function DialogShellOutput(props: { shell: ShellInfo; location: LocationR
<text fg={theme.text.muted}>{status()}</text>
</DialogHeader>
}
footer={
legacyFooter={
<DialogFooter
actions={[
{ title: "scroll", key: "↑/↓" },
{ title: "follow", key: "end" },
{ title: "back", key: "esc" },
]}
actions={{
secondary: [
{ title: "scroll", key: "↑/↓" },
{ title: "follow", key: "end" },
{ title: "back", key: "esc" },
],
}}
/>
}
>
@@ -23,6 +23,8 @@ export function DialogThemeList() {
return (
<DialogSelect
title="Themes"
searchInTitle
titlePlaceholder="Search themes"
options={options}
current={initial}
onMove={(opt) => {
+14 -3
View File
@@ -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<string>()
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={
<Show when={buttons().length > 0}>
<box flexDirection="row" justifyContent="flex-end">
<For each={buttons()}>
@@ -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() {
<DialogSelect<string>
options={options()}
title={"Select variant"}
renderFilter={!newDesigns()}
current={local.model.variant.current() ?? "default"}
flat={true}
/>
@@ -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 (
<DialogLayout
header={<DialogHeader title={props.title ?? "File Changes Found"} />}
footer={
footer={{
actions: {
primary: [
{ title: "cancel", key: "esc", onTrigger: () => choose("no") },
{ title: confirmAction().title, key: confirmAction().key, onTrigger: () => choose("yes") },
],
},
}}
legacyFooter={
<box flexDirection="row" justifyContent="flex-end">
<For each={options}>
{(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<WorkspaceFileChangesChoice | undefined>((resolve) => {
dialog.replace(
@@ -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()
@@ -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 (
<DialogPrompt
title="Name worktree"
@@ -10,7 +12,7 @@ export function DialogWorktreeName(props: { onConfirm: (name: string) => 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()),
+6
View File
@@ -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
}
+4 -1
View File
@@ -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),
+5 -5
View File
@@ -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<string, ThemeDocumentSource> =>
config.experimental?.["new-designs"] === true
? { opencode2: getOpenCode2Theme(), ...themeSources() }
: themeSources(),
const available = createMemo(
(): Record<string, ThemeDocumentSource> =>
newDesigns() ? { opencode2: getOpenCode2Theme(), ...themeSources() } : themeSources(),
)
const has = (name: string) => Boolean(name) && available()[name] !== undefined
@@ -139,14 +139,25 @@ export function Answer(props: {
</text>
</DialogHeader>
}
footer={
<DialogFooter
actions={[
footer={{
actions: {
primary: [
copied()
? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy }
: { title: "copy", key: "c", onTrigger: copy },
{ title: "scroll", key: "j/k ↑/↓" },
]}
: { title: "copy response", key: "c", onTrigger: copy },
],
},
}}
legacyFooter={
<DialogFooter
actions={{
secondary: [
copied()
? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy }
: { title: "copy", key: "c", onTrigger: copy },
{ title: "scroll", key: "j/k ↑/↓" },
],
}}
/>
}
>
@@ -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)}
@@ -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 (
<DialogLayout
header={
@@ -113,18 +122,16 @@ export function DialogExecute(props: { part: SessionMessageAssistantTool }) {
<text fg={failed() ? theme.text.feedback.error.base : theme.text.muted}>{status()}</text>
</DialogHeader>
}
footer={
footer={{ actions: { primary: copyActions() } }}
legacyFooter={
<DialogFooter
actions={[
{ title: "scroll", key: "↑/↓" },
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") },
{ title: "back", key: "esc", onTrigger: () => dialog.clear() },
]}
actions={{
secondary: [
{ title: "scroll", key: "↑/↓" },
...copyActions(),
{ title: "back", key: "esc", onTrigger: () => dialog.clear() },
],
}}
/>
}
>
@@ -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 (
<DialogSelect
title="Message Actions"
renderFilter={!newDesigns()}
options={[
{
title: "Jump to",
@@ -38,5 +38,13 @@ export function DialogTimeline(props: {
return result
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} />
return (
<DialogSelect
onMove={(option) => props.onMove(option.value)}
title="Timeline"
searchInTitle
titlePlaceholder="Search timeline"
options={options()}
/>
)
}
+6 -2
View File
@@ -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(() => (
<DialogSelect
title="Queued prompts"
searchInTitle
titlePlaceholder="Search queued prompts"
options={queuedPrompts().map((prompt, index) => ({
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<SessionMessageInfo, { type: "she
function UserMessage(props: { message: SessionMessageUser }) {
const ctx = use()
const newDesigns = useNewDesigns()
const data = useData()
const local = useLocal()
const files = createMemo(() => deduplicateVisibleImages(props.message.files ?? []))
@@ -2258,6 +2261,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
dialog.replace(() => (
<DialogSelect
title="Pending steer"
renderFilter={!newDesigns()}
options={[
{ title: "Move to queue", value: "queue" as const },
{ title: "Delete", value: "cancel" as const },
@@ -21,7 +21,8 @@
},
"secondary": {
"base": "$text.muted",
"$hovered": "$text.base"
"$hovered": "$text.base",
"$focused": "$hue.interactive.200"
},
"destructive": {
"base": "$hue.neutral.900",
@@ -152,7 +153,10 @@
"base": "$background.raised.base",
"action": {
"primary": {
"$hovered": "$background.raised.high"
"$hovered": "$background.raised.max"
},
"secondary": {
"$hovered": "$background.raised.max"
}
}
}
@@ -293,6 +297,22 @@
"image": "#3b7dd8",
"imageText": "#318795",
"codeBlock": "#1a1a1a"
},
"@dialog": {
"background": {
"base": "$hue.neutral.800",
"raised": {
"high": "$hue.neutral.700"
},
"action": {
"primary": {
"$hovered": "$hue.neutral.600"
},
"secondary": {
"$hovered": "$hue.neutral.600"
}
}
}
}
},
"dark": {
+9 -9
View File
@@ -12,6 +12,11 @@ export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const theme = useTheme().surface("dialog")
const confirm = () => {
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 (
<DialogLayout
header={<DialogHeader title={props.title} />}
footer={
footer={{ actions: { primary: [{ title: "ok", key: "enter", onTrigger: confirm }] } }}
legacyFooter={
<box flexDirection="row" justifyContent="flex-end">
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => {
props.onConfirm?.()
dialog.clear()
}}
onMouseUp={confirm}
>
<text fg={theme.text.action.primary.focused}>ok</text>
</box>
+52 -34
View File
@@ -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 (
<DialogLayout
header={<DialogHeader title={props.title} />}
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={
<box flexDirection="row" justifyContent="flex-end">
<For each={["cancel", "confirm"] as const}>
{(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)}
>
<text fg={key === store.active ? theme.text.action.primary.focused : theme.text.muted}>
{Locale.titlecase(props.label?.[key] ?? key)}
+29 -11
View File
@@ -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 (
<DialogLayout
header={<DialogHeader title="Export session" />}
footer={
footer={{
actions: {
primary: [
{ title: "copy", key: "c", onTrigger: () => confirm("copy") },
{ title: "save to file", key: "enter", onTrigger: () => confirm("export") },
],
},
}}
legacyFooter={
<box flexDirection="row" justifyContent="flex-end" gap={1}>
<box
paddingLeft={4}
+31 -1
View File
@@ -1,16 +1,31 @@
import { createSignal } from "solid-js"
import { useNewDesigns } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { DialogHeader, DialogLayout, useDialog, type DialogContext } from "./dialog"
import { useToast } from "./toast"
export function DialogExportResult(props: { path: string; onClose?: () => 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 (
<DialogLayout
header={<DialogHeader title="Session exported" onClose={close} />}
footer={
footer={{
actions: {
primary: [
copied()
? { title: "✓ copied", color: theme.text.feedback.success.base, onTrigger: copy }
: { title: "copy path", key: "c", onTrigger: copy },
],
},
}}
legacyFooter={
<box flexDirection="row" justifyContent="flex-end" gap={1}>
<box
paddingLeft={3}
+2 -1
View File
@@ -18,7 +18,8 @@ export function DialogHelp() {
return (
<DialogLayout
header={<DialogHeader title="Help" closeLabel="esc/enter" />}
footer={
footer={{ actions: { primary: [{ title: "ok", key: "enter", onTrigger: () => dialog.clear() }] } }}
legacyFooter={
<box flexDirection="row" justifyContent="flex-end">
<box
paddingLeft={3}
+26 -11
View File
@@ -4,7 +4,7 @@ import { useTheme } from "../context/theme"
import { DialogFooter, DialogHeader, DialogLayout, useDialog, type DialogSize } from "./dialog"
import { Show, createEffect, createSignal, onMount, type JSX } from "solid-js"
import { Spinner } from "../component/spinner"
import { useConfig } from "../config"
import { useConfig, useNewDesigns } from "../config"
import { useRenderer } from "@opentui/solid"
export type DialogPromptAction = {
@@ -19,6 +19,8 @@ export type DialogPromptAction = {
export type DialogPromptProps = {
title: string
size?: DialogSize
/** Faint line below the title, under the "new-designs" experiment. */
subtitle?: string
description?: () => 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<TextareaRenderable>()
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={
<DialogHeader
title={props.title}
description={props.subtitle}
onClose={() => {
if (!props.busy) (props.onCancel ?? dialog.clear)()
}}
/>
}
footer={
footer={{
actions: {
primary: [
...extraActions(),
{ title: "submit", command: "dialog.prompt.submit", disabled: props.busy, onTrigger: confirm },
],
},
}}
legacyFooter={
<Show when={!props.busy} fallback={<text fg={theme.text.muted}>processing…</text>}>
<DialogFooter
actions={[
{ title: "submit", command: "dialog.prompt.submit", onTrigger: confirm },
...(props.actions ?? []).map((action) => ({
title: action.title,
command: action.command,
onTrigger: () => action.onTrigger(handle),
})),
]}
actions={{
secondary: [{ title: "submit", command: "dialog.prompt.submit", onTrigger: confirm }, ...extraActions()],
}}
/>
</Show>
}
@@ -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}
+143 -52
View File
@@ -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<T> {
@@ -47,20 +47,33 @@ export interface DialogSelectProps<T> {
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<T> = {
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<T> | undefined) => boolean)
}
@@ -117,6 +130,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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<T>(props: DialogSelectProps<T>) {
})
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<T>(props: DialogSelectProps<T>) {
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<typeof footerActions>[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<T>(props: DialogSelectProps<T>) {
return typeof item.disabled === "function" ? item.disabled(option) : item.disabled
}
const searchInTitle = () => newDesigns() && props.searchInTitle === true && props.renderFilter !== false
const searchInput = () => (
<input
flexGrow={1}
onInput={(e) => {
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 (
<DialogLayout
header={
// Rows reserve a gutter, so the header and footer line up with the list's inset rather than the layout's.
<box paddingLeft={DIALOG_LIST_INSET - DIALOG_INSET} paddingRight={DIALOG_LIST_INSET - DIALOG_INSET}>
<DialogHeader title={props.title} titleView={props.titleView} onClose={props.onCancel} />
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
onInput={(e) => {
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}
/>
</box>
<Show
when={searchInTitle()}
fallback={
<>
<DialogHeader title={props.title} titleView={props.titleView} onClose={props.onCancel} />
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>{searchInput()}</box>
</Show>
</>
}
>
<DialogHeader titleView={searchInput()} gap={2} onClose={props.onCancel} />
</Show>
</box>
}
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={
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
<DialogFooter inset={DIALOG_LIST_INSET - DIALOG_INSET} actions={footerActions()}>
<DialogFooter
inset={DIALOG_LIST_INSET - DIALOG_INSET}
actions={{
secondary: footerGroup((item) => item.side !== "right"),
primary: footerGroup((item) => item.side === "right"),
}}
>
{props.footer}
</DialogFooter>
</Show>
@@ -656,11 +716,17 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
>
{/* The 1-cell inset keeps the row highlight off the panel edge; rows add the rest of DIALOG_LIST_INSET. */}
<scrollbox
paddingLeft={1}
paddingRight={1}
// Under new designs rows run edge to edge: a 1-cell gutter holds the selection bar on the left.
paddingLeft={newDesigns() ? 0 : 1}
paddingRight={newDesigns() ? 0 : 1}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
ref={(r: ScrollBoxRenderable) => {
scroll = r
r.verticalScrollBar.on("change", updateMoreBelow)
r.content.on("resize", updateMoreBelow)
r.viewport.on("resize", updateMoreBelow)
}}
maxHeight={height()}
>
<For each={grouped()}>
@@ -721,6 +787,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)}
</For>
</scrollbox>
<Show when={overflows()}>
<box paddingLeft={DIALOG_LIST_INSET} flexShrink={0}>
<text fg={theme.text.faint}>{moreBelow() ? "…" : " "}</text>
</box>
</Show>
</Show>
</DialogBleed>
</DialogLayout>
@@ -729,9 +800,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function DialogSelectCategory(props: { title: string; first: boolean }) {
const theme = useTheme().surface("dialog")
const newDesigns = useNewDesigns()
return (
<box paddingTop={props.first ? 0 : 1} paddingLeft={DIALOG_LIST_INSET - 1}>
<text fg={theme.hue.accent[200]} attributes={TextAttributes.BOLD}>
<box paddingTop={props.first ? 0 : 1} paddingLeft={DIALOG_LIST_INSET - (newDesigns() ? 0 : 1)}>
<text fg={newDesigns() ? theme.text.faint : theme.hue.accent[200]} attributes={TextAttributes.BOLD}>
{props.title}
</text>
</box>
@@ -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).
<box
flexDirection="row"
flexGrow={newDesigns() ? 1 : undefined}
paddingLeft={props.current || props.gutter ? DIALOG_LIST_INSET - 3 : DIALOG_LIST_INSET - 1}
paddingRight={DIALOG_LIST_INSET - 1}
paddingRight={DIALOG_LIST_INSET - (newDesigns() ? 0 : 1)}
gap={1}
backgroundColor={
props.active ? (props.activeBackground ?? theme.background.action.primary.focused) : RGBA.fromInts(0, 0, 0, 0)
@@ -778,8 +854,8 @@ function DialogSelectRow(props: {
</box>
</Show>
<Show when={props.current && !props.gutter}>
<text flexShrink={0} fg={text()}>
●
<text flexShrink={0} fg={newDesigns() ? theme.text.formfield.selected : text()}>
{newDesigns() ? "▪" : "●"}
</text>
</Show>
<Show when={props.gutter}>
@@ -788,7 +864,7 @@ function DialogSelectRow(props: {
<text
flexGrow={1}
fg={text()}
attributes={props.active ? TextAttributes.BOLD : undefined}
attributes={props.active && !newDesigns() ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
@@ -800,16 +876,27 @@ function DialogSelectRow(props: {
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
: Locale.truncate(props.title, props.titleWidth ?? 61))}
<Show when={props.description}>
<span style={{ fg: props.active ? text() : theme.text.muted }}>{" " + props.description}</span>
<span style={{ fg: props.active ? secondary() : theme.text.muted }}>{" " + props.description}</span>
</Show>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active ? text() : (props.footerColor ?? theme.text.muted)}>{props.footer}</text>
<text fg={props.active ? secondary() : (props.footerColor ?? theme.text.muted)}>{props.footer}</text>
</box>
</Show>
</box>
)
return (
<Show when={newDesigns()} fallback={row()}>
<box flexDirection="row">
<text width={1} flexShrink={0} fg={theme.text.formfield.focused}>
{props.active ? "┃" : " "}
</text>
{row()}
</box>
</Show>
)
}
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 (
<For each={props.details}>
{(detail) => (
<box paddingLeft={DIALOG_LIST_INSET - 1} paddingRight={DIALOG_LIST_INSET - 1}>
<box
paddingLeft={DIALOG_LIST_INSET - (newDesigns() ? 0 : 1)}
paddingRight={DIALOG_LIST_INSET - (newDesigns() ? 0 : 1)}
>
<text fg={theme.text.muted} wrapMode="none">
{Locale.truncateMiddle(detail, Math.max(1, width()))}
</text>
+115 -36
View File
@@ -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 = () => (
<box flexDirection="row" justifyContent="space-between" gap={props.gap}>
{props.titleView ?? (
<text attributes={TextAttributes.BOLD} fg={theme.text.base}>
<text attributes={newDesigns() ? undefined : TextAttributes.BOLD} fg={theme.text.base}>
{props.title}
</text>
)}
{props.children}
<text fg={theme.text.muted} flexShrink={0} onMouseUp={() => (props.onClose ?? dialog.clear)()}>
<text
fg={newDesigns() ? theme.text.faint : theme.text.muted}
flexShrink={0}
onMouseUp={() => (props.onClose ?? dialog.clear)()}
>
{props.closeLabel ?? "esc"}
</text>
</box>
)
return (
<Show when={newDesigns() && props.description} fallback={row()}>
<box>
{row()}
<text fg={theme.text.faint} wrapMode="word">
{props.description}
</text>
</box>
</Show>
)
}
/**
@@ -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 (
<box id={props.id} paddingLeft={inset()} paddingRight={inset()} paddingBottom={1} gap={1}>
{props.header}
<box>{props.children}</box>
{props.footer}
<Show when={newDesigns() || props.legacyFooter === undefined} fallback={props.legacyFooter}>
<Show when={props.footer}>
{(footer) => (
<DialogFooter actions={footer().actions} inset={footer().inset}>
{footer().children}
</DialogFooter>
)}
</Show>
</Show>
</box>
)
}
@@ -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 (
<box
paddingLeft={props.inset}
paddingRight={props.inset}
marginLeft={band() ? -DIALOG_INSET : undefined}
marginRight={band() ? -DIALOG_INSET : undefined}
marginBottom={band() ? -1 : undefined}
paddingLeft={band() ? bandInset() : props.inset}
paddingRight={band() ? bandInset() : props.inset}
paddingTop={band() ? 1 : undefined}
paddingBottom={band() ? 1 : undefined}
backgroundColor={band() ? theme.background.raised.high : undefined}
flexDirection="row"
justifyContent="space-between"
gap={newDesigns() ? 2 : undefined}
flexShrink={0}
>
<box flexDirection={narrow() ? "column" : "row"} gap={narrow() ? 0 : 2}>
{props.children}
<For each={left()}>{(action) => <DialogFooterItem action={action} />}</For>
<box
flexDirection={narrow() ? "column" : "row"}
gap={narrow() || newDesigns() ? 0 : 2}
columnGap={newDesigns() && !narrow() ? 1 : undefined}
// Under new designs the primary group keeps its width and the secondary group wraps instead of overlapping.
flexWrap={newDesigns() ? "wrap" : undefined}
flexShrink={newDesigns() ? 1 : undefined}
minWidth={newDesigns() ? 0 : undefined}
>
{content()}
<For each={secondary()}>{(action) => <DialogFooterItem action={action} variant="secondary" />}</For>
</box>
<box flexDirection="row" gap={2}>
<For each={right()}>{(action) => <DialogFooterItem action={action} />}</For>
<box flexDirection="row" gap={newDesigns() ? 1 : 2} flexShrink={newDesigns() ? 0 : undefined}>
<For each={primary()}>{(action) => <DialogFooterItem action={action} variant="primary" />}</For>
</box>
</box>
)
}
function DialogFooterItem(props: { action: DialogFooterAction }) {
function DialogFooterItem(props: { action: DialogFooterAction; variant: "primary" | "secondary" }) {
const theme = useTheme().surface("dialog")
return (
<text
onMouseUp={() => {
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) => (
<>
<span
style={{
fg: props.action.disabled ? theme.text.action.primary.disabled : (props.action.color ?? theme.text.base),
@@ -214,11 +276,28 @@ function DialogFooterItem(props: { action: DialogFooterAction }) {
{props.action.title}
</span>
<Show when={props.action.key}>
<span style={{ fg: props.action.disabled ? theme.text.action.primary.disabled : theme.text.muted }}>
<span style={{ fg: props.action.disabled ? theme.text.action.primary.disabled : key }}>
{" " + props.action.key}
</span>
</Show>
</text>
</>
)
return (
<Show when={newDesigns()} fallback={<text onMouseUp={trigger}>{label(theme.text.muted)}</text>}>
<box
paddingLeft={1}
paddingRight={1}
flexShrink={0}
backgroundColor={
hovered() && !props.action.disabled ? theme.background.action[props.variant].hovered : undefined
}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseUp={trigger}
>
<text>{label(theme.text.faint)}</text>
</box>
</Show>
)
}