Compare commits

...

19 Commits

Author SHA1 Message Date
Shantur Rathore
6961efde0b Merge pull request #224 from Pagecran/upstream/tauri-prebuild-sync
fix(tauri): sync server UI bundle during prebuild
2026-03-18 20:39:22 +00:00
Shantur Rathore
b3e0233f4b Merge pull request #232 from NeuralNomadsAI/codenomad/issue-231
fix(tauri): stop CLI process group on exit
2026-03-18 20:33:55 +00:00
Pascal André
fcebcb0174 fix(tauri): sync server UI bundle during prebuild
Ensure the Tauri prebuild step refreshes packages/server/public from the current UI renderer bundle so the packaged desktop app does not serve a stale folder-selection UI.
2026-03-18 20:45:08 +01:00
Shantur Rathore
eaab5e2e9f fix(tauri): stop CLI process group on exit 2026-03-18 19:43:41 +00:00
Shantur Rathore
b12825f923 Merge pull request #227 from Pagecran/upstream/tauri-windows-runtime
fix(tauri): improve Windows desktop runtime behavior
2026-03-18 19:37:31 +00:00
Pascal André
8245f474b8 fix(tauri): avoid non-Windows spawn warning 2026-03-18 20:21:40 +01:00
Pascal André
3a15b311a8 fix(tauri): hide taskkill during Windows cleanup 2026-03-18 20:19:10 +01:00
Pascal André
6cb6c0af32 fix(tauri): align desktop bundle identifier 2026-03-18 20:19:10 +01:00
Pascal André
7f631611fd fix(tauri): hide Windows CLI console window
Set CREATE_NO_WINDOW on the spawned local CLI process so the packaged Windows Tauri app does not flash an extra console window when it launches Node.
2026-03-18 20:19:10 +01:00
Pascal André
9d91ecc649 fix(tauri): kill Windows CLI process trees on shutdown
Use taskkill /T /F for the local server process on Windows so child Node/Bun processes do not survive app shutdown or startup timeouts.
2026-03-18 20:19:10 +01:00
Pascal André
87afb06d34 fix(tauri): restore Windows app identity
Set the same explicit AppUserModelID that the legacy Electron app used so Windows taskbar grouping and notification attribution stay consistent in the Tauri desktop build.
2026-03-18 20:18:59 +01:00
Pascal André
4402d9afb0 fix(tauri): align desktop version metadata
Match the Tauri package, Cargo, and bundle version metadata to the current legacy desktop version so About dialogs and installer artifacts stop reporting 0.1.0.
2026-03-18 20:18:07 +01:00
Shantur Rathore
7c3f808d69 Minium server 0.12.3 2026-03-13 20:06:41 +00:00
Shantur Rathore
a59e929b12 Release v0.12.3 2026-03-13 20:04:20 +00:00
Shantur Rathore
8ff4019839 fix(ui): stabilize prompt async optimistic messages
Reconcile optimistic user messages by replacing the oldest synthetic pending message when the server-backed message arrives. Stop sending prompt part ids and rely on message-level replacement so v1.2.25 validation passes without duplicating optimistic content.
2026-03-13 19:17:55 +00:00
Shantur Rathore
d9068ac8c6 fix(ui): tighten settings content padding
Reduce the Settings scroll area gutter while keeping a consistent inset on all sides.
2026-03-11 11:01:04 +00:00
Shantur Rathore
51f8eff3f7 fix(ui): remove settings rounded corners
Make the Settings screen use square corners across panels, cards, and embedded controls.
2026-03-11 10:55:51 +00:00
Shantur Rathore
627ff2d42b feat(ui): centralize interaction preferences
Expose interaction defaults in Settings and reuse the same registry for command palette actions.
2026-03-11 10:53:28 +00:00
Shantur Rathore
0d9da40102 feat(ui): add unified settings screen 2026-03-11 10:10:58 +00:00
53 changed files with 2851 additions and 309 deletions

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.12.2",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -12002,7 +12002,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
@@ -12039,7 +12039,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12080,7 +12080,7 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@@ -12088,7 +12088,7 @@
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.12.2",
"version": "0.12.3",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.11.4",
"minServerVersion": "0.12.3",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.2",
"version": "0.12.3",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.14"
"@opencode-ai/plugin": "1.2.25"
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.12.3",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.12.3",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View File

