mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-12 11:54:29 +00:00
Compare commits
12 Commits
sqlite2
...
desktop-sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b2a33f5ed | ||
|
|
2bdc385b7c | ||
|
|
40c48b48ae | ||
|
|
b383026d38 | ||
|
|
aeb89297ac | ||
|
|
8cab430199 | ||
|
|
7050bdd43a | ||
|
|
3ff6ce5967 | ||
|
|
0f398e612f | ||
|
|
dad5dbc1cc | ||
|
|
13446cf8cc | ||
|
|
ed3ac35581 |
BIN
packages/app/public/release/release-example.mp4
Executable file
BIN
packages/app/public/release/release-example.mp4
Executable file
Binary file not shown.
BIN
packages/app/public/release/release-share.png
Normal file
BIN
packages/app/public/release/release-share.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
189
packages/app/src/components/dialog-release-notes.tsx
Normal file
189
packages/app/src/components/dialog-release-notes.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { markReleaseNotesSeen } from "@/lib/release-notes"
|
||||
|
||||
export interface ReleaseFeature {
|
||||
title: string
|
||||
description: string
|
||||
tag?: string
|
||||
media?: {
|
||||
type: "image" | "video"
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReleaseNote {
|
||||
version: string
|
||||
features: ReleaseFeature[]
|
||||
}
|
||||
|
||||
// Current release notes - update this with each release
|
||||
export const CURRENT_RELEASE: ReleaseNote = {
|
||||
version: "1.0.0",
|
||||
features: [
|
||||
{
|
||||
title: "Cleaner tab experience",
|
||||
description: "Chat is now fixed to the side of your tabs, and review is now available as a dedicated tab. ",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "video",
|
||||
src: "/release/release-example.mp4",
|
||||
alt: "Cleaner tab experience",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Share with control",
|
||||
description: "Keep your sessions private by default, or publish them to the web with a shareable URL.",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "image",
|
||||
src: "/release/release-share.png",
|
||||
alt: "Share with control",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Improved attachment management",
|
||||
description: "Upload and manage attachments more easily, to help build and maintain context.",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "video",
|
||||
src: "/release/release-example.mp4",
|
||||
alt: "Improved attachment management",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function DialogReleaseNotes(props: { release?: ReleaseNote }) {
|
||||
const dialog = useDialog()
|
||||
const release = props.release ?? CURRENT_RELEASE
|
||||
const [index, setIndex] = createSignal(0)
|
||||
|
||||
const feature = () => release.features[index()]
|
||||
const total = release.features.length
|
||||
const isFirst = () => index() === 0
|
||||
const isLast = () => index() === total - 1
|
||||
|
||||
function handleNext() {
|
||||
if (!isLast()) setIndex(index() + 1)
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (!isFirst()) setIndex(index() - 1)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
markReleaseNotesSeen()
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
let focusTrap: HTMLDivElement | undefined
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft" && !isFirst()) {
|
||||
e.preventDefault()
|
||||
setIndex(index() - 1)
|
||||
}
|
||||
if (e.key === "ArrowRight" && !isLast()) {
|
||||
e.preventDefault()
|
||||
setIndex(index() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
focusTrap?.focus()
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
// Refocus the trap when index changes to ensure escape always works
|
||||
createEffect(() => {
|
||||
index() // track index
|
||||
focusTrap?.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog class="dialog-release-notes">
|
||||
{/* Hidden element to capture initial focus and handle escape */}
|
||||
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
||||
{/* Left side - Text content */}
|
||||
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||
{/* Top section - feature content (fixed position from top) */}
|
||||
<div class="flex flex-col gap-2 pt-22">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-16-medium text-text-strong">{feature().title}</h1>
|
||||
{feature().tag && (
|
||||
<span
|
||||
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
|
||||
style={{ "border-width": "0.5px" }}
|
||||
>
|
||||
{feature().tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-14-regular text-text-base">{feature().description}</p>
|
||||
</div>
|
||||
|
||||
{/* Spacer to push buttons to bottom */}
|
||||
<div class="flex-1" />
|
||||
|
||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||
<div class="flex flex-col gap-12">
|
||||
<div class="flex items-center gap-3">
|
||||
{isLast() ? (
|
||||
<Button variant="primary" size="large" onClick={handleClose}>
|
||||
Get started
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > 1 && (
|
||||
<div class="flex items-center gap-1.5 -my-2.5">
|
||||
{release.features.map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||
classList={{
|
||||
"w-8": i === index(),
|
||||
"w-3": i !== index(),
|
||||
}}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
<div
|
||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||
classList={{
|
||||
"bg-icon-strong-base": i === index(),
|
||||
"bg-icon-weak-base": i !== index(),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Media content (edge to edge) */}
|
||||
{feature().media && (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
{feature().media!.type === "image" ? (
|
||||
<img
|
||||
src={feature().media!.src}
|
||||
alt={feature().media!.alt ?? "Release preview"}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<video src={feature().media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { formatKeybind, useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
@@ -1400,8 +1400,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
custom
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={command.keybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
|
||||
<Show when={cmd.keybind}>
|
||||
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
31
packages/app/src/components/release-notes-handler.tsx
Normal file
31
packages/app/src/components/release-notes-handler.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogReleaseNotes } from "./dialog-release-notes"
|
||||
import { shouldShowReleaseNotes, markReleaseNotesSeen } from "@/lib/release-notes"
|
||||
|
||||
/**
|
||||
* Component that handles showing release notes modal on app startup.
|
||||
* Shows the modal if:
|
||||
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true in lib/release-notes.ts
|
||||
* - OR the user hasn't seen the current version's release notes yet
|
||||
*
|
||||
* To disable the dev mode behavior, set DEV_ALWAYS_SHOW_RELEASE_NOTES to false
|
||||
* in packages/app/src/lib/release-notes.ts
|
||||
*/
|
||||
export function ReleaseNotesHandler() {
|
||||
const dialog = useDialog()
|
||||
|
||||
onMount(() => {
|
||||
// Small delay to ensure app is fully loaded before showing modal
|
||||
setTimeout(() => {
|
||||
if (shouldShowReleaseNotes()) {
|
||||
dialog.show(
|
||||
() => <DialogReleaseNotes />,
|
||||
() => markReleaseNotesSeen(),
|
||||
)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
232
packages/app/src/components/shortcuts-panel.tsx
Normal file
232
packages/app/src/components/shortcuts-panel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { For, createSignal, Show, onMount, onCleanup } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { parseKeybind, formatKeybind } from "@/context/command"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
const SPECIAL_CHAR_NAMES: Record<string, string> = {
|
||||
"^": "Control",
|
||||
"⌥": "Option",
|
||||
"⇧": "Shift",
|
||||
"⌘": "Command",
|
||||
"↑": "Arrow Up",
|
||||
"↓": "Arrow Down",
|
||||
"`": "Backtick",
|
||||
"'": "Quote",
|
||||
".": "Period",
|
||||
",": "Comma",
|
||||
"/": "Slash",
|
||||
"\\": "Backslash",
|
||||
"[": "Left Bracket",
|
||||
"]": "Right Bracket",
|
||||
"-": "Minus",
|
||||
"=": "Equals",
|
||||
";": "Semicolon",
|
||||
}
|
||||
|
||||
const KEY_DISPLAY_MAP: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
backspace: "⌫",
|
||||
}
|
||||
|
||||
interface Shortcut {
|
||||
title: string
|
||||
keybind: string
|
||||
}
|
||||
|
||||
interface ShortcutCategory {
|
||||
name: string
|
||||
shortcuts: Shortcut[]
|
||||
}
|
||||
|
||||
function isLetter(char: string): boolean {
|
||||
return /^[A-Za-z]$/.test(char)
|
||||
}
|
||||
|
||||
function getKeyChars(config: string): string[] {
|
||||
const keybinds = parseKeybind(config)
|
||||
if (keybinds.length === 0) return []
|
||||
|
||||
const kb = keybinds[0]
|
||||
const chars: string[] = []
|
||||
|
||||
if (kb.ctrl) chars.push(IS_MAC ? "^" : "Ctrl")
|
||||
if (kb.alt) chars.push(IS_MAC ? "⌥" : "Alt")
|
||||
if (kb.shift) chars.push(IS_MAC ? "⇧" : "Shift")
|
||||
if (kb.meta) chars.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
if (kb.key) {
|
||||
const mapped = KEY_DISPLAY_MAP[kb.key.toLowerCase()]
|
||||
if (mapped) {
|
||||
chars.push(mapped)
|
||||
} else {
|
||||
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
|
||||
for (const char of displayKey) {
|
||||
chars.push(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chars
|
||||
}
|
||||
|
||||
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
name: "General",
|
||||
shortcuts: [
|
||||
{ title: "Command palette", keybind: "mod+shift+p" },
|
||||
{ title: "Toggle sidebar", keybind: "mod+b" },
|
||||
{ title: "Toggle shortcuts", keybind: "ctrl+/" },
|
||||
{ title: "Open file", keybind: "mod+p" },
|
||||
{ title: "Open project", keybind: "mod+o" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Session",
|
||||
shortcuts: [
|
||||
{ title: "New session", keybind: "mod+shift+s" },
|
||||
{ title: "Previous session", keybind: "alt+arrowup" },
|
||||
{ title: "Next session", keybind: "alt+arrowdown" },
|
||||
{ title: "Archive session", keybind: "mod+shift+backspace" },
|
||||
{ title: "Undo", keybind: "mod+z" },
|
||||
{ title: "Redo", keybind: "mod+shift+z" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Navigation",
|
||||
shortcuts: [
|
||||
{ title: "Previous message", keybind: "mod+arrowup" },
|
||||
{ title: "Next message", keybind: "mod+arrowdown" },
|
||||
{ title: "Toggle steps", keybind: "mod+e" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Model and Agent",
|
||||
shortcuts: [
|
||||
{ title: "Choose model", keybind: "mod+'" },
|
||||
{ title: "Cycle agent", keybind: "mod+." },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Terminal",
|
||||
shortcuts: [
|
||||
{ title: "Toggle terminal", keybind: "ctrl+`" },
|
||||
{ title: "New terminal", keybind: "ctrl+shift+`" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
|
||||
|
||||
function getUsedShortcuts(): Set<string> {
|
||||
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set()
|
||||
}
|
||||
|
||||
const [usedShortcuts, setUsedShortcuts] = createSignal(getUsedShortcuts())
|
||||
|
||||
function formatKeybindForCopy(config: string): string {
|
||||
const chars = getKeyChars(config)
|
||||
return chars.join("")
|
||||
}
|
||||
|
||||
function ShortcutItem(props: { shortcut: Shortcut }) {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const used = () => usedShortcuts().has(props.shortcut.keybind)
|
||||
|
||||
function copyToClipboard() {
|
||||
const text = formatKeybindForCopy(props.shortcut.keybind)
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value="Copy to clipboard" placement="top">
|
||||
<button type="button" class="shortcut-item" classList={{ "shortcut-used": used() }} onClick={copyToClipboard}>
|
||||
<span class="text-14-regular text-text-base">{props.shortcut.title}</span>
|
||||
<Show
|
||||
when={!copied()}
|
||||
fallback={
|
||||
<div class="shortcut-copied">
|
||||
<Icon name="check" size="small" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="shortcut-keys">
|
||||
<For each={getKeyChars(props.shortcut.keybind)}>
|
||||
{(char) => {
|
||||
const tooltip = SPECIAL_CHAR_NAMES[char]
|
||||
const isSpecial = tooltip && !isLetter(char)
|
||||
const isShift = char === "⇧"
|
||||
return (
|
||||
<Show when={isSpecial} fallback={<kbd class="shortcut-key">{char}</kbd>}>
|
||||
<Tooltip value={tooltip} placement="top">
|
||||
<kbd class="shortcut-key shortcut-key-special">
|
||||
<span classList={{ "shortcut-key-shift": isShift }}>{char}</span>
|
||||
</kbd>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShortcutsPanel(props: { onClose: () => void }) {
|
||||
const [activeTab, setActiveTab] = createSignal(SHORTCUT_CATEGORIES[0].name)
|
||||
let listRef: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
const handler = () => setUsedShortcuts(getUsedShortcuts())
|
||||
window.addEventListener("shortcut-used", handler)
|
||||
onCleanup(() => window.removeEventListener("shortcut-used", handler))
|
||||
|
||||
// Focus the active tab trigger so arrow keys work immediately
|
||||
const trigger = listRef?.querySelector<HTMLButtonElement>('[data-slot="tabs-trigger"][data-selected]')
|
||||
trigger?.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="shortcuts-panel" data-component="shortcuts-panel">
|
||||
<Tabs value={activeTab()} onChange={setActiveTab}>
|
||||
<div class="shortcuts-tabs-row">
|
||||
<Tabs.List ref={listRef} class="shortcuts-tabs-list">
|
||||
<For each={SHORTCUT_CATEGORIES}>
|
||||
{(category) => <Tabs.Trigger value={category.name}>{category.name}</Tabs.Trigger>}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<span>
|
||||
Close <span class="text-text-weak">{formatKeybind("ctrl+/")}</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<IconButton icon="close" variant="ghost" onClick={props.onClose} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<For each={SHORTCUT_CATEGORIES}>
|
||||
{(category) => (
|
||||
<Tabs.Content value={category.name} class="shortcuts-content">
|
||||
<div class="shortcuts-grid">
|
||||
<For each={category.shortcuts}>{(shortcut) => <ShortcutItem shortcut={shortcut} />}</For>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
@@ -120,6 +122,73 @@ export function formatKeybind(config: string): string {
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[] }) {
|
||||
const dialog = useDialog()
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
|
||||
const handleMove = (option: CommandOption | undefined) => {
|
||||
state.cleanup?.()
|
||||
if (!option) return
|
||||
state.cleanup = option.onHighlight?.()
|
||||
}
|
||||
|
||||
const handleSelect = (option: CommandOption | undefined) => {
|
||||
if (!option) return
|
||||
state.committed = true
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
option.onSelect?.("palette")
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.committed) return
|
||||
state.cleanup?.()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog title="Commands">
|
||||
<List
|
||||
class="px-2.5"
|
||||
search={{ placeholder: "Search commands", autofocus: true }}
|
||||
emptyMessage="No commands found"
|
||||
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
||||
key={(x) => x?.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
groupBy={(x) => x.category ?? ""}
|
||||
onMove={handleMove}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
|
||||
<Show when={option.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={option.keybind}>
|
||||
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
|
||||
|
||||
function getUsedShortcuts(): Set<string> {
|
||||
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set()
|
||||
}
|
||||
|
||||
function markShortcutUsed(keybind: string) {
|
||||
const used = getUsedShortcuts()
|
||||
used.add(keybind)
|
||||
localStorage.setItem(USED_SHORTCUTS_KEY, JSON.stringify([...used]))
|
||||
window.dispatchEvent(new CustomEvent("shortcut-used", { detail: keybind }))
|
||||
}
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
@@ -163,7 +232,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
|
||||
const showPalette = () => {
|
||||
run("file.open", "palette")
|
||||
if (dialog.active) return
|
||||
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -172,6 +242,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const paletteKeybinds = parseKeybind("mod+shift+p")
|
||||
if (matchKeybind(paletteKeybinds, event)) {
|
||||
event.preventDefault()
|
||||
markShortcutUsed("mod+shift+p")
|
||||
showPalette()
|
||||
return
|
||||
}
|
||||
@@ -183,6 +254,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
if (matchKeybind(keybinds, event)) {
|
||||
event.preventDefault()
|
||||
markShortcutUsed(option.keybind)
|
||||
option.onSelect?.("keybind")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
@@ -93,6 +93,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}),
|
||||
)
|
||||
|
||||
const [shortcutsOpened, setShortcutsOpened] = createSignal(false)
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
const meta = { active: undefined as string | undefined, pruned: false }
|
||||
const used = new Map<string, number>()
|
||||
@@ -353,6 +355,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setStore("review", "diffStyle", diffStyle)
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
opened: shortcutsOpened,
|
||||
open() {
|
||||
setShortcutsOpened(true)
|
||||
},
|
||||
close() {
|
||||
setShortcutsOpened(false)
|
||||
},
|
||||
toggle() {
|
||||
setShortcutsOpened((x) => !x)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
width: createMemo(() => store.session?.width ?? 600),
|
||||
resize(width: number) {
|
||||
|
||||
@@ -9,3 +9,262 @@
|
||||
*[data-tauri-drag-region] {
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
/* Wider dialog variant for release notes modal */
|
||||
[data-component="dialog"]:has(.dialog-release-notes) {
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
[data-slot="dialog-container"] {
|
||||
width: min(100%, 720px);
|
||||
height: min(100%, 400px);
|
||||
margin-top: -80px;
|
||||
|
||||
[data-slot="dialog-content"] {
|
||||
min-height: auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
}
|
||||
|
||||
[data-slot="dialog-body"] {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Shortcuts panel */
|
||||
[data-component="shortcuts-panel"] {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-background-base);
|
||||
border-top: 1px solid var(--color-border-weak-base);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-component="tabs"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shortcuts-tabs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcuts-tabs-row > [data-component="icon-button"] {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"] {
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"]::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger-wrapper"] {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-weight: var(--font-weight-regular);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:hover {
|
||||
background: var(--color-surface-base);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:has([data-selected]) {
|
||||
background: var(--color-surface-raised-base-active);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger"] {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger"]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-xs-border-focus);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-content"] {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shortcuts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 240px);
|
||||
gap: 4px 48px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: repeat(3, 240px);
|
||||
gap: 4px 32px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"] {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: repeat(2, 240px);
|
||||
gap: 4px 24px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"] {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
||||
gap: 4px 24px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"] {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger"] {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 6px 6px 12px;
|
||||
margin: 0 -12px;
|
||||
gap: 16px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
text-align: left;
|
||||
width: calc(100% + 24px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.shortcut-item:hover {
|
||||
background: var(--color-surface-base);
|
||||
}
|
||||
|
||||
.shortcut-item:hover .shortcut-key {
|
||||
background: var(--color-background-stronger);
|
||||
}
|
||||
|
||||
.shortcut-item:active {
|
||||
background: var(--color-surface-raised-base);
|
||||
}
|
||||
|
||||
.shortcut-item.shortcut-used span {
|
||||
color: var(--color-text-interactive-base);
|
||||
}
|
||||
|
||||
.shortcut-item.shortcut-used .shortcut-key {
|
||||
color: var(--color-text-interactive-base);
|
||||
border-color: var(--color-border-interactive-base);
|
||||
background: var(--color-surface-interactive-base);
|
||||
box-shadow: var(--shadow-xs-border-interactive);
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcut-copied {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-success-base);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-subtle);
|
||||
box-shadow: var(--shadow-xs-border-base);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-key-special {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.shortcut-key-shift {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
/* Adjust main content and sidebar when shortcuts panel is open */
|
||||
main.shortcuts-open {
|
||||
padding-bottom: 280px;
|
||||
}
|
||||
|
||||
.sidebar-shortcuts-open {
|
||||
padding-bottom: 280px;
|
||||
}
|
||||
|
||||
/* Adjust dialogs when shortcuts panel is open */
|
||||
body:has([data-component="shortcuts-panel"]) [data-component="dialog"] {
|
||||
bottom: 280px;
|
||||
}
|
||||
|
||||
body:has([data-component="shortcuts-panel"]) [data-component="dialog-overlay"] {
|
||||
bottom: 280px;
|
||||
}
|
||||
|
||||
53
packages/app/src/lib/release-notes.ts
Normal file
53
packages/app/src/lib/release-notes.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { CURRENT_RELEASE } from "@/components/dialog-release-notes"
|
||||
|
||||
const STORAGE_KEY = "opencode:last-seen-version"
|
||||
|
||||
// ============================================================================
|
||||
// DEV MODE: Set this to true to always show the release notes modal on startup
|
||||
// Set to false for production behavior (only shows after updates)
|
||||
// ============================================================================
|
||||
const DEV_ALWAYS_SHOW_RELEASE_NOTES = true
|
||||
|
||||
/**
|
||||
* Check if release notes should be shown
|
||||
* Returns true if:
|
||||
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true (for development)
|
||||
* - OR the current version is newer than the last seen version
|
||||
*/
|
||||
export function shouldShowReleaseNotes(): boolean {
|
||||
if (DEV_ALWAYS_SHOW_RELEASE_NOTES) {
|
||||
console.log("[ReleaseNotes] DEV mode: always showing release notes")
|
||||
return true
|
||||
}
|
||||
|
||||
const lastSeen = localStorage.getItem(STORAGE_KEY)
|
||||
if (!lastSeen) {
|
||||
// First time user - show release notes
|
||||
return true
|
||||
}
|
||||
|
||||
// Compare versions - show if current is newer
|
||||
return CURRENT_RELEASE.version !== lastSeen
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current release notes as seen
|
||||
* Call this when the user closes the release notes modal
|
||||
*/
|
||||
export function markReleaseNotesSeen(): void {
|
||||
localStorage.setItem(STORAGE_KEY, CURRENT_RELEASE.version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version
|
||||
*/
|
||||
export function getCurrentVersion(): string {
|
||||
return CURRENT_RELEASE.version
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the seen status (useful for testing)
|
||||
*/
|
||||
export function resetReleaseNotesSeen(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
@@ -59,11 +59,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { navStart } from "@/utils/perf"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { ReleaseNotesHandler } from "@/components/release-notes-handler"
|
||||
import { ShortcutsPanel } from "@/components/shortcuts-panel"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
@@ -85,6 +87,7 @@ export default function Layout(props: ParentProps) {
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
const xlQuery = window.matchMedia("(min-width: 1280px)")
|
||||
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
||||
const [helpDropdownWidth, setHelpDropdownWidth] = createSignal<number | undefined>(undefined)
|
||||
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
|
||||
xlQuery.addEventListener("change", handleViewportChange)
|
||||
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
|
||||
@@ -749,6 +752,13 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "mod+b",
|
||||
onSelect: () => layout.sidebar.toggle(),
|
||||
},
|
||||
{
|
||||
id: "shortcuts.toggle",
|
||||
title: "Toggle shortcuts panel",
|
||||
category: "View",
|
||||
keybind: "ctrl+/",
|
||||
onSelect: () => layout.shortcuts.toggle(),
|
||||
},
|
||||
{
|
||||
id: "project.open",
|
||||
title: "Open project",
|
||||
@@ -1181,6 +1191,11 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
@@ -1882,7 +1897,7 @@ export default function Layout(props: ParentProps) {
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
|
||||
return (
|
||||
<div class="flex h-full w-full overflow-hidden">
|
||||
<div class="flex h-full w-full overflow-hidden" classList={{ "sidebar-shortcuts-open": layout.shortcuts.opened() }}>
|
||||
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
|
||||
<div class="flex-1 min-h-0 w-full">
|
||||
<DragDropProvider
|
||||
@@ -1922,14 +1937,35 @@ export default function Layout(props: ParentProps) {
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
|
||||
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
|
||||
</Tooltip>
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
|
||||
<IconButton
|
||||
icon="help"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenu
|
||||
placement="top-start"
|
||||
gutter={8}
|
||||
onOpenChange={(open) => {
|
||||
if (open && layout.sidebar.opened()) {
|
||||
setHelpDropdownWidth(layout.sidebar.width() - 16)
|
||||
return
|
||||
}
|
||||
if (!open) setHelpDropdownWidth(undefined)
|
||||
}}
|
||||
>
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
|
||||
<DropdownMenu.Trigger as={IconButton} icon="question-mark" variant="ghost" size="large" />
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{
|
||||
width: helpDropdownWidth() ? `${helpDropdownWidth()}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={() => platform.openLink("https://opencode.ai/desktop-feedback")}>
|
||||
Submit feedback
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="flex justify-between gap-6" onSelect={() => layout.shortcuts.toggle()}>
|
||||
Keyboard shortcuts <span class="text-text-weaker">{formatKeybind("ctrl+/")}</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2140,12 +2176,17 @@ export default function Layout(props: ParentProps) {
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
|
||||
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
|
||||
"shortcuts-open": layout.shortcuts.opened(),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
<Show when={layout.shortcuts.opened()}>
|
||||
<ShortcutsPanel onClose={() => layout.shortcuts.close()} />
|
||||
</Show>
|
||||
<Toast.Region />
|
||||
<ReleaseNotesHandler />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,36 @@ type PersistTarget = {
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
|
||||
function quota(error: unknown) {
|
||||
if (error instanceof DOMException) {
|
||||
if (error.name === "QuotaExceededError") return true
|
||||
if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
|
||||
if (error.code === 22 || error.code === 1014) return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (!error || typeof error !== "object") return false
|
||||
const name = (error as { name?: string }).name
|
||||
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
|
||||
return false
|
||||
}
|
||||
|
||||
function write(storage: Storage, key: string, value: string) {
|
||||
try {
|
||||
storage.setItem(key, value)
|
||||
return
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
storage.setItem(key, value)
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(value: unknown) {
|
||||
return JSON.parse(JSON.stringify(value)) as unknown
|
||||
}
|
||||
@@ -67,10 +97,19 @@ function workspaceStorage(dir: string) {
|
||||
|
||||
function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`
|
||||
const item = (key: string) => base + key
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(base + key),
|
||||
setItem: (key, value) => localStorage.setItem(base + key, value),
|
||||
removeItem: (key) => localStorage.removeItem(base + key),
|
||||
getItem: (key) => localStorage.getItem(item(key)),
|
||||
setItem: (key, value) => write(localStorage, item(key), value),
|
||||
removeItem: (key) => localStorage.removeItem(item(key)),
|
||||
}
|
||||
}
|
||||
|
||||
function localStorageDirect(): SyncStorage {
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
setItem: (key, value) => write(localStorage, key, value),
|
||||
removeItem: (key) => localStorage.removeItem(key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +138,7 @@ export function removePersisted(target: { storage?: string; key: string }) {
|
||||
}
|
||||
|
||||
if (!target.storage) {
|
||||
localStorage.removeItem(target.key)
|
||||
localStorageDirect().removeItem(target.key)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,12 +159,12 @@ export function persisted<T>(
|
||||
|
||||
const currentStorage = (() => {
|
||||
if (isDesktop) return platform.storage?.(config.storage)
|
||||
if (!config.storage) return localStorage
|
||||
if (!config.storage) return localStorageDirect()
|
||||
return localStorageWithPrefix(config.storage)
|
||||
})()
|
||||
|
||||
const legacyStorage = (() => {
|
||||
if (!isDesktop) return localStorage
|
||||
if (!isDesktop) return localStorageDirect()
|
||||
if (!config.storage) return platform.storage?.()
|
||||
return platform.storage?.(LEGACY_STORAGE)
|
||||
})()
|
||||
|
||||
94
packages/opencode/src/cli/cmd/tui/component/did-you-know.tsx
Normal file
94
packages/opencode/src/cli/cmd/tui/component/did-you-know.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { TIPS } from "./tips"
|
||||
import { EmptyBorder } from "./border"
|
||||
|
||||
const tip = TIPS[Math.floor(Math.random() * TIPS.length)]
|
||||
|
||||
type TipPart = { text: string; highlight: boolean }
|
||||
|
||||
function parseTip(tip: string): TipPart[] {
|
||||
const parts: TipPart[] = []
|
||||
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(tip)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
|
||||
}
|
||||
parts.push({ text: match[1], highlight: true })
|
||||
lastIndex = regex.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < tip.length) {
|
||||
parts.push({ text: tip.slice(lastIndex), highlight: false })
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const tipParts = parseTip(tip)
|
||||
|
||||
const BOX_WIDTH = 42
|
||||
const TITLE = " 🅘 Did you know? "
|
||||
|
||||
export function DidYouKnow() {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const dashes = createMemo(() => {
|
||||
// ╭─ + title + ─...─ + ╮ = BOX_WIDTH
|
||||
// 1 + 1 + title.length + dashes + 1 = BOX_WIDTH
|
||||
return Math.max(0, BOX_WIDTH - 2 - TITLE.length - 1)
|
||||
})
|
||||
|
||||
return (
|
||||
<box position="absolute" bottom={3} right={2} width={BOX_WIDTH}>
|
||||
<text>
|
||||
<span style={{ fg: theme.border }}>╭─</span>
|
||||
<span style={{ fg: theme.text }}>{TITLE}</span>
|
||||
<span style={{ fg: theme.border }}>{"─".repeat(dashes())}╮</span>
|
||||
</text>
|
||||
<box
|
||||
border={["left", "right", "bottom"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
bottomLeft: "╰",
|
||||
bottomRight: "╯",
|
||||
horizontal: "─",
|
||||
vertical: "│",
|
||||
}}
|
||||
>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<text>
|
||||
<For each={tipParts}>
|
||||
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
||||
</For>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end">
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>ctrl+h</span>
|
||||
<span style={{ fg: theme.textMuted }}> hide tips</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShowTipsHint() {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box position="absolute" bottom={3} right={2}>
|
||||
<box flexDirection="row" justifyContent="flex-end">
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>ctrl+h</span>
|
||||
<span style={{ fg: theme.textMuted }}> show tips</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
103
packages/opencode/src/cli/cmd/tui/component/tips.ts
Normal file
103
packages/opencode/src/cli/cmd/tui/component/tips.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export const TIPS = [
|
||||
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.",
|
||||
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
|
||||
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
|
||||
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
|
||||
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
|
||||
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
|
||||
"Drag and drop images into the terminal to add them as context for your prompts.",
|
||||
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
|
||||
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
|
||||
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.",
|
||||
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.",
|
||||
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.",
|
||||
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.",
|
||||
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.",
|
||||
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.",
|
||||
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.",
|
||||
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.",
|
||||
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.",
|
||||
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.",
|
||||
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.",
|
||||
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
|
||||
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.",
|
||||
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.",
|
||||
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.",
|
||||
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
|
||||
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.",
|
||||
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
|
||||
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
|
||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
|
||||
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
|
||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
|
||||
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
|
||||
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
|
||||
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.",
|
||||
"Configure {highlight}model{/highlight} in config to set your default model.",
|
||||
"Override any keybind in config via the {highlight}keybinds{/highlight} section.",
|
||||
"Set any keybind to {highlight}none{/highlight} to disable it completely.",
|
||||
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.",
|
||||
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.",
|
||||
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.",
|
||||
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.",
|
||||
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.",
|
||||
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.',
|
||||
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
|
||||
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.',
|
||||
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.",
|
||||
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.',
|
||||
"Define custom formatter commands with file extensions in config.",
|
||||
"OpenCode uses LSP servers for intelligent code analysis.",
|
||||
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.",
|
||||
"Tool definitions can invoke scripts written in Python, Go, etc.",
|
||||
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.",
|
||||
"Use plugins to send OS notifications when sessions complete.",
|
||||
"Create a plugin to prevent OpenCode from reading sensitive files.",
|
||||
"Use {highlight}opencode run{/highlight} for non-interactive scripting.",
|
||||
"Use {highlight}opencode run --continue{/highlight} to resume the last session.",
|
||||
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.",
|
||||
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
|
||||
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
|
||||
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
|
||||
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
|
||||
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
|
||||
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
|
||||
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
|
||||
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.",
|
||||
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.",
|
||||
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.",
|
||||
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.',
|
||||
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.",
|
||||
"Themes support dark/light variants for both modes.",
|
||||
"Reference ANSI colors 0-255 in custom themes.",
|
||||
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.",
|
||||
"Use {highlight}{file:path}{/highlight} to include file contents in config values.",
|
||||
"Use {highlight}instructions{/highlight} in config to load additional rules files.",
|
||||
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).",
|
||||
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.",
|
||||
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.',
|
||||
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.',
|
||||
"Override global tool settings per agent configuration.",
|
||||
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.',
|
||||
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.',
|
||||
"Run {highlight}/unshare{/highlight} to remove a session from public access.",
|
||||
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.",
|
||||
"Permission {highlight}external_directory{/highlight} protects files outside project.",
|
||||
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.",
|
||||
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.",
|
||||
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.",
|
||||
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.",
|
||||
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
|
||||
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
|
||||
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
|
||||
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use.",
|
||||
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
|
||||
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
|
||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
|
||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog.",
|
||||
"Use {highlight}/details{/highlight} to toggle tool execution details visibility.",
|
||||
"Use {highlight}/rename{/highlight} to rename the current session.",
|
||||
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell.",
|
||||
]
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { Logo } from "../component/logo"
|
||||
import { Tips } from "../component/tips"
|
||||
import { DidYouKnow, ShowTipsHint } from "../component/did-you-know"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
@@ -42,6 +43,22 @@ export function Home() {
|
||||
return !tipsHidden()
|
||||
})
|
||||
|
||||
function hideTips() {
|
||||
kv.set("tips_hidden", true)
|
||||
}
|
||||
|
||||
function enableTips() {
|
||||
kv.set("tips_hidden", false)
|
||||
}
|
||||
|
||||
function toggleTips() {
|
||||
if (showTips()) {
|
||||
hideTips()
|
||||
return
|
||||
}
|
||||
enableTips()
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
@@ -49,12 +66,20 @@ export function Home() {
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("tips_hidden", !tipsHidden())
|
||||
toggleTips()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
useKeyboard((evt) => {
|
||||
// Don't handle tips keybind for first-time users
|
||||
if (isFirstTimeUser()) return
|
||||
if (evt.name !== "h" || !evt.ctrl || evt.meta || evt.shift) return
|
||||
toggleTips()
|
||||
evt.preventDefault()
|
||||
})
|
||||
|
||||
const Hint = (
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
@@ -89,8 +114,6 @@ export function Home() {
|
||||
})
|
||||
const directory = useDirectory()
|
||||
|
||||
const keybind = useKeybind()
|
||||
|
||||
return (
|
||||
<>
|
||||
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
||||
@@ -112,6 +135,11 @@ export function Home() {
|
||||
</box>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={!isFirstTimeUser()}>
|
||||
<Show when={showTips()} fallback={<ShowTipsHint />}>
|
||||
<DidYouKnow />
|
||||
</Show>
|
||||
</Show>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
|
||||
@@ -66,6 +66,7 @@ const icons = {
|
||||
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
|
||||
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
|
||||
"question-mark": `<path d="M6 3.9975V2H14V6.99376L10 9.99001V11.9875M10 17.98V18" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" />`,
|
||||
}
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
|
||||
@@ -74,6 +74,24 @@
|
||||
color: var(--icon-active);
|
||||
}
|
||||
}
|
||||
|
||||
> [data-component="icon-button"] {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus:not(:disabled),
|
||||
&:active:not(:disabled) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-scroll"] {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children?: (item: T | undefined) => JSX.Element
|
||||
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
|
||||
}
|
||||
|
||||
export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
@@ -29,6 +30,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
"groupBy",
|
||||
"onSelect",
|
||||
"children",
|
||||
"placement",
|
||||
])
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
@@ -46,7 +48,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
<Kobalte<T, { category: string; options: T[] }>
|
||||
{...others}
|
||||
data-component="select"
|
||||
placement="bottom-start"
|
||||
placement={local.placement ?? "bottom-start"}
|
||||
value={local.current}
|
||||
options={grouped()}
|
||||
optionValue={(x) => (local.value ? local.value(x) : (x as string))}
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0),
|
||||
0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99));
|
||||
--shadow-xs-border-interactive:
|
||||
0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12);
|
||||
--shadow-lg-border-base:
|
||||
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)), 0 36px 80px 0 rgba(0, 0, 0, 0.03),
|
||||
0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05),
|
||||
0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09);
|
||||
|
||||
color-scheme: light;
|
||||
--text-mix-blend-mode: multiply;
|
||||
@@ -364,10 +371,10 @@
|
||||
--surface-raised-stronger-non-alpha: var(--smoke-dark-3);
|
||||
--surface-brand-base: var(--yuzu-light-9);
|
||||
--surface-brand-hover: var(--yuzu-light-10);
|
||||
--surface-interactive-base: var(--cobalt-light-3);
|
||||
--surface-interactive-hover: var(--cobalt-light-4);
|
||||
--surface-interactive-weak: var(--cobalt-light-2);
|
||||
--surface-interactive-weak-hover: var(--cobalt-light-3);
|
||||
--surface-interactive-base: var(--cobalt-dark-3);
|
||||
--surface-interactive-hover: var(--cobalt-dark-4);
|
||||
--surface-interactive-weak: var(--cobalt-dark-2);
|
||||
--surface-interactive-weak-hover: var(--cobalt-dark-3);
|
||||
--surface-success-base: var(--apple-light-3);
|
||||
--surface-success-weak: var(--apple-light-2);
|
||||
--surface-success-strong: var(--apple-light-9);
|
||||
@@ -454,12 +461,12 @@
|
||||
--border-weak-selected: var(--cobalt-dark-alpha-6);
|
||||
--border-weak-disabled: var(--smoke-dark-alpha-6);
|
||||
--border-weak-focus: var(--smoke-dark-alpha-8);
|
||||
--border-interactive-base: var(--cobalt-light-7);
|
||||
--border-interactive-hover: var(--cobalt-light-8);
|
||||
--border-interactive-active: var(--cobalt-light-9);
|
||||
--border-interactive-selected: var(--cobalt-light-9);
|
||||
--border-interactive-disabled: var(--smoke-light-8);
|
||||
--border-interactive-focus: var(--cobalt-light-9);
|
||||
--border-interactive-base: var(--cobalt-dark-7);
|
||||
--border-interactive-hover: var(--cobalt-dark-8);
|
||||
--border-interactive-active: var(--cobalt-dark-9);
|
||||
--border-interactive-selected: var(--cobalt-dark-9);
|
||||
--border-interactive-disabled: var(--smoke-dark-8);
|
||||
--border-interactive-focus: var(--cobalt-dark-9);
|
||||
--border-success-base: var(--apple-light-6);
|
||||
--border-success-hover: var(--apple-light-7);
|
||||
--border-success-selected: var(--apple-light-9);
|
||||
|
||||
Reference in New Issue
Block a user