mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
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:
committed by
GitHub
Unverified
parent
50d4280dbd
commit
00e7472faa
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" }] : []}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user