@@ -626,7 +626,7 @@ dependencies = [
[[package]]
name = "codenomad-tauri"
version = "0.1.0"
version = "0.12.3"
dependencies = [
"anyhow",
"dirs 5.0.1",
@@ -646,6 +646,7 @@ dependencies = [
"thiserror 1.0.69",
"url",
"which",
"windows-sys 0.59.0",
]
[[package]]

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.12.2",
"version": "0.12.3",
"private": true,
"license": "MIT",
"scripts": {

View File

@@ -20,6 +20,7 @@ const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
const envWithRootBin = {
...process.env,
@@ -91,6 +92,15 @@ function ensureUiBuild() {
}
}
function syncServerUiBundle() {
console.log("[prebuild] syncing server public UI bundle...")
execSync(serverPrepareUiCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
@@ -246,6 +256,7 @@ function copyUiLoadingAssets() {
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
syncServerUiBundle()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()

View File

@@ -1,6 +1,6 @@
[package]
name = "codenomad-tauri"
version = "0.1.0"
version = "0.12.3"
edition = "2021"
license = "MIT"
@@ -25,3 +25,6 @@ tauri-plugin-opener = "2"
url = "2"
tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }

View File

@@ -9,6 +9,8 @@ use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -17,10 +19,24 @@ use std::thread;
use std::time::{Duration, Instant};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
}
#[cfg(windows)]
fn configure_spawn(command: &mut Command) {
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn configure_spawn(_command: &mut Command) {}
fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 {
@@ -36,6 +52,46 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) {
// Ensure the CLI runs in its own process group so we can terminate wrapper
// processes (login shell/tsx) without leaving the server orphaned.
unsafe {
command.pre_exec(|| {
if libc::setpgid(0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(windows)]
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
if force {
args.push("/F".to_string());
}
let mut command = Command::new("taskkill");
command.args(&args);
configure_spawn(&mut command);
match command.output() {
Ok(output) => {
if output.status.success() {
return true;
}
// If the PID is already gone, treat it as success.
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
let combined = format!("{stdout}\n{stderr}");
combined.contains("not found") || combined.contains("no running instance")
}
Err(_) => false,
}
}
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
let mut display = url.to_string();
@@ -348,11 +404,19 @@ impl CliProcessManager {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
let pid = child.id() as i32;
// Prefer signaling the process group to avoid orphaning children
// when the CLI was launched via a wrapper shell.
let group_res = libc::kill(-pid, libc::SIGTERM);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGTERM);
}
}
#[cfg(windows)]
{
let _ = child.kill();
if !kill_process_tree_windows(child.id(), false) {
let _ = child.kill();
}
}
let start = Instant::now();
@@ -368,11 +432,17 @@ impl CliProcessManager {
));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
let _ = child.kill();
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
break;
}
@@ -450,9 +520,12 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()?
}
ShellCommandType::Direct(cmd) => {
@@ -462,9 +535,12 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()?
}
};
@@ -537,7 +613,24 @@ impl CliProcessManager {
locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() {
let _ = child.kill();
#[cfg(unix)]
unsafe {
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
}
}
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked);

View File

@@ -12,8 +12,20 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt;
use url::Url;
#[cfg(windows)]
use std::ffi::OsStr;
#[cfg(windows)]
use std::iter;
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
#[cfg(windows)]
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
@@ -101,6 +113,22 @@ fn emit_folder_drop_event(
}
}
#[cfg(windows)]
fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
.encode_wide()
.chain(iter::once(0))
.collect();
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
if result < 0 {
eprintln!("[tauri] failed to set AppUserModelID: {result}");
}
}
#[cfg(not(windows))]
fn set_windows_app_user_model_id() {}
fn main() {
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
@@ -116,6 +144,7 @@ fn main() {
manager: CliProcessManager::new(),
})
.setup(|app| {
set_windows_app_user_model_id();
build_menu(&app.handle())?;
let dev_mode = is_dev_mode();
let app_handle = app.handle().clone();

View File

@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.1.0",
"identifier": "ai.opencode.client",
"version": "0.12.3",
"identifier": "ai.neuralnomads.codenomad.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.12.2",
"version": "0.12.3",
"private": true,
"license": "MIT",
"type": "module",

View File

@@ -9,7 +9,7 @@ import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { SettingsScreen } from "./components/settings-screen"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars"
@@ -54,6 +54,7 @@ import {
} from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen"
const log = getLogger("actions")
@@ -77,8 +78,6 @@ const App: Component = () => {
setToolInputsVisibility,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const phoneQuery = useMediaQuery("(max-width: 767px)")
@@ -252,7 +251,6 @@ const App: Component = () => {
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", {
instanceId,
@@ -274,7 +272,7 @@ const App: Component = () => {
function handleLaunchErrorAdvanced() {
clearLaunchError()
setIsAdvancedSettingsOpen(true)
openSettings("opencode")
}
function handleNewInstanceRequest() {
@@ -487,7 +485,6 @@ const App: Component = () => {
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
@@ -533,10 +530,6 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
@@ -546,12 +539,8 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
/>
@@ -559,7 +548,7 @@ const App: Component = () => {
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<SettingsScreen />
<AlertDialog />

View File

@@ -2,10 +2,8 @@ import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { ThemeModeToggle } from "./theme-mode-toggle"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
import VersionPill from "./version-pill"
@@ -14,6 +12,7 @@ import { githubStars } from "../stores/github-stars"
import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -21,15 +20,11 @@ const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).h
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
onClose?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
@@ -196,7 +191,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
})
function dropTargetBlocked() {
return isLoading() || isFolderBrowserOpen() || Boolean(props.advancedSettingsOpen)
return isLoading() || isFolderBrowserOpen() || settingsOpen()
}
function showInvalidFolderDropAlert() {
@@ -264,11 +259,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
handleFolderSelect(path)
}
function handleBinaryChange(binary: string) {
setSelectedBinary(binary)
}
function handleRemove(path: string, e?: Event) {
if (isLoading()) return
e?.stopPropagation()
@@ -398,16 +388,24 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select>
</div>
<div class="absolute top-4 right-6 flex items-center gap-2">
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
<Show when={props.onOpenRemoteAccess}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => openSettings("appearance")}
aria-label={t("settings.open.title")}
title={t("settings.open.title")}
>
<Settings class="w-4 h-4" />
</button>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => openSettings("remote")}
aria-label={t("instanceTabs.remote.ariaLabel")}
title={t("instanceTabs.remote.title")}
>
<MonitorUp class="w-4 h-4" />
</button>
<Show when={props.onClose}>
<button
type="button"
@@ -595,12 +593,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button>
</div>
{/* Advanced settings section */}
{/* OpenCode settings section */}
<div class="panel-section w-full">
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
@@ -661,14 +659,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
</div>
<AdvancedSettingsModal
open={Boolean(props.advancedSettingsOpen)}
onClose={() => props.onAdvancedSettingsClose?.()}
selectedBinary={selectedBinary()}
onBinaryChange={handleBinaryChange}
isLoading={props.isLoading}
/>
<DirectoryBrowserDialog
open={isFolderBrowserOpen()}
title={t("folderSelection.dialog.title")}

View File

@@ -1,15 +1,14 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Component, For, Show, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n"
import { ThemeModeToggle } from "./theme-mode-toggle"
import NotificationsSettingsModal from "./notifications-settings-modal"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen"
interface InstanceTabsProps {
instances: Map<string, Instance>
@@ -17,13 +16,11 @@ interface InstanceTabsProps {
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
onOpenRemoteAccess?: () => void
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
@@ -33,8 +30,10 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
})
const notificationTitle = createMemo(() => {
if (!notificationsSupported()) return "Notifications unsupported"
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
return notificationsEnabled()
? t("settings.notifications.status.enabled")
: t("settings.notifications.status.disabled")
})
return (
@@ -72,32 +71,35 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
/>
</div>
</Show>
<ThemeModeToggle class="new-tab-button" />
<button
class="new-tab-button"
onClick={() => openSettings("appearance")}
title={t("settings.open.title")}
aria-label={t("settings.open.ariaLabel")}
>
<Settings class="w-4 h-4" />
</button>
<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => setNotificationsOpen(true)}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => openSettings("notifications")}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
<Show when={Boolean(props.onOpenRemoteAccess)}>
<button
class="new-tab-button tab-remote-button"
onClick={() => props.onOpenRemoteAccess?.()}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<button
class="new-tab-button tab-remote-button"
onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</div>
</div>
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
</div>
)

View File

@@ -0,0 +1,107 @@
import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n"
import {
activeSettingsSection,
closeSettings,
settingsOpen,
setActiveSettingsSection,
type SettingsSectionId,
} from "../stores/settings-screen"
import { AppearanceSettingsSection } from "./settings/appearance-settings-section"
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
const sections = createMemo(() => [
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
const renderSection = () => {
switch (activeSettingsSection()) {
case "notifications":
return <NotificationsSettingsSection />
case "remote":
return <RemoteAccessSettingsSection />
case "opencode":
return <OpenCodeSettingsSection />
case "appearance":
default:
return <AppearanceSettingsSection />
}
}
return (
<Dialog open={settingsOpen()} onOpenChange={(open) => !open && closeSettings()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="settings-screen-frame">
<Dialog.Content class="modal-surface settings-screen-shell">
<Dialog.Title class="sr-only">{t("settings.title")}</Dialog.Title>
<aside class="settings-screen-nav">
<div class="settings-screen-nav-header">
<div class="settings-screen-nav-title-row">
<span class="settings-screen-nav-icon-wrap">
<Settings class="settings-screen-nav-icon" />
</span>
<div>
<h2 class="settings-screen-title">{t("settings.title")}</h2>
</div>
</div>
</div>
<nav class="settings-screen-nav-list" aria-label={t("settings.navigationAriaLabel")}>
<For each={sections()}>
{(section) => {
const Icon = section.icon
return (
<button
type="button"
class="settings-nav-button"
data-selected={activeSettingsSection() === section.id ? "true" : "false"}
onClick={() => setActiveSettingsSection(section.id)}
>
<Icon class="settings-nav-button-icon" />
<span>{section.label}</span>
</button>
)
}}
</For>
</nav>
</aside>
<div class="settings-screen-content">
<header class="settings-screen-content-header">
<div class="settings-screen-content-header-title-group">
<p class="settings-screen-content-eyebrow">{t("settings.content.eyebrow")}</p>
<h1 class="settings-screen-content-title">
{sections().find((section) => section.id === activeSettingsSection())?.label}
</h1>
</div>
<button
type="button"
class="selector-button selector-button-secondary settings-screen-close"
onClick={closeSettings}
aria-label={t("settings.close")}
title={t("settings.close")}
>
<X class="w-4 h-4" />
</button>
</header>
<div class="settings-screen-scroll">{renderSection()}</div>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -0,0 +1,270 @@
import { Select } from "@kobalte/core/select"
import { createEffect, createMemo, createSignal, For, type Component } from "solid-js"
import { Check, ChevronDown, Laptop, Moon, Sun } from "lucide-solid"
import { useI18n } from "../../lib/i18n"
import { useTheme, type ThemeMode } from "../../lib/theme"
import { useConfig } from "../../stores/preferences"
import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry"
const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
{ value: "system", icon: Laptop },
{ value: "light", icon: Sun },
{ value: "dark", icon: Moon },
]
export const AppearanceSettingsSection: Component = () => {
const { t } = useI18n()
const { themeMode, setThemeMode } = useTheme()
const {
preferences,
updatePreferences,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
} = useConfig()
const behaviorSettings = createMemo(() =>
getBehaviorSettings({
preferences,
updatePreferences,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
}),
)
const [overrides, setOverrides] = createSignal<Map<string, unknown>>(new Map())
const setOverride = (id: string, value: unknown) => {
setOverrides((prev) => {
const next = new Map(prev)
next.set(id, value)
return next
})
}
createEffect(() => {
const current = overrides()
if (current.size === 0) return
const prefs = preferences()
const settings = behaviorSettings()
let changed = false
const next = new Map(current)
for (const setting of settings) {
if (!next.has(setting.id)) continue
const overrideValue = next.get(setting.id)
const actualValue = setting.get(prefs)
if (Object.is(actualValue, overrideValue)) {
next.delete(setting.id)
changed = true
}
}
if (changed) {
setOverrides(next)
}
})
const readSettingValue = (setting: BehaviorSetting) => {
const current = overrides()
if (current.has(setting.id)) return current.get(setting.id)
return setting.get(preferences())
}
type SelectOption = { value: string; label: string }
const BehaviorRow: Component<{ setting: BehaviorSetting }> = (props) => {
const setting = props.setting
const disabled = createMemo(() => (setting.disabled ? Boolean(setting.disabled()) : false))
if (setting.kind === "toggle") {
const options = createMemo<SelectOption[]>(() => [
{ value: "true", label: t("settings.common.enabled") },
{ value: "false", label: t("settings.common.disabled") },
])
const currentValue = createMemo(() => String(Boolean(readSettingValue(setting))))
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
return (
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
<div>
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
</div>
<Select<SelectOption>
value={selectedOption()}
onChange={(opt) => {
if (!opt) return
const next = opt.value === "true"
setOverride(setting.id, next)
setting.set(next)
}}
options={options()}
optionValue="value"
optionTextValue="label"
disabled={disabled()}
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
<div class="flex-1 min-w-0">
<Select.Value<SelectOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
)
}
const enumSetting = setting as Extract<BehaviorSetting, { kind: "enum" }>
const options = createMemo<SelectOption[]>(() =>
enumSetting.options.map((opt: { value: string; labelKey: string }) => ({
value: String(opt.value),
label: t(opt.labelKey),
})),
)
const currentValue = createMemo(() => String(readSettingValue(setting) ?? ""))
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
return (
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
<div>
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
</div>
<Select<SelectOption>
value={selectedOption()}
onChange={(opt) => {
if (!opt) return
setOverride(setting.id, opt.value)
enumSetting.set(opt.value as any)
}}
options={options()}
optionValue="value"
optionTextValue="label"
disabled={disabled()}
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
<div class="flex-1 min-w-0">
<Select.Value<SelectOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
)
}
const modeLabel = (mode: ThemeMode) => {
if (mode === "system") return t("theme.mode.system")
if (mode === "light") return t("theme.mode.light")
return t("theme.mode.dark")
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.appearance.theme.title")}</h3>
<p class="settings-card-subtitle">{t("settings.appearance.theme.subtitle")}</p>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-choice-grid">
{themeModeOptions.map((option) => {
const Icon = option.icon
return (
<button
type="button"
class="settings-choice"
data-selected={themeMode() === option.value ? "true" : "false"}
onClick={() => setThemeMode(option.value)}
>
<span class="settings-choice-icon-wrap">
<Icon class="settings-choice-icon" />
</span>
<span class="settings-choice-copy">
<span class="settings-choice-label">{modeLabel(option.value)}</span>
<span class="settings-choice-description">{t(`settings.appearance.theme.option.${option.value}`)}</span>
</span>
<span class="settings-choice-check" aria-hidden="true">
<Check class="w-4 h-4" />
</span>
</button>
)
})}
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.appearance.behavior.title")}</h3>
<p class="settings-card-subtitle">{t("settings.appearance.behavior.subtitle")}</p>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-stack">
<For each={behaviorSettings()}>{(setting) => <BehaviorRow setting={setting} />}</For>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { Show, createEffect, createResource, type Component } from "solid-js"
import { Bell } from "lucide-solid"
import { showToastNotification } from "../../lib/notifications"
import {
getOsNotificationCapability,
requestOsNotificationPermission,
type OsNotificationPermission,
} from "../../lib/os-notifications"
import { useConfig } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
function formatPermissionLabel(permission: OsNotificationPermission, t: ReturnType<typeof useI18n>["t"]): string {
switch (permission) {
case "granted":
return t("settings.notifications.permission.granted")
case "denied":
return t("settings.notifications.permission.denied")
case "default":
return t("settings.notifications.permission.default")
case "unsupported":
return t("settings.notifications.permission.unsupported")
default:
return String(permission)
}
}
export const NotificationsSettingsSection: Component = () => {
const { t } = useI18n()
const { preferences, updatePreferences } = useConfig()
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
createEffect(() => {
void refetch()
})
const handleEnableToggle = async (enabled: boolean) => {
if (!enabled) {
updatePreferences({ osNotificationsEnabled: false })
return
}
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: t("settings.section.notifications.title"),
message: cap.info ?? t("settings.notifications.messages.unsupportedEnvironment"),
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
const permission = await requestOsNotificationPermission()
if (permission !== "granted") {
showToastNotification({
title: t("settings.section.notifications.title"),
message:
permission === "denied"
? t("settings.notifications.messages.permissionDenied")
: t("settings.notifications.messages.permissionNotGranted"),
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
updatePreferences({ osNotificationsEnabled: true })
void refetch()
}
const handleRequestPermission = async () => {
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: t("settings.section.notifications.title"),
message: cap.info ?? t("settings.notifications.messages.unsupportedGeneral"),
variant: "warning",
})
return
}
const permission = await requestOsNotificationPermission()
if (permission === "granted") {
showToastNotification({
title: t("settings.section.notifications.title"),
message: t("settings.notifications.messages.permissionGranted"),
variant: "success",
duration: 6000,
})
void refetch()
return
}
showToastNotification({
title: t("settings.section.notifications.title"),
message:
permission === "denied"
? t("settings.notifications.messages.permissionRequestDenied")
: t("settings.notifications.messages.permissionNotGranted"),
variant: "warning",
})
void refetch()
}
const supported = () => capability()?.supported ?? false
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported", t)
const infoMessage = () => capability()?.info
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Bell class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.notifications.sessionStatus.title")}</h3>
<p class="settings-card-subtitle">{t("settings.notifications.sessionStatus.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.notifications.enable.title")}</div>
<div class="settings-toggle-caption">
{t("settings.notifications.enable.permission", { permission: permissionLabel() })}
</div>
</div>
<label class="settings-checkbox-toggle">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsEnabled)}
disabled={!supported() && capability.state === "ready"}
onChange={(event) => void handleEnableToggle(event.currentTarget.checked)}
/>
<span>{t("settings.common.enabled")}</span>
</label>
</div>
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.notifications.requestPermission.title")}</div>
<div class="settings-toggle-caption">{t("settings.notifications.requestPermission.subtitle")}</div>
</div>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => void handleRequestPermission()}
>
{t("settings.notifications.requestPermission.action")}
</button>
</div>
</Show>
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.notifications.allowVisible.title")}</div>
<div class="settings-toggle-caption">{t("settings.notifications.allowVisible.subtitle")}</div>
</div>
<label class="settings-checkbox-toggle">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
disabled={!preferences().osNotificationsEnabled}
onChange={(event) => updatePreferences({ osNotificationsAllowWhenVisible: event.currentTarget.checked })}
/>
<span>{t("settings.common.enabled")}</span>
</label>
</div>
<Show when={Boolean(infoMessage())}>
<div class="settings-inline-note">{infoMessage()}</div>
</Show>
<Show when={!supported() && capability.state === "ready"}>
<div class="settings-inline-note">{t("settings.notifications.unsupportedNote")}</div>
</Show>
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.notifications.events.title")}</h3>
<p class="settings-card-subtitle">{t("settings.notifications.events.subtitle")}</p>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.notifications.events.needsInput")}</div>
</div>
<label class="settings-checkbox-toggle">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnNeedsInput)}
disabled={!preferences().osNotificationsEnabled}
onChange={(event) => updatePreferences({ notifyOnNeedsInput: event.currentTarget.checked })}
/>
<span>{t("settings.common.enabled")}</span>
</label>
</div>
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.notifications.events.idle")}</div>
</div>
<label class="settings-checkbox-toggle">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnIdle)}
disabled={!preferences().osNotificationsEnabled}
onChange={(event) => updatePreferences({ notifyOnIdle: event.currentTarget.checked })}
/>
<span>{t("settings.common.enabled")}</span>
</label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { createEffect, createSignal, type Component } from "solid-js"
import { Terminal } from "lucide-solid"
import OpenCodeBinarySelector from "../opencode-binary-selector"
import EnvironmentVariablesEditor from "../environment-variables-editor"
import { useConfig } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
export const OpenCodeSettingsSection: Component = () => {
const { t } = useI18n()
const { serverSettings, updateLastUsedBinary } = useConfig()
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
createEffect(() => {
const binary = serverSettings().opencodeBinary || "opencode"
setSelectedBinary((current) => (current === binary ? current : binary))
})
const handleBinaryChange = (binary: string) => {
setSelectedBinary(binary)
updateLastUsedBinary(binary)
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Terminal class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.opencode.runtime.title")}</h3>
<p class="settings-card-subtitle">{t("settings.opencode.runtime.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("advancedSettings.environmentVariables.title")}</h3>
<p class="settings-card-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<EnvironmentVariablesEditor />
</div>
</div>
)
}

View File

@@ -0,0 +1,401 @@
import { Switch } from "@kobalte/core/switch"
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
import { toDataURL } from "qrcode"
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
import { serverApi } from "../../lib/api-client"
import { restartCli } from "../../lib/native/cli"
import { serverSettings, setListeningMode } from "../../stores/preferences"
import { showConfirmDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
import { useI18n } from "../../lib/i18n"
const log = getLogger("actions")
export const RemoteAccessSettingsSection: Component = () => {
const { t } = useI18n()
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{
authenticated: boolean
username?: string
passwordUserProvided?: boolean
} | null>(null)
const [loading, setLoading] = createSignal(false)
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
const [passwordValue, setPasswordValue] = createSignal("")
const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => {
const list = addresses()
if (!allowExternalConnections()) return []
return list.filter((address) => address.scope !== "loopback")
})
const refreshMeta = async () => {
setLoading(true)
setError(null)
setPasswordError(null)
try {
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult)
setAuthStatus(authResult)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
onMount(() => {
void refreshMeta()
})
const toggleExpanded = async (url: string) => {
if (expandedUrl() === url) {
setExpandedUrl(null)
return
}
setExpandedUrl(url)
if (!qrCodes()[url]) {
try {
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
} catch (err) {
log.error("Failed to generate QR code", err)
}
}
}
const handleAllowConnectionsChange = async (checked: boolean) => {
const targetMode: "local" | "all" = checked ? "all" : "local"
if (targetMode === currentMode() || applyingListeningMode()) return
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: checked
? t("remoteAccess.listeningMode.restartConfirm.title.all")
: t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
})
if (!confirmed) return
setApplyingListeningMode(true)
setError(null)
try {
await setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplyingListeningMode(false)
}
void refreshMeta()
}
const handleOpenUrl = (url: string) => {
try {
window.open(url, "_blank", "noopener,noreferrer")
} catch (err) {
log.error("Failed to open URL", err)
}
}
const handleSubmitPassword = async () => {
setPasswordError(null)
const next = passwordValue()
const confirm = passwordConfirm()
if (next.trim().length < 8) {
setPasswordError(t("remoteAccess.password.error.tooShort"))
return
}
if (next !== confirm) {
setPasswordError(t("remoteAccess.password.error.mismatch"))
return
}
setSavingPassword(true)
try {
const result = await serverApi.setServerPassword(next)
setAuthStatus({
authenticated: true,
username: result.username,
passwordUserProvided: result.passwordUserProvided,
})
setPasswordValue("")
setPasswordConfirm("")
setPasswordFormOpen(false)
} catch (err) {
setPasswordError(err instanceof Error ? err.message : String(err))
} finally {
setSavingPassword(false)
}
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Shield class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("remoteAccess.sections.listeningMode.label")}</h3>
<p class="settings-card-subtitle">{t("remoteAccess.sections.listeningMode.help")}</p>
</div>
</div>
<div class="settings-toolbar-inline">
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
<button
class="selector-button selector-button-secondary w-auto"
type="button"
onClick={() => void refreshMeta()}
disabled={loading()}
>
<RefreshCw class={`w-4 h-4 ${loading() ? "remote-spin" : ""}`} />
<span>{t("remoteAccess.refresh")}</span>
</button>
</div>
</div>
<Switch
class="remote-toggle"
checked={allowExternalConnections()}
onChange={(nextChecked) => void handleAllowConnectionsChange(nextChecked)}
disabled={loading() || applyingListeningMode()}
>
<Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
<span class="remote-toggle-state">
{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}
</span>
<Switch.Thumb class="remote-toggle-thumb" />
</Switch.Control>
<div class="remote-toggle-copy">
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
<span class="remote-toggle-caption">
{allowExternalConnections()
? t("remoteAccess.toggle.caption.all")
: t("remoteAccess.toggle.caption.local")}
</span>
</div>
</Switch>
<p class="remote-toggle-note">{t("remoteAccess.toggle.note")}</p>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Shield class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("remoteAccess.sections.serverPassword.label")}</h3>
<p class="settings-card-subtitle">{t("remoteAccess.sections.serverPassword.help")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<Show
when={authStatus() && authStatus()!.authenticated}
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
>
<div class="settings-card-content">
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
<p class="settings-help-text">
{authStatus()!.passwordUserProvided
? t("remoteAccess.password.status.set")
: t("remoteAccess.password.status.unset")}
</p>
<div class="settings-password-actions">
<button
class="settings-pill-button"
type="button"
onClick={() => {
setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null)
}}
>
{passwordFormOpen()
? t("remoteAccess.password.actions.cancel")
: authStatus()!.passwordUserProvided
? t("remoteAccess.password.actions.change")
: t("remoteAccess.password.actions.set")}
</button>
</div>
<Show when={passwordFormOpen()}>
<div class="settings-form-group">
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
<input
class="selector-input w-full"
type="password"
value={passwordValue()}
onInput={(event) => setPasswordValue(event.currentTarget.value)}
placeholder={t("remoteAccess.password.form.placeholder")}
/>
</div>
<div class="settings-form-group">
<label class="settings-form-label">{t("remoteAccess.password.form.confirmPassword")}</label>
<input
class="selector-input w-full"
type="password"
value={passwordConfirm()}
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
/>
</div>
<Show when={passwordError()}>
{(message) => <div class="settings-error-message">{message()}</div>}
</Show>
<div class="settings-password-actions">
<button class="settings-pill-button" type="button" disabled={savingPassword()} onClick={() => void handleSubmitPassword()}>
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
</button>
</div>
</Show>
</div>
</Show>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Wifi class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("remoteAccess.sections.addresses.label")}</h3>
<p class="settings-card-subtitle">{t("remoteAccess.sections.addresses.help")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show
when={displayAddresses().length > 0 || meta()?.localUrl}
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
>
<div class="remote-address-list">
<Show when={meta()?.localUrl}>
{(url) => {
const value = () => url()
const expandedState = () => expandedUrl() === value()
const qr = () => qrCodes()[value()]
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{value()}</p>
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(value())}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url: value() })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</Show>
<For each={displayAddresses()}>
{(address) => {
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
)}
</Show>
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -14,7 +14,7 @@ import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
import { registerBehaviorCommands } from "../settings/behavior-registry"
const log = getLogger("actions")
@@ -427,178 +427,19 @@ export function useCommands(options: UseCommandsOptions) {
},
})
commandRegistry.register({
id: "prompt-submit-shortcut",
label: () =>
options.preferences().promptSubmitOnEnter
? tGlobal("commands.promptSubmitShortcut.label.swapped")
: tGlobal("commands.promptSubmitShortcut.label.default"),
description: () => tGlobal("commands.promptSubmitShortcut.description"),
category: "Input & Focus",
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
action: options.togglePromptSubmitOnEnter,
})
commandRegistry.register({
id: "thinking",
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
description: () => tGlobal("commands.thinkingBlocks.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
action: options.toggleShowThinkingBlocks,
})
commandRegistry.register({
id: "timeline-tools",
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
description: () => tGlobal("commands.timelineToolCalls.description"),
category: "System",
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
action: options.toggleShowTimelineTools,
})
commandRegistry.register({
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
options.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: options.toggleKeyboardShortcutHints,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.thinkingBlocksDefault.label", { state })
},
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
action: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setThinkingBlocksExpansion(next)
},
})
commandRegistry.register({
id: "diff-view-split",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
},
description: () => tGlobal("commands.diffViewSplit.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
action: () => options.setDiffViewMode("split"),
})
commandRegistry.register({
id: "diff-view-unified",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
},
description: () => tGlobal("commands.diffViewUnified.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
action: () => options.setDiffViewMode("unified"),
})
commandRegistry.register({
id: "tool-output-default-visibility",
label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.toolOutputsDefault.label", { state })
},
description: () => tGlobal("commands.toolOutputsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setToolOutputExpansion(next)
},
})
commandRegistry.register({
id: "diagnostics-default-visibility",
label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.diagnosticsDefault.label", { state })
},
description: () => tGlobal("commands.diagnosticsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setDiagnosticsExpansion(next)
},
})
commandRegistry.register({
id: "tool-inputs-visibility",
label: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
options.setToolInputsVisibility(next)
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
return tGlobal("commands.tokenUsageDisplay.label", { state })
},
description: () => tGlobal("commands.tokenUsageDisplay.description"),
category: "System",
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
action: options.toggleUsageMetrics,
})
commandRegistry.register({
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = options.preferences().autoCleanupBlankSessions
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
},
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
category: "System",
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
action: options.toggleAutoCleanupBlankSessions,
registerBehaviorCommands((command) => commandRegistry.register(command), {
preferences: options.preferences,
toggleShowThinkingBlocks: options.toggleShowThinkingBlocks,
toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints,
toggleShowTimelineTools: options.toggleShowTimelineTools,
toggleUsageMetrics: options.toggleUsageMetrics,
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
setDiffViewMode: options.setDiffViewMode,
setToolOutputExpansion: options.setToolOutputExpansion,
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
setThinkingBlocksExpansion: options.setThinkingBlocksExpansion,
setToolInputsVisibility: options.setToolInputsVisibility,
})
commandRegistry.register({

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "Unable to launch OpenCode",
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from Advanced Settings.",
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from OpenCode settings.",
"app.launchError.binaryPathLabel": "Binary path",
"app.launchError.errorOutputLabel": "Error output",
"app.launchError.openAdvancedSettings": "Open Advanced Settings",
"app.launchError.openAdvancedSettings": "Open OpenCode Settings",
"app.launchError.close": "Close",
"app.launchError.closeTitle": "Close (Esc)",
"app.launchError.fallbackMessage": "Failed to launch workspace",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Opening...",
"folderSelection.advancedSettings": "Advanced Settings",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Navigate",
"folderSelection.hints.select": "Select",

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Used",
"contextUsagePanel.labels.available": "Avail",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Disabled",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
"settings.behavior.keyboardHints.title": "Keyboard shortcut hints",
"settings.behavior.keyboardHints.subtitle": "Show keyboard shortcut hints across the UI.",
"settings.behavior.thinking.title": "Thinking sections",
"settings.behavior.thinking.subtitle": "Show or hide AI thinking sections in messages.",
"settings.behavior.thinkingDefault.title": "Thinking default",
"settings.behavior.thinkingDefault.subtitle": "Choose whether thinking sections start expanded or collapsed.",
"settings.behavior.timelineTools.title": "Timeline tool calls",
"settings.behavior.timelineTools.subtitle": "Show or hide tool call entries in the message timeline.",
"settings.behavior.diffView.title": "Diff view",
"settings.behavior.diffView.subtitle": "Choose how tool-call diffs are displayed.",
"settings.behavior.diffView.option.split": "Split",
"settings.behavior.diffView.option.unified": "Unified",
"settings.behavior.toolOutputsDefault.title": "Tool outputs default",
"settings.behavior.toolOutputsDefault.subtitle": "Choose whether tool outputs start expanded or collapsed.",
"settings.behavior.diagnosticsDefault.title": "Diagnostics default",
"settings.behavior.diagnosticsDefault.subtitle": "Choose whether diagnostics output starts expanded or collapsed.",
"settings.behavior.toolInputsVisibility.title": "Tool inputs visibility",
"settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.",
"settings.behavior.usageMetrics.title": "Token usage metrics",
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"settings.behavior.promptSubmit.title": "Enter to submit",
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
} as const

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "No se pudo iniciar OpenCode",
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en Configuración avanzada.",
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en la configuración de OpenCode.",
"app.launchError.binaryPathLabel": "Ruta del binario",
"app.launchError.errorOutputLabel": "Salida de error",
"app.launchError.openAdvancedSettings": "Abrir Configuración avanzada",
"app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode",
"app.launchError.close": "Cerrar",
"app.launchError.closeTitle": "Cerrar (Esc)",
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Abriendo...",
"folderSelection.advancedSettings": "Configuración avanzada",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Navegar",
"folderSelection.hints.select": "Seleccionar",

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Usado",
"contextUsagePanel.labels.available": "Disp.",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Desactivado",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
"settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado",
"settings.behavior.keyboardHints.subtitle": "Muestra sugerencias de atajos de teclado en toda la interfaz.",
"settings.behavior.thinking.title": "Secciones de pensamiento",
"settings.behavior.thinking.subtitle": "Muestra u oculta las secciones de pensamiento de la IA en los mensajes.",
"settings.behavior.thinkingDefault.title": "Pensamiento por defecto",
"settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraidas.",
"settings.behavior.timelineTools.title": "Llamadas de herramientas en la linea de tiempo",
"settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la linea de tiempo de mensajes.",
"settings.behavior.diffView.title": "Vista de diferencias",
"settings.behavior.diffView.subtitle": "Elige como se muestran los diffs de llamadas de herramientas.",
"settings.behavior.diffView.option.split": "Dividida",
"settings.behavior.diffView.option.unified": "Unificada",
"settings.behavior.toolOutputsDefault.title": "Salidas de herramientas por defecto",
"settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraidas.",
"settings.behavior.diagnosticsDefault.title": "Diagnosticos por defecto",
"settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnosticos comienza expandida o contraida.",
"settings.behavior.toolInputsVisibility.title": "Visibilidad de entradas de herramientas",
"settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.",
"settings.behavior.usageMetrics.title": "Metricas de uso de tokens",
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.promptSubmit.title": "Enter para enviar",
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
} as const

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "Impossible de lancer OpenCode",
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les Paramètres avancés.",
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les paramètres OpenCode.",
"app.launchError.binaryPathLabel": "Chemin du binaire",
"app.launchError.errorOutputLabel": "Sortie d'erreur",
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres avancés",
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode",
"app.launchError.close": "Fermer",
"app.launchError.closeTitle": "Fermer (Esc)",
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Ouverture...",
"folderSelection.advancedSettings": "Paramètres avancés",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Naviguer",
"folderSelection.hints.select": "Sélectionner",

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Utilisé",
"contextUsagePanel.labels.available": "Dispo",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Desactive",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
"settings.behavior.keyboardHints.title": "Indications de raccourcis clavier",
"settings.behavior.keyboardHints.subtitle": "Afficher des indications de raccourcis clavier dans toute l'interface.",
"settings.behavior.thinking.title": "Sections de reflexion",
"settings.behavior.thinking.subtitle": "Afficher ou masquer les sections de reflexion de l'IA dans les messages.",
"settings.behavior.thinkingDefault.title": "Etat initial de la reflexion",
"settings.behavior.thinkingDefault.subtitle": "Choisir si les sections de reflexion commencent developpees ou reduites.",
"settings.behavior.timelineTools.title": "Appels d'outils dans la chronologie",
"settings.behavior.timelineTools.subtitle": "Afficher ou masquer les entrees d'appels d'outils dans la chronologie des messages.",
"settings.behavior.diffView.title": "Vue du diff",
"settings.behavior.diffView.subtitle": "Choisir comment les diffs des appels d'outils sont affiches.",
"settings.behavior.diffView.option.split": "Scinde",
"settings.behavior.diffView.option.unified": "Unifie",
"settings.behavior.toolOutputsDefault.title": "Etat initial des sorties d'outils",
"settings.behavior.toolOutputsDefault.subtitle": "Choisir si les sorties d'outils commencent developpees ou reduites.",
"settings.behavior.diagnosticsDefault.title": "Etat initial des diagnostics",
"settings.behavior.diagnosticsDefault.subtitle": "Choisir si la sortie des diagnostics commence developpee ou reduite.",
"settings.behavior.toolInputsVisibility.title": "Visibilite des entrees d'outils",
"settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.",
"settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens",
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
} as const

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "OpenCode を起動できません",
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、詳細設定から別のバイナリを選択してください。",
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。",
"app.launchError.binaryPathLabel": "バイナリのパス",
"app.launchError.errorOutputLabel": "エラー出力",
"app.launchError.openAdvancedSettings": "詳細設定を開く",
"app.launchError.openAdvancedSettings": "OpenCode 設定を開く",
"app.launchError.close": "閉じる",
"app.launchError.closeTitle": "閉じる (Esc)",
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "開いています...",
"folderSelection.advancedSettings": "詳細設定",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "移動",
"folderSelection.hints.select": "選択",

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "使用",
"contextUsagePanel.labels.available": "残り",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "無効",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "操作",
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
"settings.behavior.keyboardHints.title": "キーボードショートカットのヒント",
"settings.behavior.keyboardHints.subtitle": "UI全体でキーボードショートカットのヒントを表示します。",
"settings.behavior.thinking.title": "思考セクション",
"settings.behavior.thinking.subtitle": "メッセージ内のAIの思考セクションを表示/非表示にします。",
"settings.behavior.thinkingDefault.title": "思考の既定",
"settings.behavior.thinkingDefault.subtitle": "思考セクションを最初に展開/折りたたみのどちらで表示するかを選びます。",
"settings.behavior.timelineTools.title": "タイムラインのツール呼び出し",
"settings.behavior.timelineTools.subtitle": "メッセージタイムラインでツール呼び出しを表示/非表示にします。",
"settings.behavior.diffView.title": "差分表示",
"settings.behavior.diffView.subtitle": "ツール呼び出しの差分の表示方法を選びます。",
"settings.behavior.diffView.option.split": "分割",
"settings.behavior.diffView.option.unified": "統合",
"settings.behavior.toolOutputsDefault.title": "ツール出力の既定",
"settings.behavior.toolOutputsDefault.subtitle": "ツール出力を最初に展開/折りたたみのどちらで表示するかを選びます。",
"settings.behavior.diagnosticsDefault.title": "診断の既定",
"settings.behavior.diagnosticsDefault.subtitle": "診断出力を最初に展開/折りたたみのどちらで表示するかを選びます。",
"settings.behavior.toolInputsVisibility.title": "ツール入力の表示",
"settings.behavior.toolInputsVisibility.subtitle": "ツール呼び出しの入力引数の既定の表示状態を設定します。",
"settings.behavior.usageMetrics.title": "トークン使用量メトリクス",
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
"settings.behavior.promptSubmit.title": "Enterで送信",
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
} as const

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "Не удалось запустить OpenCode",
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в расширенных настройках.",
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в настройках OpenCode.",
"app.launchError.binaryPathLabel": "Путь к бинарнику",
"app.launchError.errorOutputLabel": "Вывод ошибки",
"app.launchError.openAdvancedSettings": "Открыть расширенные настройки",
"app.launchError.openAdvancedSettings": "Открыть настройки OpenCode",
"app.launchError.close": "Закрыть",
"app.launchError.closeTitle": "Закрыть (Esc)",
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Открытие…",
"folderSelection.advancedSettings": "Расширенные настройки",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Навигация",
"folderSelection.hints.select": "Выбрать",

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Использовано",
"contextUsagePanel.labels.available": "Доступно",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Отключено",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Взаимодействие",
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
"settings.behavior.keyboardHints.title": "Подсказки сочетаний клавиш",
"settings.behavior.keyboardHints.subtitle": "Показывать подсказки сочетаний клавиш по всему интерфейсу.",
"settings.behavior.thinking.title": "Разделы размышлений",
"settings.behavior.thinking.subtitle": "Показывать или скрывать разделы размышлений ИИ в сообщениях.",
"settings.behavior.thinkingDefault.title": "Размышления по умолчанию",
"settings.behavior.thinkingDefault.subtitle": "Выберите, начинать ли разделы размышлений развернутыми или свернутыми.",
"settings.behavior.timelineTools.title": "Вызовы инструментов в таймлайне",
"settings.behavior.timelineTools.subtitle": "Показывать или скрывать записи вызовов инструментов в таймлайне сообщений.",
"settings.behavior.diffView.title": "Вид диффа",
"settings.behavior.diffView.subtitle": "Выберите, как отображаются диффы вызовов инструментов.",
"settings.behavior.diffView.option.split": "Раздельный",
"settings.behavior.diffView.option.unified": "Единый",
"settings.behavior.toolOutputsDefault.title": "Выводы инструментов по умолчанию",
"settings.behavior.toolOutputsDefault.subtitle": "Выберите, начинать ли выводы инструментов развернутыми или свернутыми.",
"settings.behavior.diagnosticsDefault.title": "Диагностика по умолчанию",
"settings.behavior.diagnosticsDefault.subtitle": "Выберите, начинать ли вывод диагностики развернутым или свернутым.",
"settings.behavior.toolInputsVisibility.title": "Видимость входных данных инструмента",
"settings.behavior.toolInputsVisibility.subtitle": "Задайте видимость по умолчанию для входных аргументов вызовов инструментов.",
"settings.behavior.usageMetrics.title": "Метрики использования токенов",
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
"settings.behavior.promptSubmit.title": "Enter для отправки",
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
} as const

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "无法启动 OpenCode",
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在“高级设置中选择其他可执行文件。",
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在 OpenCode 设置中选择其他可执行文件。",
"app.launchError.binaryPathLabel": "可执行文件路径",
"app.launchError.errorOutputLabel": "错误输出",
"app.launchError.openAdvancedSettings": "打开高级设置",
"app.launchError.openAdvancedSettings": "打开 OpenCode 设置",
"app.launchError.close": "关闭",
"app.launchError.closeTitle": "关闭 (Esc)",
"app.launchError.fallbackMessage": "启动工作区失败",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "正在打开...",
"folderSelection.advancedSettings": "高级设置",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "导航",
"folderSelection.hints.select": "选择",

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "已用",
"contextUsagePanel.labels.available": "可用",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "已禁用",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "交互",
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
"settings.behavior.keyboardHints.title": "键盘快捷键提示",
"settings.behavior.keyboardHints.subtitle": "在整个界面中显示键盘快捷键提示。",
"settings.behavior.thinking.title": "思考区块",
"settings.behavior.thinking.subtitle": "在消息中显示或隐藏AI的思考区块。",
"settings.behavior.thinkingDefault.title": "思考默认状态",
"settings.behavior.thinkingDefault.subtitle": "选择思考区块默认是展开还是折叠。",
"settings.behavior.timelineTools.title": "时间线工具调用",
"settings.behavior.timelineTools.subtitle": "在消息时间线中显示或隐藏工具调用条目。",
"settings.behavior.diffView.title": "差异视图",
"settings.behavior.diffView.subtitle": "选择工具调用差异的显示方式。",
"settings.behavior.diffView.option.split": "分栏",
"settings.behavior.diffView.option.unified": "统一",
"settings.behavior.toolOutputsDefault.title": "工具输出默认状态",
"settings.behavior.toolOutputsDefault.subtitle": "选择工具输出默认是展开还是折叠。",
"settings.behavior.diagnosticsDefault.title": "诊断默认状态",
"settings.behavior.diagnosticsDefault.subtitle": "选择诊断输出默认是展开还是折叠。",
"settings.behavior.toolInputsVisibility.title": "工具输入可见性",
"settings.behavior.toolInputsVisibility.subtitle": "设置工具调用输入参数的默认可见性。",
"settings.behavior.usageMetrics.title": "令牌用量指标",
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
"settings.behavior.autoCleanup.title": "自动清理空会话",
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
"settings.behavior.promptSubmit.title": "回车发送",
"settings.behavior.promptSubmit.subtitle": "使用回车发送Cmd/Ctrl+回车插入新行。",
} as const

