Compare commits

..

32 Commits

Author SHA1 Message Date
Github Action 08029f44b1 Update Nix flake.lock and hashes 2026-01-12 14:33:03 +00:00
Dax 1ec9a1c555 Merge branch 'dev' into style-new-tips-layout 2026-01-12 09:31:25 -05:00
Dax Raad e6bc3b253b tui: remove update complete toast notification 2026-01-12 09:30:31 -05:00
Brendan Allan f1a13f25a4 ci: don't continue-on-error in tauri action 2026-01-12 21:27:52 +08:00
GitHub Action 4695e685cf ignore: update download stats 2026-01-12 2026-01-12 12:05:37 +00:00
Brendan Allan c6092e4ad9 disable appimage 2026-01-12 19:28:55 +08:00
GitHub Action e6045ca925 chore: generate 2026-01-12 09:58:50 +00:00
Brendan Allan ebbb4dd88a fix(desktop): improve server detection & connection logic (#7962) 2026-01-12 17:58:13 +08:00
GitHub Action 087473be6e chore: generate 2026-01-12 05:22:12 +00:00
Brendan Allan e0eb460fc8 app: resolve defaultServerUrl inside AppInterface
The desktop app sometimes modified __OPENCODE__.serverUrl after the
window is created. Previously this was ignored since defaultServerUrl
was created at module scope, now it isn't created until desktop's
ensure_server_started query complete, after which point serverUrl is
guaranteed to be updated.
2026-01-12 13:21:21 +08:00
opencode 7c6b3f981e release: v1.1.14 2026-01-12 05:08:25 +00:00
Aiden Cline a5b6c57a76 tweak: make the subagent header have clickable nav 2026-01-11 22:43:09 -06:00
Eyal Cherevatsky 65724b693b docs: fix scroll_speed default value (#7867) 2026-01-11 21:40:23 -06:00
Andrey Taranov 8b9a85b7e7 fix(mcp): support resource content type in MCP tool output (#7879) 2026-01-11 21:39:42 -06:00
Zeke Sikelianos 7cbec9a1a7 docs: fix typos in settings doc (#7892) 2026-01-11 21:20:19 -06:00
Prince Carlo Juguilon c4ba5961c8 chore: update GitHub stars count to 60K (#7899) 2026-01-11 21:19:59 -06:00
indeep99 82b432349e feat(tui): add mouse hover and click support to questions tool (#7905) 2026-01-11 22:01:48 -05:00
Dax Raad 68ed664a3f tui: fix prompt ref initialization to prevent undefined reference errors 2026-01-11 19:40:16 -05:00
Dax Raad 3a30773874 tui: refactor event streaming to use SDK instead of manual RPC subscription 2026-01-11 18:53:39 -05:00
Octane 0c0057a7de Fix: TUI single-line paste cursor position (#7277) 2026-01-12 00:50:27 +01:00
Dillon Mulroy fa79736b87 fix: check worktree for external_directory permission in subdirs (#7811) 2026-01-11 14:17:36 -06:00
Jacob Gillespie 2e0c2c9db7 chore(lander): fix spacing in desktop app banner (#7822) 2026-01-11 14:16:29 -06:00
Daniel Polito 3e9366487a feat(desktop): User Message Badges on Hover (#7835) 2026-01-11 14:14:26 -06:00
Daniel Polito 025ed04da0 feat(desktop): Image Preview support for Image Attachments (#7841) 2026-01-11 14:12:52 -06:00
Zeke Sikelianos b81eca4ebc docs: fix typos on the providers page (#7829) 2026-01-11 13:04:04 -06:00
Aiden Cline 20c18689c0 bump copilot plugin version 2026-01-11 12:58:14 -06:00
Kit Langton a803cf8aee feat(tui): add mouse hover and click support to autocomplete (#7820) 2026-01-11 12:45:20 -05:00
King'ori Maina c526e2d908 fix(tui): copy oauth url when no device code (#7812) 2026-01-11 10:27:17 -06:00
GitHub Action bdbbcd8a0c chore: generate 2026-01-11 16:12:14 +00:00
Kit Langton 43c2da24d0 fix(tui): slash command autocomplete highlighted row jumping (#7815) 2026-01-11 11:11:40 -05:00
GitHub Action 3205db9c16 ignore: update download stats 2026-01-11 2026-01-11 12:04:38 +00:00
David Hill 0795e5e15a new tips layout 2025-12-24 20:38:43 +00:00
59 changed files with 793 additions and 557 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ jobs:
publish-tauri:
needs: publish
continue-on-error: true
continue-on-error: false
strategy:
fail-fast: false
matrix:
+2
View File
@@ -197,3 +197,5 @@
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
+65 -2
View File
@@ -4,8 +4,71 @@
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type
- AVOID `let` statements
- PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file()
# Avoid let statements
we don't like let statements, especially combined with if/else statements.
prefer const
This is bad:
Good:
```ts
const foo = condition ? 1 : 2
```
Bad:
```ts
let foo
if (condition) foo = 1
else foo = 2
```
# Avoid else statements
Prefer early returns or even using `iife` to avoid else statements
Good:
```ts
function foo() {
if (condition) return 1
return 2
}
```
Bad:
```ts
function foo() {
if (condition) return 1
else return 2
}
```
# Prefer single word naming
Try your best to find a single word name for your variables, functions, etc.
Only use multiple words if you cannot.
Good:
```ts
const foo = 1
const bar = 2
const baz = 3
```
Bad:
```ts
const fooBar = 1
const barBaz = 2
const bazFoo = 3
```
+15 -15
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -99,7 +99,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -126,7 +126,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -150,7 +150,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -174,7 +174,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -232,7 +232,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -248,7 +248,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.13",
"version": "1.1.14",
"bin": {
"opencode": "./bin/opencode",
},
@@ -351,7 +351,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -371,7 +371,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.13",
"version": "1.1.14",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -382,7 +382,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -395,7 +395,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -434,7 +434,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"zod": "catalog:",
},
@@ -445,7 +445,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767966113,
"narHash": "sha256-mSTsvXa4WveSRJexsmCbm9dY17B1fKp7NLpJxllpQw4=",
"lastModified": 1768178648,
"narHash": "sha256-kz/F6mhESPvU1diB7tOM3nLcBfQe7GU7GQCymRlTi/s=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5f02c91314c8ba4afe83b256b023756412218535",
"rev": "3fbab70c6e69c87ea2b6e48aa6629da2aa6a23b0",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.13",
"version": "1.1.14",
"description": "",
"type": "module",
"exports": {
+12 -13
View File
@@ -33,20 +33,10 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean; serverUrl?: string }
__OPENCODE__?: { updaterEnabled?: boolean }
}
}
const defaultServerUrl = iife(() => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__?.serverUrl) return window.__OPENCODE__.serverUrl
if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
})
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
@@ -75,9 +65,18 @@ function ServerKey(props: ParentProps) {
)
}
export function AppInterface() {
export function AppInterface(props: { defaultUrl?: string }) {
const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
}
return (
<ServerProvider defaultUrl={defaultServerUrl}>
<ServerProvider defaultUrl={defaultServerUrl()}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
+5 -1
View File
@@ -38,6 +38,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
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"
@@ -1484,7 +1485,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<img
src={attachment.dataUrl}
alt={attachment.filename}
class="size-16 rounded-md object-cover border border-border-base"
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
onClick={() =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
/>
</Show>
<button
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "50K",
full: "50,000",
compact: "60K",
full: "60,000",
},
},
@@ -244,7 +244,8 @@ export default function Download() {
Download
</a>
</div>
<div data-component="download-row">
{/* Disabled temporarily as it doesn't work */}
{/*<div data-component="download-row">
<div data-component="download-info">
<span data-slot="icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -259,7 +260,7 @@ export default function Download() {
<a href={getDownloadHref("linux-x64-appimage")} data-component="action-button">
Download
</a>
</div>
</div>*/}
</div>
</section>
+1 -1
View File
@@ -522,7 +522,7 @@ body {
[data-slot="content"] {
display: flex;
align-items: center;
gap: 4px;
gap: 1ch;
}
[data-slot="text"] {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.13",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.13",
"version": "1.1.14",
"$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.1.13",
"version": "1.1.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -3,7 +3,7 @@ name = "opencode-desktop"
version = "0.0.0"
description = "The open source AI coding agent"
authors = ["Anomaly Innovations"]
edition = "2021"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+55 -1
View File
@@ -1,8 +1,30 @@
use tauri::Manager;
use tauri::{path::BaseDirectory, AppHandle, Manager};
use tauri_plugin_shell::{process::Command, ShellExt};
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
#[derive(serde::Deserialize)]
pub struct ServerConfig {
pub hostname: Option<String>,
pub port: Option<u32>,
}
#[derive(serde::Deserialize)]
pub struct Config {
pub server: Option<ServerConfig>,
}
pub async fn get_config(app: &AppHandle) -> Option<Config> {
create_command(app, "debug config")
.output()
.await
.inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
.ok()
.and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
.and_then(|s| serde_json::from_str::<Config>(&s).ok())
}
fn get_cli_install_path() -> Option<std::path::PathBuf> {
std::env::var("HOME").ok().map(|home| {
std::path::PathBuf::from(home)
@@ -117,3 +139,35 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
let state_dir = app
.path()
.resolve("", BaseDirectory::AppLocalData)
.expect("Failed to resolve app local data dir");
#[cfg(target_os = "windows")]
return app
.shell()
.sidecar("opencode-cli")
.unwrap()
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir);
#[cfg(not(target_os = "windows"))]
return {
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
app.shell()
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args(["-il", "-c", &format!("\"{}\" {}", sidecar.display(), args)])
};
}
+110 -178
View File
@@ -1,23 +1,18 @@
mod cli;
mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli};
use cli::{install_cli, sync_cli};
use futures::FutureExt;
use std::{
collections::VecDeque,
net::{SocketAddr, TcpListener},
net::TcpListener,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tauri::{
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
WebviewWindow,
};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_store::StoreExt;
use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin;
@@ -27,13 +22,13 @@ const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
}
impl ServerState {
pub fn new(
child: Option<CommandChild>,
status: tokio::sync::oneshot::Receiver<Result<(), String>>,
status: tokio::sync::oneshot::Receiver<Result<String, String>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
@@ -85,7 +80,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
state
.status
.clone()
@@ -94,7 +89,7 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
}
#[tauri::command]
async fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
@@ -142,49 +137,16 @@ fn get_sidecar_port() -> u32 {
}) as u32
}
fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone();
let state_dir = app
.path()
.resolve("", BaseDirectory::AppLocalData)
.expect("Failed to resolve app local data dir");
println!("spawning sidecar on port {port}");
#[cfg(target_os = "windows")]
let (mut rx, child) = app
.shell()
.sidecar("opencode-cli")
.unwrap()
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args(["serve", &format!("--port={port}")])
let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
.spawn()
.expect("Failed to spawn opencode");
#[cfg(not(target_os = "windows"))]
let (mut rx, child) = {
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
app.shell()
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args([
"-il",
"-c",
&format!("\"{}\" serve --port={}", sidecar.display(), port),
])
.spawn()
.expect("Failed to spawn opencode")
};
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
@@ -222,17 +184,6 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
child
}
async fn is_server_running(port: u32) -> bool {
TcpSocket::new_v4()
.unwrap()
.connect(SocketAddr::new(
"127.0.0.1".parse().expect("Failed to parse IP"),
port as u16,
))
.await
.is_ok()
}
async fn check_server_health(url: &str) -> bool {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let client = reqwest::Client::builder()
@@ -251,12 +202,6 @@ async fn check_server_health(url: &str) -> bool {
.unwrap_or(false)
}
fn get_configured_server_url(app: &AppHandle) -> Option<String> {
let store = app.store(SETTINGS_STORE).ok()?;
let value = store.get(DEFAULT_SERVER_URL_KEY)?;
value.as_str().map(String::from)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
@@ -283,7 +228,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
kill_sidecar,
install_cli,
ensure_server_started,
ensure_server_ready,
get_default_server_url,
set_default_server_url
])
@@ -293,15 +238,11 @@ pub fn run() {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
// Get port and create window immediately for faster perceived startup
let port = get_sidecar_port();
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
// Create window immediately with serverReady = false
#[allow(unused_mut)]
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
@@ -314,7 +255,6 @@ pub fn run() {
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
@@ -325,7 +265,7 @@ pub fn run() {
.hidden_title(true);
}
let window = window_builder.build().expect("Failed to create window");
window_builder.build().expect("Failed to create window");
let (tx, rx) = tokio::sync::oneshot::channel();
app.manage(ServerState::new(None, rx));
@@ -333,116 +273,29 @@ pub fn run() {
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
// Check for configured default server URL
let configured_url = get_configured_server_url(&app);
let mut custom_url = None;
let (child, res, server_url) = if let Some(ref url) = configured_url {
println!("Configured default server URL: {}", url);
// Try to connect to the configured server
let mut healthy = false;
let mut should_fallback = false;
loop {
if check_server_health(url).await {
healthy = true;
println!("Connected to configured server: {}", url);
break;
}
let res = app.dialog()
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
.title("Connection Failed")
.buttons(MessageDialogButtons::OkCancelCustom("Retry".to_string(), "Start Local".to_string()))
.blocking_show_with_result();
match res {
MessageDialogResult::Custom(name) if name == "Retry" => {
continue;
},
_ => {
should_fallback = true;
break;
}
}
}
if healthy {
(None, Ok(()), Some(url.clone()))
} else if should_fallback {
// Fall back to spawning local sidecar
let child = spawn_sidecar(&app, port);
let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
tokio::time::sleep(Duration::from_millis(10)).await;
if is_server_running(port).await {
tokio::time::sleep(Duration::from_millis(10)).await;
break Ok(());
}
};
println!("Server ready after {:?}", timestamp.elapsed());
(Some(child), res, None)
} else {
(None, Err("User cancelled".to_string()), None)
}
} else {
// No configured URL, spawn local sidecar as before
let should_spawn_sidecar = !is_server_running(port).await;
let (child, res) = if should_spawn_sidecar {
let child = spawn_sidecar(&app, port);
let timestamp = Instant::now();
let res = loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
tokio::time::sleep(Duration::from_millis(10)).await;
if is_server_running(port).await {
tokio::time::sleep(Duration::from_millis(10)).await;
break Ok(());
}
};
println!("Server ready after {:?}", timestamp.elapsed());
(Some(child), res)
} else {
(None, Ok(()))
};
(child, res, None)
};
app.state::<ServerState>().set_child(child);
if res.is_ok() {
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
// If using a configured server URL, inject it
if let Some(url) = server_url {
let escaped_url = url.replace('\\', "\\\\").replace('"', "\\\"");
let _ = window.eval(format!(
"window.__OPENCODE__.serverUrl = \"{escaped_url}\";",
));
}
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
println!("Using desktop-specific custom URL: {url}");
custom_url = Some(url);
}
if custom_url.is_none()
&& let Some(cli_config) = cli::get_config(&app).await
&& let Some(url) = get_server_url_from_config(&cli_config)
{
println!("Using custom server URL from config: {url}");
custom_url = Some(url);
}
let res = match setup_server_connection(&app, custom_url).await {
Ok((child, url)) => {
app.state::<ServerState>().set_child(child);
Ok(url)
}
Err(e) => Err(e),
};
let _ = tx.send(res);
});
}
@@ -474,3 +327,82 @@ pub fn run() {
}
});
}
fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
let server = config.server.as_ref()?;
let port = server.port?;
println!("server.port found in OC config: {port}");
let hostname = server.hostname.as_ref();
Some(format!(
"http://{}:{}",
hostname.map(|v| v.as_str()).unwrap_or("127.0.0.1"),
port
))
}
async fn setup_server_connection(
app: &AppHandle,
custom_url: Option<String>,
) -> Result<(Option<CommandChild>, String), String> {
if let Some(url) = custom_url {
loop {
if check_server_health(&url).await {
println!("Connected to custom server: {}", url);
return Ok((None, url.clone()));
}
const RETRY: &str = "Retry";
let res = app.dialog()
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
.title("Connection Failed")
.buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
.blocking_show_with_result();
match res {
MessageDialogResult::Custom(name) if name == RETRY => {
continue;
}
_ => {
break;
}
}
}
}
let local_port = get_sidecar_port();
let local_url = format!("http://127.0.0.1:{local_port}");
if !check_server_health(&local_url).await {
match spawn_local_server(app, local_port).await {
Ok(child) => Ok(Some(child)),
Err(err) => Err(err),
}
} else {
Ok(None)
}
.map(|child| (child, local_url))
}
async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
let child = spawn_sidecar(app, port);
let url = format!("http://127.0.0.1:{port}");
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
}
tokio::time::sleep(Duration::from_millis(10)).await;
if check_server_health(&url).await {
println!("Server ready after {:?}", timestamp.elapsed());
break Ok(child);
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@
"icons/dev/icon.ico"
],
"active": true,
"targets": ["deb", "rpm", "dmg", "nsis", "app", "appimage"],
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/opencode-cli"],
"macOS": {
"entitlements": "./entitlements.plist"
+7 -14
View File
@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
import { Suspense, createResource, ParentProps } from "solid-js"
import { Accessor, JSX, createResource } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
@@ -282,35 +282,28 @@ render(() => {
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<AppBaseProviders>
<ServerGate>
<AppInterface />
</ServerGate>
<ServerGate>{(serverUrl) => <AppInterface defaultUrl={serverUrl()} />}</ServerGate>
</AppBaseProviders>
</PlatformProvider>
)
}, root!)
// Gate component that waits for the server to be ready
function ServerGate(props: ParentProps) {
const [status] = createResource(async () => {
if (window.__OPENCODE__?.serverReady) return
return await invoke("ensure_server_started")
})
function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
return (
// Not using suspense as not all components are compatible with it (undefined refs)
<Show
when={status.state !== "pending"}
when={serverUrl.state !== "pending" && serverUrl()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" />
<div class="mt-8 text-14-regular text-text-weak">Starting server...</div>
<div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
</div>
}
>
{/* Trigger error boundary without rendering the returned value */}
{(status(), null)}
{props.children}
{(serverUrl) => props.children(serverUrl)}
</Show>
)
}
+2 -3
View File
@@ -57,11 +57,10 @@ Example: `mintlify`
Hex color codes for your global theme
<Expandable title="Colors">
<ResponseField name="primary" type="string" required>
The primary color. Used for most often for highlighted content, section headers, accents, in light mode
The primary color. Used most often for highlighted content, section headers, accents, in light mode
</ResponseField>
<ResponseField name="light" type="string">
The primary color for dark mode. Used for most often for highlighted content, section headers, accents, in dark
mode
The primary color for dark mode. Used most often for highlighted content, section headers, accents, in dark mode
</ResponseField>
<ResponseField name="dark" type="string">
The primary color for important buttons
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.13",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.13"
version = "1.1.14"
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.13/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.13/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.13/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.13/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.13/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.13",
"version": "1.1.14",
"$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.1.13",
"version": "1.1.14",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -606,15 +606,6 @@ function App() {
})
})
sdk.event.on(Installation.Event.Updated.type, (evt) => {
toast.show({
variant: "success",
title: "Update Complete",
message: `OpenCode updated to v${evt.properties.version}`,
duration: 5000,
})
})
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
toast.show({
variant: "info",
@@ -112,8 +112,7 @@ function AutoMethod(props: AutoMethodProps) {
useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
const code =
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.instructions
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.url
Clipboard.copy(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -1,8 +1,6 @@
import { createMemo, createSignal, For } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
import { TIPS } from "./tips"
import { EmptyBorder } from "./border"
type TipPart = { text: string; highlight: boolean }
@@ -33,53 +31,21 @@ export function randomizeTip() {
setTipIndex(Math.floor(Math.random() * TIPS.length))
}
const BOX_WIDTH = 42
const TITLE = " 🅘 Did you know? "
export function DidYouKnow() {
const { theme } = useTheme()
const keybind = useKeybind()
const tipParts = createMemo(() => parseTip(TIPS[tipIndex()]))
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>
<box flexDirection="row" maxWidth="100%">
<text flexShrink={0} style={{ fg: theme.warning }}>
Tip{" "}
</text>
<text flexShrink={1}>
<For each={tipParts()}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</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 }}>{keybind.print("tips_toggle")}</span>
<span style={{ fg: theme.textMuted }}> hide tips</span>
</text>
</box>
</box>
)
}
@@ -1,7 +1,7 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import fuzzysort from "fuzzysort"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, For, Show, createSignal } from "solid-js"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
@@ -686,7 +686,7 @@ export function Autocomplete(props: {
height={height()}
scrollbarOptions={{ visible: false }}
>
<For
<Index
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
@@ -698,20 +698,22 @@ export function Autocomplete(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === store.selected ? theme.primary : undefined}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseOver={() => moveTo(index)}
onMouseUp={() => select()}
>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option.display}
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option().display}
</text>
<Show when={option.description}>
<text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option.description}
<Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option().description}
</text>
</Show>
</box>
)}
</For>
</Index>
</scrollbox>
</box>
)
@@ -89,7 +89,7 @@ export function Prompt(props: PromptProps) {
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number
let promptPartTypeId = 0
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
input.insertText(evt.properties.text)
@@ -310,47 +310,44 @@ export function Prompt(props: PromptProps) {
]
})
const ref: PromptRef = {
get focused() {
return input.focused
},
get current() {
return store.prompt
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.input)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
},
}
createEffect(() => {
if (props.visible !== false) input?.focus()
if (props.visible === false) input?.blur()
})
onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
props.ref?.({
get focused() {
return input.focused
},
get current() {
return store.prompt
},
focus() {
input.focus()
},
blur() {
input.blur()
},
set(prompt) {
input.setText(prompt.input)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
},
})
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
@@ -915,12 +912,15 @@ export function Prompt(props: PromptProps) {
// Force layout update and render for the pasted content
setTimeout(() => {
input.getLayoutNode().markDirty()
input.gotoBufferEnd()
renderer.requestRender()
}, 0)
}}
ref={(r: TextareaRenderable) => {
input = r
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
props.ref?.(ref)
setTimeout(() => {
input.cursorColor = theme.text
}, 0)
@@ -1,103 +1,103 @@
export const TIPS = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.",
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
"Drag and drop images into the terminal to add them as context for your prompts.",
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.",
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.",
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.",
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.",
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.",
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.",
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.",
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.",
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.",
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.",
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.",
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.",
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.",
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.",
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.",
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
"Use {highlight}@<agent-name>{/highlight} in prompts to invoke specialized subagents.",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.",
"Configure {highlight}model{/highlight} in config to set your default model.",
"Override any keybind in config via the {highlight}keybinds{/highlight} section.",
"Set any keybind to {highlight}none{/highlight} to disable it completely.",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.",
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.',
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.",
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.',
"Define custom formatter commands with file extensions in config.",
"OpenCode uses LSP servers for intelligent code analysis.",
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.",
"Tool definitions can invoke scripts written in Python, Go, etc.",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.",
"Use plugins to send OS notifications when sessions complete.",
"Create a plugin to prevent OpenCode from reading sensitive files.",
"Use {highlight}opencode run{/highlight} for non-interactive scripting.",
"Use {highlight}opencode run --continue{/highlight} to resume the last session.",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.",
"Themes support dark/light variants for both modes.",
"Reference ANSI colors 0-255 in custom themes.",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.",
"Use {highlight}{file:path}{/highlight} to include file contents in config values.",
"Use {highlight}instructions{/highlight} in config to load additional rules files.",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).",
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.',
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.',
"Override global tool settings per agent configuration.",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.',
"Run {highlight}/unshare{/highlight} to remove a session from public access.",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.",
"Permission {highlight}external_directory{/highlight} protects files outside project.",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.",
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/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.",
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
"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 and Plan agents",
"Use {highlight}/undo{/highlight} to revert the last message and file changes",
"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",
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard 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",
"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 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 near 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 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",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version",
"Run {highlight}opencode auth list{/highlight} to see all configured providers",
"Run {highlight}opencode agent create{/highlight} for guided agent creation",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
"Themes support dark/light variants for both modes",
"Reference ANSI colors 0-255 in custom themes",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing',
"Run {highlight}/unshare{/highlight} to remove a session from public access",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops",
"Permission {highlight}external_directory{/highlight} protects files outside project",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
"Use {highlight}/details{/highlight} to toggle tool execution details visibility",
"Use {highlight}/rename{/highlight} to rename the current session",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
]
@@ -1,6 +1,7 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
import { Logo } from "../component/logo"
import { DidYouKnow, randomizeTip } from "../component/did-you-know"
import { Locale } from "@/util/locale"
@@ -36,7 +37,6 @@ export function Home() {
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
const showTips = createMemo(() => {
return false
// Don't show tips for first-time users
if (isFirstTimeUser()) return false
return !tipsHidden()
@@ -90,6 +90,8 @@ export function Home() {
})
const directory = useDirectory()
const keybind = useKeybind()
return (
<>
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
@@ -103,12 +105,22 @@ export function Home() {
hint={Hint}
/>
</box>
<Show when={!isFirstTimeUser()}>
<Show when={showTips()}>
<box width="100%" maxWidth={75} paddingTop={3} alignItems="center">
<DidYouKnow />
</box>
</Show>
</Show>
<Toast />
</box>
<Show when={!isFirstTimeUser()}>
<Show when={showTips()}>
<DidYouKnow />
</Show>
<Show when={showTips()}>
<box position="absolute" bottom={2} right={2}>
<text>
<span style={{ fg: theme.text }}>{keybind.print("tips_toggle")}</span>
<span style={{ fg: theme.textMuted }}> Hide tips</span>
</text>
</box>
</Show>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
@@ -1,10 +1,11 @@
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
const Title = (props: { session: Accessor<Session> }) => {
@@ -59,6 +60,8 @@ export function Header() {
const { theme } = useTheme()
const keybind = useKeybind()
const command = useCommandDialog()
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
return (
<box flexShrink={0}>
@@ -79,15 +82,36 @@ export function Header() {
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
</box>
<box flexGrow={1} flexShrink={1} />
<ContextInfo context={context} cost={cost} />
</box>
@@ -86,6 +86,38 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
setStore("answers", answers)
}
function moveTo(index: number) {
setStore("selected", index)
}
function selectTab(index: number) {
setStore("tab", index)
setStore("selected", 0)
}
function selectOption() {
if (other()) {
if (!multi()) {
setStore("editing", true)
return
}
const value = input()
if (value && customPicked()) {
toggle(value)
return
}
setStore("editing", true)
return
}
const opt = options()[store.selected]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
const dialog = useDialog()
useKeyboard((evt) => {
@@ -149,16 +181,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
const next = (store.tab - 1 + tabs()) % tabs()
setStore("tab", next)
setStore("selected", 0)
selectTab((store.tab - 1 + tabs()) % tabs())
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
const next = (store.tab + 1) % tabs()
setStore("tab", next)
setStore("selected", 0)
selectTab((store.tab + 1) % tabs())
}
if (confirm()) {
@@ -176,36 +204,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
setStore("selected", (store.selected - 1 + total) % total)
moveTo((store.selected - 1 + total) % total)
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
setStore("selected", (store.selected + 1) % total)
moveTo((store.selected + 1) % total)
}
if (evt.name === "return") {
evt.preventDefault()
if (other()) {
if (!multi()) {
setStore("editing", true)
return
}
const value = input()
if (value && customPicked()) {
toggle(value)
return
}
setStore("editing", true)
return
}
const opt = opts[store.selected]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
selectOption()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
@@ -236,6 +245,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
paddingLeft={1}
paddingRight={1}
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
onMouseUp={() => selectTab(index())}
>
<text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
{q.header}
@@ -244,7 +254,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
)
}}
</For>
<box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={confirm() ? theme.accent : theme.backgroundElement}
onMouseUp={() => selectTab(questions().length)}
>
<text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
</box>
</box>
@@ -264,7 +279,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const active = () => i() === store.selected
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<box>
<box onMouseOver={() => moveTo(i())} onMouseUp={() => selectOption()}>
<box flexDirection="row" gap={1}>
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
@@ -280,7 +295,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
)
}}
</For>
<box>
<box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
<box flexDirection="row" gap={1}>
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
+3 -12
View File
@@ -34,15 +34,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
return fn as typeof fetch
}
function createEventSource(client: RpcClient, directory: string): EventSource {
function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) =>
client.on<Event>("event", (event) => {
handler(event)
if (event.type === "server.instance.disposed") {
client.call("subscribe", { directory }).catch(() => {})
}
}),
on: (handler) => client.on<Event>("event", handler),
}
}
@@ -131,9 +125,6 @@ export const TuiThreadCommand = cmd({
networkOpts.port !== 0 ||
networkOpts.hostname !== "127.0.0.1"
// Subscribe to events from worker
await client.call("subscribe", { directory: cwd })
let url: string
let customFetch: typeof fetch | undefined
let events: EventSource | undefined
@@ -146,7 +137,7 @@ export const TuiThreadCommand = cmd({
// Use direct RPC communication (no HTTP)
url = "http://opencode.internal"
customFetch = createWorkerFetch(client)
events = createEventSource(client, cwd)
events = createEventSource(client)
}
const tuiPromise = tui({
+57 -15
View File
@@ -6,8 +6,8 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
await Log.init({
@@ -38,6 +38,61 @@ GlobalBus.on("event", (event) => {
let server: Bun.Server<BunWebSocketData> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
}
const startEventStream = (directory: string) => {
if (eventStream.abort) eventStream.abort.abort()
const abort = new AbortController()
eventStream.abort = abort
const signal = abort.signal
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
return Server.App().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
directory,
fetch: fetchFn,
signal,
})
;(async () => {
while (!signal.aborted) {
const events = await Promise.resolve(
sdk.event.subscribe(
{},
{
signal,
},
),
).catch(() => undefined)
if (!events) {
await Bun.sleep(250)
continue
}
for await (const event of events.stream) {
Rpc.emit("event", event as Event)
}
if (!signal.aborted) {
await Bun.sleep(250)
}
}
})().catch((error) => {
Log.Default.error("event stream error", {
error: error instanceof Error ? error.message : error,
})
})
}
startEventStream(process.cwd())
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const request = new Request(input.url, {
@@ -58,20 +113,6 @@ export const rpc = {
server = Server.listen(input)
return { url: server.url.toString() }
},
async subscribe(input: { directory: string }) {
return Instance.provide({
directory: input.directory,
init: InstanceBootstrap,
fn: async () => {
Bus.subscribeAll((event) => {
Rpc.emit("event", event)
})
// Emit connected event
Rpc.emit("event", { type: "server.connected", properties: {} })
return { subscribed: true }
},
})
},
async checkUpgrade(input: { directory: string }) {
await Instance.provide({
directory: input.directory,
@@ -87,6 +128,7 @@ export const rpc = {
},
async shutdown() {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
if (server) server.stop(true)
},
+2 -2
View File
@@ -279,7 +279,7 @@ export namespace File {
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Filesystem.contains(Instance.directory, full)) {
if (!Instance.containsPath(full)) {
throw new Error(`Access denied: path escapes project directory`)
}
@@ -339,7 +339,7 @@ export namespace File {
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Filesystem.contains(Instance.directory, resolved)) {
if (!Instance.containsPath(resolved)) {
throw new Error(`Access denied: path escapes project directory`)
}
+1 -1
View File
@@ -33,7 +33,7 @@ await Promise.all([
fs.mkdir(Global.Path.bin, { recursive: true }),
])
const CACHE_VERSION = "16"
const CACHE_VERSION = "17"
const version = await Bun.file(path.join(Global.Path.cache, "version"))
.text()
+1 -1
View File
@@ -12,7 +12,7 @@ import { CodexAuthPlugin } from "./codex"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
+13
View File
@@ -4,6 +4,7 @@ import { Project } from "./project"
import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
interface Context {
directory: string
@@ -46,6 +47,18 @@ export const Instance = {
get project() {
return context.use().project
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
+16 -1
View File
@@ -777,8 +777,23 @@ export namespace SessionPrompt {
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
})
} else if (contentItem.type === "resource") {
const { resource } = contentItem
if (resource.text) {
textParts.push(resource.text)
}
if (resource.blob) {
attachments.push({
id: Identifier.ascending("part"),
sessionID: input.session.id,
messageID: input.processor.message.id,
type: "file",
mime: resource.mimeType ?? "application/octet-stream",
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
filename: resource.uri,
})
}
}
// Add support for other types if needed
}
return {
+2 -2
View File
@@ -85,7 +85,7 @@ export const BashTool = Tool.define("bash", async () => {
throw new Error("Failed to parse command")
}
const directories = new Set<string>()
if (!Filesystem.contains(Instance.directory, cwd)) directories.add(cwd)
if (!Instance.containsPath(cwd)) directories.add(cwd)
const patterns = new Set<string>()
const always = new Set<string>()
@@ -124,7 +124,7 @@ export const BashTool = Tool.define("bash", async () => {
process.platform === "win32" && resolved.match(/^\/[a-z]\//)
? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
: resolved
if (!Filesystem.contains(Instance.directory, normalized)) directories.add(normalized)
if (!Instance.containsPath(normalized)) directories.add(normalized)
}
}
}
@@ -1,6 +1,5 @@
import path from "path"
import type { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
type Kind = "file" | "directory"
@@ -15,7 +14,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return
if (Filesystem.contains(Instance.directory, target)) return
if (Instance.containsPath(target)) return
const kind = options?.kind ?? "file"
const parentDir = kind === "directory" ? target : path.dirname(target)
@@ -1,5 +1,6 @@
import { test, expect, describe } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../src/util/filesystem"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
@@ -113,3 +114,85 @@ describe("File.list path traversal protection", () => {
})
})
})
describe("Instance.containsPath", () => {
test("returns true for path inside directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true)
expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true)
},
})
})
test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => {
await using tmp = await tmpdir({ git: true })
const subdir = path.join(tmp.path, "packages", "lib")
await fs.mkdir(subdir, { recursive: true })
await Instance.provide({
directory: subdir,
fn: () => {
// .opencode at worktree root, but we're running from packages/lib
expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true)
// sibling package should also be accessible
expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true)
// worktree root itself
expect(Instance.containsPath(tmp.path)).toBe(true)
},
})
})
test("returns false for path outside both directory and worktree", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath("/etc/passwd")).toBe(false)
expect(Instance.containsPath("/tmp/other-project")).toBe(false)
},
})
})
test("returns false for path with .. escaping worktree", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false)
},
})
})
test("handles directory === worktree (running from repo root)", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: () => {
expect(Instance.directory).toBe(Instance.worktree)
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
},
})
})
test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
await using tmp = await tmpdir() // no git: true
await Instance.provide({
directory: tmp.path,
fn: () => {
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
expect(Instance.containsPath("/etc/passwd")).toBe(false)
expect(Instance.containsPath("/tmp/other")).toBe(false)
},
})
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
@@ -25,4 +25,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
@@ -30,4 +30,4 @@
"publishConfig": {
"directory": "dist"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"exports": {
@@ -40,10 +40,6 @@
border-color: var(--border-strong-base);
}
&[data-clickable="true"] {
cursor: pointer;
}
&[data-type="image"] {
width: 48px;
height: 48px;
@@ -327,7 +327,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
<div
data-slot="user-message-attachment"
data-type={file.mime.startsWith("image/") ? "image" : "file"}
data-clickable={file.mime.startsWith("image/") && !!file.url}
onClick={() => {
if (file.mime.startsWith("image/") && file.url) {
openImagePreview(file.url, file.filename)
+31 -1
View File
@@ -30,6 +30,35 @@
overflow-anchor: none;
}
[data-slot="session-turn-user-badges"] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
&[data-visible="true"] {
opacity: 1;
pointer-events: auto;
}
}
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
white-space: nowrap;
color: var(--text-base);
background: var(--surface-raised-base);
}
[data-slot="session-turn-sticky-title"] {
width: 100%;
position: sticky;
@@ -52,7 +81,8 @@
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
gap: 12px;
align-self: stretch;
height: 32px;
}
@@ -5,6 +5,7 @@ import {
type PermissionRequest,
TextPart,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
@@ -373,6 +374,7 @@ export function SessionTurn(
diffLimit: diffInit,
status: rawStatus(),
duration: duration(),
userMessageHovered: false,
})
createEffect(
@@ -473,6 +475,8 @@ export function SessionTurn(
data-slot="session-turn-message-container"
class={props.classes?.container}
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
onMouseEnter={() => setStore("userMessageHovered", true)}
onMouseLeave={() => setStore("userMessageHovered", false)}
>
<Switch>
<Match when={isShellMode()}>
@@ -492,6 +496,15 @@ export function SessionTurn(
</Match>
</Switch>
</div>
<div data-slot="session-turn-user-badges" data-visible={store.userMessageHovered}>
<Show when={(msg() as UserMessage).agent}>
<span data-slot="session-turn-badge">{(msg() as UserMessage).agent}</span>
</Show>
<Show when={(msg() as UserMessage).model?.modelID}>
<span data-slot="session-turn-badge">{(msg() as UserMessage).model?.modelID}</span>
</Show>
<span data-slot="session-turn-badge">{(msg() as UserMessage).variant || "default"}</span>
</div>
</div>
</div>
{/* User Message */}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.13",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.13",
"version": "1.1.14",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -168,7 +168,7 @@ You can configure TUI-specific settings through the `tui` option.
Available options:
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
- `scroll_speed` - Custom scroll speed multiplier (default: `1`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
[Learn more about using the TUI here](/docs/tui).
+3 -4
View File
@@ -6,7 +6,7 @@ description: Using any LLM provider in OpenCode.
import config from "../../../config.mjs"
export const console = config.console
OpenCode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support for **75+ LLM providers** and it supports running local models.
OpenCode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support **75+ LLM providers** and it supports running local models.
To add a provider you need to:
@@ -80,8 +80,7 @@ If you are new, we recommend starting with OpenCode Zen.
/models
```
It works like any other provider in OpenCode. And is completely optional to use
it.
It works like any other provider in OpenCode and is completely optional to use.
---
@@ -226,7 +225,7 @@ We recommend signing up for [Claude Pro](https://www.anthropic.com/news/claude-p
```
3. Now all the the Anthropic models should be available when you use the `/models` command.
3. Now all the Anthropic models should be available when you use the `/models` command.
```txt
/models
+1 -1
View File
@@ -358,7 +358,7 @@ You can customize TUI behavior through your OpenCode config file.
### Options
- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `1` on Unix and `3` on Windows. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
---
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.1.13",
"version": "1.1.14",
"publisher": "sst-dev",
"repository": {
"type": "git",