diff --git a/packages/app/public/release/release-example.mp4 b/packages/app/public/release/release-example.mp4 new file mode 100755 index 0000000000..6cb4dd5859 Binary files /dev/null and b/packages/app/public/release/release-example.mp4 differ diff --git a/packages/app/public/release/release-share.png b/packages/app/public/release/release-share.png new file mode 100644 index 0000000000..e4b99d2db7 Binary files /dev/null and b/packages/app/public/release/release-share.png differ diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx new file mode 100644 index 0000000000..8d8818f10c --- /dev/null +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -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 ( + + {/* Hidden element to capture initial focus and handle escape */} +
+ {/* Left side - Text content */} +
+ {/* Top section - feature content (fixed position from top) */} +
+
+

{feature().title}

+ {feature().tag && ( + + {feature().tag} + + )} +
+

{feature().description}

+
+ + {/* Spacer to push buttons to bottom */} +
+ + {/* Bottom section - buttons and indicators (fixed position) */} +
+
+ {isLast() ? ( + + ) : ( + + )} +
+ + {total > 1 && ( +
+ {release.features.map((_, i) => ( + + ))} +
+ )} +
+
+ + {/* Right side - Media content (edge to edge) */} + {feature().media && ( +
+ {feature().media!.type === "image" ? ( + {feature().media!.alt + ) : ( +
+ )} +
+ ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c74edd94e6..f68531adc5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -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 = (props) => { custom - - {command.keybind(cmd.id)} + + {formatKeybind(cmd.keybind!)} diff --git a/packages/app/src/components/release-notes-handler.tsx b/packages/app/src/components/release-notes-handler.tsx new file mode 100644 index 0000000000..45237b5779 --- /dev/null +++ b/packages/app/src/components/release-notes-handler.tsx @@ -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( + () => , + () => markReleaseNotesSeen(), + ) + } + }, 500) + }) + + return null +} diff --git a/packages/app/src/components/shortcuts-panel.tsx b/packages/app/src/components/shortcuts-panel.tsx new file mode 100644 index 0000000000..5e8d3786b7 --- /dev/null +++ b/packages/app/src/components/shortcuts-panel.tsx @@ -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 = { + "^": "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 = { + 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 { + 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 ( + + + + ) +} + +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('[data-slot="tabs-trigger"][data-selected]') + trigger?.focus() + }) + + return ( +
+ +
+ + + {(category) => {category.name}} + + + + Close {formatKeybind("ctrl+/")} + + } + > + + +
+ + {(category) => ( + +
+ {(shortcut) => } +
+
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index d8dc13e234..fd3beddb84 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -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 ( + + 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) => ( +
+
+ {option.title} + + {option.description} + +
+ + {formatKeybind(option.keybind!)} + +
+ )} +
+
+ ) +} + +const USED_SHORTCUTS_KEY = "opencode:used-shortcuts" + +function getUsedShortcuts(): Set { + 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(() => !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 } diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index a8da156092..ecf25f4e42 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -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() @@ -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) { diff --git a/packages/app/src/index.css b/packages/app/src/index.css index d9d51aa8fb..febb67576f 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -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; +} diff --git a/packages/app/src/lib/release-notes.ts b/packages/app/src/lib/release-notes.ts new file mode 100644 index 0000000000..a28843acdf --- /dev/null +++ b/packages/app/src/lib/release-notes.ts @@ -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) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 81177d1386..f0106f22c3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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(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 ( -
+
@@ -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}
+ + layout.shortcuts.close()} /> + +
) } diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 0c20ee31ca..06e80142a5 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -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( 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) })() diff --git a/packages/opencode/src/cli/cmd/tui/component/did-you-know.tsx b/packages/opencode/src/cli/cmd/tui/component/did-you-know.tsx new file mode 100644 index 0000000000..df6d1e5b66 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/did-you-know.tsx @@ -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 ( + + + ╭─ + {TITLE} + {"─".repeat(dashes())}╮ + + + + + + {(part) => {part.text}} + + + + + + + ctrl+h + hide tips + + + + ) +} + +export function ShowTipsHint() { + const { theme } = useTheme() + + return ( + + + + ctrl+h + show tips + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.ts b/packages/opencode/src/cli/cmd/tui/component/tips.ts new file mode 100644 index 0000000000..be329076c0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/tips.ts @@ -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.", +] diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 59923c69d9..9591eaf7ce 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -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 = ( 0}> @@ -89,8 +114,6 @@ export function Home() { }) const directory = useDirectory() - const keybind = useKeybind() - return ( <> @@ -112,6 +135,11 @@ export function Home() { + + }> + + + {directory()} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 98f96c8e80..b263fc52cb 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -66,6 +66,7 @@ const icons = { dash: ``, "cloud-upload": ``, trash: ``, + "question-mark": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index ee9be422c9..4522725370 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -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"] { diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index e60fcbee14..5ca92773c6 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -15,6 +15,7 @@ export type SelectProps = Omit>, "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(props: SelectProps & ButtonProps) { @@ -29,6 +30,7 @@ export function Select(props: SelectProps & ButtonProps) { "groupBy", "onSelect", "children", + "placement", ]) const grouped = createMemo(() => { const result = pipe( @@ -46,7 +48,7 @@ export function Select(props: SelectProps & ButtonProps) { {...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))} diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 6ff19635d4..f9ebfbc46c 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -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);