View File

@@ -0,0 +1,452 @@
import type { Accessor } from "solid-js"
import type {
Preferences,
ExpansionPreference,
ToolInputsVisibilityPreference,
} from "../../stores/preferences"
import type { Command } from "../commands"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
export type BehaviorSettingKind = "toggle" | "enum"
export type BehaviorToggleSetting = {
kind: "toggle"
id: string
titleKey: string
subtitleKey: string
get: (preferences: Preferences) => boolean
set: (next: boolean) => void
disabled?: () => boolean
}
export type BehaviorEnumSetting<T extends string = string> = {
kind: "enum"
id: string
titleKey: string
subtitleKey: string
get: (preferences: Preferences) => T
set: (next: T) => void
options: Array<{ value: T; labelKey: string }>
disabled?: () => boolean
}
export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting
export type BehaviorRegistryActions = {
preferences: Accessor<Preferences>
updatePreferences?: (updates: Partial<Preferences>) => void
toggleShowThinkingBlocks: () => void
toggleKeyboardShortcutHints: () => void
toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
}
function splitKeywords(key: string): string[] {
return tGlobal(key)
.split(",")
.map((value) => value.trim())
.filter(Boolean)
}
function setBooleanByToggle(getCurrent: () => boolean, toggle: () => void, next: boolean) {
if (getCurrent() === next) return
toggle()
}
export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorSetting[] {
const prefs = actions.preferences
const updatePreferences = actions.updatePreferences
return [
{
kind: "toggle",
id: "behavior.keyboardShortcutHints",
titleKey: "settings.behavior.keyboardHints.title",
subtitleKey: "settings.behavior.keyboardHints.subtitle",
get: (p) => Boolean(p.showKeyboardShortcutHints ?? true),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showKeyboardShortcutHints: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showKeyboardShortcutHints ?? true),
actions.toggleKeyboardShortcutHints,
next,
)
},
disabled: () => runtimeEnv.host === "web",
},
{
kind: "toggle",
id: "behavior.thinkingBlocks",
titleKey: "settings.behavior.thinking.title",
subtitleKey: "settings.behavior.thinking.subtitle",
get: (p) => Boolean(p.showThinkingBlocks),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showThinkingBlocks: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showThinkingBlocks),
actions.toggleShowThinkingBlocks,
next,
)
},
},
{
kind: "enum",
id: "behavior.thinkingBlocksDefault",
titleKey: "settings.behavior.thinkingDefault.title",
subtitleKey: "settings.behavior.thinkingDefault.subtitle",
get: (p) => (p.thinkingBlocksExpansion ?? "expanded") as ExpansionPreference,
set: (next) => {
if (updatePreferences) {
updatePreferences({ thinkingBlocksExpansion: next as ExpansionPreference })
return
}
actions.setThinkingBlocksExpansion(next as ExpansionPreference)
},
options: [
{ value: "expanded", labelKey: "commands.common.expanded" },
{ value: "collapsed", labelKey: "commands.common.collapsed" },
],
},
{
kind: "toggle",
id: "behavior.timelineToolCalls",
titleKey: "settings.behavior.timelineTools.title",
subtitleKey: "settings.behavior.timelineTools.subtitle",
get: (p) => Boolean(p.showTimelineTools),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showTimelineTools: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showTimelineTools),
actions.toggleShowTimelineTools,
next,
)
},
},
{
kind: "enum",
id: "behavior.diffViewMode",
titleKey: "settings.behavior.diffView.title",
subtitleKey: "settings.behavior.diffView.subtitle",
get: (p) => (p.diffViewMode ?? "split") as "split" | "unified",
set: (next) => {
if (updatePreferences) {
updatePreferences({ diffViewMode: next as "split" | "unified" })
return
}
actions.setDiffViewMode(next as "split" | "unified")
},
options: [
{ value: "split", labelKey: "settings.behavior.diffView.option.split" },
{ value: "unified", labelKey: "settings.behavior.diffView.option.unified" },
],
},
{
kind: "enum",
id: "behavior.toolOutputsDefault",
titleKey: "settings.behavior.toolOutputsDefault.title",
subtitleKey: "settings.behavior.toolOutputsDefault.subtitle",
get: (p) => (p.toolOutputExpansion ?? "expanded") as ExpansionPreference,
set: (next) => {
if (updatePreferences) {
updatePreferences({ toolOutputExpansion: next as ExpansionPreference })
return
}
actions.setToolOutputExpansion(next as ExpansionPreference)
},
options: [
{ value: "expanded", labelKey: "commands.common.expanded" },
{ value: "collapsed", labelKey: "commands.common.collapsed" },
],
},
{
kind: "enum",
id: "behavior.diagnosticsDefault",
titleKey: "settings.behavior.diagnosticsDefault.title",
subtitleKey: "settings.behavior.diagnosticsDefault.subtitle",
get: (p) => (p.diagnosticsExpansion ?? "expanded") as ExpansionPreference,
set: (next) => {
if (updatePreferences) {
updatePreferences({ diagnosticsExpansion: next as ExpansionPreference })
return
}
actions.setDiagnosticsExpansion(next as ExpansionPreference)
},
options: [
{ value: "expanded", labelKey: "commands.common.expanded" },
{ value: "collapsed", labelKey: "commands.common.collapsed" },
],
},
{
kind: "enum",
id: "behavior.toolInputsVisibility",
titleKey: "settings.behavior.toolInputsVisibility.title",
subtitleKey: "settings.behavior.toolInputsVisibility.subtitle",
get: (p) => (p.toolInputsVisibility ?? "hidden") as ToolInputsVisibilityPreference,
set: (next) => {
if (updatePreferences) {
updatePreferences({ toolInputsVisibility: next as ToolInputsVisibilityPreference })
return
}
actions.setToolInputsVisibility(next as ToolInputsVisibilityPreference)
},
options: [
{ value: "hidden", labelKey: "commands.common.hidden" },
{ value: "collapsed", labelKey: "commands.common.collapsed" },
{ value: "expanded", labelKey: "commands.common.expanded" },
],
},
{
kind: "toggle",
id: "behavior.usageMetrics",
titleKey: "settings.behavior.usageMetrics.title",
subtitleKey: "settings.behavior.usageMetrics.subtitle",
get: (p) => Boolean(p.showUsageMetrics ?? true),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showUsageMetrics: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showUsageMetrics ?? true),
actions.toggleUsageMetrics,
next,
)
},
},
{
kind: "toggle",
id: "behavior.autoCleanupBlankSessions",
titleKey: "settings.behavior.autoCleanup.title",
subtitleKey: "settings.behavior.autoCleanup.subtitle",
get: (p) => Boolean(p.autoCleanupBlankSessions),
set: (next) => {
if (updatePreferences) {
updatePreferences({ autoCleanupBlankSessions: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().autoCleanupBlankSessions),
actions.toggleAutoCleanupBlankSessions,
next,
)
},
},
{
kind: "toggle",
id: "behavior.promptSubmitOnEnter",
titleKey: "settings.behavior.promptSubmit.title",
subtitleKey: "settings.behavior.promptSubmit.subtitle",
get: (p) => Boolean(p.promptSubmitOnEnter),
set: (next) => {
if (updatePreferences) {
updatePreferences({ promptSubmitOnEnter: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().promptSubmitOnEnter),
actions.togglePromptSubmitOnEnter,
next,
)
},
},
]
}
export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] {
return [
{
id: "prompt-submit-shortcut",
label: () =>
actions.preferences().promptSubmitOnEnter
? tGlobal("commands.promptSubmitShortcut.label.swapped")
: tGlobal("commands.promptSubmitShortcut.label.default"),
description: () => tGlobal("commands.promptSubmitShortcut.description"),
category: "Input & Focus",
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
action: actions.togglePromptSubmitOnEnter,
},
{
id: "thinking",
label: () =>
tGlobal(
actions.preferences().showThinkingBlocks
? "commands.thinkingBlocks.label.hide"
: "commands.thinkingBlocks.label.show",
),
description: () => tGlobal("commands.thinkingBlocks.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
action: actions.toggleShowThinkingBlocks,
},
{
id: "timeline-tools",
label: () =>
tGlobal(
actions.preferences().showTimelineTools
? "commands.timelineToolCalls.label.hide"
: "commands.timelineToolCalls.label.show",
),
description: () => tGlobal("commands.timelineToolCalls.description"),
category: "System",
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
action: actions.toggleShowTimelineTools,
},
{
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
actions.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: actions.toggleKeyboardShortcutHints,
},
{
id: "thinking-default-visibility",
label: () => {
const mode = actions.preferences().thinkingBlocksExpansion ?? "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.thinkingBlocksDefault.label", { state })
},
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
action: () => {
const mode = actions.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
actions.setThinkingBlocksExpansion(next)
},
},
{
id: "diff-view-split",
label: () => {
const prefix = (actions.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
},
description: () => tGlobal("commands.diffViewSplit.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
action: () => actions.setDiffViewMode("split"),
},
{
id: "diff-view-unified",
label: () => {
const prefix = (actions.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
},
description: () => tGlobal("commands.diffViewUnified.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
action: () => actions.setDiffViewMode("unified"),
},
{
id: "tool-output-default-visibility",
label: () => {
const mode = actions.preferences().toolOutputExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.toolOutputsDefault.label", { state })
},
description: () => tGlobal("commands.toolOutputsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
action: () => {
const mode = actions.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
actions.setToolOutputExpansion(next)
},
},
{
id: "diagnostics-default-visibility",
label: () => {
const mode = actions.preferences().diagnosticsExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.diagnosticsDefault.label", { state })
},
description: () => tGlobal("commands.diagnosticsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
action: () => {
const mode = actions.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
actions.setDiagnosticsExpansion(next)
},
},
{
id: "tool-inputs-visibility",
label: () => {
const mode = actions.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = actions.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
actions.setToolInputsVisibility(next)
},
},
{
id: "token-usage-visibility",
label: () => {
const visible = actions.preferences().showUsageMetrics ?? true
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
return tGlobal("commands.tokenUsageDisplay.label", { state })
},
description: () => tGlobal("commands.tokenUsageDisplay.description"),
category: "System",
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
action: actions.toggleUsageMetrics,
},
{
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = actions.preferences().autoCleanupBlankSessions
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
},
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
category: "System",
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
action: actions.toggleAutoCleanupBlankSessions,
},
]
}
export function registerBehaviorCommands(register: (command: Command) => void, actions: BehaviorRegistryActions) {
const commands = getBehaviorCommands(actions)
commands.forEach((command) => register(command))
}

View File

@@ -5,7 +5,7 @@ import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
import type { Message, MessageInfo, ClientPart } from "../../types/message"
import type { Session } from "../../types/session"
import { messageStoreBus } from "./bus"
import type { MessageStatus, SessionRevertState } from "./types"
import type { MessageStatus, ReplaceMessageIdOptions, SessionRevertState } from "./types"
interface SessionMetadata {
id: string
@@ -121,10 +121,10 @@ export function applyPartDeltaV2(
})
}
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string, options?: Omit<ReplaceMessageIdOptions, "oldId" | "newId">): void {
if (!oldId || !newId || oldId === newId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.replaceMessageId({ oldId, newId })
store.replaceMessageId({ oldId, newId, ...(options ?? {}) })
}
function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined {

View File

@@ -586,10 +586,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
return
}
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
const cloned = clonePart(input.part)
setState(
"messages",
input.messageId,
@@ -792,6 +792,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
id: options.newId,
isEphemeral: false,
updatedAt: Date.now(),
partIds: options.clearParts ? [] : existing.partIds,
parts: options.clearParts ? {} : existing.parts,
}
setState("messages", options.newId, cloned)

View File

@@ -152,6 +152,7 @@ export interface PartUpdateInput {
export interface ReplaceMessageIdOptions {
oldId: string
newId: string
clearParts?: boolean
}
export interface ScrollCacheKey {

View File

@@ -94,7 +94,7 @@ async function sendMessage(
}
const messageId = createId("msg")
const textPartId = createId("part")
const textPartId = createId("prt")
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
@@ -110,7 +110,6 @@ async function sendMessage(
const requestParts: any[] = [
{
id: textPartId,
type: "text" as const,
text: resolvedPrompt,
},
@@ -120,9 +119,8 @@ async function sendMessage(
for (const att of attachments) {
const source = att.source
if (source.type === "file") {
const partId = createId("part")
const partId = createId("prt")
requestParts.push({
id: partId,
type: "file" as const,
url: att.url,
mime: source.mime,
@@ -148,9 +146,8 @@ async function sendMessage(
continue
}
const partId = createId("part")
const partId = createId("prt")
requestParts.push({
id: partId,
type: "text" as const,
text: value,
})
@@ -184,7 +181,6 @@ async function sendMessage(
})
const requestBody = {
messageID: messageId,
parts: requestParts,
...(session.agent && { agent: session.agent }),
...(session.model.providerId &&

View File

@@ -240,19 +240,22 @@ function resolveMessageRole(info?: MessageInfo | null): MessageRole {
return info?.role === "user" ? "user" : "assistant"
}
function findPendingMessageId(
function findPendingSyntheticMessageId(
store: InstanceMessageStore,
sessionId: string,
role: MessageRole,
): string | undefined {
const messageIds = store.getSessionMessageIds(sessionId)
const lastId = messageIds[messageIds.length - 1]
if (!lastId) return undefined
const record = store.getMessage(lastId)
if (!record) return undefined
if (record.sessionId !== sessionId) return undefined
if (record.role !== role) return undefined
return record.status === "sending" ? record.id : undefined
for (const messageId of messageIds) {
const record = store.getMessage(messageId)
if (!record) continue
if (record.sessionId !== sessionId) continue
if (record.role !== role) continue
if (record.status !== "sending") continue
if (!record.isEphemeral) continue
return record.id
}
return undefined
}
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
@@ -282,9 +285,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
let record = store.getMessage(messageId)
if (!record) {
const pendingId = findPendingMessageId(store, sessionId, role)
const pendingId = findPendingSyntheticMessageId(store, sessionId, role)
if (pendingId && pendingId !== messageId) {
replaceMessageIdV2(instanceId, pendingId, messageId)
replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" })
record = store.getMessage(messageId)
}
}
@@ -345,9 +348,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
let record = store.getMessage(messageId)
if (!record) {
const pendingId = findPendingMessageId(store, sessionId, role)
const pendingId = findPendingSyntheticMessageId(store, sessionId, role)
if (pendingId && pendingId !== messageId) {
replaceMessageIdV2(instanceId, pendingId, messageId)
replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" })
record = store.getMessage(messageId)
}
}

View File

@@ -0,0 +1,17 @@
import { createSignal } from "solid-js"
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "opencode"
const [settingsOpen, setSettingsOpen] = createSignal(false)
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
export function openSettings(section: SettingsSectionId = "appearance") {
setActiveSettingsSection(section)
setSettingsOpen(true)
}
export function closeSettings() {
setSettingsOpen(false)
}
export { settingsOpen, activeSettingsSection, setActiveSettingsSection }

View File

@@ -15,6 +15,9 @@
ring-color: var(--accent-primary);
}
.selector-trigger:disabled,
.selector-trigger[aria-disabled="true"],
.selector-trigger[data-disabled],
.selector-trigger-disabled {
@apply opacity-50 cursor-not-allowed;
}

View File

@@ -0,0 +1,538 @@
.settings-screen-frame {
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
}
/* Override .modal-surface (defined later in panels.css). */
.modal-surface.settings-screen-shell {
width: min(1120px, 100%);
height: min(88vh, 920px);
max-height: none;
display: grid;
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--border-base);
border-radius: 0;
box-shadow: 0 32px 96px color-mix(in oklab, var(--overlay-scrim) 55%, transparent);
}
/* Settings UI uses square corners (no radius). */
.modal-surface.settings-screen-shell .selector-trigger,
.modal-surface.settings-screen-shell .selector-popover,
.modal-surface.settings-screen-shell .selector-option,
.modal-surface.settings-screen-shell .selector-button,
.modal-surface.settings-screen-shell .selector-input,
.modal-surface.settings-screen-shell .selector-search-input,
.modal-surface.settings-screen-shell .remote-close,
.modal-surface.settings-screen-shell .remote-section,
.modal-surface.settings-screen-shell .remote-refresh,
.modal-surface.settings-screen-shell .remote-toggle,
.modal-surface.settings-screen-shell .remote-toggle-switch,
.modal-surface.settings-screen-shell .remote-toggle-thumb,
.modal-surface.settings-screen-shell .remote-address,
.modal-surface.settings-screen-shell .remote-pill,
.modal-surface.settings-screen-shell .remote-qr,
.modal-surface.settings-screen-shell .remote-card,
.modal-surface.settings-screen-shell .remote-error {
border-radius: 0;
}
.settings-screen-nav {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background:
linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary));
border-right: 1px solid var(--border-base);
}
.settings-screen-nav-header {
padding-bottom: 0.75rem;
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 82%, transparent);
}
.settings-screen-nav-title-row {
display: flex;
align-items: flex-start;
gap: 0.875rem;
}
.settings-screen-nav-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0;
background: color-mix(in oklab, var(--accent-primary) 16%, var(--surface-base));
color: var(--accent-primary);
}
.settings-screen-nav-icon {
width: 1.125rem;
height: 1.125rem;
}
.settings-screen-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.settings-screen-subtitle {
margin-top: 0.25rem;
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.settings-screen-nav-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.settings-nav-button {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 0.875rem;
border-radius: 0;
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease;
outline: none;
}
.settings-nav-button:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.settings-nav-button:hover {
background: color-mix(in oklab, var(--surface-base) 70%, transparent);
color: var(--text-primary);
}
.settings-nav-button[data-selected="true"] {
background: color-mix(in oklab, var(--accent-primary) 14%, var(--surface-base));
border-color: color-mix(in oklab, var(--accent-primary) 26%, var(--border-base));
color: var(--text-primary);
transform: translateX(2px);
}
.settings-nav-button-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.settings-screen-content {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background:
radial-gradient(circle at top right, color-mix(in oklab, var(--accent-primary) 9%, transparent), transparent 28%),
var(--surface-base);
}
.settings-screen-content-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-base);
background: color-mix(in oklab, var(--surface-base) 92%, var(--surface-secondary) 8%);
flex-shrink: 0;
}
.settings-screen-content-header-title-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.settings-screen-content-eyebrow {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.settings-screen-content-title {
font-size: clamp(1.35rem, 2vw, 1.85rem);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
line-height: 1.2;
}
.settings-screen-close {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.settings-screen-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1rem;
}
.settings-section-stack,
.settings-panel-body,
.settings-stack {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.settings-card {
padding: 1.25rem;
border: 1px solid var(--border-base);
border-radius: 0;
background: color-mix(in oklab, var(--surface-base) 86%, var(--surface-secondary) 14%);
}
.settings-card-padless {
padding: 0;
overflow: hidden;
}
.settings-card-content,
.settings-card-header-padded {
padding: 1rem;
}
.settings-card-content {
padding-top: 0;
}
.settings-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 65%, transparent);
}
.settings-card-heading-with-icon {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.settings-card-heading-icon {
width: 1rem;
height: 1rem;
margin-top: 0.15rem;
color: var(--accent-primary);
}
.settings-card-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.settings-card-subtitle {
margin-top: 0.2rem;
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.settings-card-message {
padding: 1rem;
border: 1px dashed var(--border-base);
border-radius: 0;
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.settings-card-content {
padding: 1rem;
border: 1px solid var(--border-base);
border-radius: 0;
background: var(--surface-base);
}
.settings-help-text {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.settings-password-actions {
display: flex;
justify-content: flex-start;
margin-top: 0.75rem;
}
.settings-form-group {
margin-top: 0.75rem;
}
.settings-form-label {
display: block;
margin-bottom: 0.375rem;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
}
.settings-pill-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 0;
border: 1px solid var(--border-base);
background: var(--surface-secondary);
color: var(--text-primary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease;
}
.settings-pill-button:hover {
background: var(--surface-hover);
border-color: color-mix(in oklab, var(--accent-primary) 28%, var(--border-base));
}
.settings-pill-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-error-message {
margin-top: 0.625rem;
padding: 0.75rem;
border: 1px solid var(--border-critical, #e65c5c);
background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent);
border-radius: 0;
color: var(--text-primary);
font-size: var(--font-size-sm);
}
.settings-scope-badge {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.6rem;
border-radius: 0;
background: color-mix(in oklab, var(--surface-secondary) 75%, var(--surface-base));
color: var(--text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.settings-scope-badge-server {
background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-base));
color: var(--accent-primary);
}
.settings-choice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.875rem;
}
.settings-choice {
display: flex;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.95rem;
border-radius: 0;
border: 1px solid var(--border-base);
background: var(--surface-base);
color: var(--text-primary);
text-align: left;
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
outline: none;
cursor: pointer;
}
.settings-choice:hover {
border-color: color-mix(in oklab, var(--accent-primary) 28%, var(--border-base));
background: var(--surface-hover);
}
.settings-choice:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.settings-choice[data-selected="true"] {
border-color: color-mix(in oklab, var(--accent-primary) 45%, var(--border-base));
background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base));
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 20%, transparent);
transform: translateY(-1px);
}
.settings-choice-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0;
background: color-mix(in oklab, var(--surface-secondary) 76%, var(--surface-base));
color: var(--accent-primary);
flex-shrink: 0;
}
.settings-choice-icon {
width: 1rem;
height: 1rem;
}
.settings-choice-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.settings-choice-label {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
.settings-choice-description {
margin-top: 0.15rem;
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.settings-choice-check {
margin-left: auto;
color: var(--accent-primary);
opacity: 0;
}
.settings-choice[data-selected="true"] .settings-choice-check {
opacity: 1;
}
.settings-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 0;
border-top: 1px solid color-mix(in oklab, var(--border-base) 78%, transparent);
}
.settings-toggle-row:first-child {
border-top: none;
padding-top: 0;
}
.settings-toggle-row-compact {
align-items: flex-start;
}
.settings-toggle-title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.settings-toggle-caption,
.settings-inline-note {
margin-top: 0.2rem;
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.settings-checkbox-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.settings-checkbox-toggle input {
accent-color: var(--accent-primary);
}
.settings-toolbar-inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
@media (max-width: 900px) {
.modal-surface.settings-screen-shell {
min-height: min(760px, calc(100vh - 1rem));
grid-template-columns: 1fr;
}
.settings-screen-nav {
gap: 0.75rem;
padding: 1rem;
border-right: none;
border-bottom: 1px solid var(--border-base);
}
.settings-screen-nav-list {
flex-direction: row;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.settings-nav-button {
width: auto;
flex-shrink: 0;
}
}
@media (max-width: 640px) {
.settings-screen-frame {
padding: 0;
}
.modal-surface.settings-screen-shell {
width: 100%;
height: 100%;
max-height: none;
min-height: 100%;
border-radius: 0;
}
.settings-screen-content-header,
.settings-screen-scroll {
padding: 0.75rem;
}
.settings-card-header,
.settings-toggle-row {
flex-direction: column;
align-items: stretch;
}
.settings-toolbar-inline {
justify-content: flex-start;
}
.settings-choice-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -8,3 +8,4 @@
@import "./components/directory-browser.css";
@import "./components/remote-access.css";
@import "./components/permission-notification.css";
@import "./components/settings-screen.css";

View File

@@ -2,6 +2,7 @@
color-scheme: light;
/* Surface tokens */
--surface-base: #ffffff;
--surface-primary: var(--surface-base);
--surface-secondary: #f5f5f5;
--surface-muted: #f8fafc;
--surface-code: #f1f5f9;
@@ -178,6 +179,7 @@
color-scheme: dark;
/* Surface tokens */
--surface-base: #1a1a1a;
--surface-primary: var(--surface-base);
--surface-secondary: #2a2a2a;
--surface-muted: #212529;
--surface-code: #1a1a1a;
@@ -347,6 +349,7 @@
color-scheme: dark;
/* Surface tokens */
--surface-base: #1a1a1a;
--surface-primary: var(--surface-base);
--surface-secondary: #2a2a2a;
--surface-muted: #212529;
--surface-code: #1a1a1a;