feat(terminal) refactoring and stability improvements (#98)

* feat(terminal): replace xterm with ghostty-web

Replace xterm.js terminal with ghostty-web implementation
Add terminal serialization support for state restoration
Apply custom patches to ghostty-web for enhancements

* feat(terminal): add bun-pty backend support

Switch terminal to ghostty-web with bun-pty backend for better performance
Auto-detect and prefer Bun runtime when available for terminal sessions
Update terminal viewport write queue handling for improved reliability

* fix(terminal): prevent unnecessary resize events

Only report terminal resize when dimensions actually change
Simplify chunk processing state tracking
Disable terminal transparency for consistent rendering

* feat(terminal): increase scrollback and buffer limits

Increase terminal scrollback buffer from 10k to 50k lines
Increase terminal buffer limit from 256k to 1M bytes
Add rate limiting and improve output handling for terminal streams
This commit is contained in:
Bohdan Triapitsyn
2026-01-02 21:57:53 +02:00
committed by GitHub
parent dca8be01ca
commit 110b0e5d8f
36 changed files with 2417 additions and 821 deletions
@@ -73,11 +73,13 @@ async fn run_once(
let prefix = opencode.api_prefix();
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) {
if let Some(dir) = opencode
.get_working_directory()
.to_str()
.map(|s| s.to_string())
{
let mut parsed = reqwest::Url::parse(&url)?;
parsed
.query_pairs_mut()
.append_pair("directory", &dir);
parsed.query_pairs_mut().append_pair("directory", &dir);
url = parsed.to_string();
}
@@ -1,5 +1,5 @@
use crate::{DesktopRuntime, SettingsStore};
use crate::path_utils::expand_tilde_path;
use crate::{DesktopRuntime, SettingsStore};
use serde::Serialize;
use std::{
collections::{HashSet, VecDeque},
@@ -233,7 +233,11 @@ pub async fn search_files(
let match_all = normalized_query.is_empty();
// Collect more candidates for fuzzy matching, then sort and trim
let collect_limit = if match_all { limit } else { (limit * 3).max(200) };
let collect_limit = if match_all {
limit
} else {
(limit * 3).max(200)
};
let mut candidates: Vec<ScoredFileHit> = Vec::new();
let mut queue = VecDeque::new();
@@ -314,16 +318,14 @@ pub async fn search_files(
// Sort by score descending, then by path length, then alphabetically
if !match_all {
candidates.sort_by(|a, b| {
match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => {
match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) {
std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path),
other => other,
}
candidates.sort_by(|a, b| match b.score.cmp(&a.score) {
std::cmp::Ordering::Equal => {
match a.hit.relative_path.len().cmp(&b.hit.relative_path.len()) {
std::cmp::Ordering::Equal => a.hit.relative_path.cmp(&b.hit.relative_path),
other => other,
}
other => other,
}
other => other,
});
}
@@ -497,7 +499,11 @@ fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
continue;
}
let search_start = if last_index < 0 { 0 } else { (last_index + 1) as usize };
let search_start = if last_index < 0 {
0
} else {
(last_index + 1) as usize
};
let idx = c[search_start..].iter().position(|&c_char| c_char == *ch);
match idx {
@@ -520,7 +526,8 @@ fn fuzzy_match_score(query: &str, candidate: &str) -> Option<i32> {
if idx == 0 {
score += 12;
} else if let Some(prev) = c.get(idx - 1) {
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' ' {
if *prev == '/' || *prev == '_' || *prev == '-' || *prev == '.' || *prev == ' '
{
score += 10;
}
}
+92 -81
View File
@@ -1,5 +1,5 @@
use crate::{DesktopRuntime, SettingsStore};
use crate::path_utils::expand_tilde_path;
use crate::{DesktopRuntime, SettingsStore};
use anyhow::{anyhow, Context, Result};
use log::{error, info, warn};
use regex::Regex;
@@ -435,7 +435,10 @@ async fn resolve_git_paths(root: &Path, path_str: &str) -> (PathBuf, PathBuf, St
input_path.to_path_buf()
} else {
let from_root = root.join(input_path);
if metadata_with_timeout(&from_root, GIT_FILE_DIFF_TIMEOUT_MS).await.is_ok() {
if metadata_with_timeout(&from_root, GIT_FILE_DIFF_TIMEOUT_MS)
.await
.is_ok()
{
from_root
} else {
repo_root.join(input_path)
@@ -832,13 +835,10 @@ pub async fn get_git_status(
let mut selected_base: Option<String> = None;
for candidate in base_candidates {
let verified = run_git_with_allowed_exit(
&["rev-parse", "--verify", &candidate],
&path,
&[1],
)
.await
.unwrap_or_default();
let verified =
run_git_with_allowed_exit(&["rev-parse", "--verify", &candidate], &path, &[1])
.await
.unwrap_or_default();
if !verified.trim().is_empty() {
selected_base = Some(candidate);
@@ -916,7 +916,9 @@ pub async fn get_git_diff(
Ok(output)
}
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif"];
const IMAGE_EXTENSIONS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif",
];
fn is_image_file(path: &str) -> bool {
if let Some(ext) = path.rsplit('.').next() {
@@ -970,7 +972,11 @@ fn cap_ipc_payload(value: String) -> String {
};
}
truncate_string_to_char_boundary(value, GIT_FILE_IPC_MAX_CHARS, "\n…(truncated for desktop)\n")
truncate_string_to_char_boundary(
value,
GIT_FILE_IPC_MAX_CHARS,
"\n…(truncated for desktop)\n",
)
}
async fn run_git_binary(args: &[&str], cwd: &Path) -> Result<Vec<u8>> {
@@ -983,8 +989,8 @@ pub async fn get_git_file_diff(
path_str: String,
state: State<'_, DesktopRuntime>,
) -> Result<(String, String), String> {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use tokio::fs;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
let root = validate_git_path(&directory, state.settings())
.await
@@ -992,7 +998,11 @@ pub async fn get_git_file_diff(
let (repo_root, full_path, relative_path) = resolve_path_for_git_show(&root, &path_str).await;
let is_image = is_image_file(&relative_path);
let mime_type = if is_image { get_image_mime_type(&relative_path) } else { "" };
let mime_type = if is_image {
get_image_mime_type(&relative_path)
} else {
""
};
// Original from HEAD
let original = if is_image {
@@ -1035,47 +1045,50 @@ pub async fn get_git_file_diff(
};
// Modified from working tree (if file exists)
let modified = if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await {
if metadata.is_file() {
if is_image {
// For images, read as binary and convert to data URL
if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES {
String::new()
let modified =
if let Ok(metadata) = metadata_with_timeout(&full_path, GIT_FILE_DIFF_TIMEOUT_MS).await {
if metadata.is_file() {
if is_image {
// For images, read as binary and convert to data URL
if metadata.len() > GIT_FILE_IMAGE_MAX_BYTES {
String::new()
} else {
match tokio::time::timeout(
std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS),
fs::read(&full_path),
)
.await
{
Ok(Ok(bytes)) => {
format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes))
}
_ => String::new(),
}
}
} else {
match tokio::time::timeout(
std::time::Duration::from_millis(GIT_FILE_DIFF_TIMEOUT_MS),
fs::read(&full_path),
match read_file_bytes_limited_with_timeout(
&full_path,
GIT_FILE_TEXT_MAX_BYTES,
GIT_FILE_DIFF_TIMEOUT_MS,
)
.await
{
Ok(Ok(bytes)) => format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)),
_ => String::new(),
Ok((bytes, truncated)) => {
let mut text = String::from_utf8_lossy(&bytes).to_string();
if truncated {
text.push_str("\n…(truncated)\n");
}
text
}
Err(_) => String::new(),
}
}
} else {
match read_file_bytes_limited_with_timeout(
&full_path,
GIT_FILE_TEXT_MAX_BYTES,
GIT_FILE_DIFF_TIMEOUT_MS,
)
.await
{
Ok((bytes, truncated)) => {
let mut text = String::from_utf8_lossy(&bytes).to_string();
if truncated {
text.push_str("\n…(truncated)\n");
}
text
}
Err(_) => String::new(),
}
String::new()
}
} else {
String::new()
}
} else {
String::new()
};
};
Ok((cap_ipc_payload(original), cap_ipc_payload(modified)))
}
@@ -1144,31 +1157,32 @@ pub async fn get_git_branches(
.map_err(|e| e.to_string())?;
// Discover actual remote heads so we can drop stale remote-tracking refs
let allowed_remote_heads: Option<HashSet<String>> = match run_git_bytes_with_allowed_exit_timeout(
&["ls-remote", "--heads", "origin"],
&root,
&[0],
GIT_LS_REMOTE_TIMEOUT_MS,
)
.await
{
Ok(bytes) => {
let ls_remote = String::from_utf8_lossy(&bytes);
let mut set = HashSet::new();
for line in ls_remote.lines() {
if let Some((_, ref_name)) = line.split_once('\t') {
if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") {
set.insert(stripped.to_string());
let allowed_remote_heads: Option<HashSet<String>> =
match run_git_bytes_with_allowed_exit_timeout(
&["ls-remote", "--heads", "origin"],
&root,
&[0],
GIT_LS_REMOTE_TIMEOUT_MS,
)
.await
{
Ok(bytes) => {
let ls_remote = String::from_utf8_lossy(&bytes);
let mut set = HashSet::new();
for line in ls_remote.lines() {
if let Some((_, ref_name)) = line.split_once('\t') {
if let Some(stripped) = ref_name.trim().strip_prefix("refs/heads/") {
set.insert(stripped.to_string());
}
}
}
Some(set)
}
Some(set)
}
Err(err) => {
warn!("Failed to list remote heads: {}", err);
None
}
};
Err(err) => {
warn!("Failed to list remote heads: {}", err);
None
}
};
// Structured for-each-ref output so we can mark remotes consistently with the web runtime
let output = run_git(
@@ -1518,21 +1532,15 @@ pub async fn git_push(
let remote_key = format!("branch.{}.remote", branch_name);
let merge_key = format!("branch.{}.merge", branch_name);
let upstream_remote = run_git_with_allowed_exit(
&["config", "--get", &remote_key],
&root,
&[1],
)
.await
.unwrap_or_default();
let upstream_remote =
run_git_with_allowed_exit(&["config", "--get", &remote_key], &root, &[1])
.await
.unwrap_or_default();
let upstream_merge = run_git_with_allowed_exit(
&["config", "--get", &merge_key],
&root,
&[1],
)
.await
.unwrap_or_default();
let upstream_merge =
run_git_with_allowed_exit(&["config", "--get", &merge_key], &root, &[1])
.await
.unwrap_or_default();
if upstream_remote.trim().is_empty() || upstream_merge.trim().is_empty() {
args.push("--set-upstream".to_string());
@@ -1904,7 +1912,10 @@ pub async fn get_commit_files(
file.path.clone()
};
if let Some(status) = status_map.get(&base_path).or_else(|| status_map.get(&file.path)) {
if let Some(status) = status_map
.get(&base_path)
.or_else(|| status_map.get(&file.path))
{
file.change_type = status.clone();
}
}
@@ -1,7 +1,7 @@
pub mod files;
pub mod git;
pub mod logs;
pub mod notifications;
pub mod permissions;
pub mod settings;
pub mod terminal;
pub mod notifications;
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri::State;
use crate::DesktopRuntime;
use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -3,8 +3,8 @@ use serde_json::{json, Value};
use std::collections::HashSet;
use tauri::State;
use crate::DesktopRuntime;
use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -160,7 +160,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|value| if value >= 0 { Some(value as u64) } else { None }))
.or_else(|| {
n.as_i64()
.and_then(|value| if value >= 0 { Some(value as u64) } else { None })
})
.or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(1).min(365);
@@ -198,13 +201,31 @@ fn sanitize_settings_update(payload: &Value) -> Value {
let mut catalogs: Vec<Value> = vec![];
for entry in arr {
let Some(obj) = entry.as_object() else { continue };
let Some(obj) = entry.as_object() else {
continue;
};
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or("").trim();
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();
let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim();
let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim();
let label = obj
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let source = obj
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let subpath = obj
.get("subpath")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let git_identity_id = obj
.get("gitIdentityId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if id.is_empty() || label.is_empty() || source.is_empty() {
continue;
@@ -1,4 +1,5 @@
use log::error;
use parking_lot::Mutex;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use serde::{Deserialize, Serialize};
use std::{
@@ -6,8 +7,9 @@ use std::{
env,
io::{Read, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex},
sync::Arc,
thread,
time::Duration,
};
use tauri::{Emitter, State, Window};
@@ -18,6 +20,10 @@ const DEFAULT_LOCALE: &str = "en_US.UTF-8";
const TERM_PROGRAM_NAME: &str = "OpenChamber";
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
// Emit at most ~60fps and avoid tiny payload spam.
const EMIT_INTERVAL: Duration = Duration::from_millis(16);
const EMIT_MAX_BUFFER_BYTES: usize = 64 * 1024;
pub struct TerminalSession {
pub master: Box<dyn MasterPty + Send>,
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
@@ -94,7 +100,7 @@ pub async fn create_terminal_session(
let child = Arc::new(Mutex::new(child));
let session_id = uuid::Uuid::new_v4().to_string();
state.sessions.lock().unwrap().insert(
state.sessions.lock().insert(
session_id.clone(),
TerminalSession {
master,
@@ -115,21 +121,18 @@ pub async fn send_terminal_input(
data: String,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let sessions = state.sessions.lock().unwrap();
let Some(session) = sessions.get(&session_id) else {
return Err("Terminal session not found".to_string());
let writer = {
let sessions = state.sessions.lock();
let Some(session) = sessions.get(&session_id) else {
return Err("Terminal session not found".to_string());
};
session.writer.clone()
};
let mut writer = session
.writer
.lock()
.map_err(|_| "Terminal busy".to_string())?;
writer
let mut guard = writer.lock();
guard
.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
writer
.flush()
.map_err(|e| format!("Failed to flush terminal input: {e}"))?;
Ok(())
}
@@ -140,7 +143,7 @@ pub async fn resize_terminal(
rows: u16,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let mut sessions = state.sessions.lock().unwrap();
let mut sessions = state.sessions.lock();
let Some(session) = sessions.get_mut(&session_id) else {
return Err("Terminal session not found".to_string());
};
@@ -154,6 +157,7 @@ pub async fn resize_terminal(
pixel_height: 0,
})
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
Ok(())
}
@@ -162,15 +166,10 @@ pub async fn close_terminal(
session_id: String,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let session = {
let mut sessions = state.sessions.lock().unwrap();
sessions.remove(&session_id)
};
let session = { state.sessions.lock().remove(&session_id) };
if let Some(session) = session {
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
let _ = session.child.lock().kill();
}
Ok(())
@@ -191,11 +190,9 @@ pub async fn restart_terminal_session(
window: Window,
) -> Result<CreateTerminalResponse, String> {
{
let mut sessions = state.sessions.lock().unwrap();
if let Some(session) = sessions.remove(&payload.session_id) {
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
let session = state.sessions.lock().remove(&payload.session_id);
if let Some(session) = session {
let _ = session.child.lock().kill();
}
}
@@ -239,7 +236,7 @@ pub async fn restart_terminal_session(
let child = Arc::new(Mutex::new(child));
let session_id = uuid::Uuid::new_v4().to_string();
state.sessions.lock().unwrap().insert(
state.sessions.lock().insert(
session_id.clone(),
TerminalSession {
master,
@@ -265,65 +262,145 @@ pub async fn force_kill_terminal(
payload: ForceKillPayload,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let mut sessions = state.sessions.lock().unwrap();
let mut sessions = state.sessions.lock();
if let Some(session_id) = payload.session_id {
// Kill by session_id
if let Some(session) = sessions.remove(&session_id) {
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
let _ = session.child.lock().kill();
}
} else if let Some(cwd) = payload.cwd {
let ids: Vec<String> = sessions.keys().cloned().collect();
for id in ids {
if let Some(session) = sessions.remove(&id) {
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
}
}
let _ = cwd;
} else {
let ids: Vec<String> = sessions.keys().cloned().collect();
for id in ids {
if let Some(session) = sessions.remove(&id) {
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
}
return Ok(());
}
// Current API ignores cwd; keep behavior but avoid holding poisoned locks.
let _ = payload.cwd;
let ids: Vec<String> = sessions.keys().cloned().collect();
for id in ids {
if let Some(session) = sessions.remove(&id) {
let _ = session.child.lock().kill();
}
}
Ok(())
}
fn spawn_reader_thread(mut reader: Box<dyn Read + Send>, window: Window, session_id: String) {
fn spawn_reader_thread(reader: Box<dyn Read + Send>, window: Window, session_id: String) {
thread::spawn(move || {
let mut buffer = [0u8; 16384];
let event_name = format!("terminal://{}", session_id);
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
if data.is_empty() {
continue;
}
use std::sync::mpsc;
let event_name = format!("terminal://{}", session_id);
let (tx, rx) = mpsc::channel::<Vec<u8>>();
// Dedicated blocking reader thread.
let reader_handle = thread::spawn(move || {
let mut reader = reader;
let mut buffer = [0u8; 16384];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
if tx.send(buffer[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let mut pending = String::new();
let mut pending_bytes: Vec<u8> = Vec::new();
let flush = |pending: &mut String| -> bool {
if pending.is_empty() {
return true;
}
let payload_data = std::mem::take(pending);
let payload = serde_json::json!({ "type": "data", "data": payload_data });
match window.emit(&event_name, payload) {
Ok(_) => true,
Err(error) => {
error!("Failed to emit terminal data: {error}");
false
}
}
};
let decode_pending = |pending_bytes: &mut Vec<u8>, pending: &mut String| {
loop {
match std::str::from_utf8(pending_bytes) {
Ok(text) => {
if !text.is_empty() {
pending.push_str(text);
}
pending_bytes.clear();
break;
}
Err(error) => {
let valid = error.valid_up_to();
if valid > 0 {
let text = std::str::from_utf8(&pending_bytes[..valid]).unwrap_or("");
if !text.is_empty() {
pending.push_str(text);
}
pending_bytes.drain(..valid);
continue;
}
// Incomplete UTF-8 at end; wait for more bytes.
if error.error_len().is_none() {
break;
}
// Invalid leading byte; consume 1 byte and replace.
if !pending_bytes.is_empty() {
pending_bytes.drain(..1);
pending.push('\u{FFFD}');
continue;
}
if let Err(error) =
window.emit(&event_name, serde_json::json!({ "type": "data", "data": data }))
{
error!("Failed to emit terminal data: {error}");
break;
}
}
Err(error) => {
error!("Terminal read error: {error}");
}
};
loop {
match rx.recv_timeout(EMIT_INTERVAL) {
Ok(bytes) => {
pending_bytes.extend_from_slice(&bytes);
decode_pending(&mut pending_bytes, &mut pending);
if pending.len() >= EMIT_MAX_BUFFER_BYTES {
if !flush(&mut pending) {
break;
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
// Flush any buffered output even if the PTY is idle.
if !pending_bytes.is_empty() {
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
pending_bytes.clear();
}
if !flush(&mut pending) {
break;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
if !pending_bytes.is_empty() {
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
pending_bytes.clear();
}
let _ = flush(&mut pending);
break;
}
}
}
let _ = reader_handle.join();
});
}
@@ -334,10 +411,7 @@ fn spawn_exit_watcher(
session_id: String,
) {
thread::spawn(move || {
let status = {
let mut guard = child.lock().expect("terminal child poisoned");
guard.wait()
};
let status = { child.lock().wait() };
let (exit_code, signal) = match status {
Ok(status) => (
@@ -358,8 +432,7 @@ fn spawn_exit_watcher(
});
let _ = window.emit(&event_name, payload);
let mut sessions = sessions.lock().unwrap();
sessions.remove(&session_id);
sessions.lock().remove(&session_id);
});
}
@@ -388,9 +461,7 @@ fn shell_accepts_login_flag(shell_path: &str) -> bool {
}
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
let maybe_path = input
.map(|value| PathBuf::from(value))
.or_else(|| dirs::home_dir());
let maybe_path = input.map(PathBuf::from).or_else(|| dirs::home_dir());
let Some(path) = maybe_path else {
return Err("Unable to determine working directory".to_string());
+1
View File
@@ -0,0 +1 @@
+384 -247
View File
@@ -1,19 +1,25 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod assistant_notifications;
mod commands;
mod logging;
mod assistant_notifications;
mod session_activity;
mod opencode_auth;
mod opencode_config;
mod opencode_manager;
mod window_state;
mod path_utils;
mod session_activity;
mod skills_catalog;
mod window_state;
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}};
use std::{
collections::HashMap,
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
use anyhow::{anyhow, Result};
use assistant_notifications::spawn_assistant_notifications;
use axum::{
body::{to_bytes, Body},
extract::{OriginalUri, State},
@@ -22,23 +28,22 @@ use axum::{
routing::{any, get, post},
Json, Router,
};
use assistant_notifications::spawn_assistant_notifications;
use session_activity::spawn_session_activity_tracker;
use commands::files::{create_directory, list_directory, search_files};
use commands::git::{
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit,
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
ensure_openchamber_ignored, generate_commit_message, get_commit_files, get_current_git_identity,
get_git_branches, get_git_diff, get_git_file_diff, get_git_identities, get_git_log, get_git_status,
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
revert_git_file, set_git_identity, update_git_identity,
ensure_openchamber_ignored, generate_commit_message, get_commit_files,
get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
get_git_identities, get_git_log, get_git_status, git_fetch, git_pull, git_push,
is_linked_worktree, list_git_worktrees, remove_git_worktree, revert_git_file, set_git_identity,
update_git_identity,
};
use commands::logs::fetch_desktop_logs;
use commands::notifications::desktop_notify;
use commands::permissions::{
pick_directory, process_directory_selection, request_directory_access,
restore_bookmarks_on_startup, start_accessing_directory, stop_accessing_directory,
};
use commands::notifications::desktop_notify;
use commands::settings::{load_settings, restart_opencode, save_settings};
use commands::terminal::{
close_terminal, create_terminal_session, force_kill_terminal, resize_terminal,
@@ -47,13 +52,15 @@ use commands::terminal::{
use futures_util::StreamExt as FuturesStreamExt;
use log::{error, info, warn};
use opencode_manager::OpenCodeManager;
use path_utils::expand_tilde_path;
use portpicker::pick_unused_port;
use reqwest::{header, Body as ReqwestBody, Client};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{Emitter, Manager};
use session_activity::spawn_session_activity_tracker;
#[cfg(feature = "devtools")]
use tauri::WebviewWindow;
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::init as dialog_plugin;
use tauri_plugin_fs::init as fs_plugin;
use tauri_plugin_log::{Target, TargetKind};
@@ -66,7 +73,6 @@ use tokio::{
};
use tower_http::cors::CorsLayer;
use window_state::{load_window_state, persist_window_state, WindowStateManager};
use path_utils::expand_tilde_path;
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicBool, Ordering};
@@ -134,8 +140,10 @@ const MENU_ITEM_HELP_DIALOG_ID: &str = "openchamber_help_dialog";
#[cfg(target_os = "macos")]
const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "openchamber_download_logs";
const GITHUB_BUG_REPORT_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml";
const GITHUB_FEATURE_REQUEST_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml";
const GITHUB_BUG_REPORT_URL: &str =
"https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml";
const GITHUB_FEATURE_REQUEST_URL: &str =
"https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml";
const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA";
#[derive(Clone)]
@@ -149,7 +157,9 @@ pub(crate) struct DesktopRuntime {
impl DesktopRuntime {
fn initialize_sync() -> Result<Self> {
let settings = Arc::new(SettingsStore::new()?);
let initial_dir = tauri::async_runtime::block_on(settings.last_directory()).ok().flatten();
let initial_dir = tauri::async_runtime::block_on(settings.last_directory())
.ok()
.flatten();
let opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone()));
let client = Client::builder().build()?;
@@ -265,7 +275,13 @@ struct ServerInfoPayload {
async fn desktop_server_info(
state: tauri::State<'_, DesktopRuntime>,
) -> Result<ServerInfoPayload, String> {
let has_last_directory = state.settings().last_directory().await.ok().flatten().is_some();
let has_last_directory = state
.settings()
.last_directory()
.await
.ok()
.flatten()
.is_some();
Ok(ServerInfoPayload {
server_port: state.server_port,
opencode_port: state.opencode.current_port(),
@@ -300,10 +316,14 @@ fn get_macos_major_version() -> isize {
}
#[cfg(target_os = "macos")]
fn adjust_traffic_lights_position<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, x: f64, y: f64) {
use objc2::runtime::AnyObject;
fn adjust_traffic_lights_position<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
x: f64,
y: f64,
) {
use objc2::msg_send;
use objc2_foundation::{NSRect, NSPoint};
use objc2::runtime::AnyObject;
use objc2_foundation::{NSPoint, NSRect};
if let Ok(ns_window) = window.ns_window() {
unsafe {
@@ -338,8 +358,12 @@ fn prevent_app_nap() {
}
#[cfg(target_os = "macos")]
fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> {
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID};
fn build_macos_menu<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
) -> tauri::Result<tauri::menu::Menu<R>> {
use tauri::menu::{
Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
let pkg_info = app.package_info();
@@ -360,13 +384,7 @@ fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Resu
)?;
// App menu items
let settings = MenuItem::with_id(
app,
MENU_ITEM_SETTINGS_ID,
"Settings",
true,
Some("Cmd+,"),
)?;
let settings = MenuItem::with_id(app, MENU_ITEM_SETTINGS_ID, "Settings", true, Some("Cmd+,"))?;
let command_palette = MenuItem::with_id(
app,
@@ -402,13 +420,8 @@ fn build_macos_menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Resu
)?;
// View menu items
let open_git_tab = MenuItem::with_id(
app,
MENU_ITEM_OPEN_GIT_TAB_ID,
"Git",
true,
Some("Ctrl+G"),
)?;
let open_git_tab =
MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Ctrl+G"))?;
let open_diff_tab = MenuItem::with_id(
app,
@@ -664,12 +677,21 @@ fn main() {
info!("[macos] Detected macOS version: {}", macos_version);
let corner_radius = if macos_version >= 26 { 24.0 } else { 10.0 };
if let Err(error) =
apply_vibrancy(&window, NSVisualEffectMaterial::Sidebar, None, Some(corner_radius))
{
warn!("[desktop:vibrancy] Failed to apply macOS vibrancy: {}", error);
if let Err(error) = apply_vibrancy(
&window,
NSVisualEffectMaterial::Sidebar,
None,
Some(corner_radius),
) {
warn!(
"[desktop:vibrancy] Failed to apply macOS vibrancy: {}",
error
);
} else {
info!("[desktop:vibrancy] Applied macOS Sidebar vibrancy with radius {}", corner_radius);
info!(
"[desktop:vibrancy] Applied macOS Sidebar vibrancy with radius {}",
corner_radius
);
}
if macos_version < 26 {
@@ -691,7 +713,11 @@ fn main() {
let app_handle = app.app_handle().clone();
let runtime_clone = runtime.clone();
let has_initial_dir = tauri::async_runtime::block_on(runtime.settings().last_directory()).ok().flatten().is_some();
let has_initial_dir =
tauri::async_runtime::block_on(runtime.settings().last_directory())
.ok()
.flatten()
.is_some();
tauri::async_runtime::spawn(async move {
// Only start opencode if we have a saved directory, otherwise frontend will prompt
if has_initial_dir {
@@ -700,7 +726,9 @@ fn main() {
info!("[desktop] No saved directory - waiting for user to select one");
}
if let Err(e) = restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await {
if let Err(e) =
restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await
{
warn!("Failed to restore bookmarks on startup: {}", e);
}
@@ -728,8 +756,12 @@ fn main() {
Ok(false) => {
let _ = app_handle.emit("server.instance.disposed", ());
if runtime.opencode_manager().is_cli_available() {
if let Err(err) = runtime.opencode_manager().ensure_running().await {
warn!("[desktop:watchdog] Failed to restart OpenCode: {err}");
if let Err(err) =
runtime.opencode_manager().ensure_running().await
{
warn!(
"[desktop:watchdog] Failed to restart OpenCode: {err}"
);
} else {
backoff_ms = 1000;
}
@@ -779,10 +811,12 @@ fn main() {
};
let changed = match &last_snapshot {
Some(prev) => prev.ok != snapshot.ok
|| prev.port != snapshot.port
|| prev.api_prefix != snapshot.api_prefix
|| prev.cli_available != snapshot.cli_available,
Some(prev) => {
prev.ok != snapshot.ok
|| prev.port != snapshot.port
|| prev.api_prefix != snapshot.api_prefix
|| prev.cli_available != snapshot.cli_available
}
None => true,
};
@@ -990,7 +1024,9 @@ fn main() {
tauri::WindowEvent::Focused(true) => {
// Clear dock badge and underlying badge state when the window gains focus
let _ = window.set_badge_count(None);
let _ = window.app_handle().emit("openchamber:clear-badge-sessions", ());
let _ = window
.app_handle()
.emit("openchamber:clear-badge-sessions", ());
}
tauri::WindowEvent::Moved(position) => {
let is_maximized = window.is_maximized().unwrap_or(false);
@@ -1037,7 +1073,6 @@ fn main() {
app.run(|_app_handle, _event| {});
}
fn spawn_http_server(port: u16, state: ServerState, shutdown_rx: broadcast::Receiver<()>) {
tauri::async_runtime::spawn(async move {
if let Err(error) = run_http_server(port, state, shutdown_rx).await {
@@ -1053,7 +1088,10 @@ async fn run_http_server(
) -> Result<()> {
let router = Router::new()
.route("/health", get(health_handler))
.route("/api/openchamber/models-metadata", get(models_metadata_handler))
.route(
"/api/openchamber/models-metadata",
get(models_metadata_handler),
)
.route("/api/opencode/directory", post(change_directory_handler))
.route("/api", any(proxy_to_opencode))
.route("/api/{*rest}", any(proxy_to_opencode))
@@ -1084,7 +1122,9 @@ async fn health_handler(State(state): State<ServerState>) -> Json<HealthResponse
})
}
async fn models_metadata_handler(State(state): State<ServerState>) -> Result<Json<Value>, StatusCode> {
async fn models_metadata_handler(
State(state): State<ServerState>,
) -> Result<Json<Value>, StatusCode> {
let now = Instant::now();
let cached_payload: Option<Value> = {
let cache = state.models_metadata_cache.lock().await;
@@ -1150,12 +1190,17 @@ fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response<Body>
}
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response<Body> {
json_response(status, ConfigErrorResponse {
error: message.into(),
})
json_response(
status,
ConfigErrorResponse {
error: message.into(),
},
)
}
async fn parse_request_payload(req: Request<Body>) -> Result<HashMap<String, Value>, Response<Body>> {
async fn parse_request_payload(
req: Request<Body>,
) -> Result<HashMap<String, Value>, Response<Body>> {
let (_, body) = req.into_parts();
let body_bytes = to_bytes(body, PROXY_BODY_LIMIT)
.await
@@ -1174,14 +1219,12 @@ async fn refresh_opencode_after_config_change(
reason: &str,
) -> Result<(), Response<Body>> {
info!("[desktop:config] Restarting OpenCode after {}", reason);
state
.opencode
.restart()
.await
.map_err(|err| config_error_response(
state.opencode.restart().await.map_err(|err| {
config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to restart OpenCode: {}", err),
))?;
)
})?;
Ok(())
}
@@ -1193,7 +1236,7 @@ async fn handle_agent_route(
) -> Result<Response<Body>, StatusCode> {
// Get working directory for project-level agent detection
let working_directory = state.opencode.get_working_directory();
match method {
Method::GET => {
match opencode_config::get_agent_sources(&name, Some(&working_directory)).await {
@@ -1226,9 +1269,10 @@ async fn handle_agent_route(
Ok(data) => data,
Err(resp) => return Ok(resp),
};
// Extract scope from payload if present
let scope = payload.get("scope")
let scope = payload
.get("scope")
.and_then(|v| v.as_str())
.and_then(|s| match s {
"project" => Some(opencode_config::AgentScope::Project),
@@ -1236,7 +1280,9 @@ async fn handle_agent_route(
_ => None,
});
match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope).await {
match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope)
.await
{
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "agent creation").await
@@ -1302,35 +1348,37 @@ async fn handle_agent_route(
}
}
}
Method::DELETE => match opencode_config::delete_agent(&name, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "agent deletion").await
{
return Ok(resp);
}
Method::DELETE => {
match opencode_config::delete_agent(&name, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "agent deletion").await
{
return Ok(resp);
}
Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: true,
message: format!(
"Agent {} deleted successfully. Reloading interface...",
name
),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
},
))
Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: true,
message: format!(
"Agent {} deleted successfully. Reloading interface...",
name
),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
},
))
}
Err(err) => {
error!("[desktop:config] Failed to delete agent {}: {}", name, err);
Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
))
}
}
Err(err) => {
error!("[desktop:config] Failed to delete agent {}: {}", name, err);
Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
))
}
},
}
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
}
}
@@ -1366,12 +1414,10 @@ struct SkillFileResponse {
content: String,
}
async fn handle_skill_list_route(
state: &ServerState,
) -> Result<Response<Body>, StatusCode> {
async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>, StatusCode> {
let working_directory = state.opencode.get_working_directory();
let discovered = opencode_config::discover_skills(Some(&working_directory));
let mut skills = Vec::new();
for skill in discovered {
match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await {
@@ -1385,12 +1431,18 @@ async fn handle_skill_list_route(
});
}
Err(err) => {
error!("[desktop:config] Failed to get skill sources for {}: {}", skill.name, err);
error!(
"[desktop:config] Failed to get skill sources for {}: {}",
skill.name, err
);
}
}
}
Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills })))
Ok(json_response(
StatusCode::OK,
serde_json::json!({ "skills": skills }),
))
}
async fn handle_skill_route(
@@ -1401,7 +1453,7 @@ async fn handle_skill_route(
file_path: Option<String>,
) -> Result<Response<Body>, StatusCode> {
let working_directory = state.opencode.get_working_directory();
// Handle file operations: /api/config/skills/:name/files/*
if let Some(ref fp) = file_path {
match method {
@@ -1410,17 +1462,37 @@ async fn handle_skill_route(
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
Ok(sources) => {
if !sources.md.exists {
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
return Ok(config_error_response(
StatusCode::NOT_FOUND,
"Skill not found",
));
}
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
match opencode_config::read_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await {
Ok(content) => Ok(json_response(StatusCode::OK, SkillFileResponse { path: fp.clone(), content })),
Err(_) => Ok(config_error_response(StatusCode::NOT_FOUND, "File not found")),
match opencode_config::read_skill_supporting_file(
std::path::Path::new(&skill_dir),
fp,
)
.await
{
Ok(content) => Ok(json_response(
StatusCode::OK,
SkillFileResponse {
path: fp.clone(),
content,
},
)),
Err(_) => Ok(config_error_response(
StatusCode::NOT_FOUND,
"File not found",
)),
}
}
Err(err) => {
error!("[desktop:config] Failed to read skill sources: {}", err);
Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to read skill"))
Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to read skill",
))
}
}
}
@@ -1430,25 +1502,46 @@ async fn handle_skill_route(
Ok(data) => data,
Err(resp) => return Ok(resp),
};
let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
let content = payload
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("");
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
Ok(sources) => {
if !sources.md.exists {
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
return Ok(config_error_response(
StatusCode::NOT_FOUND,
"Skill not found",
));
}
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
match opencode_config::write_skill_supporting_file(std::path::Path::new(&skill_dir), fp, content).await {
Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse {
success: true,
requires_reload: false,
message: format!("File {} saved successfully", fp),
reload_delay_ms: 0,
})),
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
match opencode_config::write_skill_supporting_file(
std::path::Path::new(&skill_dir),
fp,
content,
)
.await
{
Ok(()) => Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: false,
message: format!("File {} saved successfully", fp),
reload_delay_ms: 0,
},
)),
Err(err) => Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
)),
}
}
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
Err(err) => Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
)),
}
}
Method::DELETE => {
@@ -1456,20 +1549,37 @@ async fn handle_skill_route(
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
Ok(sources) => {
if !sources.md.exists {
return Ok(config_error_response(StatusCode::NOT_FOUND, "Skill not found"));
return Ok(config_error_response(
StatusCode::NOT_FOUND,
"Skill not found",
));
}
let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
match opencode_config::delete_skill_supporting_file(std::path::Path::new(&skill_dir), fp).await {
Ok(()) => Ok(json_response(StatusCode::OK, ConfigActionResponse {
success: true,
requires_reload: false,
message: format!("File {} deleted successfully", fp),
reload_delay_ms: 0,
})),
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
match opencode_config::delete_skill_supporting_file(
std::path::Path::new(&skill_dir),
fp,
)
.await
{
Ok(()) => Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: false,
message: format!("File {} deleted successfully", fp),
reload_delay_ms: 0,
},
)),
Err(err) => Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
)),
}
}
Err(err) => Ok(config_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())),
Err(err) => Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
)),
}
}
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
@@ -1507,8 +1617,9 @@ async fn handle_skill_route(
Ok(data) => data,
Err(resp) => return Ok(resp),
};
let scope = payload.get("scope")
let scope = payload
.get("scope")
.and_then(|v| v.as_str())
.and_then(|s| match s {
"project" => Some(opencode_config::SkillScope::Project),
@@ -1516,7 +1627,14 @@ async fn handle_skill_route(
_ => None,
});
match opencode_config::create_skill(&name, &payload, Some(&working_directory), scope).await {
match opencode_config::create_skill(
&name,
&payload,
Some(&working_directory),
scope,
)
.await
{
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "skill creation").await
@@ -1552,7 +1670,8 @@ async fn handle_skill_route(
Err(resp) => return Ok(resp),
};
match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await {
match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await
{
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "skill update").await
@@ -1582,37 +1701,39 @@ async fn handle_skill_route(
}
}
}
Method::DELETE => match opencode_config::delete_skill(&name, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "skill deletion").await
{
return Ok(resp);
}
Method::DELETE => {
match opencode_config::delete_skill(&name, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "skill deletion").await
{
return Ok(resp);
}
Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: true,
message: format!(
"Skill {} deleted successfully. Reloading interface...",
name
),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
},
))
Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: true,
message: format!(
"Skill {} deleted successfully. Reloading interface...",
name
),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
},
))
}
Err(err) => {
error!("[desktop:config] Failed to delete skill {}: {}", name, err);
let status = if err.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Ok(config_error_response(status, err.to_string()))
}
}
Err(err) => {
error!("[desktop:config] Failed to delete skill {}: {}", name, err);
let status = if err.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Ok(config_error_response(status, err.to_string()))
}
},
}
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
}
}
@@ -1626,7 +1747,7 @@ async fn handle_command_route(
) -> Result<Response<Body>, StatusCode> {
// Get working directory for project-level command detection
let working_directory = state.opencode.get_working_directory();
match method {
Method::GET => {
match opencode_config::get_command_sources(&name, Some(&working_directory)).await {
@@ -1659,9 +1780,10 @@ async fn handle_command_route(
Ok(data) => data,
Err(resp) => return Ok(resp),
};
// Extract scope from payload if present
let scope = payload.get("scope")
let scope = payload
.get("scope")
.and_then(|v| v.as_str())
.and_then(|s| match s {
"project" => Some(opencode_config::CommandScope::Project),
@@ -1669,7 +1791,9 @@ async fn handle_command_route(
_ => None,
});
match opencode_config::create_command(&name, &payload, Some(&working_directory), scope).await {
match opencode_config::create_command(&name, &payload, Some(&working_directory), scope)
.await
{
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "command creation").await
@@ -1691,7 +1815,10 @@ async fn handle_command_route(
))
}
Err(err) => {
error!("[desktop:config] Failed to create command {}: {}", name, err);
error!(
"[desktop:config] Failed to create command {}: {}",
name, err
);
Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
@@ -1727,7 +1854,10 @@ async fn handle_command_route(
))
}
Err(err) => {
error!("[desktop:config] Failed to update command {}: {}", name, err);
error!(
"[desktop:config] Failed to update command {}: {}",
name, err
);
Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
@@ -1735,37 +1865,42 @@ async fn handle_command_route(
}
}
}
Method::DELETE => match opencode_config::delete_command(&name, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "command deletion").await
{
return Ok(resp);
}
Method::DELETE => {
match opencode_config::delete_command(&name, Some(&working_directory)).await {
Ok(()) => {
if let Err(resp) =
refresh_opencode_after_config_change(state, "command deletion").await
{
return Ok(resp);
}
Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: true,
message: format!(
"Command {} deleted successfully. Reloading interface...",
name
),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
},
))
Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: true,
message: format!(
"Command {} deleted successfully. Reloading interface...",
name
),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
},
))
}
Err(err) => {
error!(
"[desktop:config] Failed to delete command {}: {}",
name, err
);
let status = if err.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Ok(config_error_response(status, err.to_string()))
}
}
Err(err) => {
error!("[desktop:config] Failed to delete command {}: {}", name, err);
let status = if err.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Ok(config_error_response(status, err.to_string()))
}
},
}
_ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()),
}
}
@@ -1818,25 +1953,26 @@ async fn handle_config_routes(
};
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
let scan_request = match serde_json::from_value::<skills_catalog::SkillsScanRequest>(payload_value) {
Ok(v) => v,
Err(_) => {
return Ok(json_response(
StatusCode::BAD_REQUEST,
skills_catalog::SkillsRepoScanResponse {
ok: false,
items: None,
error: Some(skills_catalog::SkillsRepoError {
kind: "invalidSource".to_string(),
message: "Malformed scan request".to_string(),
ssh_only: None,
identities: None,
conflicts: None,
}),
},
))
}
};
let scan_request =
match serde_json::from_value::<skills_catalog::SkillsScanRequest>(payload_value) {
Ok(v) => v,
Err(_) => {
return Ok(json_response(
StatusCode::BAD_REQUEST,
skills_catalog::SkillsRepoScanResponse {
ok: false,
items: None,
error: Some(skills_catalog::SkillsRepoError {
kind: "invalidSource".to_string(),
message: "Malformed scan request".to_string(),
ssh_only: None,
identities: None,
conflicts: None,
}),
},
))
}
};
let response = skills_catalog::scan_repository(scan_request).await;
let status = if response.ok {
@@ -1857,26 +1993,27 @@ async fn handle_config_routes(
};
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
let install_request = match serde_json::from_value::<skills_catalog::SkillsInstallRequest>(payload_value) {
Ok(v) => v,
Err(_) => {
return Ok(json_response(
StatusCode::BAD_REQUEST,
skills_catalog::SkillsInstallResponse {
ok: false,
installed: None,
skipped: None,
error: Some(skills_catalog::SkillsRepoError {
kind: "invalidSource".to_string(),
message: "Malformed install request".to_string(),
ssh_only: None,
identities: None,
conflicts: None,
}),
},
))
}
};
let install_request =
match serde_json::from_value::<skills_catalog::SkillsInstallRequest>(payload_value) {
Ok(v) => v,
Err(_) => {
return Ok(json_response(
StatusCode::BAD_REQUEST,
skills_catalog::SkillsInstallResponse {
ok: false,
installed: None,
skipped: None,
error: Some(skills_catalog::SkillsRepoError {
kind: "invalidSource".to_string(),
message: "Malformed install request".to_string(),
ssh_only: None,
identities: None,
conflicts: None,
}),
},
))
}
};
let working_directory = state.opencode.get_working_directory();
let response = skills_catalog::install_skills(&working_directory, install_request).await;
@@ -1904,7 +2041,7 @@ async fn handle_config_routes(
if let Some(files_start) = rest.find("/files/") {
let name = &rest[..files_start];
let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/"
// Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md")
// Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md")
let file_path = urlencoding::decode(file_path_encoded)
.map(|s| s.into_owned())
.unwrap_or_else(|_| file_path_encoded.to_string());
@@ -1914,9 +2051,10 @@ async fn handle_config_routes(
"Skill name is required",
));
}
return handle_skill_route(&state, method, req, name.to_string(), Some(file_path)).await;
return handle_skill_route(&state, method, req, name.to_string(), Some(file_path))
.await;
}
let trimmed = rest.trim();
if trimmed.is_empty() {
return Ok(config_error_response(
@@ -1939,8 +2077,7 @@ async fn handle_config_routes(
ConfigActionResponse {
success: true,
requires_reload: true,
message: "Configuration reloaded successfully. Refreshing interface..."
.to_string(),
message: "Configuration reloaded successfully. Refreshing interface...".to_string(),
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
},
));
@@ -2063,12 +2200,12 @@ async fn change_directory_handler(
.set_working_directory(resolved_path.clone())
.await
.map_err(|e| {
error!(
"[desktop:http] ERROR: Failed to set working directory: {}",
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
error!(
"[desktop:http] ERROR: Failed to set working directory: {}",
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
state.opencode.restart().await.map_err(|e| {
error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e);
@@ -94,5 +94,3 @@ pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
Ok(true)
}
+253 -113
View File
@@ -199,7 +199,11 @@ struct JsonEntrySource {
section: Option<Value>,
}
fn get_json_entry_source(layers: &ConfigLayers, section_key: &str, entry_name: &str) -> JsonEntrySource {
fn get_json_entry_source(
layers: &ConfigLayers,
section_key: &str,
entry_name: &str,
) -> JsonEntrySource {
if let Some(ref custom_path) = layers.paths.custom {
if let Some(section) = layers.custom.get(section_key).and_then(|v| v.as_object()) {
if let Some(value) = section.get(entry_name) {
@@ -308,30 +312,37 @@ async fn ensure_project_agent_dir(working_directory: &Path) -> Result<PathBuf> {
}
/// Determine agent scope based on where the .md file exists
pub fn get_agent_scope(agent_name: &str, working_directory: Option<&Path>) -> (Option<AgentScope>, Option<PathBuf>) {
pub fn get_agent_scope(
agent_name: &str,
working_directory: Option<&Path>,
) -> (Option<AgentScope>, Option<PathBuf>) {
if let Some(wd) = working_directory {
let project_path = get_project_agent_path(wd, agent_name);
if project_path.exists() {
return (Some(AgentScope::Project), Some(project_path));
}
}
let user_path = get_user_agent_path(agent_name);
if user_path.exists() {
return (Some(AgentScope::User), Some(user_path));
}
(None, None)
}
/// Get the path where an agent should be written based on scope
fn get_agent_write_path(agent_name: &str, working_directory: Option<&Path>, requested_scope: Option<AgentScope>) -> (AgentScope, PathBuf) {
fn get_agent_write_path(
agent_name: &str,
working_directory: Option<&Path>,
requested_scope: Option<AgentScope>,
) -> (AgentScope, PathBuf) {
// For updates: check existing location first (project takes precedence)
let (existing_scope, existing_path) = get_agent_scope(agent_name, working_directory);
if let Some(path) = existing_path {
return (existing_scope.unwrap(), path);
}
// For new agents or built-in overrides: use requested scope or default to user
let scope = requested_scope.unwrap_or(AgentScope::User);
if scope == AgentScope::Project {
@@ -339,7 +350,7 @@ fn get_agent_write_path(agent_name: &str, working_directory: Option<&Path>, requ
return (AgentScope::Project, get_project_agent_path(wd, agent_name));
}
}
(AgentScope::User, get_user_agent_path(agent_name))
}
@@ -368,38 +379,48 @@ async fn ensure_project_command_dir(working_directory: &Path) -> Result<PathBuf>
}
/// Determine command scope based on where the .md file exists
pub fn get_command_scope(command_name: &str, working_directory: Option<&Path>) -> (Option<CommandScope>, Option<PathBuf>) {
pub fn get_command_scope(
command_name: &str,
working_directory: Option<&Path>,
) -> (Option<CommandScope>, Option<PathBuf>) {
if let Some(wd) = working_directory {
let project_path = get_project_command_path(wd, command_name);
if project_path.exists() {
return (Some(CommandScope::Project), Some(project_path));
}
}
let user_path = get_user_command_path(command_name);
if user_path.exists() {
return (Some(CommandScope::User), Some(user_path));
}
(None, None)
}
/// Get the path where a command should be written based on scope
fn get_command_write_path(command_name: &str, working_directory: Option<&Path>, requested_scope: Option<CommandScope>) -> (CommandScope, PathBuf) {
fn get_command_write_path(
command_name: &str,
working_directory: Option<&Path>,
requested_scope: Option<CommandScope>,
) -> (CommandScope, PathBuf) {
// For updates: check existing location first (project takes precedence)
let (existing_scope, existing_path) = get_command_scope(command_name, working_directory);
if let Some(path) = existing_path {
return (existing_scope.unwrap(), path);
}
// For new commands or built-in overrides: use requested scope or default to user
let scope = requested_scope.unwrap_or(CommandScope::User);
if scope == CommandScope::Project {
if let Some(wd) = working_directory {
return (CommandScope::Project, get_project_command_path(wd, command_name));
return (
CommandScope::Project,
get_project_command_path(wd, command_name),
);
}
}
(CommandScope::User, get_user_command_path(command_name))
}
@@ -606,17 +627,20 @@ async fn write_md_file(
}
/// Get information about where agent configuration is stored
pub async fn get_agent_sources(agent_name: &str, working_directory: Option<&Path>) -> Result<ConfigSources> {
pub async fn get_agent_sources(
agent_name: &str,
working_directory: Option<&Path>,
) -> Result<ConfigSources> {
ensure_dirs().await?;
// Check project level first (takes precedence)
let project_path = working_directory.map(|wd| get_project_agent_path(wd, agent_name));
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
// Then check user level
let user_path = get_user_agent_path(agent_name);
let user_exists = user_path.exists();
// Determine which md file to use (project takes precedence)
let (md_path, md_exists, md_scope) = if project_exists {
(project_path.clone(), true, Some(Scope::Project))
@@ -684,10 +708,10 @@ pub async fn get_agent_sources(agent_name: &str, working_directory: Option<&Path
/// Create new agent as .md file
pub async fn create_agent(
agent_name: &str,
agent_name: &str,
config: &HashMap<String, Value>,
working_directory: Option<&Path>,
scope: Option<AgentScope>
scope: Option<AgentScope>,
) -> Result<()> {
ensure_dirs().await?;
@@ -701,7 +725,7 @@ pub async fn create_agent(
));
}
}
let user_path = get_user_agent_path(agent_name);
if user_path.exists() {
return Err(anyhow!(
@@ -741,7 +765,12 @@ pub async fn create_agent(
// Write .md file
write_md_file(&target_path, &frontmatter, &prompt).await?;
info!("Created new agent: {} (scope: {:?}, path: {})", agent_name, target_scope, target_path.display());
info!(
"Created new agent: {} (scope: {:?}, path: {})",
agent_name,
target_scope,
target_path.display()
);
Ok(())
}
@@ -757,7 +786,7 @@ pub async fn update_agent(
// Determine correct path: project level takes precedence
let (scope, md_path) = get_agent_write_path(agent_name, working_directory, None);
let md_exists = md_path.exists();
// Check if agent exists in opencode.json across all config layers
let mut layers = read_config_layers(working_directory).await?;
let json_source = get_json_entry_source(&layers, "agent", agent_name);
@@ -783,11 +812,11 @@ pub async fn update_agent(
get_json_write_target(&layers, preferred_scope)
};
let config = get_config_for_path(&mut layers, &json_target_path);
// Determine if we should create a new md file:
// Only for built-in agents (no md file AND no json config)
let is_builtin_override = !md_exists && !had_json_fields;
let target_path = if !md_exists && is_builtin_override {
// Built-in agent override - create at user level
get_user_agent_path(agent_name)
@@ -799,11 +828,14 @@ pub async fn update_agent(
Some(parse_md_file(&md_path).await?)
} else if is_builtin_override {
// Only create new md data for built-in overrides
Some(MdData { frontmatter: HashMap::new(), body: String::new() })
Some(MdData {
frontmatter: HashMap::new(),
body: String::new(),
})
} else {
None
};
// Only create new md if it's a built-in override
let creating_new_md = is_builtin_override;
@@ -836,8 +868,7 @@ pub async fn update_agent(
md_modified = true;
}
continue;
} else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str())
{
} else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str()) {
if is_prompt_file_reference(prompt_ref) {
if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) {
write_prompt_file(&prompt_file_path, &normalized_value).await?;
@@ -941,7 +972,10 @@ pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) ->
let project_path = get_project_agent_path(wd, agent_name);
if project_path.exists() {
fs::remove_file(&project_path).await?;
info!("Deleted project-level agent .md file: {}", project_path.display());
info!(
"Deleted project-level agent .md file: {}",
project_path.display()
);
deleted = true;
}
}
@@ -1004,17 +1038,20 @@ pub async fn delete_agent(agent_name: &str, working_directory: Option<&Path>) ->
}
/// Get information about where command configuration is stored
pub async fn get_command_sources(command_name: &str, working_directory: Option<&Path>) -> Result<ConfigSources> {
pub async fn get_command_sources(
command_name: &str,
working_directory: Option<&Path>,
) -> Result<ConfigSources> {
ensure_dirs().await?;
// Check project level first (takes precedence)
let project_path = working_directory.map(|wd| get_project_command_path(wd, command_name));
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
// Then check user level
let user_path = get_user_command_path(command_name);
let user_exists = user_path.exists();
// Determine which md file to use (project takes precedence)
let (md_path, md_exists, md_scope) = if project_exists {
(project_path.clone(), true, Some(Scope::Project))
@@ -1082,10 +1119,10 @@ pub async fn get_command_sources(command_name: &str, working_directory: Option<&
/// Create new command as .md file
pub async fn create_command(
command_name: &str,
command_name: &str,
config: &HashMap<String, Value>,
working_directory: Option<&Path>,
scope: Option<CommandScope>
scope: Option<CommandScope>,
) -> Result<()> {
ensure_dirs().await?;
@@ -1099,7 +1136,7 @@ pub async fn create_command(
));
}
}
let user_path = get_user_command_path(command_name);
if user_path.exists() {
return Err(anyhow!(
@@ -1121,7 +1158,10 @@ pub async fn create_command(
let (target_scope, target_path) = if scope == Some(CommandScope::Project) {
if let Some(wd) = working_directory {
ensure_project_command_dir(wd).await?;
(CommandScope::Project, get_project_command_path(wd, command_name))
(
CommandScope::Project,
get_project_command_path(wd, command_name),
)
} else {
(CommandScope::User, user_path)
}
@@ -1139,7 +1179,12 @@ pub async fn create_command(
// Write .md file
write_md_file(&target_path, &frontmatter, &template).await?;
info!("Created new command: {} (scope: {:?}, path: {})", command_name, target_scope, target_path.display());
info!(
"Created new command: {} (scope: {:?}, path: {})",
command_name,
target_scope,
target_path.display()
);
Ok(())
}
@@ -1194,11 +1239,14 @@ pub async fn update_command(
let mut md_data = if md_exists {
Some(parse_md_file(&md_path).await?)
} else if is_builtin_override {
Some(MdData { frontmatter: HashMap::new(), body: String::new() })
Some(MdData {
frontmatter: HashMap::new(),
body: String::new(),
})
} else {
None
};
let creating_new_md = is_builtin_override;
let mut md_modified = false;
@@ -1230,7 +1278,9 @@ pub async fn update_command(
md_modified = true;
}
continue;
} else if let Some(template_ref) = existing_command.get("template").and_then(|v| v.as_str()) {
} else if let Some(template_ref) =
existing_command.get("template").and_then(|v| v.as_str())
{
if is_prompt_file_reference(template_ref) {
if let Some(template_file_path) = resolve_prompt_file_path(template_ref) {
write_prompt_file(&template_file_path, &normalized_value).await?;
@@ -1334,7 +1384,10 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path>
let project_path = get_project_command_path(wd, command_name);
if project_path.exists() {
fs::remove_file(&project_path).await?;
info!("Deleted project-level command .md file: {}", project_path.display());
info!(
"Deleted project-level command .md file: {}",
project_path.display()
);
deleted = true;
}
}
@@ -1343,7 +1396,10 @@ pub async fn delete_command(command_name: &str, working_directory: Option<&Path>
let user_path = get_user_command_path(command_name);
if user_path.exists() {
fs::remove_file(&user_path).await?;
info!("Deleted user-level command .md file: {}", user_path.display());
info!(
"Deleted user-level command .md file: {}",
user_path.display()
);
deleted = true;
}
@@ -1471,7 +1527,10 @@ fn get_user_skill_path(skill_name: &str) -> PathBuf {
/// Get project-level skill directory (.opencode/skill/)
fn get_project_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
working_directory.join(".opencode").join("skill").join(skill_name)
working_directory
.join(".opencode")
.join("skill")
.join(skill_name)
}
/// Get project-level skill SKILL.md path
@@ -1481,7 +1540,10 @@ fn get_project_skill_path(working_directory: &Path, skill_name: &str) -> PathBuf
/// Get Claude-compatible skill directory (.claude/skills/)
fn get_claude_skill_dir(working_directory: &Path, skill_name: &str) -> PathBuf {
working_directory.join(".claude").join("skills").join(skill_name)
working_directory
.join(".claude")
.join("skills")
.join(skill_name)
}
/// Get Claude-compatible skill SKILL.md path
@@ -1504,46 +1566,62 @@ async fn ensure_project_skill_dir(working_directory: &Path, skill_name: &str) ->
}
/// Determine skill scope based on where the SKILL.md file exists
pub fn get_skill_scope(skill_name: &str, working_directory: Option<&Path>) -> (Option<SkillScope>, Option<PathBuf>, Option<SkillSource>) {
pub fn get_skill_scope(
skill_name: &str,
working_directory: Option<&Path>,
) -> (Option<SkillScope>, Option<PathBuf>, Option<SkillSource>) {
if let Some(wd) = working_directory {
// Check .opencode/skill first
let project_path = get_project_skill_path(wd, skill_name);
if project_path.exists() {
return (Some(SkillScope::Project), Some(project_path), Some(SkillSource::Opencode));
return (
Some(SkillScope::Project),
Some(project_path),
Some(SkillSource::Opencode),
);
}
// Check .claude/skills (claude-compat)
let claude_path = get_claude_skill_path(wd, skill_name);
if claude_path.exists() {
return (Some(SkillScope::Project), Some(claude_path), Some(SkillSource::Claude));
return (
Some(SkillScope::Project),
Some(claude_path),
Some(SkillSource::Claude),
);
}
}
let user_path = get_user_skill_path(skill_name);
if user_path.exists() {
return (Some(SkillScope::User), Some(user_path), Some(SkillSource::Opencode));
return (
Some(SkillScope::User),
Some(user_path),
Some(SkillSource::Opencode),
);
}
(None, None, None)
}
/// List supporting files in a skill directory (excluding SKILL.md)
fn list_supporting_files(skill_dir: &Path) -> Vec<SupportingFile> {
let mut files = Vec::new();
fn walk_dir(dir: &Path, relative_base: &Path, files: &mut Vec<SupportingFile>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
walk_dir(&path, relative_base, files);
} else if file_name != "SKILL.md" {
let relative_path = path.strip_prefix(relative_base)
let relative_path = path
.strip_prefix(relative_base)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| file_name.clone());
files.push(SupportingFile {
name: file_name,
path: relative_path,
@@ -1553,27 +1631,31 @@ fn list_supporting_files(skill_dir: &Path) -> Vec<SupportingFile> {
}
}
}
walk_dir(skill_dir, skill_dir, &mut files);
files
}
/// Discover all skills from all sources
pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill> {
let mut skills: std::collections::HashMap<String, DiscoveredSkill> = std::collections::HashMap::new();
let mut skills: std::collections::HashMap<String, DiscoveredSkill> =
std::collections::HashMap::new();
// Helper to add skill if not already found
let mut add_skill = |name: String, path: PathBuf, scope: Scope, source: SkillSource| {
if !skills.contains_key(&name) {
skills.insert(name.clone(), DiscoveredSkill {
name,
path: path.display().to_string(),
scope,
source,
});
skills.insert(
name.clone(),
DiscoveredSkill {
name,
path: path.display().to_string(),
scope,
source,
},
);
}
};
// 1. Project level .opencode/skill/ (highest priority)
if let Some(wd) = working_directory {
let project_skill_dir = wd.join(".opencode").join("skill");
@@ -1590,7 +1672,7 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill>
}
}
}
// 2. Claude-compatible .claude/skills/
let claude_skill_dir = wd.join(".claude").join("skills");
if claude_skill_dir.exists() {
@@ -1607,7 +1689,7 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill>
}
}
}
// 3. User level ~/.config/opencode/skill/
let user_skill_dir = get_skill_dir();
if user_skill_dir.exists() {
@@ -1623,53 +1705,90 @@ pub fn discover_skills(working_directory: Option<&Path>) -> Vec<DiscoveredSkill>
}
}
}
skills.into_values().collect()
}
/// Get information about where skill configuration is stored
pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path>) -> Result<SkillConfigSources> {
pub async fn get_skill_sources(
skill_name: &str,
working_directory: Option<&Path>,
) -> Result<SkillConfigSources> {
ensure_skill_dirs().await?;
// Check all possible locations
let project_path = working_directory.map(|wd| get_project_skill_path(wd, skill_name));
let project_exists = project_path.as_ref().map(|p| p.exists()).unwrap_or(false);
let project_dir = project_exists.then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name))).flatten();
let project_dir = project_exists
.then(|| working_directory.map(|wd| get_project_skill_dir(wd, skill_name)))
.flatten();
let claude_path = working_directory.map(|wd| get_claude_skill_path(wd, skill_name));
let claude_exists = claude_path.as_ref().map(|p| p.exists()).unwrap_or(false);
let claude_dir = claude_exists.then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name))).flatten();
let claude_dir = claude_exists
.then(|| working_directory.map(|wd| get_claude_skill_dir(wd, skill_name)))
.flatten();
let user_path = get_user_skill_path(skill_name);
let user_exists = user_path.exists();
let user_dir = if user_exists { Some(get_user_skill_dir(skill_name)) } else { None };
let user_dir = if user_exists {
Some(get_user_skill_dir(skill_name))
} else {
None
};
// Determine which md file to use (priority: project > claude > user)
let (md_path, md_exists, md_scope, md_source, md_dir) = if project_exists {
(project_path.clone(), true, Some(Scope::Project), Some(SkillSource::Opencode), project_dir.clone())
(
project_path.clone(),
true,
Some(Scope::Project),
Some(SkillSource::Opencode),
project_dir.clone(),
)
} else if claude_exists {
(claude_path.clone(), true, Some(Scope::Project), Some(SkillSource::Claude), claude_dir.clone())
(
claude_path.clone(),
true,
Some(Scope::Project),
Some(SkillSource::Claude),
claude_dir.clone(),
)
} else if user_exists {
(Some(user_path.clone()), true, Some(Scope::User), Some(SkillSource::Opencode), user_dir.clone())
(
Some(user_path.clone()),
true,
Some(Scope::User),
Some(SkillSource::Opencode),
user_dir.clone(),
)
} else {
(None, false, None, None, None)
};
let mut md_fields = Vec::new();
let mut supporting_files = Vec::new();
let mut md_name: Option<String> = None;
let mut md_description: Option<String> = None;
let mut md_instructions: Option<String> = None;
if md_exists {
if let Some(ref path) = md_path {
let md_data = parse_md_file(path).await?;
md_fields.extend(md_data.frontmatter.keys().cloned());
// Extract actual content values
md_name = md_data.frontmatter.get("name").and_then(|v| v.as_str()).map(|s| s.to_string());
md_description = md_data.frontmatter.get("description").and_then(|v| v.as_str()).map(|s| s.to_string());
md_name = md_data
.frontmatter
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
md_description = md_data
.frontmatter
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if !md_data.body.trim().is_empty() {
md_fields.push("instructions".to_string());
md_instructions = Some(md_data.body.clone());
@@ -1679,7 +1798,7 @@ pub async fn get_skill_sources(skill_name: &str, working_directory: Option<&Path
supporting_files = list_supporting_files(dir);
}
}
Ok(SkillConfigSources {
md: SkillSourceInfo {
exists: md_exists,
@@ -1719,7 +1838,11 @@ pub async fn read_skill_supporting_file(skill_dir: &Path, relative_path: &str) -
}
/// Write a supporting file
pub async fn write_skill_supporting_file(skill_dir: &Path, relative_path: &str, content: &str) -> Result<()> {
pub async fn write_skill_supporting_file(
skill_dir: &Path,
relative_path: &str,
content: &str,
) -> Result<()> {
let full_path = skill_dir.join(relative_path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?;
@@ -1735,7 +1858,7 @@ pub async fn delete_skill_supporting_file(skill_dir: &Path, relative_path: &str)
if full_path.exists() {
fs::remove_file(&full_path).await?;
info!("Deleted supporting file: {}", full_path.display());
// Clean up empty parent directories
let mut parent = full_path.parent();
while let Some(p) = parent {
@@ -1778,13 +1901,13 @@ pub async fn create_skill(
) -> Result<()> {
ensure_skill_dirs().await?;
validate_skill_name(skill_name)?;
// Check if skill already exists
let (_existing_scope, existing_path, _) = get_skill_scope(skill_name, working_directory);
if existing_path.is_some() {
return Err(anyhow!("Skill {} already exists", skill_name));
}
// Determine target directory
let (target_scope, target_dir) = if scope == Some(SkillScope::Project) {
if let Some(wd) = working_directory {
@@ -1800,9 +1923,9 @@ pub async fn create_skill(
fs::create_dir_all(&dir).await?;
(SkillScope::User, dir)
};
let target_path = target_dir.join("SKILL.md");
// Extract fields
let mut frontmatter = config.clone();
let instructions = frontmatter
@@ -1811,7 +1934,7 @@ pub async fn create_skill(
.unwrap_or_default();
frontmatter.remove("scope");
frontmatter.remove("supportingFiles");
// Ensure required fields
if !frontmatter.contains_key("name") {
frontmatter.insert("name".to_string(), Value::String(skill_name.to_string()));
@@ -1819,9 +1942,9 @@ pub async fn create_skill(
if !frontmatter.contains_key("description") {
return Err(anyhow!("Skill description is required"));
}
write_md_file(&target_path, &frontmatter, &instructions).await?;
// Write supporting files if provided
if let Some(supporting_files) = config.get("supportingFiles").and_then(|v| v.as_array()) {
for file in supporting_files {
@@ -1833,8 +1956,13 @@ pub async fn create_skill(
}
}
}
info!("Created new skill: {} (scope: {:?}, path: {})", skill_name, target_scope, target_path.display());
info!(
"Created new skill: {} (scope: {:?}, path: {})",
skill_name,
target_scope,
target_path.display()
);
Ok(())
}
@@ -1846,23 +1974,25 @@ pub async fn update_skill(
) -> Result<()> {
let (_, existing_path, _) = get_skill_scope(skill_name, working_directory);
let md_path = existing_path.ok_or_else(|| anyhow!("Skill \"{}\" not found", skill_name))?;
let md_dir = md_path.parent().ok_or_else(|| anyhow!("Invalid skill path"))?;
let md_dir = md_path
.parent()
.ok_or_else(|| anyhow!("Invalid skill path"))?;
let mut md_data = parse_md_file(&md_path).await?;
let mut md_modified = false;
for (field, value) in updates.iter() {
if field == "scope" {
continue;
}
if field == "instructions" {
let normalized = value.as_str().unwrap_or("").to_string();
md_data.body = normalized;
md_modified = true;
continue;
}
if field == "supportingFiles" {
if let Some(files) = value.as_array() {
for file in files {
@@ -1880,42 +2010,52 @@ pub async fn update_skill(
}
continue;
}
md_data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
if md_modified {
write_md_file(&md_path, &md_data.frontmatter, &md_data.body).await?;
}
info!("Updated skill: {} (path: {})", skill_name, md_path.display());
info!(
"Updated skill: {} (path: {})",
skill_name,
md_path.display()
);
Ok(())
}
/// Delete skill
pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) -> Result<()> {
let mut deleted = false;
// Check and delete from all locations
if let Some(wd) = working_directory {
// Project level .opencode/skill/
let project_dir = get_project_skill_dir(wd, skill_name);
if project_dir.exists() {
fs::remove_dir_all(&project_dir).await?;
info!("Deleted project-level skill directory: {}", project_dir.display());
info!(
"Deleted project-level skill directory: {}",
project_dir.display()
);
deleted = true;
}
// Claude-compat .claude/skills/
let claude_dir = get_claude_skill_dir(wd, skill_name);
if claude_dir.exists() {
fs::remove_dir_all(&claude_dir).await?;
info!("Deleted claude-compat skill directory: {}", claude_dir.display());
info!(
"Deleted claude-compat skill directory: {}",
claude_dir.display()
);
deleted = true;
}
}
// User level
let user_dir = get_user_skill_dir(skill_name);
if user_dir.exists() {
@@ -1923,10 +2063,10 @@ pub async fn delete_skill(skill_name: &str, working_directory: Option<&Path>) ->
info!("Deleted user-level skill directory: {}", user_dir.display());
deleted = true;
}
if !deleted {
return Err(anyhow!("Skill \"{}\" not found", skill_name));
}
Ok(())
}
@@ -268,14 +268,12 @@ impl OpenCodeManager {
}
async fn spawn_process(&self) -> Result<Child> {
let binary = self.binary.as_ref().ok_or_else(|| {
anyhow!("Cannot spawn process: OpenCode CLI is not available")
})?;
let binary = self
.binary
.as_ref()
.ok_or_else(|| anyhow!("Cannot spawn process: OpenCode CLI is not available"))?;
info!(
"[desktop:opencode] launching {} {:?}",
binary, self.args
);
info!("[desktop:opencode] launching {} {:?}", binary, self.args);
let working_dir = self.working_dir.read().clone();
let mut cmd = Command::new(binary);
@@ -520,7 +518,10 @@ fn resolve_opencode_binary() -> Option<String> {
if let Ok(value) = std::env::var("OPENCODE_BINARY") {
if !value.is_empty() && Path::new(&value).exists() {
info!("[desktop:opencode] using binary from OPENCODE_BINARY env: {}", value);
info!(
"[desktop:opencode] using binary from OPENCODE_BINARY env: {}",
value
);
return Some(value);
}
}
@@ -529,7 +530,10 @@ fn resolve_opencode_binary() -> Option<String> {
if let Some(ref binary) = shell_env.opencode_binary {
if Path::new(binary).exists() {
info!("[desktop:opencode] using binary from shell OPENCODE_BINARY: {}", binary);
info!(
"[desktop:opencode] using binary from shell OPENCODE_BINARY: {}",
binary
);
return Some(binary.clone());
}
}
@@ -547,7 +551,10 @@ fn resolve_opencode_binary() -> Option<String> {
if let Some(home) = dirs::home_dir() {
let fallback = home.join(".opencode/bin/opencode");
if fallback.exists() {
info!("[desktop:opencode] found binary in fallback location: {:?}", fallback);
info!(
"[desktop:opencode] found binary in fallback location: {:?}",
fallback
);
return Some(fallback.to_string_lossy().to_string());
}
}
@@ -590,8 +597,8 @@ struct ShellEnv {
fn get_user_shell() -> Option<String> {
use std::process::Command;
let username = dirs::home_dir()
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
let username =
dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
let output = Command::new("dscl")
.args([".", "-read", &format!("/Users/{}", username), "UserShell"])
@@ -663,7 +670,10 @@ fn detect_shell_env() -> ShellEnv {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("[desktop:opencode] shell env detection failed for {}, stderr: {}", shell, stderr);
warn!(
"[desktop:opencode] shell env detection failed for {}, stderr: {}",
shell, stderr
);
return ShellEnv::default();
}
@@ -683,7 +693,10 @@ fn detect_shell_env() -> ShellEnv {
}
}
info!("[desktop:opencode] parsed path exists: {}", env.path.is_some());
info!(
"[desktop:opencode] parsed path exists: {}",
env.path.is_some()
);
env
}
}
@@ -18,4 +18,3 @@ pub fn expand_tilde_path(value: &str) -> PathBuf {
PathBuf::from(trimmed)
}
@@ -1,8 +1,4 @@
use std::{
collections::HashMap,
sync::Arc,
time::Duration,
};
use std::{collections::HashMap, sync::Arc, time::Duration};
use anyhow::Result;
use futures_util::TryStreamExt;
@@ -57,7 +53,10 @@ pub fn spawn_session_activity_tracker(
let mut shutdown_rx = runtime.subscribe_shutdown();
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
let cooldowns = Arc::new(Mutex::new(HashMap::<String, tauri::async_runtime::JoinHandle<()>>::new()));
let cooldowns = Arc::new(Mutex::new(HashMap::<
String,
tauri::async_runtime::JoinHandle<()>,
>::new()));
loop {
tokio::select! {
@@ -112,7 +111,11 @@ async fn run_once(
loop {
buf.clear();
let bytes_read = match tokio::time::timeout(Duration::from_secs(2), reader.read_until(b'\n', &mut buf)).await
let bytes_read = match tokio::time::timeout(
Duration::from_secs(2),
reader.read_until(b'\n', &mut buf),
)
.await
{
Ok(Ok(n)) => n,
Ok(Err(err)) => {
@@ -155,7 +158,9 @@ async fn run_once(
data_lines.clear();
match parse_event_envelope(&raw) {
Ok((event, _directory)) => handle_event(app, event, phases.clone(), cooldowns.clone()).await,
Ok((event, _directory)) => {
handle_event(app, event, phases.clone(), cooldowns.clone()).await
}
Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"),
};
continue;
@@ -214,7 +219,9 @@ async fn connect_activity_sse(
let working_dir = opencode.get_working_directory();
let directory = working_dir.to_string_lossy().to_string();
let mut parsed = reqwest::Url::parse(&event_url)?;
parsed.query_pairs_mut().append_pair("directory", &directory);
parsed
.query_pairs_mut()
.append_pair("directory", &directory);
let directory_url = parsed.to_string();
let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?;
@@ -222,7 +229,11 @@ async fn connect_activity_sse(
Ok((response, SseScope::Directory(working_dir)))
}
async fn try_connect_sse(client: &Client, url: &str, log_prefix: &str) -> Result<reqwest::Response> {
async fn try_connect_sse(
client: &Client,
url: &str,
log_prefix: &str,
) -> Result<reqwest::Response> {
debug!("{log_prefix} Connecting SSE: {url}");
let response = client
@@ -280,7 +291,14 @@ async fn handle_event(
.and_then(Value::as_str)
.map(|s| s.to_string());
if let Some(id) = session_id {
set_phase(app, &id, ActivityPhase::Idle, phases.clone(), cooldowns.clone()).await;
set_phase(
app,
&id,
ActivityPhase::Idle,
phases.clone(),
cooldowns.clone(),
)
.await;
}
}
"message.updated" => {
@@ -326,7 +344,14 @@ async fn handle_event(
// Mark session busy when we see assistant parts streaming (covers cases where session.status is missing).
if is_streaming_assistant_part(&event.properties) {
set_phase(app, &id, ActivityPhase::Busy, phases.clone(), cooldowns.clone()).await;
set_phase(
app,
&id,
ActivityPhase::Busy,
phases.clone(),
cooldowns.clone(),
)
.await;
}
// Derive cooldown from info.finish === 'stop' when present.
+210 -52
View File
@@ -11,9 +11,8 @@ use uuid::Uuid;
use crate::opencode_config;
static SKILL_NAME_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex")
});
static SKILL_NAME_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").expect("valid skill name regex"));
static AUTH_ERROR_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(permission denied|publickey|could not read from remote repository|authentication failed)")
@@ -188,7 +187,10 @@ fn list_identities() -> Vec<IdentitySummary> {
wrapper
.profiles
.into_iter()
.map(|p| IdentitySummary { id: p.id, name: p.name })
.map(|p| IdentitySummary {
id: p.id,
name: p.name,
})
.collect()
}
@@ -314,7 +316,12 @@ fn parse_skill_md_frontmatter(contents: &str) -> (Option<String>, Option<String>
(name, description, warnings)
}
async fn run_git(args: &[String], cwd: &Path, ssh_key: Option<&str>, timeout: Duration) -> Result<(String, String)> {
async fn run_git(
args: &[String],
cwd: &Path,
ssh_key: Option<&str>,
timeout: Duration,
) -> Result<(String, String)> {
let mut cmd = Command::new("git");
if let Some(key) = ssh_key {
@@ -324,7 +331,8 @@ async fn run_git(args: &[String], cwd: &Path, ssh_key: Option<&str>, timeout: Du
"ssh -i {} -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
key
);
cmd.arg("-c").arg(format!("core.sshCommand={}", ssh_command));
cmd.arg("-c")
.arg(format!("core.sshCommand={}", ssh_command));
}
}
@@ -404,7 +412,10 @@ async fn clone_repo(clone_url: &str, target_dir: &Path, ssh_key: Option<&str>) -
let cwd = std::env::temp_dir();
if run_git(&preferred, &cwd, ssh_key, Duration::from_secs(60)).await.is_ok() {
if run_git(&preferred, &cwd, ssh_key, Duration::from_secs(60))
.await
.is_ok()
{
return Ok(());
}
@@ -421,7 +432,18 @@ async fn scan_repo_items(
subpath: Option<&str>,
default_subpath: Option<&str>,
ssh_key: Option<&str>,
) -> Result<(String, Option<String>, Vec<(String, String, Option<String>, Option<String>, Vec<String>, bool)>)> {
) -> Result<(
String,
Option<String>,
Vec<(
String,
String,
Option<String>,
Option<String>,
Vec<String>,
bool,
)>,
)> {
let parsed = parse_repo_source(source, subpath)?;
let effective_subpath = parsed
.effective_subpath
@@ -435,7 +457,10 @@ async fn scan_repo_items(
parsed.clone_https.clone()
};
let temp_base = std::env::temp_dir().join(format!("openchamber-desktop-skills-scan-{}", Uuid::new_v4()));
let temp_base = std::env::temp_dir().join(format!(
"openchamber-desktop-skills-scan-{}",
Uuid::new_v4()
));
// Clone into temp_base (directory must not exist for git clone target)
let _ = tokio::fs::remove_dir_all(&temp_base).await;
@@ -482,7 +507,13 @@ async fn scan_repo_items(
];
set_args.extend(patterns.clone());
let sparse_set = run_git(&set_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(30)).await;
let sparse_set = run_git(
&set_args,
&std::env::temp_dir(),
ssh_key,
Duration::from_secs(30),
)
.await;
if sparse_set.is_ok() {
let checkout = run_git(
&vec![
@@ -539,7 +570,13 @@ async fn scan_repo_items(
list_args.push(sp.clone());
}
let list_out = run_git(&list_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(30)).await;
let list_out = run_git(
&list_args,
&std::env::temp_dir(),
ssh_key,
Duration::from_secs(30),
)
.await;
let stdout = match list_out {
Ok((out, _)) => out,
Err(_) => {
@@ -605,7 +642,14 @@ async fn scan_repo_items(
format!("HEAD:{}", skill_md_repo_path),
];
match run_git(&show_args, &std::env::temp_dir(), ssh_key, Duration::from_secs(15)).await {
match run_git(
&show_args,
&std::env::temp_dir(),
ssh_key,
Duration::from_secs(15),
)
.await
{
Ok((out, _)) => out,
Err(_) => {
warnings.push("Failed to read SKILL.md".to_string());
@@ -615,7 +659,8 @@ async fn scan_repo_items(
}
};
let (frontmatter_name, description, mut fm_warnings) = parse_skill_md_frontmatter(&contents);
let (frontmatter_name, description, mut fm_warnings) =
parse_skill_md_frontmatter(&contents);
warnings.append(&mut fm_warnings);
let installable = validate_skill_name(&skill_name);
@@ -644,7 +689,8 @@ struct CacheEntry {
items: Vec<SkillsCatalogItem>,
}
static CATALOG_CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> = Lazy::new(|| Mutex::new(HashMap::new()));
static CATALOG_CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&str>) -> String {
format!(
@@ -656,13 +702,12 @@ fn cache_key(normalized_repo: &str, subpath: Option<&str>, identity_id: Option<&
}
fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
let settings_path = dirs::home_dir()
.map(|mut home| {
home.push(".config");
home.push("openchamber");
home.push("settings.json");
home
});
let settings_path = dirs::home_dir().map(|mut home| {
home.push(".config");
home.push("openchamber");
home.push("settings.json");
home
});
let Some(path) = settings_path else {
return vec![];
@@ -684,13 +729,31 @@ fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
let mut seen = std::collections::HashSet::new();
for entry in arr {
let Some(obj) = entry.as_object() else { continue };
let Some(obj) = entry.as_object() else {
continue;
};
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or("").trim();
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();
let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim();
let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim();
let label = obj
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let source = obj
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let subpath = obj
.get("subpath")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let git_identity_id = obj
.get("gitIdentityId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if id.is_empty() || label.is_empty() || source.is_empty() {
continue;
@@ -706,8 +769,16 @@ fn load_custom_catalog_sources() -> Vec<SkillsCatalogSource> {
label: label.to_string(),
description: Some(source.to_string()),
source: source.to_string(),
default_subpath: if subpath.is_empty() { None } else { Some(subpath.to_string()) },
git_identity_id: if git_identity_id.is_empty() { None } else { Some(git_identity_id.to_string()) },
default_subpath: if subpath.is_empty() {
None
} else {
Some(subpath.to_string())
},
git_identity_id: if git_identity_id.is_empty() {
None
} else {
Some(git_identity_id.to_string())
},
});
}
@@ -732,8 +803,10 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
let sources = get_curated_sources().await;
let discovered = opencode_config::discover_skills(Some(working_directory));
let installed_by_name: HashMap<String, opencode_config::DiscoveredSkill> =
discovered.into_iter().map(|s| (s.name.clone(), s)).collect();
let installed_by_name: HashMap<String, opencode_config::DiscoveredSkill> = discovered
.into_iter()
.map(|s| (s.name.clone(), s))
.collect();
let mut items_by_source: HashMap<String, Vec<SkillsCatalogItem>> = HashMap::new();
@@ -752,7 +825,11 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
.or(parsed.effective_subpath.as_deref())
.unwrap_or("");
let key = cache_key(&parsed.normalized_repo, Some(effective_subpath), src.git_identity_id.as_deref());
let key = cache_key(
&parsed.normalized_repo,
Some(effective_subpath),
src.git_identity_id.as_deref(),
);
let maybe_cached = if refresh {
None
@@ -773,7 +850,13 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
items
} else {
let ssh_key = resolve_identity_ssh_key(src.git_identity_id.as_deref());
let scan = scan_repo_items(&src.source, None, src.default_subpath.as_deref(), ssh_key.as_deref()).await;
let scan = scan_repo_items(
&src.source,
None,
src.default_subpath.as_deref(),
ssh_key.as_deref(),
)
.await;
let (_, _, raw_items) = match scan {
Ok(v) => v,
@@ -804,7 +887,11 @@ pub async fn get_catalog(working_directory: &Path, refresh: bool) -> SkillsCatal
frontmatter_name: fm_name,
description: desc,
installable,
warnings: if warnings.is_empty() { None } else { Some(warnings) },
warnings: if warnings.is_empty() {
None
} else {
Some(warnings)
},
installed: SkillsCatalogInstalledBadge {
is_installed: installed.is_some(),
scope: installed.map(|s| match s.scope {
@@ -865,7 +952,14 @@ pub struct SkillsScanRequest {
pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse {
let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref());
match scan_repo_items(&req.source, req.subpath.as_deref(), None, ssh_key.as_deref()).await {
match scan_repo_items(
&req.source,
req.subpath.as_deref(),
None,
ssh_key.as_deref(),
)
.await
{
Ok((_normalized, effective_subpath, raw_items)) => {
let mut items = vec![];
for (repo_source, skill_dir, fm_name, desc, warnings, installable) in raw_items {
@@ -886,8 +980,15 @@ pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse {
frontmatter_name: fm_name,
description: desc,
installable,
warnings: if warnings.is_empty() { None } else { Some(warnings) },
installed: SkillsCatalogInstalledBadge { is_installed: false, scope: None },
warnings: if warnings.is_empty() {
None
} else {
Some(warnings)
},
installed: SkillsCatalogInstalledBadge {
is_installed: false,
scope: None,
},
});
}
items.sort_by(|a, b| a.skill_name.cmp(&b.skill_name));
@@ -903,7 +1004,9 @@ pub async fn scan_repository(req: SkillsScanRequest) -> SkillsRepoScanResponse {
return SkillsRepoScanResponse {
ok: false,
items: None,
error: Some(auth_required_error("Authentication required to access this repository")),
error: Some(auth_required_error(
"Authentication required to access this repository",
)),
};
}
@@ -948,7 +1051,10 @@ fn target_skill_dir(scope: &str, working_directory: &Path, skill_name: &str) ->
}
if scope == "project" {
return Ok(working_directory.join(".opencode").join("skill").join(skill_name));
return Ok(working_directory
.join(".opencode")
.join("skill")
.join(skill_name));
}
Err(anyhow!("Invalid scope"))
@@ -1017,7 +1123,10 @@ async fn copy_dir_no_symlinks(src: &Path, dst: &Path) -> Result<()> {
Ok(())
}
pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest) -> SkillsInstallResponse {
pub async fn install_skills(
working_directory: &Path,
req: SkillsInstallRequest,
) -> SkillsInstallResponse {
let ssh_key = resolve_identity_ssh_key(req.git_identity_id.as_deref());
let selections: Vec<String> = req
@@ -1032,7 +1141,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
ok: false,
installed: None,
skipped: None,
error: Some(simple_error("invalidSource", "No skills selected for installation")),
error: Some(simple_error(
"invalidSource",
"No skills selected for installation",
)),
};
}
@@ -1072,7 +1184,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
let auto = req.conflict_policy.as_deref().unwrap_or("prompt");
if decision.is_none() && auto != "skipAll" && auto != "overwriteAll" {
conflicts.push(SkillConflict { skill_name, scope: req.scope.clone() });
conflicts.push(SkillConflict {
skill_name,
scope: req.scope.clone(),
});
}
}
}
@@ -1105,7 +1220,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
parsed.clone_https.clone()
};
let temp_base = std::env::temp_dir().join(format!("openchamber-desktop-skills-install-{}", Uuid::new_v4()));
let temp_base = std::env::temp_dir().join(format!(
"openchamber-desktop-skills-install-{}",
Uuid::new_v4()
));
let _ = tokio::fs::remove_dir_all(&temp_base).await;
let clone_res = clone_repo(&clone_url, &temp_base, ssh_key.as_deref()).await;
@@ -1116,7 +1234,9 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
ok: false,
installed: None,
skipped: None,
error: Some(auth_required_error("Authentication required to access this repository")),
error: Some(auth_required_error(
"Authentication required to access this repository",
)),
};
}
@@ -1136,7 +1256,13 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
"init".to_string(),
"--cone".to_string(),
];
let _ = run_git(&init_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(15)).await;
let _ = run_git(
&init_args,
&std::env::temp_dir(),
ssh_key.as_deref(),
Duration::from_secs(15),
)
.await;
let mut set_args = vec![
"-C".to_string(),
@@ -1148,7 +1274,14 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
set_args.push(dir.clone());
}
if let Err(err) = run_git(&set_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(30)).await {
if let Err(err) = run_git(
&set_args,
&std::env::temp_dir(),
ssh_key.as_deref(),
Duration::from_secs(30),
)
.await
{
safe_rm(&temp_base).await;
return SkillsInstallResponse {
ok: false,
@@ -1166,7 +1299,14 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
"HEAD".to_string(),
];
if let Err(err) = run_git(&checkout_args, &std::env::temp_dir(), ssh_key.as_deref(), Duration::from_secs(60)).await {
if let Err(err) = run_git(
&checkout_args,
&std::env::temp_dir(),
ssh_key.as_deref(),
Duration::from_secs(60),
)
.await
{
safe_rm(&temp_base).await;
return SkillsInstallResponse {
ok: false,
@@ -1188,21 +1328,30 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
.to_string();
if !validate_skill_name(&skill_name) {
skipped.push(SkippedSkill { skill_name, reason: "Invalid skill name (directory basename)".to_string() });
skipped.push(SkippedSkill {
skill_name,
reason: "Invalid skill name (directory basename)".to_string(),
});
continue;
}
let src_dir = repo_path_to_fs(&temp_base, &skill_dir);
let skill_md = src_dir.join("SKILL.md");
if !skill_md.exists() {
skipped.push(SkippedSkill { skill_name, reason: "SKILL.md not found in selected directory".to_string() });
skipped.push(SkippedSkill {
skill_name,
reason: "SKILL.md not found in selected directory".to_string(),
});
continue;
}
let target_dir = match target_skill_dir(&req.scope, working_directory, &skill_name) {
Ok(p) => p,
Err(err) => {
skipped.push(SkippedSkill { skill_name, reason: err.to_string() });
skipped.push(SkippedSkill {
skill_name,
reason: err.to_string(),
});
continue;
}
};
@@ -1234,7 +1383,10 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
}
if exists && decision.as_deref() == Some("skip") {
skipped.push(SkippedSkill { skill_name, reason: "Already installed (skipped)".to_string() });
skipped.push(SkippedSkill {
skill_name,
reason: "Already installed (skipped)".to_string(),
});
continue;
}
@@ -1248,11 +1400,17 @@ pub async fn install_skills(working_directory: &Path, req: SkillsInstallRequest)
if let Err(err) = copy_dir_no_symlinks(&src_dir, &target_dir).await {
let _ = tokio::fs::remove_dir_all(&target_dir).await;
skipped.push(SkippedSkill { skill_name, reason: err.to_string() });
skipped.push(SkippedSkill {
skill_name,
reason: err.to_string(),
});
continue;
}
installed.push(InstalledSkill { skill_name, scope: req.scope.clone() });
installed.push(InstalledSkill {
skill_name,
scope: req.scope.clone(),
});
}
safe_rm(&temp_base).await;
+9 -2
View File
@@ -57,7 +57,9 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({
const startListening = async () => {
try {
const unlisten = await safeListen<TerminalStreamEvent>(`terminal://${sessionId}`, (event) => {
const unlisten = await safeListen<TerminalStreamEvent>(
`terminal://${sessionId}`,
(event) => {
if (cancelled) {
return;
}
@@ -67,7 +69,12 @@ export const createDesktopTerminalAPI = (): TerminalAPI => ({
if (event.payload?.type === 'exit') {
stopListening();
}
});
},
{
// Terminal streams are long-lived; never auto-expire this listener.
timeout: 0,
}
);
if (cancelled) {
unlisten();
@@ -9,6 +9,7 @@ interface PendingCallback {
type: 'invoke' | 'listen';
cleanup?: () => void;
timeout?: NodeJS.Timeout;
timeoutMs?: number;
}
interface CallbackManagerConfig {
@@ -51,10 +52,17 @@ class TauriCallbackManager {
this.callbacks.set(callback.id, fullCallback);
if (callback.type === 'listen' && this.config.listenTimeout > 0) {
const timeoutMs =
typeof fullCallback.timeoutMs === 'number'
? fullCallback.timeoutMs
: callback.type === 'listen'
? this.config.listenTimeout
: 0;
if (timeoutMs > 0) {
const timeout = setTimeout(() => {
this.cleanupCallback(callback.id, 'timeout');
}, this.config.listenTimeout);
}, timeoutMs);
fullCallback.timeout = timeout;
}
@@ -119,6 +127,9 @@ class TauriCallbackManager {
const expiredCallbacks: string[] = [];
this.callbacks.forEach((callback, id) => {
if (callback.type !== 'invoke') {
return;
}
const age = now - callback.timestamp;
if (age > this.config.maxCallbackAge) {
expiredCallbacks.push(id);
@@ -261,11 +272,12 @@ export async function safeListen<T>(
const callbackId = `listen:${event}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
try {
manager.register({
id: callbackId,
type: 'listen',
cleanup: options?.onCancel,
timeoutMs: options?.timeout,
});
const unlisten = await listen<T>(event, (event) => {
+3
View File
@@ -19,6 +19,9 @@ export default defineConfig({
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
},
},
worker: {
format: 'es',
},
define: {
'process.env': {},
'process.platform': JSON.stringify('darwin'),
+1 -3
View File
@@ -27,8 +27,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.3.0",
"ghostty-web": "0.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -37,7 +36,6 @@
"http-proxy-middleware": "^3.0.5",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"node-pty": "^1.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-syntax-highlighter": "^15.6.6",
@@ -1,14 +1,51 @@
import React from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
import type { TerminalTheme } from '@/lib/terminalTheme';
import { getTerminalOptions } from '@/lib/terminalTheme';
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
import type { TerminalChunk } from '@/stores/useTerminalStore';
import { cn } from '@/lib/utils';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
let ghosttyPromise: Promise<Ghostty> | null = null;
function getGhostty(): Promise<Ghostty> {
if (!ghosttyPromise) {
ghosttyPromise = Ghostty.load();
}
return ghosttyPromise;
}
function findScrollableViewport(container: HTMLElement): HTMLElement | null {
if (typeof window === 'undefined') {
return null;
}
const candidates = [container, ...Array.from(container.querySelectorAll<HTMLElement>('*'))];
let fallback: HTMLElement | null = null;
for (const element of candidates) {
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
if (overflowY !== 'auto' && overflowY !== 'scroll') {
continue;
}
// Prefer an element that is currently scrollable.
if (element.scrollHeight - element.clientHeight > 2) {
return element;
}
// Otherwise keep the first overflow container as a fallback so we can
// attach touch scroll before scrollback grows.
if (!fallback) {
fallback = element;
}
}
return fallback;
}
type TerminalController = {
focus: () => void;
clear: () => void;
@@ -34,25 +71,65 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const viewportRef = React.useRef<HTMLElement | null>(null);
const terminalRef = React.useRef<Terminal | null>(null);
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const inputHandlerRef = React.useRef<(data: string) => void>(onInput);
const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize);
const writeQueueRef = React.useRef<string[]>([]);
const lastReportedSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
const pendingWriteRef = React.useRef('');
const writeScheduledRef = React.useRef<number | null>(null);
const isWritingRef = React.useRef(false);
const processedCountRef = React.useRef(0);
const firstChunkIdRef = React.useRef<number | null>(null);
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
const viewportDiscoveryAttemptsRef = React.useRef(0);
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
const [, forceRender] = React.useReducer((x) => x + 1, 0);
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
inputHandlerRef.current = onInput;
resizeHandlerRef.current = onResize;
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
const input = hiddenInputRef.current;
const container = containerRef.current;
if (!input || !container) {
return;
}
// Position the input near the user's tap/cursor so the global keyboard
// avoidance logic can decide whether anything is actually obscured.
const rect = container.getBoundingClientRect();
const fallbackX = rect.left + rect.width / 2;
const fallbackY = rect.top + rect.height - 12;
const x = typeof clientX === 'number' ? clientX : fallbackX;
const y = typeof clientY === 'number' ? clientY : fallbackY;
const padding = 8;
const left = Math.max(padding, Math.min(rect.width - padding, x - rect.left));
const top = Math.max(padding, Math.min(rect.height - padding, y - rect.top));
input.style.left = `${left}px`;
input.style.top = `${top}px`;
input.style.bottom = '';
try {
input.focus({ preventScroll: true });
} catch {
try {
input.focus();
} catch { /* ignored */ }
}
}, []);
const resetWriteState = React.useCallback(() => {
writeQueueRef.current = [];
pendingWriteRef.current = '';
if (writeScheduledRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(writeScheduledRef.current);
}
writeScheduledRef.current = null;
isWritingRef.current = false;
processedCountRef.current = 0;
firstChunkIdRef.current = null;
lastProcessedChunkIdRef.current = null;
}, []);
const fitTerminal = React.useCallback(() => {
@@ -68,61 +145,85 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
try {
fitAddon.fit();
resizeHandlerRef.current(terminal.cols, terminal.rows);
const next = { cols: terminal.cols, rows: terminal.rows };
const previous = lastReportedSizeRef.current;
if (!previous || previous.cols !== next.cols || previous.rows !== next.rows) {
lastReportedSizeRef.current = next;
resizeHandlerRef.current(next.cols, next.rows);
}
} catch { /* ignored */ }
}, []);
const flushWriteQueue = React.useCallback(() => {
const flushWrites = React.useCallback(() => {
if (isWritingRef.current) {
return;
}
const consumeNext = () => {
const term = terminalRef.current;
if (!term) {
resetWriteState();
return;
}
const term = terminalRef.current;
if (!term) {
resetWriteState();
return;
}
const chunk = writeQueueRef.current.shift();
if (chunk === undefined) {
isWritingRef.current = false;
return;
}
if (!pendingWriteRef.current) {
return;
}
isWritingRef.current = true;
term.write(chunk, () => {
isWritingRef.current = false;
if (writeQueueRef.current.length > 0) {
if (typeof window !== 'undefined') {
window.setTimeout(consumeNext, 0);
} else {
consumeNext();
}
const chunk = pendingWriteRef.current;
pendingWriteRef.current = '';
isWritingRef.current = true;
term.write(chunk, () => {
isWritingRef.current = false;
if (pendingWriteRef.current) {
if (typeof window !== 'undefined') {
writeScheduledRef.current = window.requestAnimationFrame(() => {
writeScheduledRef.current = null;
flushWrites();
});
} else {
flushWrites();
}
});
};
consumeNext();
}
});
}, [resetWriteState]);
const scheduleFlushWrites = React.useCallback(() => {
if (writeScheduledRef.current !== null) {
return;
}
if (typeof window !== 'undefined') {
writeScheduledRef.current = window.requestAnimationFrame(() => {
writeScheduledRef.current = null;
flushWrites();
});
} else {
flushWrites();
}
}, [flushWrites]);
const enqueueWrite = React.useCallback(
(data: string) => {
if (!data) {
return;
}
writeQueueRef.current = [data];
isWritingRef.current = false;
flushWriteQueue();
pendingWriteRef.current += data;
scheduleFlushWrites();
},
[flushWriteQueue]
[scheduleFlushWrites]
);
const setupTouchScroll = React.useCallback(() => {
touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null;
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
viewportDiscoveryTimeoutRef.current = null;
}
if (!enableTouchScroll) {
viewportDiscoveryAttemptsRef.current = 0;
return;
}
@@ -131,11 +232,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
if (!viewport) {
// Ghostty scrollback is internal (canvas-based). On touch devices we need
// to translate touch deltas into terminal scroll calls.
const terminal = terminalRef.current;
if (!terminal) {
return;
}
viewportDiscoveryAttemptsRef.current = 0;
const baseScrollMultiplier = 2.2;
const maxScrollBoost = 2.8;
const boostDenominator = 25;
@@ -149,24 +254,35 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
lastTime: null as number | null,
velocity: 0,
rafId: null as number | null,
startX: null as number | null,
startY: null as number | null,
didMove: false,
};
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
const getMaxScrollTop = () => Math.max(0, viewport.scrollHeight - viewport.clientHeight);
const setScrollTop = (nextScrollTop: number) => {
const maxScrollTop = getMaxScrollTop();
viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop));
};
const lineHeightPx = Math.max(12, Math.round(fontSize * 1.35));
let remainderPx = 0;
const scrollByPixels = (deltaPixels: number) => {
if (!deltaPixels) {
return;
return false;
}
const previous = viewport.scrollTop;
setScrollTop(previous + deltaPixels);
return viewport.scrollTop !== previous;
const before = terminal.getViewportY();
const total = remainderPx + deltaPixels;
const lines = Math.trunc(total / lineHeightPx);
remainderPx = total - lines * lineHeightPx;
if (lines !== 0) {
// Touch delta is in pixels, convert to lines.
// Natural mobile scrolling: finger up scrolls down.
terminal.scrollLines(lines);
}
const after = terminal.getViewportY();
return after !== before;
};
const stopKinetic = () => {
@@ -176,11 +292,18 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.rafId = null;
};
const listenerOptions: AddEventListenerOptions = { passive: false, capture: true };
const listenerOptions: AddEventListenerOptions = { passive: false, capture: false };
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
if (supportsPointerEvents) {
const stateWithPointerId = Object.assign(state, { pointerId: null as number | null });
const stateWithPointerId = Object.assign(state, {
pointerId: null as number | null,
startX: null as number | null,
startY: null as number | null,
moved: false,
});
const TAP_MOVE_THRESHOLD_PX = 6;
const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') {
@@ -188,6 +311,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
stopKinetic();
stateWithPointerId.pointerId = event.pointerId;
stateWithPointerId.startX = event.clientX;
stateWithPointerId.startY = event.clientY;
stateWithPointerId.moved = false;
stateWithPointerId.lastY = event.clientY;
stateWithPointerId.lastTime = nowMs();
stateWithPointerId.velocity = 0;
@@ -201,6 +327,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
if (stateWithPointerId.startX !== null && stateWithPointerId.startY !== null && !stateWithPointerId.moved) {
const dx = event.clientX - stateWithPointerId.startX;
const dy = event.clientY - stateWithPointerId.startY;
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
stateWithPointerId.moved = true;
}
}
if (stateWithPointerId.lastY === null) {
stateWithPointerId.lastY = event.clientY;
stateWithPointerId.lastTime = nowMs();
@@ -230,10 +364,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
stateWithPointerId.velocity = -maxVelocity;
}
if (event.cancelable) {
event.preventDefault();
// Only prevent default once we're actually scrolling.
if (stateWithPointerId.moved) {
if (event.cancelable) {
event.preventDefault();
}
event.stopPropagation();
}
event.stopPropagation();
scrollByPixels(deltaPixels);
};
@@ -241,13 +379,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
return;
}
const wasTap = !stateWithPointerId.moved;
stateWithPointerId.pointerId = null;
stateWithPointerId.startX = null;
stateWithPointerId.startY = null;
stateWithPointerId.moved = false;
stateWithPointerId.lastY = null;
stateWithPointerId.lastTime = null;
try {
container.releasePointerCapture(event.pointerId);
} catch { /* ignored */ }
if (wasTap) {
focusHiddenInput(event.clientX, event.clientY);
return;
}
if (typeof window === 'undefined') {
return;
}
@@ -287,10 +436,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
container.addEventListener('pointercancel', handlePointerUp, listenerOptions);
const previousTouchAction = container.style.touchAction;
container.style.touchAction = 'none';
container.style.touchAction = 'manipulation';
touchScrollCleanupRef.current = () => {
stopKinetic();
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
viewportDiscoveryTimeoutRef.current = null;
}
viewportDiscoveryAttemptsRef.current = 0;
container.removeEventListener('pointerdown', handlePointerDown, listenerOptions);
container.removeEventListener('pointermove', handlePointerMove, listenerOptions);
container.removeEventListener('pointerup', handlePointerUp, listenerOptions);
@@ -301,6 +455,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const TAP_MOVE_THRESHOLD_PX = 6;
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
return;
@@ -309,6 +465,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.lastY = event.touches[0].clientY;
state.lastTime = nowMs();
state.velocity = 0;
state.startX = event.touches[0].clientX;
state.startY = event.touches[0].clientY;
state.didMove = false;
};
const handleTouchMove = (event: TouchEvent) => {
@@ -316,11 +475,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.lastY = null;
state.lastTime = null;
state.velocity = 0;
state.startX = null;
state.startY = null;
state.didMove = false;
stopKinetic();
return;
}
const currentX = event.touches[0].clientX;
const currentY = event.touches[0].clientY;
if (state.startX !== null && state.startY !== null && !state.didMove) {
const dx = currentX - state.startX;
const dy = currentY - state.startY;
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
state.didMove = true;
}
}
if (state.lastY === null) {
state.lastY = currentY;
state.lastTime = nowMs();
@@ -350,20 +522,37 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
state.velocity = -maxVelocity;
}
event.preventDefault();
event.stopPropagation();
if (state.didMove) {
event.preventDefault();
event.stopPropagation();
}
scrollByPixels(deltaPixels);
};
const handleTouchEnd = () => {
const handleTouchEnd = (event: TouchEvent) => {
const wasTap = !state.didMove;
state.lastY = null;
state.lastTime = null;
const velocity = state.velocity;
state.startX = null;
state.startY = null;
state.didMove = false;
if (wasTap) {
const point = event.changedTouches?.[0];
focusHiddenInput(point?.clientX, point?.clientY);
return;
}
if (typeof window === 'undefined') {
return;
}
if (Math.abs(state.velocity) < minVelocity) {
if (Math.abs(velocity) < minVelocity) {
state.velocity = 0;
return;
}
@@ -394,77 +583,114 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
container.addEventListener('touchend', handleTouchEnd, listenerOptions);
container.addEventListener('touchcancel', handleTouchEnd, listenerOptions);
container.addEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
container.addEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
const previousTouchAction = container.style.touchAction;
container.style.touchAction = 'none';
container.style.touchAction = 'manipulation';
touchScrollCleanupRef.current = () => {
stopKinetic();
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
viewportDiscoveryTimeoutRef.current = null;
}
viewportDiscoveryAttemptsRef.current = 0;
container.removeEventListener('touchstart', handleTouchStart, listenerOptions);
container.removeEventListener('touchmove', handleTouchMove, listenerOptions);
container.removeEventListener('touchend', handleTouchEnd, listenerOptions);
container.removeEventListener('touchcancel', handleTouchEnd, listenerOptions);
container.removeEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
container.style.touchAction = previousTouchAction;
};
}, [enableTouchScroll]);
}, [enableTouchScroll, focusHiddenInput, fontSize]);
React.useEffect(() => {
const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme));
const fitAddon = new FitAddon();
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
let disposed = false;
let localTerminal: GhosttyTerminal | null = null;
let localResizeObserver: ResizeObserver | null = null;
let localDisposables: Array<{ dispose: () => void }> = [];
const container = containerRef.current;
if (container) {
terminal.open(container);
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
if (viewport) {
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
viewportRef.current = viewport;
forceRender();
}
fitTerminal();
terminal.focus();
}
const disposables = [
terminal.onData((data) => {
inputHandlerRef.current(data);
}),
];
const resizeObserver = new ResizeObserver(() => {
fitTerminal();
});
if (container) {
resizeObserver.observe(container);
}
return () => {
touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null;
disposables.forEach((disposable) => disposable.dispose());
resizeObserver.disconnect();
terminal.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
resetWriteState();
};
}, [fitTerminal, fontFamily, fontSize, theme, resetWriteState]);
React.useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
if (!container) {
return;
}
const options = getTerminalOptions(fontFamily, fontSize, theme);
Object.assign(terminal.options as Record<string, unknown>, options);
fitTerminal();
}, [fitTerminal, fontFamily, fontSize, theme]);
container.tabIndex = 0;
const initialize = async () => {
try {
const ghostty = await getGhostty();
if (disposed) {
return;
}
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty);
const terminal = new GhosttyTerminal(options);
const fitAddon = new FitAddon();
localTerminal = terminal;
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.open(container);
bumpTerminalReady();
const viewport = findScrollableViewport(container);
if (viewport) {
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
viewportRef.current = viewport;
forceRender();
} else {
viewportRef.current = null;
}
fitTerminal();
setupTouchScroll();
terminal.focus();
localDisposables = [
terminal.onData((data: string) => {
inputHandlerRef.current(data);
}),
];
localResizeObserver = new ResizeObserver(() => {
fitTerminal();
});
localResizeObserver.observe(container);
if (typeof window !== 'undefined') {
window.setTimeout(() => {
fitTerminal();
}, 0);
}
} catch {
// ignored
}
};
void initialize();
return () => {
disposed = true;
touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null;
localDisposables.forEach((disposable) => disposable.dispose());
localResizeObserver?.disconnect();
localTerminal?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
viewportRef.current = null;
lastReportedSizeRef.current = null;
resetWriteState();
};
}, [fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
React.useEffect(() => {
const terminal = terminalRef.current;
@@ -473,9 +699,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
terminal.reset();
resetWriteState();
lastReportedSizeRef.current = null;
fitTerminal();
terminal.focus();
}, [sessionKey, fitTerminal, resetWriteState]);
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
React.useEffect(() => {
setupTouchScroll();
@@ -492,7 +719,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
if (chunks.length === 0) {
if (processedCountRef.current !== 0) {
if (lastProcessedChunkIdRef.current !== null) {
terminal.reset();
resetWriteState();
fitTerminal();
@@ -500,31 +727,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const currentFirstId = chunks[0].id;
if (firstChunkIdRef.current === null) {
firstChunkIdRef.current = currentFirstId;
const lastProcessedId = lastProcessedChunkIdRef.current;
let pending: TerminalChunk[];
if (lastProcessedId === null) {
pending = chunks;
} else {
const lastProcessedIndex = chunks.findIndex((chunk) => chunk.id === lastProcessedId);
pending = lastProcessedIndex >= 0 ? chunks.slice(lastProcessedIndex + 1) : chunks;
}
const shouldReset =
firstChunkIdRef.current !== currentFirstId || processedCountRef.current > chunks.length;
if (shouldReset) {
terminal.reset();
resetWriteState();
firstChunkIdRef.current = currentFirstId;
}
if (processedCountRef.current < chunks.length) {
const pending = chunks.slice(processedCountRef.current);
if (pending.length > 0) {
enqueueWrite(pending.map((chunk) => chunk.data).join(''));
processedCountRef.current = chunks.length;
}
}, [chunks, enqueueWrite, fitTerminal, resetWriteState]);
lastProcessedChunkIdRef.current = chunks[chunks.length - 1].id;
}, [chunks, terminalReadyVersion, enqueueWrite, fitTerminal, resetWriteState]);
React.useImperativeHandle(
ref,
(): TerminalController => ({
focus: () => {
if (enableTouchScroll) {
focusHiddenInput();
return;
}
terminalRef.current?.focus();
},
clear: () => {
@@ -540,12 +767,69 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
fitTerminal();
},
}),
[fitTerminal, resetWriteState]
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
);
return (
<div ref={containerRef} className={cn('relative h-full w-full', className)}>
{viewportRef.current ? (
<div
ref={containerRef}
className={cn('relative h-full w-full', className)}
style={{ backgroundColor: theme.background }}
onClick={(event) => {
if (enableTouchScroll) {
focusHiddenInput(event.clientX, event.clientY);
} else {
terminalRef.current?.focus();
}
}}
>
{enableTouchScroll ? (
<textarea
ref={hiddenInputRef}
inputMode="text"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
tabIndex={-1}
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 0,
width: 1,
height: 1,
opacity: 0.001,
zIndex: 1,
background: 'transparent',
color: 'transparent',
border: 'none',
padding: 0,
margin: 0,
outline: 'none',
}}
onInput={(event) => {
const raw = String(event.currentTarget.value || '');
if (!raw) {
return;
}
// iOS often inserts `\n` for Enter; the PTY expects CR.
const value = raw.replace(/\r\n|\r|\n/g, '\r');
inputHandlerRef.current(value);
event.currentTarget.value = '';
}}
onKeyDown={(event) => {
if (event.key === 'Backspace') {
// If there's nothing in the input buffer, emulate DEL.
if (!event.currentTarget.value) {
inputHandlerRef.current('\x7f');
}
}
}}
/>
) : null}
{viewportRef.current && !enableTouchScroll ? (
<OverlayScrollbar
containerRef={viewportRef}
disableHorizontal
@@ -190,6 +190,11 @@ export const TerminalView: React.FC = () => {
switch (event.type) {
case 'connected': {
if (event.runtime || event.ptyBackend) {
console.log(
`[Terminal] connected runtime=${event.runtime ?? 'unknown'} pty=${event.ptyBackend ?? 'unknown'}`
);
}
setConnecting(directory, false);
setConnectionError(null);
setIsFatalError(false);
+3
View File
@@ -42,6 +42,9 @@ export interface TerminalStreamEvent {
signal?: number | null;
attempt?: number;
maxAttempts?: number;
runtime?: 'node' | 'bun';
ptyBackend?: string;
}
export interface CreateTerminalOptions {
@@ -0,0 +1,497 @@
/**
* SerializeAddon for ghostty-web
*
* Port of xterm.js addon-serialize for ghostty-web terminal.
* Enables serialization of terminal contents to restore state after reconnection.
*
* Features:
* - ANSI color preservation (16-color, 256-color, RGB)
* - Text attributes (bold, italic, underline, faint, strikethrough, blink, inverse, invisible, dim)
* - Scrollback support with configurable limits
* - Round-trip compatibility
* - Cursor positioning
*/
import type { Terminal as GhosttyTerminal } from 'ghostty-web';
// Constants for ANSI escape codes
const C0 = {
ESC: '\u001b',
};
const SGR = {
RESET: 0,
BOLD: 1,
DIM: 2,
ITALIC: 3,
UNDERLINE: 4,
SLOW_BLINK: 5,
RAPID_BLINK: 6,
INVERSE: 7,
INVISIBLE: 8,
STRIKETHROUGH: 9,
NORMAL_INTENSITY: 22,
NO_ITALIC: 23,
NO_UNDERLINE: 24,
NO_BLINK: 25,
NO_INVERSE: 27,
VISIBLE: 28,
NO_STRIKETHROUGH: 29,
FG_DEFAULT: 39,
BG_DEFAULT: 49,
};
export interface SerializeOptions {
/**
* The row range to serialize. When an explicit range is specified, the cursor
* will get its final repositioning.
*/
range?: {
start: number;
end: number;
};
/**
* The number of rows in the scrollback buffer to serialize, starting from
* the bottom of the scrollback buffer. When not specified, all available
* rows in the scrollback buffer will be serialized.
*/
scrollback?: number;
/**
* Whether to exclude the terminal modes from the serialization.
* Default: false
*/
excludeModes?: boolean;
/**
* Whether to exclude the alt buffer from the serialization.
* Default: false
*/
excludeAltBuffer?: boolean;
}
export interface TextSerializeOptions {
/**
* The number of rows in the scrollback buffer to serialize, starting from
* the bottom of the scrollback buffer.
*/
scrollback?: number;
/**
* Whether to trim trailing whitespace from lines.
* Default: true
*/
trimWhitespace?: boolean;
}
interface CellState {
fg: number | null;
bg: number | null;
bold: boolean;
dim: boolean;
italic: boolean;
underline: boolean;
blink: boolean;
inverse: boolean;
invisible: boolean;
strikethrough: boolean;
}
const NULL_CELL_STATE: CellState = {
fg: null,
bg: null,
bold: false,
dim: false,
italic: false,
underline: false,
blink: false,
inverse: false,
invisible: false,
strikethrough: false,
};
/**
* SerializeAddon for ghostty-web terminal
*/
export class SerializeAddon {
private _terminal: GhosttyTerminal | undefined;
/**
* Activate the addon
*/
activate(terminal: GhosttyTerminal): void {
this._terminal = terminal;
}
/**
* Dispose the addon
*/
dispose(): void {
this._terminal = undefined;
}
/**
* Serialize the terminal buffer to ANSI escape sequences
*/
serialize(options: SerializeOptions = {}): string {
if (!this._terminal) {
throw new Error('SerializeAddon not activated');
}
const buffer = this._terminal.buffer.active;
if (!buffer) {
return '';
}
const result: string[] = [];
let currentState: CellState = { ...NULL_CELL_STATE };
// Determine range to serialize
const scrollbackLimit = options.scrollback ?? buffer.length;
let startRow: number;
let endRow: number;
if (options.range) {
startRow = options.range.start;
endRow = options.range.end;
} else {
// Serialize scrollback + viewport
const totalRows = buffer.length;
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
startRow = Math.max(0, buffer.baseY - scrollbackRows);
endRow = buffer.baseY + buffer.cursorY;
}
// Clamp to valid range
startRow = Math.max(0, startRow);
endRow = Math.min(buffer.length - 1, endRow);
for (let y = startRow; y <= endRow; y++) {
const line = buffer.getLine(y);
if (!line) {
result.push('\r\n');
continue;
}
let lineContent = '';
let lastNonSpaceCol = -1;
// Find the last non-space column
for (let x = line.length - 1; x >= 0; x--) {
const cell = line.getCell(x);
if (cell) {
const char = this._getCellChar(cell);
if (char !== ' ' && char !== '') {
lastNonSpaceCol = x;
break;
}
}
}
// Serialize each cell up to the last non-space
for (let x = 0; x <= lastNonSpaceCol; x++) {
const cell = line.getCell(x);
if (!cell) {
lineContent += ' ';
continue;
}
// Get cell attributes and generate SGR sequences if needed
const newState = this._getCellState(cell);
const sgrSequences = this._generateSgrDiff(currentState, newState);
if (sgrSequences) {
lineContent += sgrSequences;
currentState = newState;
}
// Get character
const char = this._getCellChar(cell);
lineContent += char || ' ';
}
// Reset attributes at end of line if any were set
if (this._hasAttributes(currentState)) {
lineContent += `${C0.ESC}[${SGR.RESET}m`;
currentState = { ...NULL_CELL_STATE };
}
result.push(lineContent);
// Add newline unless it's the last row with cursor
if (y < endRow) {
result.push('\r\n');
}
}
// Position cursor
const cursorY = buffer.cursorY;
const cursorX = buffer.cursorX;
if (cursorY >= 0 && cursorX >= 0) {
// Use CUP (Cursor Position) to move cursor to correct position
// CUP is 1-based, so add 1 to both coordinates
const relativeY = cursorY - (endRow - buffer.baseY);
if (relativeY !== 0 || cursorX !== 0) {
result.push(`${C0.ESC}[${cursorY + 1};${cursorX + 1}H`);
}
}
return result.join('');
}
/**
* Serialize the terminal buffer to plain text (no escape sequences)
*/
serializeAsText(options: TextSerializeOptions = {}): string {
if (!this._terminal) {
throw new Error('SerializeAddon not activated');
}
const buffer = this._terminal.buffer.active;
if (!buffer) {
return '';
}
const trimWhitespace = options.trimWhitespace ?? true;
const scrollbackLimit = options.scrollback ?? buffer.length;
const result: string[] = [];
// Determine range
const totalRows = buffer.length;
const scrollbackRows = Math.min(scrollbackLimit, totalRows - buffer.baseY);
const startRow = Math.max(0, buffer.baseY - scrollbackRows);
const endRow = buffer.baseY + buffer.cursorY;
for (let y = startRow; y <= endRow; y++) {
const line = buffer.getLine(y);
if (!line) {
result.push('');
continue;
}
let lineContent = '';
for (let x = 0; x < line.length; x++) {
const cell = line.getCell(x);
if (cell) {
const char = this._getCellChar(cell);
lineContent += char || ' ';
} else {
lineContent += ' ';
}
}
if (trimWhitespace) {
lineContent = lineContent.trimEnd();
}
result.push(lineContent);
}
return result.join('\n');
}
/**
* Get the character from a cell, handling wide characters and special codepoints
*/
private _getCellChar(cell: { getChars?: () => string; getCodepoint?: () => number }): string {
// Try getChars() first (ghostty-web standard)
if (typeof cell.getChars === 'function') {
const chars = cell.getChars();
if (chars) return chars;
}
// Try getCodepoint()
if (typeof cell.getCodepoint === 'function') {
const codepoint = cell.getCodepoint();
if (codepoint && codepoint > 0 && codepoint <= 0x10FFFF &&
!(codepoint >= 0xD800 && codepoint <= 0xDFFF)) {
return String.fromCodePoint(codepoint);
}
}
// Fallback
return ' ';
}
/**
* Get the state of a cell (colors and attributes)
*/
private _getCellState(cell: {
getFgColor?: () => number;
getBgColor?: () => number;
isBold?: () => boolean | number;
isDim?: () => boolean | number;
isFaint?: () => boolean | number;
isItalic?: () => boolean | number;
isUnderline?: () => boolean | number;
isBlink?: () => boolean | number;
isInverse?: () => boolean | number;
isInvisible?: () => boolean | number;
isStrikethrough?: () => boolean | number;
}): CellState {
const state: CellState = { ...NULL_CELL_STATE };
// Get foreground color
if (typeof cell.getFgColor === 'function') {
const fg = cell.getFgColor();
if (fg !== undefined && fg !== null && fg !== -1) {
state.fg = fg;
}
}
// Get background color
if (typeof cell.getBgColor === 'function') {
const bg = cell.getBgColor();
if (bg !== undefined && bg !== null && bg !== -1) {
state.bg = bg;
}
}
// Get attributes
if (typeof cell.isBold === 'function') {
state.bold = !!cell.isBold();
}
if (typeof cell.isDim === 'function') {
state.dim = !!cell.isDim();
} else if (typeof cell.isFaint === 'function') {
state.dim = !!cell.isFaint();
}
if (typeof cell.isItalic === 'function') {
state.italic = !!cell.isItalic();
}
if (typeof cell.isUnderline === 'function') {
state.underline = !!cell.isUnderline();
}
if (typeof cell.isBlink === 'function') {
state.blink = !!cell.isBlink();
}
if (typeof cell.isInverse === 'function') {
state.inverse = !!cell.isInverse();
}
if (typeof cell.isInvisible === 'function') {
state.invisible = !!cell.isInvisible();
}
if (typeof cell.isStrikethrough === 'function') {
state.strikethrough = !!cell.isStrikethrough();
}
return state;
}
/**
* Generate SGR escape sequences for the difference between two cell states
*/
private _generateSgrDiff(from: CellState, to: CellState): string | null {
const codes: number[] = [];
// Check if we need a full reset
const needsReset =
(from.bold && !to.bold) ||
(from.dim && !to.dim) ||
(from.italic && !to.italic) ||
(from.underline && !to.underline) ||
(from.blink && !to.blink) ||
(from.inverse && !to.inverse) ||
(from.invisible && !to.invisible) ||
(from.strikethrough && !to.strikethrough);
if (needsReset) {
codes.push(SGR.RESET);
// After reset, we need to re-apply all 'to' attributes
if (to.bold) codes.push(SGR.BOLD);
if (to.dim) codes.push(SGR.DIM);
if (to.italic) codes.push(SGR.ITALIC);
if (to.underline) codes.push(SGR.UNDERLINE);
if (to.blink) codes.push(SGR.SLOW_BLINK);
if (to.inverse) codes.push(SGR.INVERSE);
if (to.invisible) codes.push(SGR.INVISIBLE);
if (to.strikethrough) codes.push(SGR.STRIKETHROUGH);
// Re-apply colors
if (to.fg !== null) {
this._appendColorCode(codes, to.fg, true);
}
if (to.bg !== null) {
this._appendColorCode(codes, to.bg, false);
}
} else {
// Apply only changed attributes
if (!from.bold && to.bold) codes.push(SGR.BOLD);
if (!from.dim && to.dim) codes.push(SGR.DIM);
if (!from.italic && to.italic) codes.push(SGR.ITALIC);
if (!from.underline && to.underline) codes.push(SGR.UNDERLINE);
if (!from.blink && to.blink) codes.push(SGR.SLOW_BLINK);
if (!from.inverse && to.inverse) codes.push(SGR.INVERSE);
if (!from.invisible && to.invisible) codes.push(SGR.INVISIBLE);
if (!from.strikethrough && to.strikethrough) codes.push(SGR.STRIKETHROUGH);
// Handle color changes
if (from.fg !== to.fg) {
if (to.fg === null) {
codes.push(SGR.FG_DEFAULT);
} else {
this._appendColorCode(codes, to.fg, true);
}
}
if (from.bg !== to.bg) {
if (to.bg === null) {
codes.push(SGR.BG_DEFAULT);
} else {
this._appendColorCode(codes, to.bg, false);
}
}
}
if (codes.length === 0) {
return null;
}
return `${C0.ESC}[${codes.join(';')}m`;
}
/**
* Append color code to the codes array
*/
private _appendColorCode(codes: number[], color: number, isForeground: boolean): void {
const base = isForeground ? 30 : 40;
const extBase = isForeground ? 38 : 48;
if (color < 8) {
// Basic 8 colors
codes.push(base + color);
} else if (color < 16) {
// Bright 8 colors
codes.push(base + 60 + (color - 8));
} else if (color < 256) {
// 256-color palette
codes.push(extBase, 5, color);
} else {
// RGB (24-bit) color encoded as 0xRRGGBB + 0x1000000
const rgb = color - 0x1000000;
const r = (rgb >> 16) & 0xFF;
const g = (rgb >> 8) & 0xFF;
const b = rgb & 0xFF;
codes.push(extBase, 2, r, g, b);
}
}
/**
* Check if the state has any attributes set
*/
private _hasAttributes(state: CellState): boolean {
return (
state.fg !== null ||
state.bg !== null ||
state.bold ||
state.dim ||
state.italic ||
state.underline ||
state.blink ||
state.inverse ||
state.invisible ||
state.strikethrough
);
}
}
+50 -1
View File
@@ -1,3 +1,4 @@
import type { Ghostty } from 'ghostty-web';
import type { Theme } from '@/types/theme';
export interface TerminalTheme {
@@ -79,7 +80,7 @@ export function getTerminalOptions(
cursorStyle: 'block' as const,
theme,
allowTransparency: false,
scrollback: 10000,
scrollback: 50_000,
minimumContrastRatio: 1,
fastScrollModifier: 'shift' as const,
fastScrollSensitivity: 5,
@@ -89,3 +90,51 @@ export function getTerminalOptions(
rightClickSelectsWord: true,
};
}
/**
* Get terminal options for Ghostty Web terminal
*/
export function getGhosttyTerminalOptions(
fontFamily: string,
fontSize: number,
theme: TerminalTheme,
ghostty: Ghostty
) {
const powerlineFallbacks =
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
return {
cursorBlink: true,
fontSize,
lineHeight: 1.15,
fontFamily: augmentedFontFamily,
allowTransparency: false,
theme: {
background: theme.background,
foreground: theme.foreground,
cursor: theme.cursor,
cursorAccent: theme.cursorAccent,
selectionBackground: theme.selectionBackground,
selectionForeground: theme.selectionForeground,
black: theme.black,
red: theme.red,
green: theme.green,
yellow: theme.yellow,
blue: theme.blue,
magenta: theme.magenta,
cyan: theme.cyan,
white: theme.white,
brightBlack: theme.brightBlack,
brightRed: theme.brightRed,
brightGreen: theme.brightGreen,
brightYellow: theme.brightYellow,
brightBlue: theme.brightBlue,
brightMagenta: theme.brightMagenta,
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
},
scrollback: 50_000,
ghostty,
};
}
+1 -1
View File
@@ -30,7 +30,7 @@ interface TerminalStore {
clearAllTerminalSessions: () => void;
}
const TERMINAL_BUFFER_LIMIT = 256_000;
const TERMINAL_BUFFER_LIMIT = 1_000_000;
function normalizeDirectory(dir: string): string {
let normalized = dir.trim();
+11
View File
@@ -0,0 +1,11 @@
export {};
declare module 'ghostty-web' {
export interface ITerminalOptions {
lineHeight?: number;
}
export interface RendererOptions {
lineHeight?: number;
}
}
+3
View File
@@ -17,6 +17,9 @@ export default defineConfig({
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
},
},
worker: {
format: 'es',
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
'global': 'globalThis',
+55 -2
View File
@@ -11,6 +11,35 @@ const __dirname = path.dirname(__filename);
const DEFAULT_PORT = 3000;
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
function getBunBinary() {
if (typeof process.env.BUN_BINARY === 'string' && process.env.BUN_BINARY.trim().length > 0) {
return process.env.BUN_BINARY.trim();
}
if (typeof process.env.BUN_INSTALL === 'string' && process.env.BUN_INSTALL.trim().length > 0) {
return path.join(process.env.BUN_INSTALL.trim(), 'bin', 'bun');
}
return 'bun';
}
const BUN_BIN = getBunBinary();
function isBunRuntime() {
return typeof globalThis.Bun !== 'undefined';
}
function isBunInstalled() {
try {
const result = spawnSync(BUN_BIN, ['--version'], { stdio: 'ignore', env: process.env });
return result.status === 0;
} catch {
return false;
}
}
function getPreferredServerRuntime() {
return isBunInstalled() ? 'bun' : 'node';
}
function generateRandomPassword(length = 16) {
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
let password = '';
@@ -384,9 +413,12 @@ const commands = {
serverArgs.push('--try-cf-tunnel');
}
const preferredRuntime = getPreferredServerRuntime();
const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath;
if (options.daemon) {
const child = spawn(process.execPath, serverArgs, {
const child = spawn(runtimeBin, serverArgs, {
detached: true,
stdio: 'ignore',
env: {
@@ -430,8 +462,29 @@ const commands = {
writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword });
// Prefer bun when installed (much faster PTY). If CLI is running under Node,
// run the server in a child process so Node doesn't have to load bun-pty.
if (preferredRuntime === 'bun' && !isBunRuntime()) {
const child = spawn(runtimeBin, serverArgs, {
stdio: 'inherit',
env: {
...process.env,
OPENCHAMBER_PORT: options.port.toString(),
OPENCODE_BINARY: opencodeBinary,
...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false',
},
});
child.on('exit', (code) => {
process.exit(typeof code === 'number' ? code : 1);
});
return;
}
const { startWebUiServer } = await import(serverPath);
const server = await startWebUiServer({
await startWebUiServer({
port: options.port,
attachSignals: true,
exitOnShutdown: true,
+3 -3
View File
@@ -37,15 +37,15 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.3.0",
"ghostty-web": "0.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"express": "^5.1.0",
"http-proxy-middleware": "^3.0.5",
"next-themes": "^0.4.6",
"node-pty": "^1.0.0",
"bun-pty": "^0.4.5",
"node-pty": "^1.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
+41 -21
View File
@@ -3692,23 +3692,39 @@ async function main(options = {}) {
}
});
let ptyLib = null;
let ptyLoadError = null;
const getPtyLib = async () => {
if (ptyLib) return ptyLib;
if (ptyLoadError) throw ptyLoadError;
try {
ptyLib = await import('node-pty');
console.log('node-pty loaded successfully');
return ptyLib;
} catch (error) {
ptyLoadError = error;
console.error('Failed to load node-pty:', error.message);
console.error('Terminal functionality will not be available.');
console.error('To fix: run "npm rebuild node-pty" or "npm install"');
throw new Error('node-pty is not available. Run: npm rebuild node-pty');
let ptyProviderPromise = null;
const getPtyProvider = async () => {
if (ptyProviderPromise) {
return ptyProviderPromise;
}
ptyProviderPromise = (async () => {
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
if (isBunRuntime) {
try {
const bunPty = await import('bun-pty');
console.log('Using bun-pty for terminal sessions');
return { spawn: bunPty.spawn, backend: 'bun-pty' };
} catch (error) {
console.warn('bun-pty unavailable, falling back to node-pty');
}
}
try {
const nodePty = await import('node-pty');
console.log('Using node-pty for terminal sessions');
return { spawn: nodePty.spawn, backend: 'node-pty' };
} catch (error) {
console.error('Failed to load node-pty:', error && error.message ? error.message : error);
if (isBunRuntime) {
throw new Error('No PTY backend available. Install bun-pty or node-pty.');
}
throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
}
})();
return ptyProviderPromise;
};
const terminalSessions = new Map();
@@ -3745,7 +3761,6 @@ async function main(options = {}) {
return res.status(400).json({ error: 'Invalid working directory' });
}
const pty = await getPtyLib();
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
const sessionId = Math.random().toString(36).substring(2, 15) +
@@ -3754,6 +3769,7 @@ async function main(options = {}) {
const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath };
const pty = await getPtyProvider();
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: cols || 80,
@@ -3768,6 +3784,7 @@ async function main(options = {}) {
const session = {
ptyProcess,
ptyBackend: pty.backend,
cwd,
lastActivity: Date.now(),
clients: new Set(),
@@ -3801,12 +3818,14 @@ async function main(options = {}) {
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.write('data: {"type":"connected"}\n\n');
const clientId = Math.random().toString(36).substring(7);
session.clients.add(clientId);
session.lastActivity = Date.now();
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
const ptyBackend = session.ptyBackend || 'unknown';
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
const heartbeatInterval = setInterval(() => {
try {
@@ -3871,7 +3890,7 @@ async function main(options = {}) {
req.on('close', cleanup);
req.on('error', cleanup);
console.log(`Client ${clientId} connected to terminal session ${sessionId}`);
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
});
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
@@ -3958,7 +3977,6 @@ async function main(options = {}) {
return res.status(400).json({ error: 'Invalid working directory' });
}
const pty = await getPtyLib();
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
const newSessionId = Math.random().toString(36).substring(2, 15) +
@@ -3967,6 +3985,7 @@ async function main(options = {}) {
const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath };
const pty = await getPtyProvider();
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: cols || 80,
@@ -3981,6 +4000,7 @@ async function main(options = {}) {
const session = {
ptyProcess,
ptyBackend: pty.backend,
cwd,
lastActivity: Date.now(),
clients: new Set(),
+3
View File
@@ -22,6 +22,9 @@ export default defineConfig({
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
},
},
worker: {
format: 'es',
},
define: {
'process.env': {},
global: 'globalThis',