Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e521fee002 | ||
|
|
04e60f2b3d | ||
|
|
23d71e125c | ||
|
|
27406cf8ef | ||
|
|
0596b02f19 | ||
|
|
5145b72c4a | ||
|
|
347cd8ac63 | ||
|
|
b711ca57f2 | ||
|
|
353115a895 | ||
|
|
5f0372183a | ||
|
|
616329ae97 | ||
|
|
9706aaf552 | ||
|
|
8b379329a6 | ||
|
|
68d1755a9e | ||
|
|
419004992d | ||
|
|
0d49df46ef | ||
|
|
36f5ba52e9 | ||
|
|
088b537657 | ||
|
|
4ddfa86e7f | ||
|
|
b91b76e9eb | ||
|
|
6ed656a615 | ||
|
|
7b336add88 | ||
|
|
7f9ffe57f9 | ||
|
|
ad31b555a8 | ||
|
|
a05c334702 | ||
|
|
cf284e32aa | ||
|
|
054ccee78d | ||
|
|
bfa986d45e | ||
|
|
aa4b06e165 | ||
|
|
2542693f7b | ||
|
|
bec294b781 | ||
|
|
1ee8a9c0b2 | ||
|
|
4e04bee0c9 | ||
|
|
673e79f457 | ||
|
|
79ae749ed8 | ||
|
|
d605a78a05 | ||
|
|
69b3b35ea5 | ||
|
|
3173ba1288 | ||
|
|
a4d1824412 | ||
|
|
cac35bc52d | ||
|
|
ecc51ddb4e | ||
|
|
769c97af08 | ||
|
|
e29120317f | ||
|
|
88c5a7fe9e | ||
|
|
091e88c1e1 | ||
|
|
d19e76d96c | ||
|
|
c3393ecc6c | ||
|
|
889c60d63b | ||
|
|
c47699536f |
@@ -205,3 +205,4 @@
|
||||
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
|
||||
| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) |
|
||||
| 2026-01-19 | 4,861,108 (+233,485) | 1,863,112 (+23,941) | 6,724,220 (+257,426) |
|
||||
| 2026-01-20 | 5,128,999 (+267,891) | 1,903,665 (+40,553) | 7,032,664 (+308,444) |
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -71,7 +71,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -105,7 +105,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -156,7 +156,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -180,7 +180,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -209,7 +209,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -238,7 +238,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -358,7 +358,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -378,7 +378,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.4",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -389,7 +389,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -402,7 +402,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -443,7 +443,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -454,7 +454,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
|
||||
<link rel="shortcut icon" href="/favicon-v2.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#F8F7F7" />
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/apple-touch-icon-v2.png
|
||||
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-96x96-v2.png
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-v2.ico
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-v2.svg
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
@@ -22,7 +22,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
const [store, setStore] = createStore({
|
||||
name: defaultName(),
|
||||
color: props.project.icon?.color || "pink",
|
||||
iconUrl: props.project.icon?.url || "",
|
||||
iconUrl: props.project.icon?.override || "",
|
||||
saving: false,
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
name,
|
||||
icon: { color: store.color, url: store.iconUrl },
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
})
|
||||
setStore("saving", false)
|
||||
dialog.close()
|
||||
@@ -82,7 +82,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
|
||||
return (
|
||||
<Dialog title="Edit project" class="w-full max-w-[480px] mx-auto">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
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 { formatKeybind, useCommand } from "@/context/command"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
@@ -300,7 +300,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||
const fileItems = items.filter((item) => item.kind === "file")
|
||||
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
for (const item of imageItems) {
|
||||
@@ -310,7 +311,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
showToast({
|
||||
title: "Unsupported paste",
|
||||
description: "Only images or PDFs can be pasted here.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||
if (!plainText) return
|
||||
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
@@ -1400,8 +1410,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
custom
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={cmd.keybind}>
|
||||
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
|
||||
<Show when={command.keybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
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,8 +1,6 @@
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
|
||||
import { createMemo, createSignal, onCleanup, onMount, 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)
|
||||
|
||||
@@ -122,73 +120,6 @@ 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: () => {
|
||||
@@ -232,8 +163,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
|
||||
const showPalette = () => {
|
||||
if (dialog.active) return
|
||||
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
|
||||
run("file.open", "palette")
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -242,7 +172,6 @@ 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
|
||||
}
|
||||
@@ -254,7 +183,6 @@ 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, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
@@ -33,8 +33,6 @@ type SessionTabs = {
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
terminalOpened?: boolean
|
||||
reviewPanelOpened?: boolean
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
@@ -78,9 +76,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
terminal: {
|
||||
height: 280,
|
||||
opened: false,
|
||||
},
|
||||
review: {
|
||||
diffStyle: "split" as ReviewDiffStyle,
|
||||
panelOpened: true,
|
||||
},
|
||||
session: {
|
||||
width: 600,
|
||||
@@ -93,8 +93,6 @@ 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>()
|
||||
@@ -174,7 +172,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const current = store.sessionView[sessionKey]
|
||||
const keep = meta.active ?? sessionKey
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
|
||||
setStore("sessionView", sessionKey, { scroll: next })
|
||||
prune(keep)
|
||||
return
|
||||
}
|
||||
@@ -210,10 +208,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
})
|
||||
})
|
||||
|
||||
const usedColors = new Set<AvatarColorKey>()
|
||||
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
|
||||
|
||||
function pickAvailableColor(): AvatarColorKey {
|
||||
const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
|
||||
function pickAvailableColor(used: Set<string>): AvatarColorKey {
|
||||
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
|
||||
if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
|
||||
return available[Math.floor(Math.random() * available.length)]
|
||||
}
|
||||
@@ -224,24 +222,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const metadata = projectID
|
||||
? globalSync.data.project.find((x) => x.id === projectID)
|
||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
return [
|
||||
{
|
||||
...(metadata ?? {}),
|
||||
...project,
|
||||
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
||||
return {
|
||||
...(metadata ?? {}),
|
||||
...project,
|
||||
icon: {
|
||||
url: metadata?.icon?.url,
|
||||
override: metadata?.icon?.override,
|
||||
color: metadata?.icon?.color,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function colorize(project: LocalProject) {
|
||||
if (project.icon?.color) return project
|
||||
const color = pickAvailableColor()
|
||||
usedColors.add(color)
|
||||
project.icon = { ...project.icon, color }
|
||||
if (project.id) {
|
||||
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
const roots = createMemo(() => {
|
||||
@@ -279,8 +268,37 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
})
|
||||
})
|
||||
|
||||
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
|
||||
const list = createMemo(() => enriched().flatMap(colorize))
|
||||
const enriched = createMemo(() => server.projects.list().map(enrich))
|
||||
const list = createMemo(() => {
|
||||
const projects = enriched()
|
||||
return projects.map((project) => {
|
||||
const color = project.icon?.color ?? colors[project.worktree]
|
||||
if (!color) return project
|
||||
const icon = project.icon ? { ...project.icon, color } : { color }
|
||||
return { ...project, icon }
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
|
||||
const used = new Set<string>()
|
||||
for (const project of projects) {
|
||||
const color = project.icon?.color ?? colors[project.worktree]
|
||||
if (color) used.add(color)
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.icon?.color) continue
|
||||
if (colors[project.worktree]) continue
|
||||
const color = pickAvailableColor(used)
|
||||
used.add(color)
|
||||
setColors(project.worktree, color)
|
||||
if (!project.id) continue
|
||||
void globalSdk.client.project.update({ projectID: project.id, icon: { color } })
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
Promise.all(
|
||||
@@ -355,18 +373,6 @@ 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) {
|
||||
@@ -393,31 +399,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
touch(sessionKey)
|
||||
scroll.seed(sessionKey)
|
||||
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
|
||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
|
||||
|
||||
function setTerminalOpened(next: boolean) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const current = store.terminal
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
|
||||
setStore("terminal", { height: 280, opened: next })
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.terminalOpened ?? false
|
||||
const value = current.opened ?? false
|
||||
if (value === next) return
|
||||
setStore("sessionView", sessionKey, "terminalOpened", next)
|
||||
setStore("terminal", "opened", next)
|
||||
}
|
||||
|
||||
function setReviewPanelOpened(next: boolean) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const current = store.review
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
|
||||
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.reviewPanelOpened ?? true
|
||||
const value = current.panelOpened ?? true
|
||||
if (value === next) return
|
||||
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
|
||||
setStore("review", "panelOpened", next)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -458,8 +464,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, {
|
||||
scroll: {},
|
||||
terminalOpened: false,
|
||||
reviewPanelOpened: true,
|
||||
reviewOpen: open,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -37,7 +37,7 @@ const platform: Platform = {
|
||||
.then(() => {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96.png",
|
||||
icon: "https://opencode.ai/favicon-96x96-v2.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
|
||||
@@ -9,262 +9,3 @@
|
||||
*[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;
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+135
-111
@@ -59,13 +59,11 @@ 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 { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { 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"
|
||||
|
||||
@@ -78,6 +76,7 @@ export default function Layout(props: ParentProps) {
|
||||
activeWorkspace: undefined as string | undefined,
|
||||
workspaceOrder: {} as Record<string, string[]>,
|
||||
workspaceName: {} as Record<string, string>,
|
||||
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
||||
workspaceExpanded: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
@@ -87,7 +86,6 @@ 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))
|
||||
@@ -122,6 +120,17 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
const editorRef = { current: undefined as HTMLInputElement | undefined }
|
||||
|
||||
const autoselecting = createMemo(() => {
|
||||
if (params.dir) return false
|
||||
if (initialDir) return false
|
||||
if (!autoselect()) return false
|
||||
if (!pageReady()) return true
|
||||
if (!layoutReady()) return true
|
||||
const list = layout.projects.list()
|
||||
if (list.length === 0) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const editorOpen = (id: string) => editor.active === id
|
||||
const editorValue = () => editor.value
|
||||
|
||||
@@ -201,7 +210,10 @@ export default function Layout(props: ParentProps) {
|
||||
value={editorValue()}
|
||||
class={props.class}
|
||||
onInput={(event) => setEditor("value", event.currentTarget.value)}
|
||||
onKeyDown={(event) => editorKeyDown(event, props.onSave)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
editorKeyDown(event, props.onSave)
|
||||
}}
|
||||
onBlur={() => closeEditor()}
|
||||
onPointerDown={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
@@ -461,9 +473,27 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
)
|
||||
|
||||
const workspaceName = (directory: string) => store.workspaceName[directory]
|
||||
const workspaceLabel = (directory: string, branch?: string) =>
|
||||
workspaceName(directory) ?? branch ?? getFilename(directory)
|
||||
const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
|
||||
|
||||
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
const direct = store.workspaceName[key] ?? store.workspaceName[directory]
|
||||
if (direct) return direct
|
||||
if (!projectId) return
|
||||
if (!branch) return
|
||||
return store.workspaceBranchName[projectId]?.[branch]
|
||||
}
|
||||
|
||||
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
setStore("workspaceName", (prev) => ({ ...(prev ?? {}), [key]: next }))
|
||||
if (!projectId) return
|
||||
if (!branch) return
|
||||
setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next }))
|
||||
}
|
||||
|
||||
const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
|
||||
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
|
||||
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
|
||||
|
||||
@@ -543,7 +573,7 @@ export default function Layout(props: ParentProps) {
|
||||
running: number
|
||||
}
|
||||
|
||||
const prefetchChunk = 200
|
||||
const prefetchChunk = 600
|
||||
const prefetchConcurrency = 1
|
||||
const prefetchPendingLimit = 6
|
||||
const prefetchToken = { value: 0 }
|
||||
@@ -752,13 +782,6 @@ 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",
|
||||
@@ -895,10 +918,10 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const renameWorkspace = (directory: string, next: string) => {
|
||||
const current = workspaceName(directory) ?? getFilename(directory)
|
||||
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
if (current === next) return
|
||||
setStore("workspaceName", directory, next)
|
||||
setWorkspaceName(directory, next, projectId, branch)
|
||||
}
|
||||
|
||||
function closeProject(directory: string) {
|
||||
@@ -1191,11 +1214,6 @@ 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
|
||||
@@ -1299,7 +1317,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div class="size-full rounded overflow-clip">
|
||||
<Avatar
|
||||
fallback={name()}
|
||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
|
||||
{...getAvatarColors(props.project.icon?.color)}
|
||||
class="size-full rounded"
|
||||
style={
|
||||
@@ -1426,7 +1444,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
>
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
@@ -1436,7 +1454,7 @@ export default function Layout(props: ParentProps) {
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} trigger={item}>
|
||||
<HoverCard openDelay={150} closeDelay={100} placement="right-start" gutter={28} trigger={item}>
|
||||
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages…</div>}>
|
||||
<MessageNav
|
||||
messages={hoverMessages() ?? []}
|
||||
@@ -1444,10 +1462,11 @@ export default function Layout(props: ParentProps) {
|
||||
getLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive()) {
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
|
||||
navigate(`${props.slug}/session/${props.session.id}`)
|
||||
return
|
||||
}
|
||||
window.location.hash = `message-${message.id}`
|
||||
window.history.replaceState(null, "", `#message-${message.id}`)
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"))
|
||||
}}
|
||||
size="normal"
|
||||
@@ -1505,7 +1524,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const [workspaceStore] = globalSync.child(directory)
|
||||
const kind = directory === project.worktree ? "local" : "sandbox"
|
||||
const name = workspaceLabel(directory, workspaceStore.vcs?.branch)
|
||||
const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
||||
return `${kind} : ${name}`
|
||||
})
|
||||
|
||||
@@ -1522,6 +1541,7 @@ export default function Layout(props: ParentProps) {
|
||||
const sortable = createSortable(props.directory)
|
||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [pendingRename, setPendingRename] = createSignal(false)
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
const sessions = createMemo(() =>
|
||||
workspaceStore.session
|
||||
@@ -1531,8 +1551,9 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
const local = createMemo(() => props.directory === props.project.worktree)
|
||||
const workspaceValue = createMemo(() => {
|
||||
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
|
||||
return workspaceName(props.directory) ?? name
|
||||
const branch = workspaceStore.vcs?.branch
|
||||
const name = branch ?? getFilename(props.directory)
|
||||
return workspaceName(props.directory, props.project.id, branch) ?? name
|
||||
})
|
||||
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
|
||||
const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
|
||||
@@ -1551,6 +1572,44 @@ export default function Layout(props: ParentProps) {
|
||||
if (editorOpen(`workspace:${props.directory}`)) closeEditor()
|
||||
}
|
||||
|
||||
const header = () => (
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center shrink-0 size-6">
|
||||
<Icon name="branch" size="small" />
|
||||
</div>
|
||||
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
|
||||
<Show
|
||||
when={!local()}
|
||||
fallback={
|
||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineEditor
|
||||
id={`workspace:${props.directory}`}
|
||||
value={workspaceValue}
|
||||
onSave={(next) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
|
||||
setEditor("value", workspaceValue())
|
||||
}}
|
||||
class="text-14-medium text-text-base min-w-0 truncate"
|
||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||
editing={workspaceEditActive()}
|
||||
stopPropagation={false}
|
||||
openOnDblClick={false}
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={open() ? "chevron-down" : "chevron-right"}
|
||||
size="small"
|
||||
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
@@ -1558,43 +1617,16 @@ export default function Layout(props: ParentProps) {
|
||||
<div class="px-2 py-1">
|
||||
<div class="group/workspace relative">
|
||||
<div class="flex items-center gap-1">
|
||||
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center shrink-0 size-6">
|
||||
<Icon name="branch" size="small" />
|
||||
</div>
|
||||
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
|
||||
<Show
|
||||
when={!local()}
|
||||
fallback={
|
||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineEditor
|
||||
id={`workspace:${props.directory}`}
|
||||
value={workspaceValue}
|
||||
onSave={(next) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
renameWorkspace(props.directory, trimmed)
|
||||
setEditor("value", workspaceValue())
|
||||
}}
|
||||
class="text-14-medium text-text-base min-w-0 truncate"
|
||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||
editing={workspaceEditActive()}
|
||||
stopPropagation={false}
|
||||
openOnDblClick={false}
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={open() ? "chevron-down" : "chevron-right"}
|
||||
size="small"
|
||||
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show
|
||||
when={workspaceEditActive()}
|
||||
fallback={
|
||||
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||
{header()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
|
||||
</Show>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
||||
classList={{
|
||||
@@ -1609,21 +1641,37 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!pendingRename()) return
|
||||
event.preventDefault()
|
||||
setPendingRename(false)
|
||||
openEditor(`workspace:${props.directory}`, workspaceValue())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
|
||||
<DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local()}
|
||||
onSelect={() => {
|
||||
setPendingRename(true)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Rename</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local()}
|
||||
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Reset workspace</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>Reset</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local()}
|
||||
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Delete workspace</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>Delete</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
@@ -1695,7 +1743,7 @@ export default function Layout(props: ParentProps) {
|
||||
const label = (directory: string) => {
|
||||
const [data] = globalSync.child(directory)
|
||||
const kind = directory === props.project.worktree ? "local" : "sandbox"
|
||||
const name = workspaceLabel(directory, data.vcs?.branch)
|
||||
const name = workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
||||
return `${kind} : ${name}`
|
||||
}
|
||||
|
||||
@@ -1744,7 +1792,7 @@ export default function Layout(props: ParentProps) {
|
||||
trigger={trigger}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div class="-m-3 flex flex-col w-72">
|
||||
<div class="-m-3 p-2 flex flex-col w-72">
|
||||
<div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
|
||||
<div class="px-4 pb-2 text-12-medium text-text-weak">Recent sessions</div>
|
||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||
@@ -1897,7 +1945,7 @@ export default function Layout(props: ParentProps) {
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
|
||||
return (
|
||||
<div class="flex h-full w-full overflow-hidden" classList={{ "sidebar-shortcuts-open": layout.shortcuts.opened() }}>
|
||||
<div class="flex h-full w-full overflow-hidden">
|
||||
<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
|
||||
@@ -1937,35 +1985,14 @@ 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>
|
||||
<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>
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
|
||||
<IconButton
|
||||
icon="help"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2002,7 +2029,7 @@ export default function Layout(props: ParentProps) {
|
||||
transform: "translate3d(52px, 0, 0)",
|
||||
}}
|
||||
>
|
||||
<span class="text-12-regular text-text-base truncate">
|
||||
<span class="text-12-regular text-text-base truncate select-text">
|
||||
{project()?.worktree.replace(homedir(), "~")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -2176,17 +2203,14 @@ 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}
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
<Show when={layout.shortcuts.opened()}>
|
||||
<ShortcutsPanel onClose={() => layout.shortcuts.close()} />
|
||||
</Show>
|
||||
<Toast.Region />
|
||||
<ReleaseNotesHandler />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -167,6 +167,7 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const permission = usePermission()
|
||||
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
@@ -530,7 +531,7 @@ export default function Page() {
|
||||
title: "Cycle thinking effort",
|
||||
description: "Switch to the next effort level",
|
||||
category: "Model",
|
||||
keybind: "shift+mod+t",
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
@@ -823,10 +824,22 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const isWorking = createMemo(() => status().type !== "idle")
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working: isWorking,
|
||||
working: () => true,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
isWorking,
|
||||
(working, prev) => {
|
||||
if (!working || prev) return
|
||||
autoScroll.forceScrollToBottom()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
let scrollSpyFrame: number | undefined
|
||||
let scrollSpyTarget: HTMLDivElement | undefined
|
||||
|
||||
@@ -943,17 +956,30 @@ export default function Page() {
|
||||
window.history.replaceState(null, "", `#${anchor(id)}`)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const raw = sessionStorage.getItem("opencode.pendingMessage")
|
||||
if (!raw) return
|
||||
const parts = raw.split("|")
|
||||
const pendingSessionID = parts[0]
|
||||
const messageID = parts[1]
|
||||
if (!pendingSessionID || !messageID) return
|
||||
if (pendingSessionID !== sessionID) return
|
||||
|
||||
sessionStorage.removeItem("opencode.pendingMessage")
|
||||
setPendingMessage(messageID)
|
||||
})
|
||||
|
||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||
const root = scroller
|
||||
if (!root) {
|
||||
el.scrollIntoView({ behavior, block: "start" })
|
||||
return
|
||||
}
|
||||
if (!root) return false
|
||||
|
||||
const a = el.getBoundingClientRect()
|
||||
const b = root.getBoundingClientRect()
|
||||
const top = a.top - b.top + root.scrollTop
|
||||
root.scrollTo({ top, behavior })
|
||||
return true
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
@@ -967,7 +993,15 @@ export default function Page() {
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(anchor(message.id))
|
||||
if (el) scrollToElement(el, behavior)
|
||||
if (!el) {
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(anchor(message.id))
|
||||
if (!next) return
|
||||
scrollToElement(next, behavior)
|
||||
})
|
||||
return
|
||||
}
|
||||
scrollToElement(el, behavior)
|
||||
})
|
||||
|
||||
updateHash(message.id)
|
||||
@@ -975,10 +1009,57 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const el = document.getElementById(anchor(message.id))
|
||||
if (el) scrollToElement(el, behavior)
|
||||
if (!el) {
|
||||
updateHash(message.id)
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
return
|
||||
}
|
||||
if (scrollToElement(el, behavior)) {
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
updateHash(message.id)
|
||||
}
|
||||
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
autoScroll.forceScrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const match = hash.match(/^message-(.+)$/)
|
||||
if (match) {
|
||||
const msg = visibleUserMessages().find((m) => m.id === match[1])
|
||||
if (msg) {
|
||||
scrollToMessage(msg, behavior)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have a message hash but the message isn't loaded/rendered yet,
|
||||
// don't fall back to "bottom". We'll retry once messages arrive.
|
||||
return
|
||||
}
|
||||
|
||||
const target = document.getElementById(hash)
|
||||
if (target) {
|
||||
scrollToElement(target, behavior)
|
||||
return
|
||||
}
|
||||
|
||||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
|
||||
const getActiveMessageId = (container: HTMLDivElement) => {
|
||||
const cutoff = container.scrollTop + 100
|
||||
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
|
||||
@@ -1019,31 +1100,47 @@ export default function Page() {
|
||||
if (!sessionID || !ready) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
autoScroll.forceScrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const hashTarget = document.getElementById(hash)
|
||||
if (hashTarget) {
|
||||
scrollToElement(hashTarget, "auto")
|
||||
return
|
||||
}
|
||||
|
||||
const match = hash.match(/^message-(.+)$/)
|
||||
if (match) {
|
||||
const msg = visibleUserMessages().find((m) => m.id === match[1])
|
||||
if (msg) {
|
||||
scrollToMessage(msg, "auto")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
autoScroll.forceScrollToBottom()
|
||||
applyHash("auto")
|
||||
})
|
||||
})
|
||||
|
||||
// Retry message navigation once the target message is actually loaded.
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
const ready = messagesReady()
|
||||
if (!sessionID || !ready) return
|
||||
|
||||
// dependencies
|
||||
visibleUserMessages().length
|
||||
store.turnStart
|
||||
|
||||
const targetId =
|
||||
pendingMessage() ??
|
||||
(() => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
const match = hash.match(/^message-(.+)$/)
|
||||
if (!match) return undefined
|
||||
return match[1]
|
||||
})()
|
||||
if (!targetId) return
|
||||
if (store.messageId === targetId) return
|
||||
|
||||
const msg = visibleUserMessages().find((m) => m.id === targetId)
|
||||
if (!msg) return
|
||||
if (pendingMessage() === targetId) setPendingMessage(undefined)
|
||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
const ready = messagesReady()
|
||||
if (!sessionID || !ready) return
|
||||
|
||||
const handler = () => requestAnimationFrame(() => applyHash("auto"))
|
||||
window.addEventListener("hashchange", handler)
|
||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
@@ -1163,9 +1260,9 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="px-4 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
|
||||
<Mark class="w-6 opacity-40" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
|
||||
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1255,10 +1352,6 @@ export default function Page() {
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200": !showTabs(),
|
||||
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform !== "desktop",
|
||||
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform === "desktop",
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
@@ -1439,9 +1532,9 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="px-6 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
|
||||
<Mark class="w-6 opacity-40" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
|
||||
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -16,11 +16,15 @@ type PersistTarget = {
|
||||
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
const LOCAL_PREFIX = "opencode."
|
||||
const fallback = { disabled: false }
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
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.name === "QUOTA_EXCEEDED_ERR") return true
|
||||
if (error.code === 22 || error.code === 1014) return true
|
||||
return false
|
||||
}
|
||||
@@ -28,13 +32,53 @@ function quota(error: unknown) {
|
||||
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
|
||||
if (name && /quota/i.test(name)) return true
|
||||
|
||||
const code = (error as { code?: number }).code
|
||||
if (code === 22 || code === 1014) return true
|
||||
|
||||
const message = (error as { message?: string }).message
|
||||
if (typeof message !== "string") return false
|
||||
if (/quota/i.test(message)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
type Evict = { key: string; size: number }
|
||||
|
||||
function evict(storage: Storage, keep: string, value: string) {
|
||||
const total = storage.length
|
||||
const indexes = Array.from({ length: total }, (_, index) => index)
|
||||
const items: Evict[] = []
|
||||
|
||||
for (const index of indexes) {
|
||||
const name = storage.key(index)
|
||||
if (!name) continue
|
||||
if (!name.startsWith(LOCAL_PREFIX)) continue
|
||||
if (name === keep) continue
|
||||
const stored = storage.getItem(name)
|
||||
items.push({ key: name, size: stored?.length ?? 0 })
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.size - a.size)
|
||||
|
||||
for (const item of items) {
|
||||
storage.removeItem(item.key)
|
||||
|
||||
try {
|
||||
storage.setItem(keep, value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function write(storage: Storage, key: string, value: string) {
|
||||
try {
|
||||
storage.setItem(key, value)
|
||||
return
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
@@ -42,9 +86,12 @@ function write(storage: Storage, key: string, value: string) {
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
storage.setItem(key, value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
|
||||
return evict(storage, key, value)
|
||||
}
|
||||
|
||||
function snapshot(value: unknown) {
|
||||
@@ -99,17 +146,64 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`
|
||||
const item = (key: string) => base + key
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(item(key)),
|
||||
setItem: (key, value) => write(localStorage, item(key), value),
|
||||
removeItem: (key) => localStorage.removeItem(item(key)),
|
||||
getItem: (key) => {
|
||||
const name = item(key)
|
||||
const cached = cache.get(name)
|
||||
if (fallback.disabled && cached !== undefined) return cached
|
||||
|
||||
const stored = localStorage.getItem(name)
|
||||
if (stored === null) return cached ?? null
|
||||
cache.set(name, stored)
|
||||
return stored
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
const name = item(key)
|
||||
cache.set(name, value)
|
||||
if (fallback.disabled) return
|
||||
try {
|
||||
if (write(localStorage, name, value)) return
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
return
|
||||
}
|
||||
fallback.disabled = true
|
||||
},
|
||||
removeItem: (key) => {
|
||||
const name = item(key)
|
||||
cache.delete(name)
|
||||
if (fallback.disabled) return
|
||||
localStorage.removeItem(name)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function localStorageDirect(): SyncStorage {
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
setItem: (key, value) => write(localStorage, key, value),
|
||||
removeItem: (key) => localStorage.removeItem(key),
|
||||
getItem: (key) => {
|
||||
const cached = cache.get(key)
|
||||
if (fallback.disabled && cached !== undefined) return cached
|
||||
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored === null) return cached ?? null
|
||||
cache.set(key, stored)
|
||||
return stored
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
cache.set(key, value)
|
||||
if (fallback.disabled) return
|
||||
try {
|
||||
if (write(localStorage, key, value)) return
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
return
|
||||
}
|
||||
fallback.disabled = true
|
||||
},
|
||||
removeItem: (key) => {
|
||||
cache.delete(key)
|
||||
if (fallback.disabled) return
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -35,7 +35,7 @@ export const subjects = createSubjects({
|
||||
|
||||
const MY_THEME: Theme = {
|
||||
...THEME_OPENAUTH,
|
||||
logo: "https://opencode.ai/favicon.svg",
|
||||
logo: "https://opencode.ai/favicon-v2.svg",
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
|
||||
<link rel="shortcut icon" href="/favicon-v2.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#F8F7F7" />
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"url": "/",
|
||||
"decorations": true,
|
||||
"dragDropEnabled": false,
|
||||
"zoomHotkeysEnabled": true,
|
||||
"zoomHotkeysEnabled": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @refresh reload
|
||||
import "./webview-zoom"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
|
||||
import { open, save } from "@tauri-apps/plugin-dialog"
|
||||
@@ -94,6 +95,21 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
|
||||
const memoryCache = new Map<string, StoreLike>()
|
||||
|
||||
const flushAll = async () => {
|
||||
const apis = Array.from(apiCache.values())
|
||||
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
|
||||
}
|
||||
|
||||
if ("addEventListener" in globalThis) {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
void flushAll()
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", () => void flushAll())
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
|
||||
const createMemoryStore = () => {
|
||||
const data = new Map<string, string>()
|
||||
const store: StoreLike = {
|
||||
@@ -253,7 +269,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
.then(() => {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96.png",
|
||||
icon: "https://opencode.ai/favicon-96x96-v2.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
const win = getCurrentWindow()
|
||||
@@ -303,11 +319,6 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
|
||||
createMenu()
|
||||
|
||||
// Stops mousewheel events from reaching Tauri's pinch-to-zoom handler
|
||||
root?.addEventListener("mousewheel", (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
|
||||
render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
|
||||
const OS_NAME = ostype()
|
||||
|
||||
let zoomLevel = 1
|
||||
|
||||
const MAX_ZOOM_LEVEL = 10
|
||||
const MIN_ZOOM_LEVEL = 0.2
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) {
|
||||
if (event.key === "-") {
|
||||
zoomLevel -= 0.2
|
||||
} else if (event.key === "=" || event.key === "+") {
|
||||
zoomLevel += 0.2
|
||||
} else if (event.key === "0") {
|
||||
zoomLevel = 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
invoke("plugin:webview|set_webview_zoom", {
|
||||
value: zoomLevel,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -7,7 +7,7 @@
|
||||
"light": "#07C983",
|
||||
"dark": "#15803D"
|
||||
},
|
||||
"favicon": "/favicon.svg",
|
||||
"favicon": "/favicon-v2.svg",
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
|
||||
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
|
||||
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#18E299"/>
|
||||
<stop offset="1" stop-color="#15803D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#16A34A"/>
|
||||
<stop offset="1" stop-color="#4ADE80"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4ADE80"/>
|
||||
<stop offset="1" stop-color="#0D9373"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.25"
|
||||
version = "1.1.27"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.27/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -134,7 +134,7 @@ const AgentCreateCommand = cmd({
|
||||
selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select tools to enable",
|
||||
message: "Select tools to enable (Space to toggle)",
|
||||
options: AVAILABLE_TOOLS.map((tool) => ({
|
||||
label: tool,
|
||||
value: tool,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export function Autocomplete(props: {
|
||||
index: 0,
|
||||
selected: 0,
|
||||
visible: false as AutocompleteRef["visible"],
|
||||
input: "keyboard" as "keyboard" | "mouse",
|
||||
})
|
||||
|
||||
const [positionTick, setPositionTick] = createSignal(0)
|
||||
@@ -128,6 +129,14 @@ export function Autocomplete(props: {
|
||||
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
|
||||
})
|
||||
|
||||
// When the filter changes due to how TUI works, the mousemove might still be triggered
|
||||
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
|
||||
// that the mouseover event doesn't trigger when filtering.
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("input", "keyboard")
|
||||
})
|
||||
|
||||
function insertPart(text: string, part: PromptInfo["parts"][number]) {
|
||||
const input = props.input()
|
||||
const currentCursorOffset = input.cursorOffset
|
||||
@@ -525,11 +534,13 @@ export function Autocomplete(props: {
|
||||
const isNavDown = name === "down" || (ctrlOnly && name === "n")
|
||||
|
||||
if (isNavUp) {
|
||||
setStore("input", "keyboard")
|
||||
move(-1)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isNavDown) {
|
||||
setStore("input", "keyboard")
|
||||
move(1)
|
||||
e.preventDefault()
|
||||
return
|
||||
@@ -612,7 +623,17 @@ export function Autocomplete(props: {
|
||||
paddingRight={1}
|
||||
backgroundColor={index === store.selected ? theme.primary : undefined}
|
||||
flexDirection="row"
|
||||
onMouseOver={() => moveTo(index)}
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (store.input !== "mouse") return
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
setStore("input", "mouse")
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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.",
|
||||
]
|
||||
@@ -106,7 +106,7 @@ const TIPS = [
|
||||
"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 --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",
|
||||
|
||||
@@ -241,9 +241,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
if (draft.length > 100) draft.shift()
|
||||
}),
|
||||
)
|
||||
const updated = store.message[event.properties.info.sessionID]
|
||||
if (updated.length > 100) {
|
||||
const oldest = updated[0]
|
||||
batch(() => {
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.shift()
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"part",
|
||||
produce((draft) => {
|
||||
delete draft[oldest.id]
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "message.removed": {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
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"
|
||||
@@ -43,22 +42,6 @@ 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",
|
||||
@@ -66,20 +49,12 @@ export function Home() {
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
toggleTips()
|
||||
kv.set("tips_hidden", !tipsHidden())
|
||||
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}>
|
||||
@@ -114,6 +89,8 @@ export function Home() {
|
||||
})
|
||||
const directory = useDirectory()
|
||||
|
||||
const keybind = useKeybind()
|
||||
|
||||
return (
|
||||
<>
|
||||
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
||||
@@ -135,11 +112,6 @@ 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}>
|
||||
|
||||
@@ -52,6 +52,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const [store, setStore] = createStore({
|
||||
selected: 0,
|
||||
filter: "",
|
||||
input: "keyboard" as "keyboard" | "mouse",
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -83,6 +84,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return result
|
||||
})
|
||||
|
||||
// When the filter changes due to how TUI works, the mousemove might still be triggered
|
||||
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard
|
||||
// that the mouseover event doesn't trigger when filtering.
|
||||
createEffect(() => {
|
||||
filtered()
|
||||
setStore("input", "keyboard")
|
||||
})
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
filtered(),
|
||||
@@ -157,12 +166,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
|
||||
const keybind = useKeybind()
|
||||
useKeyboard((evt) => {
|
||||
setStore("input", "keyboard")
|
||||
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
||||
if (evt.name === "pageup") move(-10)
|
||||
if (evt.name === "pagedown") move(10)
|
||||
if (evt.name === "home") moveTo(0)
|
||||
if (evt.name === "end") moveTo(flat().length - 1)
|
||||
|
||||
if (evt.name === "return") {
|
||||
const option = selected()
|
||||
if (option) {
|
||||
@@ -259,11 +271,20 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
option.onSelect?.(dialog)
|
||||
props.onSelect?.(option)
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (store.input !== "mouse") return
|
||||
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
@@ -337,6 +358,7 @@ function Option(props: {
|
||||
fg={props.active ? fg : props.current ? theme.primary : theme.text}
|
||||
attributes={props.active ? TextAttributes.BOLD : undefined}
|
||||
overflow="hidden"
|
||||
wrapMode="none"
|
||||
paddingLeft={3}
|
||||
>
|
||||
{Locale.truncate(props.title, 61)}
|
||||
|
||||
@@ -162,34 +162,32 @@ export namespace Ripgrep {
|
||||
})
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
if (config.extension === "zip") {
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
|
||||
const entries = await zipFileReader.getEntries()
|
||||
let rgEntry: any
|
||||
for (const entry of entries) {
|
||||
if (entry.filename.endsWith("rg.exe")) {
|
||||
rgEntry = entry
|
||||
break
|
||||
}
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
|
||||
const entries = await zipFileReader.getEntries()
|
||||
let rgEntry: any
|
||||
for (const entry of entries) {
|
||||
if (entry.filename.endsWith("rg.exe")) {
|
||||
rgEntry = entry
|
||||
break
|
||||
}
|
||||
|
||||
if (!rgEntry) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "rg.exe not found in zip archive",
|
||||
})
|
||||
}
|
||||
|
||||
const rgBlob = await rgEntry.getData(new BlobWriter())
|
||||
if (!rgBlob) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "Failed to extract rg.exe from zip archive",
|
||||
})
|
||||
}
|
||||
await Bun.write(filepath, await rgBlob.arrayBuffer())
|
||||
await zipFileReader.close()
|
||||
}
|
||||
|
||||
if (!rgEntry) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "rg.exe not found in zip archive",
|
||||
})
|
||||
}
|
||||
|
||||
const rgBlob = await rgEntry.getData(new BlobWriter())
|
||||
if (!rgBlob) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "Failed to extract rg.exe from zip archive",
|
||||
})
|
||||
}
|
||||
await Bun.write(filepath, await rgBlob.arrayBuffer())
|
||||
await zipFileReader.close()
|
||||
}
|
||||
await fs.unlink(archivePath)
|
||||
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
|
||||
|
||||
@@ -25,6 +25,7 @@ export namespace Project {
|
||||
icon: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
override: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
@@ -190,6 +191,7 @@ export namespace Project {
|
||||
if (!existing.sandboxes) existing.sandboxes = []
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
|
||||
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree,
|
||||
@@ -213,6 +215,7 @@ export namespace Project {
|
||||
|
||||
export async function discover(input: Info) {
|
||||
if (input.vcs !== "git") return
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
|
||||
const matches = await Array.fromAsync(
|
||||
@@ -293,6 +296,7 @@ export namespace Project {
|
||||
...draft.icon,
|
||||
}
|
||||
if (input.icon.url !== undefined) draft.icon.url = input.icon.url
|
||||
if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
|
||||
if (input.icon.color !== undefined) draft.icon.color = input.icon.color
|
||||
}
|
||||
draft.time.updated = Date.now()
|
||||
|
||||
@@ -44,6 +44,7 @@ import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Truncate } from "@/tool/truncation"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -801,10 +802,17 @@ export namespace SessionPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent)
|
||||
const metadata = {
|
||||
...(result.metadata ?? {}),
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
}
|
||||
|
||||
return {
|
||||
title: "",
|
||||
metadata: result.metadata ?? {},
|
||||
output: textParts.join("\n\n"),
|
||||
metadata,
|
||||
output: truncated.content,
|
||||
attachments,
|
||||
content: result.content, // directly return content to preserve ordering when outputting to model
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
You are OpenCode, the best coding agent on the planet.
|
||||
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
- Use Read to view files, Edit to modify files, and Write only when needed.
|
||||
- Use Glob to find files by name and Grep to search file contents.
|
||||
- Use Bash for terminal operations (git, bun, builds, tests, running scripts).
|
||||
- Run tool calls in parallel when neither call needs the other’s output; otherwise run sequentially.
|
||||
|
||||
## Git and workspace hygiene
|
||||
- You may be in a dirty git worktree.
|
||||
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
||||
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
|
||||
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
|
||||
* If the changes are in unrelated files, just ignore them and don't revert them.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
|
||||
|
||||
## Frontend tasks
|
||||
When doing frontend design tasks, avoid collapsing into bland, generic layouts.
|
||||
Aim for interfaces that feel intentional and deliberate.
|
||||
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
|
||||
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
|
||||
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
|
||||
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
|
||||
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
|
||||
- Ensure the page loads properly on both desktop and mobile.
|
||||
|
||||
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
|
||||
|
||||
## Presenting your work and final message
|
||||
|
||||
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
||||
|
||||
- Default: be very concise; friendly coding teammate tone.
|
||||
- Ask only when needed; suggest ideas; mirror the user's style.
|
||||
- For substantial work, summarize clearly; follow final‑answer formatting.
|
||||
- Skip heavy formatting for simple confirmations.
|
||||
- Don't dump large files you've written; reference paths only.
|
||||
- No "save/copy this file" - User is on the same machine.
|
||||
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
|
||||
- For code changes:
|
||||
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
|
||||
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
|
||||
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
|
||||
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
|
||||
## Final answer structure and style guidelines
|
||||
|
||||
- Plain text; CLI handles styling. Use structure only when it helps scanability.
|
||||
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
|
||||
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
|
||||
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
|
||||
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
|
||||
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
|
||||
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
|
||||
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
|
||||
- File References: When referencing files in your response follow the below rules:
|
||||
* Use inline code to make file paths clickable.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
||||
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
|
||||
@@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
|
||||
@@ -13,8 +13,7 @@ import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
|
||||
import PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
@@ -25,7 +24,7 @@ export namespace SystemPrompt {
|
||||
}
|
||||
|
||||
export function instructions() {
|
||||
return PROMPT_CODEX_INSTRUCTIONS.trim()
|
||||
return PROMPT_CODEX.trim()
|
||||
}
|
||||
|
||||
export function provider(model: Provider.Model) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -96,8 +95,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
|
||||
}
|
||||
|
||||
// Read file and update time tracking (like edit tool does)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||
let newContent = oldContent
|
||||
|
||||
@@ -202,12 +199,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
}
|
||||
|
||||
// Update file time tracking
|
||||
FileTime.read(ctx.sessionID, change.filePath)
|
||||
if (change.movePath) {
|
||||
FileTime.read(ctx.sessionID, change.movePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
|
||||
@@ -33,8 +33,8 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
const { Session } = await import("../session")
|
||||
const { Identifier } = await import("../id/id")
|
||||
|
||||
const toolCalls = params.tool_calls.slice(0, 10)
|
||||
const discardedCalls = params.tool_calls.slice(10)
|
||||
const toolCalls = params.tool_calls.slice(0, 25)
|
||||
const discardedCalls = params.tool_calls.slice(25)
|
||||
|
||||
const { ToolRegistry } = await import("./registry")
|
||||
const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
|
||||
@@ -139,14 +139,14 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
state: {
|
||||
status: "error",
|
||||
input: call.parameters,
|
||||
error: "Maximum of 10 tools allowed in batch",
|
||||
error: "Maximum of 25 tools allowed in batch",
|
||||
time: { start: now, end: now },
|
||||
},
|
||||
})
|
||||
results.push({
|
||||
success: false as const,
|
||||
tool: call.tool,
|
||||
error: new Error("Maximum of 10 tools allowed in batch"),
|
||||
error: new Error("Maximum of 25 tools allowed in batch"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Payload Format (JSON array):
|
||||
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
|
||||
|
||||
Notes:
|
||||
- 1–10 tool calls per batch
|
||||
- 1–25 tool calls per batch
|
||||
- All calls start in parallel; ordering NOT guaranteed
|
||||
- Partial failures do not stop other tool calls
|
||||
- Do NOT use the batch tool within another batch tool.
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const baseCtx = {
|
||||
@@ -71,8 +70,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
const deletePath = path.join(fixture.path, "delete.txt")
|
||||
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
|
||||
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, modifyPath)
|
||||
FileTime.read(ctx.sessionID, deletePath)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -101,7 +98,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi.txt")
|
||||
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
|
||||
@@ -122,7 +118,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "insert_only.txt")
|
||||
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
|
||||
|
||||
@@ -142,7 +137,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "no_newline.txt")
|
||||
await fs.writeFile(target, "no newline at end", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
|
||||
@@ -166,7 +160,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.writeFile(original, "old content\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, original)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
|
||||
@@ -193,7 +186,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(original, "from\n", "utf-8")
|
||||
await fs.writeFile(destination, "existing\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, original)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
|
||||
@@ -294,7 +286,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "modify.txt")
|
||||
await fs.writeFile(target, "line1\nline2\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
|
||||
|
||||
@@ -331,7 +322,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "tail.txt")
|
||||
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
|
||||
|
||||
@@ -350,7 +340,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "two_chunks.txt")
|
||||
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
|
||||
|
||||
@@ -369,7 +358,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi_ctx.txt")
|
||||
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
|
||||
|
||||
@@ -389,7 +377,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
const target = path.join(fixture.path, "eof_anchor.txt")
|
||||
// File has duplicate "marker" lines - one in middle, one at end
|
||||
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// With EOF anchor, should match the LAST "marker" line, not the first
|
||||
const patchText =
|
||||
@@ -454,7 +441,6 @@ EOF`
|
||||
const target = path.join(fixture.path, "trailing_ws.txt")
|
||||
// File has trailing spaces on some lines
|
||||
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch doesn't have trailing spaces - should still match via rstrip pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -475,7 +461,6 @@ EOF`
|
||||
const target = path.join(fixture.path, "leading_ws.txt")
|
||||
// File has leading spaces
|
||||
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch without leading spaces - should match via trim pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -499,7 +484,6 @@ EOF`
|
||||
const rightQuote = "\u201D"
|
||||
const emDash = "\u2014"
|
||||
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch uses ASCII equivalents - should match via normalized pass
|
||||
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -25,4 +25,4 @@
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -30,4 +30,4 @@
|
||||
"publishConfig": {
|
||||
"directory": "dist"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,7 @@ export class Project extends HeyApiClient {
|
||||
name?: string
|
||||
icon?: {
|
||||
url?: string
|
||||
override?: string
|
||||
color?: string
|
||||
}
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ export type Project = {
|
||||
name?: string
|
||||
icon?: {
|
||||
url?: string
|
||||
override?: string
|
||||
color?: string
|
||||
}
|
||||
time: {
|
||||
@@ -2229,6 +2230,7 @@ export type ProjectUpdateData = {
|
||||
name?: string
|
||||
icon?: {
|
||||
url?: string
|
||||
override?: string
|
||||
color?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"override": {
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -5796,6 +5799,9 @@
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"override": {
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 536 B |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" fill="#131010"></rect>
|
||||
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
|
||||
@media (prefers-color-scheme: dark) { :root { filter: none; } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 612 B |
@@ -3,9 +3,9 @@ import { Link, Meta } from "@solidjs/meta"
|
||||
export const Favicon = () => {
|
||||
return (
|
||||
<>
|
||||
<Link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<Link rel="shortcut icon" href="/favicon.ico" />
|
||||
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<Link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
|
||||
<Link rel="shortcut icon" href="/favicon-v2.ico" />
|
||||
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
|
||||
<Link rel="manifest" href="/site.webmanifest" />
|
||||
<Meta name="apple-mobile-web-app-title" content="OpenCode" />
|
||||
</>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
z-index: 50;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: 8px;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
|
||||
[data-slot="hover-card-body"] {
|
||||
padding: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ 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,24 +74,6 @@
|
||||
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"] {
|
||||
|
||||
@@ -58,6 +58,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
|
||||
const searchProps = () => (typeof props.search === "object" ? props.search : {})
|
||||
|
||||
const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
|
||||
|
||||
createEffect(() => {
|
||||
if (props.filter !== undefined) {
|
||||
onInput(props.filter)
|
||||
@@ -227,7 +229,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
data-selected={item === props.current}
|
||||
onClick={() => handleSelect(item, i())}
|
||||
type="button"
|
||||
onMouseMove={() => {
|
||||
onMouseMove={(event) => {
|
||||
if (!moved(event)) return
|
||||
setStore("mouseActive", true)
|
||||
setActive(props.key(item))
|
||||
}}
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
[data-slot="message-nav-title-preview"] {
|
||||
font-size: 14px; /* text-14-regular */
|
||||
color: var(--text-weak);
|
||||
color: var(--text-base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -113,11 +113,22 @@
|
||||
|
||||
[data-component="reasoning-part"] {
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
color: var(--text-base);
|
||||
opacity: 0.8;
|
||||
line-height: var(--line-height-large);
|
||||
|
||||
[data-component="markdown"] {
|
||||
margin-top: 24px;
|
||||
font-style: italic !important;
|
||||
|
||||
p:has(strong) {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -357,6 +357,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const toggleExpanded = () => {
|
||||
if (!canExpand()) return
|
||||
setExpanded((value) => !value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
|
||||
<Show when={attachments().length > 0}>
|
||||
@@ -388,19 +393,29 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={text()}>
|
||||
<div data-slot="user-message-text" ref={(el) => (textRef = el)}>
|
||||
<div data-slot="user-message-text" ref={(el) => (textRef = el)} onClick={toggleExpanded}>
|
||||
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
|
||||
<button
|
||||
data-slot="user-message-expand"
|
||||
type="button"
|
||||
aria-label={expanded() ? "Collapse message" : "Expand message"}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggleExpanded()
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
<div data-slot="user-message-copy-wrapper">
|
||||
<Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
|
||||
<IconButton icon={copied() ? "check" : "copy"} variant="secondary" onClick={handleCopy} />
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: var(--color-border-strong-base);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
|
||||
@@ -15,7 +15,6 @@ 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) {
|
||||
@@ -30,7 +29,6 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
"groupBy",
|
||||
"onSelect",
|
||||
"children",
|
||||
"placement",
|
||||
])
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
@@ -48,7 +46,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
<Kobalte<T, { category: string; options: T[] }>
|
||||
{...others}
|
||||
data-component="select"
|
||||
placement={local.placement ?? "bottom-start"}
|
||||
placement="bottom-start"
|
||||
value={local.current}
|
||||
options={grouped()}
|
||||
optionValue={(x) => (local.value ? local.value(x) : (x as string))}
|
||||
|
||||
@@ -75,6 +75,17 @@
|
||||
background-color: var(--background-stronger);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 32px;
|
||||
background: linear-gradient(to bottom, var(--background-stronger), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-turn-response-trigger"] {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type PermissionRequest,
|
||||
TextPart,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
@@ -21,8 +20,6 @@ import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { Card } from "./card"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Button } from "./button"
|
||||
@@ -352,7 +349,6 @@ export function SessionTurn(
|
||||
const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0)
|
||||
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
|
||||
|
||||
const [responseCopied, setResponseCopied] = createSignal(false)
|
||||
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
|
||||
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
|
||||
|
||||
@@ -362,13 +358,6 @@ export function SessionTurn(
|
||||
const next = Math.ceil(height)
|
||||
root.style.setProperty("--session-turn-sticky-height", `${next}px`)
|
||||
}
|
||||
const handleCopyResponse = async () => {
|
||||
const content = response()
|
||||
if (!content) return
|
||||
await navigator.clipboard.writeText(content)
|
||||
setResponseCopied(true)
|
||||
setTimeout(() => setResponseCopied(false), 2000)
|
||||
}
|
||||
|
||||
function duration() {
|
||||
const msg = message()
|
||||
@@ -589,15 +578,6 @@ export function SessionTurn(
|
||||
{/* Response */}
|
||||
<Show when={!working() && (response() || hasDiffs())}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-copy">
|
||||
<Tooltip value={responseCopied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
|
||||
<IconButton
|
||||
icon={responseCopied() ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
onClick={handleCopyResponse}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<h2 data-slot="session-turn-summary-title">Response</h2>
|
||||
<Markdown
|
||||
|
||||
@@ -73,13 +73,6 @@
|
||||
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;
|
||||
@@ -371,10 +364,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-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-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-success-base: var(--apple-light-3);
|
||||
--surface-success-weak: var(--apple-light-2);
|
||||
--surface-success-strong: var(--apple-light-9);
|
||||
@@ -461,12 +454,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-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-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-success-base: var(--apple-light-6);
|
||||
--border-success-hover: var(--apple-light-7);
|
||||
--border-success-selected: var(--apple-light-9);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -32,6 +32,34 @@ export default defineConfig({
|
||||
solidJs(),
|
||||
starlight({
|
||||
title: "OpenCode",
|
||||
favicon: "/favicon-v2.svg",
|
||||
head: [
|
||||
{
|
||||
tag: "link",
|
||||
attrs: {
|
||||
rel: "icon",
|
||||
href: "/favicon-v2.ico",
|
||||
sizes: "32x32",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "link",
|
||||
attrs: {
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
href: "/favicon-96x96-v2.png",
|
||||
sizes: "96x96",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "link",
|
||||
attrs: {
|
||||
rel: "apple-touch-icon",
|
||||
href: "/apple-touch-icon-v2.png",
|
||||
sizes: "180x180",
|
||||
},
|
||||
},
|
||||
],
|
||||
lastUpdated: true,
|
||||
expressiveCode: { themes: ["github-light", "github-dark"] },
|
||||
social: [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/apple-touch-icon-v2.png
|
||||
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-96x96-v2.png
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-v2.ico
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-v2.svg
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.27",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user