Compare commits

..

33 Commits

Author SHA1 Message Date
opencode 91d01fd4cc release: v1.0.5 2025-10-31 23:51:36 +00:00
Dax Raad 9beb0f8512 tui: improve keyboard navigation and MCP server status display 2025-10-31 19:47:08 -04:00
Dax Raad d4cb47eadc tui: add keyboard shortcuts to cycle through recently used models
Users can now press F2 to cycle forward and Shift+F2 to cycle backward through their recently used models, making it faster to switch between commonly used AI models without opening the model selection dialog.
2025-10-31 19:42:41 -04:00
Dax Raad 261ff416a9 sync 2025-10-31 23:05:11 +00:00
opencode d0a70cb217 release: v1.0.4 2025-10-31 23:05:10 +00:00
Aiden Cline 20fc56d020 Revert "opentui: fix: Make worker.ts path independent from cwd (#3600)"
This reverts commit d473d4ffc8.
2025-10-31 17:57:56 -05:00
opencode a57ae3ec93 release: v1.0.3 2025-10-31 22:52:57 +00:00
Dax Raad 30f9fa12d9 tui: add session rename functionality with /rename command
- Add /rename command to autocomplete when a session is active
- Add rename dialog component for changing session names
- Add rename option to session list dialog with 'r' keybind
- Add session rename command to command registry
2025-10-31 18:44:33 -04:00
Haris Gušić d473d4ffc8 opentui: fix: Make worker.ts path independent from cwd (#3600) 2025-10-31 17:37:31 -05:00
Haris Gušić af50596529 fix: grep failing when pattern started with a dash 2025-10-31 17:20:22 -05:00
Dax Raad 3823d8d50e tui: simplify theme selection API by renaming setSelectedTheme to set 2025-10-31 18:11:36 -04:00
Dax Raad 7a926b32ce respect theme in config 2025-10-31 18:04:38 -04:00
Haris Gušić a5ede68241 fix: Remove conflicting "-h" aliases in TUI spawn and thread commands (#3651) 2025-10-31 16:59:59 -05:00
Aiden Cline 60dc38050d fix: unsupported option 2025-10-31 16:53:08 -05:00
Dax Raad 31d0caee38 tui: add /editor command to autocomplete for opening external editor 2025-10-31 17:47:08 -04:00
Dax Raad 2a7ab45605 add /theme 2025-10-31 17:44:41 -04:00
Aiden Cline 019054dd1e Revert "fix: ensure flags & docs match (#3638)"
This reverts commit a018a15f32.
2025-10-31 16:43:29 -05:00
opencode-agent[bot] a018a15f32 fix: ensure flags & docs match (#3638)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-10-31 16:33:46 -05:00
Nathan Thomas e630d680dd feat: allow ctrl+d to exit the app (#3636) 2025-10-31 16:27:41 -05:00
Haris Gušić 9e392f25a6 feat: Improve error boundary add button to easily create issue in github (#3639) 2025-10-31 16:20:32 -05:00
Aiden Cline 2cc4e6ad7c ci: change model 2025-10-31 16:06:22 -05:00
Adam 70d8d1ab1e wip: desktop work 2025-10-31 15:57:21 -05:00
Adam 342aa27e03 wip: desktop work 2025-10-31 15:37:50 -05:00
Adam e1aed0cd01 wip: desktop work 2025-10-31 15:37:50 -05:00
opencode c8ea2c5ce0 release: v1.0.2 2025-10-31 20:33:50 +00:00
Dax Raad 5e8309a353 tui: update hello command with test content 2025-10-31 16:21:30 -04:00
Dax Raad aae0ce9921 tui: improve autocomplete component styling and update test command 2025-10-31 16:21:30 -04:00
Dax Raad 81b94d84dc ignore 2025-10-31 20:17:40 +00:00
opencode ceab70f8d9 release: v1.0.1 2025-10-31 20:17:39 +00:00
Dax Raad afe8cecc2b tui: add persistent key-value storage for user preferences
- Add KVProvider context for storing user preferences like theme and warnings
- Update theme context to use KV storage instead of sync config
- Move openrouter warning to persistent KV storage
- Refactor theme selection to persist user choice across sessions
2025-10-31 16:13:02 -04:00
Aiden Cline 4a292bf977 ci: auto assign 2025-10-31 14:58:18 -05:00
Aiden Cline e249b41513 ci: autolabel action 2025-10-31 14:55:33 -05:00
Dax Raad 9021dd60a1 tui: add /session command to list available sessions 2025-10-31 15:41:36 -04:00
50 changed files with 998 additions and 257 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Auto-label TUI Issues
on:
issues:
types: [opened]
jobs:
auto-label:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Add opentui label
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const issue = context.payload.issue;
const title = issue.title;
const description = issue.body || '';
// Check for version patterns like v1.0.x or 1.0.x
const versionPattern = /\b[v]?1\.0\.[x\d]\b/i;
if (versionPattern.test(title) || versionPattern.test(description)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['opentui']
});
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['thdxr']
});
}
+1 -1
View File
@@ -26,4 +26,4 @@ jobs:
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/kimi-k2
model: opencode/glm-4.6
+1 -1
View File
@@ -1,5 +1,5 @@
---
description: hello world
description: hello world iaosd ioasjdoiasjd oisadjoisajd osiajd oisaj dosaij dsoajsajdaijdoisa jdoias jdoias jdoia jois jo jdois jdoias jdoias j djoasdj
---
hey there $ARGUMENTS
+11 -11
View File
@@ -39,7 +39,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -66,7 +66,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -90,7 +90,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -111,7 +111,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -150,7 +150,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -166,7 +166,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.0",
"version": "1.0.5",
"bin": {
"opencode": "./bin/opencode",
},
@@ -243,7 +243,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -263,7 +263,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.0",
"version": "1.0.5",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -274,7 +274,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -287,7 +287,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -317,7 +317,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.0"
"version": "1.0.5"
},
"dependencies": {
"@ibm/plex": "6.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.0",
"version": "1.0.5",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.0",
"version": "1.0.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.0",
"version": "1.0.5",
"description": "",
"type": "module",
"scripts": {
@@ -1,34 +1,64 @@
import { For, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Part } from "@opencode-ai/ui"
import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { Markdown, Part } from "@opencode-ai/ui"
import { useSync } from "@/context/sync"
import type { AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
import type { AssistantMessage as AssistantMessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk"
import { Spinner } from "./spinner"
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[] }) {
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
const sync = useSync()
const items = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
const parts = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
const done = createMemo(() => props.done ?? false)
const currentTask = createMemo(
() =>
parts().findLast(
(p) =>
p &&
p.type === "tool" &&
p.tool === "task" &&
p.state &&
"metadata" in p.state &&
p.state.metadata &&
p.state.metadata.sessionId &&
p.state.status === "running",
) as ToolPart,
)
const finishedItems = createMemo(() => [
"",
"",
"Loading...",
...items().filter(
(p) =>
p?.type === "text" ||
(p?.type === "reasoning" && p.time?.end) ||
(p?.type === "tool" && p.state.status === "completed"),
),
"",
const resolvedParts = createMemo(() => {
let resolved = parts()
const task = currentTask()
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
const messages = sync.data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
resolved = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
}
return resolved
})
const currentText = createMemo(
() =>
resolvedParts().findLast((p) => p?.type === "text")?.text ||
resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
)
const eligibleItems = createMemo(() => {
return resolvedParts().filter((p) => p?.type === "tool" && p.state.status === "completed")
})
const finishedItems = createMemo<(JSXElement | PartType)[]>(() => [
<div class="h-8 w-full" />,
<div class="h-8 w-full" />,
<div class="flex items-center gap-x-5 pl-3 text-text-base">
<Spinner /> <span class="text-12-medium">Thinking...</span>
</div>,
...eligibleItems(),
...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
])
const MINIMUM_DELAY = 400
const [visibleCount, setVisibleCount] = createSignal(1)
const delay = createMemo(() => (done() ? 220 : 400))
const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
createEffect(() => {
const total = finishedItems().length
if (total > visibleCount()) {
const timer = setTimeout(() => {
setVisibleCount((prev) => prev + 1)
}, MINIMUM_DELAY)
}, delay())
onCleanup(() => clearTimeout(timer))
} else if (total < visibleCount()) {
setVisibleCount(total)
@@ -42,41 +72,57 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
})
return (
<div
class="h-30 overflow-hidden pointer-events-none
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
mask-b-from-90% mask-b-from-background-base mask-b-to-transparent"
>
<div class="flex flex-col gap-3">
<div
class="w-full flex flex-col items-start self-stretch gap-2 py-8
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
class="h-30 overflow-hidden pointer-events-none pb-1
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
>
<For each={finishedItems()}>
{(part) => {
if (typeof part === "string") return <div class="h-8 flex items-center w-full">{part}</div>
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
return (
<div class="h-8 flex items-center w-full">
<Switch>
<Match when={part.type === "text" && part}>
{(p) => (
<div
textContent={p().text}
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
/>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
<Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match>
</Switch>
</div>
)
}}
</For>
<div
class="w-full flex flex-col items-start self-stretch gap-2 py-8
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
>
<For each={finishedItems()}>
{(part) => {
if (part && typeof part === "object" && "type" in part) {
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
return (
<div class="h-8 flex items-center w-full">
<Switch>
<Match when={part.type === "text" && part}>
{(p) => (
<div
textContent={p().text}
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
/>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
<Match when={part.type === "tool" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
</Switch>
</div>
)
}
return <div class="h-8 flex items-center w-full">{part}</div>
}}
</For>
</div>
</div>
<Show when={currentText()}>
{(text) => (
<div
class="max-h-36 flex flex-col justify-end overflow-hidden py-3
mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent"
>
<Markdown text={text()} class="w-full shrink-0 overflow-visible" />
</div>
)}
</Show>
</div>
)
}
@@ -334,7 +334,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit={handleSubmit}
classList={{
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
"rounded-2xl overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
[props.class ?? ""]: !!props.class,
}}
>
+22
View File
@@ -162,10 +162,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
const cycle = (direction: 1 | -1) => {
const recentList = recent()
const current = currentModel()
if (!current) return
const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id)
if (index === -1) return
let next = index + direction
if (next < 0) next = recentList.length - 1
if (next >= recentList.length) next = 0
const val = recentList[next]
if (!val) return
model.set({
providerID: val.provider.id,
modelID: val.id,
})
}
return {
current: currentModel,
recent,
list,
cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("model", agent.current().name, model ?? fallbackModel())
+114 -16
View File
@@ -9,7 +9,6 @@ import {
Accordion,
Diff,
Collapsible,
Part,
DiffChanges,
ProgressCircle,
Message,
@@ -548,7 +547,79 @@ export default function Page() {
<For each={local.session.userMessages()}>
{(message) => {
const diffs = createMemo(() => message.summary?.diffs ?? [])
const working = createMemo(() => !message.summary?.title)
const working = createMemo(() => !message.summary?.body)
const assistantMessages = createMemo(() => {
return sync.data.message[activeSession().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const parts = createMemo(() =>
assistantMessages().flatMap((m) => sync.data.part[m.id]),
)
const lastPart = createMemo(() => parts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const defaultStatus = "Working..."
const last = lastPart()
if (!last) return defaultStatus
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work..."
case "todowrite":
case "todoread":
return "Planning next steps..."
case "read":
return "Gathering context..."
case "list":
case "grep":
case "glob":
return "Searching the codebase..."
case "webfetch":
return "Searching the web..."
case "edit":
case "write":
return "Making edits..."
case "bash":
return "Running commands..."
default:
break
}
} else if (last.type === "reasoning") {
return "Thinking..."
} else if (last.type === "text") {
return "Gathering thoughts..."
}
return defaultStatus
})
const [status, setStatus] = createSignal(rawStatus())
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === status()) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 1000) {
setStatus(newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStatus(rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 1000 - timeSinceLastChange) as unknown as number
}
})
return (
<li class="group/li flex items-center self-stretch">
<button
@@ -570,7 +641,10 @@ export default function Page() {
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
{message.summary?.title ?? local.session.getMessageText(message)}
<Switch>
<Match when={working()}>{status()}</Match>
<Match when={true}>{message.summary?.title}</Match>
</Switch>
</div>
</button>
</li>
@@ -583,7 +657,8 @@ export default function Page() {
<For each={local.session.userMessages()}>
{(message) => {
const isActive = createMemo(() => local.session.activeMessage()?.id === message.id)
const [initialized, setInitialized] = createSignal(!!message.summary?.title)
const [titled, setTitled] = createSignal(!!message.summary?.title)
const [completed, setCompleted] = createSignal(!!message.summary?.body)
const [expanded, setExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const title = createMemo(() => message.summary?.title)
@@ -597,11 +672,18 @@ export default function Page() {
const hasToolPart = createMemo(() =>
assistantMessages()
?.flatMap((m) => sync.data.part[m.id])
.some((p) => p.type === "tool"),
.some((p) => p?.type === "tool"),
)
const working = createMemo(() => !summary())
// allowing time for the animations to finish
createEffect(() => {
setTimeout(() => setInitialized(!!title()), 10_000)
title()
setTimeout(() => setTitled(!!title()), 10_000)
})
createEffect(() => {
summary()
setTimeout(() => setCompleted(!!summary()), 1200)
})
return (
@@ -612,9 +694,18 @@ export default function Page() {
>
{/* Title */}
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
<div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
<Show when={initialized()} fallback={<Typewriter as="h1" text={title()} />}>
<h1>{title()}</h1>
<div class="w-full text-14-medium text-text-strong">
<Show
when={titled()}
fallback={
<Typewriter
as="h1"
text={title()}
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
/>
}
>
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">{title()}</h1>
</Show>
</div>
</div>
@@ -622,7 +713,7 @@ export default function Page() {
<Message message={message} parts={parts()} />
</div>
{/* Summary */}
<Show when={!working()}>
<Show when={completed()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">
@@ -631,7 +722,14 @@ export default function Page() {
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
<Show when={summary()}>
{(summary) => (
<Markdown
classList={{ "[&>*]:fade-up-text": !diffs().length }}
text={summary()}
/>
)}
</Show>
</div>
<Accordion class="w-full" multiple>
<For each={diffs()}>
@@ -684,17 +782,17 @@ export default function Page() {
{/* Response */}
<div class="w-full">
<Switch>
<Match when={working()}>
<MessageProgress assistantMessages={assistantMessages} />
<Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
</Match>
<Match when={!working() && hasToolPart()}>
<Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={expanded()}>Hide steps</Match>
<Match when={!expanded()}>Show steps</Match>
<Match when={expanded()}>Hide details</Match>
<Match when={!expanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.0",
"version": "1.0.5",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.0",
"version": "1.0.5",
"name": "opencode",
"type": "module",
"private": true,
-1
View File
@@ -12,7 +12,6 @@ export const ServeCommand = cmd({
default: 0,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
-1
View File
@@ -65,7 +65,6 @@ export const TuiCommand = cmd({
default: 0,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
+105 -25
View File
@@ -27,6 +27,7 @@ import { ExitProvider } from "./context/exit"
import type { SessionRoute } from "./context/route"
import { Session as SessionApi } from "@/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
export function tui(input: {
url: string
@@ -52,29 +53,35 @@ export function tui(input: {
render(
() => {
return (
<ErrorBoundary fallback={<text>Something went wrong</text>}>
<ErrorBoundary
fallback={(error, reset) => (
<ErrorComponent error={error} reset={reset} onExit={onExit} />
)}
>
<ExitProvider onExit={onExit}>
<ToastProvider>
<RouteProvider data={routeData}>
<SDKProvider url={input.url}>
<SyncProvider>
<ThemeProvider>
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
</ToastProvider>
<KVProvider>
<ToastProvider>
<RouteProvider data={routeData}>
<SDKProvider url={input.url}>
<SyncProvider>
<ThemeProvider>
<LocalProvider initialModel={input.model} initialAgent={input.agent}>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App />
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ErrorBoundary>
)
@@ -95,6 +102,7 @@ function App() {
renderer.disableStdoutInterception()
const dialog = useDialog()
const local = useLocal()
const kv = useKV()
const command = useCommandDialog()
const { event } = useSDK()
const sync = useSync()
@@ -164,6 +172,24 @@ function App() {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Model cycle",
value: "model.cycle_recent",
keybind: "model_cycle_recent",
category: "Agent",
onSelect: () => {
local.model.cycle(1)
},
},
{
title: "Model cycle reverse",
value: "model.cycle_recent_reverse",
keybind: "model_cycle_recent_reverse",
category: "Agent",
onSelect: () => {
local.model.cycle(-1)
},
},
{
title: "Switch agent",
value: "agent.list",
@@ -222,13 +248,13 @@ function App() {
createEffect(() => {
const providerID = local.model.current().providerID
if (providerID === "openrouter" && !local.kv.data.openrouter_warning) {
if (providerID === "openrouter" && !kv.data.openrouter_warning) {
untrack(() => {
DialogAlert.show(
dialog,
"Warning",
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
).then(() => local.kv.set("openrouter_warning", true))
).then(() => kv.set("openrouter_warning", true))
})
}
})
@@ -315,7 +341,7 @@ function App() {
<text
bg={local.agent.color(local.agent.current().name)}
fg={theme.background}
wrapMode="none"
wrapMode={undefined}
>
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
@@ -325,3 +351,57 @@ function App() {
</box>
)
}
function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise<void> }) {
const term = useTerminalDimensions()
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
props.onExit()
}
})
const [copied, setCopied] = createSignal(false)
const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
if (props.error.message) {
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
}
if (props.error.stack) {
issueURL.searchParams.set(
"description",
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
)
}
const copyIssueURL = () => {
Clipboard.copy(issueURL.toString()).then(() => {
setCopied(true)
})
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text attributes={TextAttributes.BOLD}>Please report an issue.</text>
<box onMouseUp={copyIssueURL} backgroundColor="#565f89" padding={1}>
<text attributes={TextAttributes.BOLD}>Copy issue URL (exception info pre-filled)</text>
</box>
{copied() && <text>Successfully copied</text>}
</box>
<box flexDirection="row" gap={2} alignItems="center">
<text>A fatal error occurred!</text>
<box onMouseUp={props.reset} backgroundColor="#565f89" padding={1}>
<text>Reset TUI</text>
</box>
<box onMouseUp={props.onExit} backgroundColor="#565f89" padding={1}>
<text>Exit</text>
</box>
</box>
<scrollbox height={Math.floor(term().height * 0.7)}>
<text>{props.error.stack}</text>
</scrollbox>
<text>{props.error.message}</text>
</box>
)
}
@@ -47,6 +47,9 @@ function init() {
}
}
},
show() {
dialog.replace(() => <DialogCommand options={options()} />)
},
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr])
@@ -75,7 +78,7 @@ export function CommandProvider(props: ParentProps) {
const keybind = useKeybind()
useKeyboard((evt) => {
if (keybind.match("command_list", evt)) {
if (keybind.match("command_list", evt) && dialog.stack.length === 0) {
evt.preventDefault()
dialog.replace(() => <DialogCommand options={value.options} />)
return
@@ -90,7 +93,10 @@ function DialogCommand(props: { options: CommandOption[] }) {
return (
<DialogSelect
title="Commands"
options={props.options.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined }))}
options={props.options.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))}
/>
)
}
@@ -7,6 +7,7 @@ import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
export function DialogSessionList() {
const dialog = useDialog()
@@ -74,6 +75,13 @@ export function DialogSessionList() {
setToDelete(option.value)
},
},
{
keybind: Keybind.parse("r")[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
@@ -0,0 +1,35 @@
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
import { createMemo } from "solid-js"
import { useSDK } from "../context/sdk"
interface DialogSessionRenameProps {
session: string
}
export function DialogSessionRename(props: DialogSessionRenameProps) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const session = createMemo(() => sync.session.get(props.session))
return (
<DialogPrompt
title="Rename Session"
value={session()?.title}
onConfirm={(value) => {
sdk.client.session.update({
path: {
id: props.session,
},
body: {
title: value,
},
})
dialog.clear()
}}
onCancel={() => dialog.clear()}
/>
)
}
@@ -15,7 +15,7 @@ export function DialogStatus() {
<text attributes={TextAttributes.BOLD}>Status</text>
<text fg={theme.textMuted}>esc</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0}>
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}>
<box>
<text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<For each={Object.entries(sync.data.mcp)}>
@@ -4,23 +4,24 @@ import { useDialog } from "../ui/dialog"
import { onCleanup, onMount } from "solid-js"
export function DialogThemeList() {
const { selectedTheme, setSelectedTheme } = useTheme()
const theme = useTheme()
const options = Object.keys(THEMES).map((value) => ({
title: value,
value: value as keyof typeof THEMES,
}))
const initial = selectedTheme()
const dialog = useDialog()
let confirmed = false
let ref: DialogSelectRef<keyof typeof THEMES>
const initial = theme.selected
onMount(() => {
// highlight the first theme in the list when we open it for UX
setSelectedTheme(Object.keys(THEMES)[0] as keyof typeof THEMES)
theme.set(Object.keys(THEMES)[0] as keyof typeof THEMES)
})
onCleanup(() => {
// if we close the dialog without confirming, reset back to the initial theme
if (!confirmed) setSelectedTheme(initial)
if (!confirmed) theme.set(initial)
})
return (
@@ -28,10 +29,10 @@ export function DialogThemeList() {
title="Themes"
options={options}
onMove={(opt) => {
setSelectedTheme(opt.value)
theme.set(opt.value)
}}
onSelect={(opt) => {
setSelectedTheme(opt.value)
theme.set(opt.value)
confirmed = true
dialog.clear()
}}
@@ -40,12 +41,12 @@ export function DialogThemeList() {
}}
onFilter={(query) => {
if (query.length === 0) {
setSelectedTheme(initial)
theme.set(initial)
return
}
const first = ref.filtered[0]
if (first) setSelectedTheme(first.value)
if (first) theme.set(first.value)
}}
/>
)
@@ -222,6 +222,11 @@ export function Autocomplete(props: {
description: "unshare a session",
onSelect: () => command.trigger("session.unshare"),
},
{
display: "/rename",
description: "rename session",
onSelect: () => command.trigger("session.rename"),
},
)
}
results.push(
@@ -240,16 +245,36 @@ export function Autocomplete(props: {
description: "list agents",
onSelect: () => command.trigger("agent.list"),
},
{
display: "/session",
description: "list sessions",
onSelect: () => command.trigger("session.list"),
},
{
display: "/status",
description: "show status",
onSelect: () => command.trigger("opencode.status"),
},
{
display: "/theme",
description: "toggle theme",
onSelect: () => command.trigger("theme.switch"),
},
{
display: "/editor",
description: "open editor",
onSelect: () => command.trigger("prompt.editor"),
},
{
display: "/help",
description: "show help",
onSelect: () => command.trigger("help.show"),
},
{
display: "/commands",
description: "show all commands",
onSelect: () => command.show(),
},
)
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
@@ -329,8 +354,8 @@ export function Autocomplete(props: {
if (e.name === "up") move(-1)
if (e.name === "down") move(1)
if (e.name === "escape") hide()
if (e.name === "return") select()
if (["up", "down", "return", "escape"].includes(e.name)) e.preventDefault()
if (e.name === "return" || e.name === "tab") select()
if (["up", "down", "return", "tab", "escape"].includes(e.name)) e.preventDefault()
}
if (!store.visible) {
if (e.name === "@") {
@@ -386,11 +411,14 @@ export function Autocomplete(props: {
backgroundColor={index() === store.selected ? theme.primary : undefined}
flexDirection="row"
>
<text fg={index() === store.selected ? theme.background : theme.text}>
<text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}>
{option.display}
</text>
<Show when={option.description}>
<text fg={index() === store.selected ? theme.background : theme.textMuted}>
<text
fg={index() === store.selected ? theme.background : theme.textMuted}
wrapMode="none"
>
{option.description}
</text>
</Show>
@@ -530,6 +530,17 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPartIndex", new Map())
return
}
if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
const cursorOffset = input.cursorOffset
if (cursorOffset < input.plainText.length) {
const text = input.plainText
const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
input.setText(newText)
input.cursorOffset = cursorOffset
}
e.preventDefault()
return
}
if (keybind.match("app_exit", e)) {
await exit()
return
@@ -552,10 +563,11 @@ export function Prompt(props: PromptProps) {
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (
(e.name === "up" && input.cursorOffset === 0) ||
(e.name === "down" && input.cursorOffset === input.plainText.length)
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
(keybind.match("history_next", e) &&
input.cursorOffset === input.plainText.length)
) {
const direction = e.name === "up" ? -1 : 1
const direction = keybind.match("history_previous", e) ? -1 : 1
const item = history.move(direction, input.plainText)
if (item) {
@@ -0,0 +1,45 @@
import { Global } from "@/global"
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import path from "path"
export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV",
init: () => {
const [ready, setReady] = createSignal(false)
const [kvStore, setKvStore] = createStore({
openrouter_warning: false,
theme: "opencode",
})
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
file
.json()
.then((x) => {
setKvStore(x)
})
.catch(() => {})
.finally(() => {
setReady(true)
})
return {
get data() {
return kvStore
},
get ready() {
return ready()
},
set(key: string, value: any) {
setKvStore(key as any, value)
Bun.write(
file,
JSON.stringify({
[key]: value,
}),
)
},
}
},
})
@@ -15,17 +15,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sync = useSync()
const toast = useToast()
function isModelValid(model: { providerID: string, modelID: string }) {
function isModelValid(model: { providerID: string; modelID: string }) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID]
}
function getFirstValidModel(...modelFns: (() => { providerID: string, modelID: string } | undefined)[]) {
function getFirstValidModel(
...modelFns: (() => { providerID: string; modelID: string } | undefined)[]
) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model))
return model
if (isModelValid(model)) return model
}
}
@@ -141,20 +142,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
.then((x) => {
setModelStore("recent", x.recent)
})
.catch(() => { })
.catch(() => {})
.finally(() => {
setModelStore("ready", true)
})
createEffect(() => {
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
}),
)
})
const fallbackModel = createMemo(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
@@ -205,6 +197,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: model.name ?? value.modelID,
}
}),
cycle(direction: 1 | -1) {
const current = currentModel()
if (!current) return
const recent = modelStore.recent
const index = recent.findIndex(
(x) => x.providerID === current.providerID && x.modelID === current.modelID,
)
if (index === -1) return
let next = index + direction
if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
setModelStore("model", agent.current().name, { ...val })
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) {
@@ -215,61 +222,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
return
}
setModelStore("model", agent.current().name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setModelStore("recent", uniq)
Bun.write(
file,
JSON.stringify({
recent: modelStore.recent,
}),
)
}
})
},
}
})
const kv = iife(() => {
const [ready, setReady] = createSignal(false)
const [kvStore, setKvStore] = createStore({
openrouter_warning: false,
})
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
file
.json()
.then((x) => {
setKvStore(x)
})
.catch(() => { })
.finally(() => {
setReady(true)
})
return {
get data() {
return kvStore
},
get ready() {
return ready()
},
set(key: string, value: any) {
setKvStore(key as any, value)
Bun.write(
file,
JSON.stringify({
[key]: value,
}),
)
},
}
})
const result = {
model,
agent,
kv,
get ready() {
return kv.ready && model.ready
},
}
return result
},
@@ -1,5 +1,5 @@
import { SyntaxStyle, RGBA } from "@opentui/core"
import { createMemo, createSignal, createEffect } from "solid-js"
import { createMemo, createSignal } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
@@ -24,8 +24,7 @@ import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84
import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
import { iife } from "@/util/iife"
import { createStore, reconcile } from "solid-js/store"
import { useKV } from "./kv"
type Theme = {
primary: RGBA
@@ -84,7 +83,7 @@ type ThemeJson = {
theme: Record<keyof Theme, ColorValue>
}
export const THEMES = {
export const THEMES: Record<string, Theme> = {
aura: resolveTheme(aura),
ayu: resolveTheme(ayu),
catppuccin: resolveTheme(catppuccin),
@@ -628,28 +627,29 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: () => {
const sync = useSync()
const [selectedTheme, setSelectedTheme] = createSignal<keyof typeof THEMES>("opencode")
const [theme, setTheme] = createStore({} as Theme)
createEffect(() => {
if (!sync.ready) return
setSelectedTheme(
iife(() => {
if (typeof sync.data.config.theme === "string" && sync.data.config.theme in THEMES) {
return sync.data.config.theme as keyof typeof THEMES
}
return "opencode"
}),
)
})
const kv = useKV()
createEffect(() => {
setTheme(reconcile(THEMES[selectedTheme()]))
const [theme, setTheme] = createSignal(sync.data.config.theme ?? kv.data.theme)
const values = createMemo(() => {
return THEMES[theme()] ?? THEMES.opencode
})
return {
theme,
selectedTheme,
setSelectedTheme,
theme: new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error
return values()[prop]
},
}),
get selected() {
return kv.data.theme
},
set(theme: string) {
if (!THEMES[theme]) return
setTheme(theme)
kv.set("theme", theme)
},
get ready() {
return sync.ready
},
@@ -63,6 +63,7 @@ import { Sidebar } from "./sidebar"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import parsers from "../../../../../../parsers-config.ts"
import { Toast } from "../../ui/toast"
import { DialogSessionRename } from "../../component/dialog-session-rename"
addDefaultParsers(parsers.parsers)
@@ -370,6 +371,15 @@ export function Session() {
dialog.clear()
},
},
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
category: "Session",
onSelect: (dialog) => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
},
])
const revert = createMemo(() => {
@@ -18,7 +18,6 @@ export const TuiSpawnCommand = cmd({
default: 0,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
@@ -42,7 +42,6 @@ export const TuiThreadCommand = cmd({
default: 0,
})
.option("hostname", {
alias: ["h"],
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
@@ -0,0 +1,65 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { onMount } from "solid-js"
export type DialogPromptProps = {
title: string
value?: string
onConfirm?: (value: string) => void
onCancel?: () => void
}
export function DialogPrompt(props: DialogPromptProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
onMount(() => {
dialog.setSize("large")
setTimeout(() => {
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>{props.title}</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box>
<textarea
onSubmit={() => {
props.onConfirm?.(textarea.plainText)
dialog.clear()
}}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value}
placeholder="Enter text"
/>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text>
</box>
</box>
)
}
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
return new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogPrompt
title={title}
value={value}
onConfirm={(value) => resolve(value)}
onCancel={() => resolve(null)}
/>
),
() => resolve(null),
)
})
}
@@ -59,7 +59,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.options,
filter((x) => x.disabled !== true),
take(props.limit ?? Infinity),
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
(x) =>
!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj),
)
return result
})
@@ -122,20 +123,25 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const keybind = useKeybind()
useKeyboard((evt) => {
if (evt.name === "up") move(-1)
if (evt.name === "down") move(1)
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 === "return") {
const option = selected()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
if (option) {
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
}
for (const item of props.keybind ?? []) {
if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected()
if (s) item.onTrigger(s)
if (s) {
evt.preventDefault()
item.onTrigger(s)
}
}
}
})
@@ -206,11 +212,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.onSelect?.(option)
}}
onMouseOver={() => {
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
const index = filtered().findIndex((x) =>
isDeepEqual(x.value, option.value),
)
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={
active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)
}
paddingLeft={1}
paddingRight={1}
gap={1}
@@ -218,7 +228,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Option
title={option.title}
footer={option.footer}
description={option.description !== category ? option.description : undefined}
description={
option.description !== category ? option.description : undefined
}
active={active()}
current={isDeepEqual(option.value, props.current)}
/>
@@ -234,7 +246,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<For each={props.keybind ?? []}>
{(item) => (
<text>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>
{Keybind.toString(item.keybind)}
</span>
<span style={{ fg: theme.textMuted }}> {item.title}</span>
</text>
)}
@@ -263,7 +277,10 @@ function Option(props: {
wrapMode="none"
>
{Locale.truncate(props.title, 62)}
<span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
<span style={{ fg: props.active ? theme.background : theme.textMuted }}>
{" "}
{props.description}
</span>
</text>
<Show when={props.footer}>
<box flexShrink={0}>
+14 -1
View File
@@ -385,7 +385,11 @@ export namespace Config {
.optional()
.default("ctrl+x")
.describe("Leader key for keybind combinations"),
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
app_exit: z
.string()
.optional()
.default("ctrl+c,ctrl+d,<leader>q")
.describe("Exit the application"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
@@ -449,11 +453,18 @@ export namespace Config {
.default("<leader>h")
.describe("Toggle code block concealment in messages"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
model_cycle_recent_reverse: z
.string()
.optional()
.default("shift+f2")
.describe("Previous recently used model"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_forward_delete: z.string().optional().default("ctrl+d").describe("Forward delete"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("enter").describe("Submit input"),
input_newline: z
@@ -461,6 +472,8 @@ export namespace Config {
.optional()
.default("shift+enter,ctrl+j")
.describe("Insert newline in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Previous history item"),
})
.strict()
.meta({
+1
View File
@@ -367,6 +367,7 @@ export namespace Ripgrep {
args.push(`--max-count=${input.limit}`)
}
args.push("--")
args.push(input.pattern)
const command = args.join(" ")
+2 -1
View File
@@ -38,6 +38,7 @@ process.on("uncaughtException", (e) => {
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")
.help("help", "show help")
.alias("help", "h")
.version("version", "show version number", Installation.VERSION)
.alias("version", "v")
.option("print-logs", {
@@ -140,5 +141,5 @@ try {
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
// Explicitly exit to avoid any hanging subprocesses.
process.exit();
process.exit()
}
+1 -1
View File
@@ -20,7 +20,7 @@ export const GrepTool = Tool.define("grep", {
const searchPath = params.path || Instance.directory
const rgPath = await Ripgrep.filepath()
const args = ["-nH", "--field-match-separator=|", params.pattern]
const args = ["-nH", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.0",
"version": "1.0.5",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.0",
"version": "1.0.5",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+20
View File
@@ -114,6 +114,14 @@ export type KeybindsConfig = {
* List available models
*/
model_list?: string
/**
* Next recently used model
*/
model_cycle_recent?: string
/**
* Previous recently used model
*/
model_cycle_recent_reverse?: string
/**
* List available commands
*/
@@ -134,6 +142,10 @@ export type KeybindsConfig = {
* Clear input field
*/
input_clear?: string
/**
* Forward delete
*/
input_forward_delete?: string
/**
* Paste from clipboard
*/
@@ -146,6 +158,14 @@ export type KeybindsConfig = {
* Insert newline in input
*/
input_newline?: string
/**
* Previous history item
*/
history_previous?: string
/**
* Previous history item
*/
history_next?: string
}
export type AgentConfig = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.0",
"version": "1.0.5",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.0",
"version": "1.0.5",
"type": "module",
"exports": {
".": "./src/components/index.ts",
+3 -3
View File
@@ -6,12 +6,12 @@
color: var(--text-base);
text-wrap: pretty;
/* text-14-regular */
/* text-12-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 142.857% */
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
&::-webkit-scrollbar {
+107
View File
@@ -11,3 +11,110 @@
opacity: 1;
}
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-up-text {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}
+1 -1
View File
@@ -63,7 +63,7 @@
--shadow-xs: var(--shadow-xs);
--shadow-md: var(--shadow-md);
--shadow-xs-border-selected: var(--shadow-xs-border-selected);
--shadow-xs-border-select: var(--shadow-xs-border-select);
--animate-pulse: var(--animate-pulse);
}
@@ -15,3 +15,99 @@
direction: rtl;
text-align: left;
}
@utility fade-up-text {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.0",
"version": "1.0.5",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+41 -20
View File
@@ -19,6 +19,28 @@ opencode run "Explain how closures work in JavaScript"
---
### tui
Start the OpenCode terminal user interface.
```bash
opencode [project]
```
#### Flags
| Flag | Short | Description |
| ------------ | ----- | ------------------------------------------ |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--prompt` | `-p` | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--port` | | Port to listen on |
| `--hostname` | | Hostname to listen on |
---
## Commands
The OpenCode CLI also has the following commands.
@@ -164,13 +186,17 @@ opencode run Explain the use of context in Go
#### Flags
| Flag | Short | Description |
| ------------ | ----- | ------------------------------------------ |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--share` | | Share the session |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| Flag | Short | Description |
| ------------ | ----- | ------------------------------------------------------------------ |
| `--command` | | The command to run, use message for args |
| `--continue` | `-c` | Continue the last session |
| `--session` | `-s` | Session ID to continue |
| `--share` | | Share the session |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--file` | `-f` | File(s) to attach to message |
| `--format` | | Format: default (formatted) or json (raw JSON events) |
| `--title` | | Title for the session (uses truncated prompt if no value provided) |
---
@@ -189,7 +215,7 @@ This starts an HTTP server that provides API access to opencode functionality wi
| Flag | Short | Description |
| ------------ | ----- | --------------------- |
| `--port` | `-p` | Port to listen on |
| `--hostname` | `-h` | Hostname to listen on |
| `--hostname` | | Hostname to listen on |
---
@@ -221,18 +247,13 @@ opencode upgrade v0.1.48
---
## Flags
## Global Flags
The opencode CLI takes the following global flags.
| Flag | Short | Description |
| -------------- | ----- | ------------------------------------------ |
| `--help` | `-h` | Display help |
| `--version` | | Print version number |
| `--print-logs` | | Print logs to stderr |
| `--log-level` | | Log level (DEBUG, INFO, WARN, ERROR) |
| `--prompt` | `-p` | Prompt to use |
| `--model` | `-m` | Model to use in the form of provider/model |
| `--agent` | | Agent to use |
| `--port` | | Port to listen on |
| `--hostname` | | Hostname to listen on |
| Flag | Short | Description |
| -------------- | ----- | ------------------------------------ |
| `--help` | `-h` | Display help |
| `--version` | `-v` | Print version number |
| `--print-logs` | | Print logs to stderr |
| `--log-level` | | Log level (DEBUG, INFO, WARN, ERROR) |
+2 -1
View File
@@ -11,7 +11,7 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"keybinds": {
"leader": "ctrl+x",
"app_help": "<leader>h",
"app_exit": "ctrl+c,<leader>q",
"app_exit": "ctrl+c,ctrl+d,<leader>q",
"editor_open": "<leader>e",
"theme_list": "<leader>t",
"project_init": "<leader>i",
@@ -42,6 +42,7 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"agent_cycle": "tab",
"agent_cycle_reverse": "shift+tab",
"input_clear": "ctrl+c",
"input_forward_delete": "ctrl+d",
"input_paste": "ctrl+v",
"input_submit": "enter",
"input_newline": "shift+enter,ctrl+j"
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.0",
"version": "1.0.5",
"publisher": "sst-dev",
"repository": {
"type": "git",