diff --git a/AGENTS.md b/AGENTS.md index 23d9efe9..bcb41be0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,8 +263,10 @@ Code map: │ ├── dotmatrix-hooks.ts │ └── stream.ts ├── lib + │ ├── appearance.ts │ ├── fileIcon.ts │ ├── ipc.ts │ ├── language.ts │ ├── models.ts + │ ├── monacoTheme.ts │ └── recents.ts diff --git a/Cargo.lock b/Cargo.lock index 0a1f1bfc..a8d1d659 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "which", ] [[package]] @@ -810,6 +811,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "embed-resource" version = "3.0.8" @@ -830,6 +837,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -5504,6 +5517,18 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6047,6 +6072,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/package-lock.json b/package-lock.json index 867c9f34..771499b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sinew", - "version": "0.1.12", + "version": "0.1.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sinew", - "version": "0.1.12", + "version": "0.1.13", "dependencies": { "@iconify-json/solar": "1.2.5", "@iconify-json/vscode-icons": "1.2.45", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c7603911..5d9ce51e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ tracing-subscriber = { workspace = true } url = { workspace = true } portable-pty = "0.9.0" reqwest = { workspace = true } +which = "7" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.4" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 314b8a04..2ed98abd 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main", "sinew-window-*"], "permissions": [ "core:default", + "core:webview:allow-set-webview-zoom", "core:window:allow-close", "core:window:allow-minimize", "core:window:allow-start-dragging", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index a6956956..0672da95 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Main desktop capability","local":true,"windows":["main","sinew-window-*"],"permissions":["core:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-start-dragging","core:window:allow-toggle-maximize","dialog:default","updater:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Main desktop capability","local":true,"windows":["main","sinew-window-*"],"permissions":["core:default","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-minimize","core:window:allow-start-dragging","core:window:allow-toggle-maximize","dialog:default","updater:default"]}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 192ca8b1..00520ba6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -204,39 +204,151 @@ pub fn run() { install_macos_dock_menu(app.handle()); } - #[cfg(not(target_os = "windows"))] + #[cfg(unix)] { let handle = app.handle(); - let menu = tauri::menu::Menu::default(handle)?; + let new_conversation_item = tauri::menu::MenuItemBuilder::with_id( + NEW_CONVERSATION_MENU_ID, + "New Conversation", + ) + .accelerator("CmdOrCtrl+N") + .build(handle)?; let new_window_item = tauri::menu::MenuItemBuilder::with_id(NEW_WINDOW_MENU_ID, "New Window") .accelerator("CmdOrCtrl+Shift+N") .build(handle)?; - let file_menu = tauri::menu::SubmenuBuilder::new(handle, "File") - .item(&new_window_item) + let open_workspace_item = tauri::menu::MenuItemBuilder::with_id( + OPEN_WORKSPACE_MENU_ID, + "Open Workspace…", + ) + .accelerator("CmdOrCtrl+O") + .build(handle)?; + let settings_item = + tauri::menu::MenuItemBuilder::with_id(SETTINGS_OPEN_MENU_ID, "Settings…") + .accelerator("CmdOrCtrl+,") + .build(handle)?; + let terminal_open_item = tauri::menu::MenuItemBuilder::with_id( + TERMINAL_OPEN_MENU_ID, + "Open Terminal", + ) + .accelerator("CmdOrCtrl+`") + .build(handle)?; + let edit_menu = tauri::menu::SubmenuBuilder::new(handle, "Edit") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() .build()?; let terminal_menu = tauri::menu::SubmenuBuilder::new(handle, "Terminal") - .text(TERMINAL_OPEN_MENU_ID, "Open Terminal") + .item(&terminal_open_item) + .build()?; + let window_menu = tauri::menu::SubmenuBuilder::new(handle, "Window") + .minimize() + .maximize() + .separator() + .close_window() .build()?; - menu.append(&file_menu)?; - menu.append(&terminal_menu)?; + + #[cfg(target_os = "macos")] + let menu = { + // macOS convention: Settings + About live in the app + // (Sinew) submenu, not in File. Native About dialog + // gets the description / GitHub link from the + // bundled metadata. + let about_metadata = tauri::menu::AboutMetadataBuilder::new() + .name(Some("Sinew")) + .version(Some(env!("CARGO_PKG_VERSION"))) + .copyright(Some("MIT — github.com/Paseru/sinew")) + .website(Some("https://github.com/Paseru/sinew")) + .website_label(Some("github.com/Paseru/sinew")) + .comments(Some(concat!( + "Sinew is a flexible AI coding harness. ", + "You shape it: tweak the description of every tool, turn the ", + "ones you don't need off, and the assistant only sees what ", + "you keep.\n\n", + "Run it minimal with a couple of tools, or unlock the full ", + "set: shell, search, MCP, web, images, sub-agents. ", + "Multi-provider by default." + ))) + .build(); + let app_menu = tauri::menu::SubmenuBuilder::new(handle, "Sinew") + .about(Some(about_metadata)) + .separator() + .item(&settings_item) + .separator() + .services() + .separator() + .hide() + .hide_others() + .show_all() + .separator() + .quit() + .build()?; + let file_menu = tauri::menu::SubmenuBuilder::new(handle, "File") + .item(&new_conversation_item) + .item(&new_window_item) + .separator() + .item(&open_workspace_item) + .separator() + .close_window() + .build()?; + tauri::menu::MenuBuilder::new(handle) + .item(&app_menu) + .item(&file_menu) + .item(&edit_menu) + .item(&terminal_menu) + .item(&window_menu) + .build()? + }; + + #[cfg(not(target_os = "macos"))] + let menu = { + // Linux has no app submenu, so Settings lives at the + // bottom of the File menu (matches GTK app convention). + let file_menu = tauri::menu::SubmenuBuilder::new(handle, "File") + .item(&new_conversation_item) + .item(&new_window_item) + .separator() + .item(&open_workspace_item) + .separator() + .item(&settings_item) + .separator() + .close_window() + .build()?; + tauri::menu::MenuBuilder::new(handle) + .item(&file_menu) + .item(&edit_menu) + .item(&terminal_menu) + .item(&window_menu) + .build()? + }; + app.set_menu(menu)?; } Ok(()) }) .on_menu_event(|app, event| { - if event.id() == NEW_WINDOW_MENU_ID { + let id = event.id(); + if id == NEW_WINDOW_MENU_ID { create_new_window_detached(app); - } else if event.id() == TERMINAL_OPEN_MENU_ID { - let focused = app - .webview_windows() - .into_values() - .find(|window| window.is_focused().unwrap_or(false)); - if let Some(window) = focused { - let _ = window.emit(TERMINAL_OPEN_EVENT_NAME, ()); - } else { - let _ = app.emit(TERMINAL_OPEN_EVENT_NAME, ()); - } + return; + } + let event_name: Option<&'static str> = if id == TERMINAL_OPEN_MENU_ID { + Some(TERMINAL_OPEN_EVENT_NAME) + } else if id == SETTINGS_OPEN_MENU_ID { + Some(SETTINGS_OPEN_EVENT_NAME) + } else if id == NEW_CONVERSATION_MENU_ID { + Some(NEW_CONVERSATION_EVENT_NAME) + } else if id == OPEN_WORKSPACE_MENU_ID { + Some(OPEN_WORKSPACE_EVENT_NAME) + } else { + None + }; + if let Some(name) = event_name { + let _ = app.emit(name, ()); } }) .manage(state) @@ -323,6 +435,7 @@ pub fn run() { terminal::write_terminal, terminal::resize_terminal, terminal::kill_terminal, + terminal::list_terminal_shells, updater::updater_check, updater::updater_download_and_install, updater::updater_restart, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index b72b38ec..54d5c7fe 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -457,6 +457,15 @@ pub(super) struct TerminalSpawnInput { pub(super) pixel_width: u16, #[serde(default)] pub(super) pixel_height: u16, + #[serde(default)] + pub(super) shell: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct TerminalShellOption { + pub(super) label: String, + pub(super) path: String, } #[derive(Debug, Serialize)] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index c5ce3279..b42a58a9 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -11,6 +11,12 @@ pub(super) const TERMINAL_OPEN_EVENT_NAME: &str = "terminal-open-requested"; pub(super) const ACTIVE_TURNS_EVENT_NAME: &str = "active-turns-changed"; pub(super) const TERMINAL_OPEN_MENU_ID: &str = "terminal-open"; pub(super) const NEW_WINDOW_MENU_ID: &str = "new-window"; +pub(super) const SETTINGS_OPEN_MENU_ID: &str = "settings-open"; +pub(super) const SETTINGS_OPEN_EVENT_NAME: &str = "settings-open-requested"; +pub(super) const NEW_CONVERSATION_MENU_ID: &str = "new-conversation"; +pub(super) const NEW_CONVERSATION_EVENT_NAME: &str = "new-conversation-requested"; +pub(super) const OPEN_WORKSPACE_MENU_ID: &str = "open-workspace"; +pub(super) const OPEN_WORKSPACE_EVENT_NAME: &str = "open-workspace-requested"; pub(super) const NEW_WINDOW_LABEL_PREFIX: &str = "sinew-window"; pub(super) const NEW_WINDOW_URL: &str = "index.html?newWindow=1"; pub(super) const MAX_ATTACHMENT_BYTES: usize = 128 * 1024; diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 3d036fa6..aa956f6f 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -50,7 +50,7 @@ pub(super) async fn spawn_terminal( )) .map_err(error_to_string)?; - let mut command = default_terminal_command(); + let mut command = terminal_command_for(input.shell.as_deref()); command.cwd(workspace_root.as_os_str()); command.env("TERM", "xterm-256color"); command.env("COLORTERM", "truecolor"); @@ -107,6 +107,55 @@ fn default_terminal_command() -> CommandBuilder { } } +fn terminal_command_for(shell: Option<&str>) -> CommandBuilder { + let Some(path) = shell.map(str::trim).filter(|s| !s.is_empty()) else { + return default_terminal_command(); + }; + let mut command = CommandBuilder::new(path); + let lower = std::path::Path::new(path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(path) + .to_ascii_lowercase(); + if lower.starts_with("powershell") || lower.starts_with("pwsh") { + command.arg("-NoLogo"); + command.arg("-NoProfile"); + command.arg("-ExecutionPolicy"); + command.arg("Bypass"); + } + command +} + +#[tauri::command] +pub(super) fn list_terminal_shells() -> Vec { + let candidates: &[(&str, &str)] = if cfg!(windows) { + &[ + ("PowerShell Core", "pwsh.exe"), + ("Windows PowerShell", "powershell.exe"), + ("Command Prompt", "cmd.exe"), + ("WSL", "wsl.exe"), + ("Git Bash", "bash.exe"), + ] + } else { + &[ + ("zsh", "zsh"), + ("bash", "bash"), + ("fish", "fish"), + ("nu", "nu"), + ("sh", "sh"), + ] + }; + candidates + .iter() + .filter_map(|(label, exe)| { + which::which(exe).ok().map(|path| TerminalShellOption { + label: (*label).to_string(), + path: path.display().to_string(), + }) + }) + .collect() +} + #[tauri::command] pub(super) async fn write_terminal( state: State<'_, DesktopState>, diff --git a/src/App.tsx b/src/App.tsx index 1a2220fb..bd342869 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,14 @@ import { useCallback, useEffect, useState } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-dialog"; import { Welcome } from "./components/Welcome"; import { Workspace } from "./components/Workspace"; import { loadLastWorkspace, recordRecent, deriveName } from "./lib/recents"; import { api } from "./lib/ipc"; import type { WorkspaceBootstrap } from "./types"; +const OPEN_WORKSPACE_EVENT = "open-workspace-requested"; + type AppState = | { kind: "welcome" } | { kind: "workspace"; bootstrap: WorkspaceBootstrap }; @@ -45,6 +49,24 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + let disposed = false; + let unlisten: UnlistenFn | null = null; + void listen(OPEN_WORKSPACE_EVENT, async () => { + const selected = await open({ directory: true, multiple: false }); + if (typeof selected === "string" && selected.length > 0) { + await openWorkspace(selected); + } + }).then((off) => { + if (disposed) off(); + else unlisten = off; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, [openWorkspace]); + const backToWelcome = useCallback(() => { void api.resetWindowTitle().catch(() => { // best-effort; leaving the previous title is harmless diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index 453e534d..a2cc4a91 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -11,6 +11,28 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react" import { Icon } from "@iconify/react"; import { languageForPath } from "../lib/language"; import { fileIcon } from "../lib/fileIcon"; +import { + ACCENT_CHANGED_EVENT, + EDITOR_AUTOSAVE_CHANGED_EVENT, + EDITOR_FONT_CHANGED_EVENT, + EDITOR_LINE_NUMBERS_CHANGED_EVENT, + EDITOR_RENDER_WHITESPACE_CHANGED_EVENT, + getAccent, + getEditorAutosaveDelay, + getEditorAutosaveMode, + getEditorFontSize, + getEditorLineNumbers, + getEditorRenderWhitespace, + getEffectiveTheme, + THEME_CHANGED_EVENT, + type Accent, + type EditorAutosaveMode, + type EditorAutosaveState, + type EditorLineNumbers, + type EditorRenderWhitespace, + type EffectiveTheme, +} from "../lib/appearance"; +import { defineMonacoThemes, monacoThemeName } from "../lib/monacoTheme"; import { Markdown } from "./chat/Markdown"; import type { EditorRevealTarget, EditorTab } from "../types"; import { ImageContextMenu } from "./ImageContextMenu"; @@ -89,6 +111,62 @@ export function EditorPane({ const [imageMenu, setImageMenu] = useState<{ x: number; y: number } | null>( null, ); + const [editorFontSize, setEditorFontSize] = useState(() => + getEditorFontSize(), + ); + const [lineNumbers, setLineNumbers] = useState(() => + getEditorLineNumbers(), + ); + const [renderWhitespace, setRenderWhitespace] = useState( + () => getEditorRenderWhitespace(), + ); + const [theme, setTheme] = useState(() => getEffectiveTheme()); + useEffect(() => { + const onFont = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail === "number") setEditorFontSize(detail); + }; + const onTheme = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail === "dark" || detail === "light") { + setTheme(detail); + monacoNs.editor.setTheme(monacoThemeName(detail)); + } + }; + const onAccent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + defineMonacoThemes(monacoNs, detail); + monacoNs.editor.setTheme(monacoThemeName(getEffectiveTheme())); + }; + const onLineNumbers = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail === "on" || detail === "off" || detail === "relative") { + setLineNumbers(detail); + } + }; + const onWhitespace = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail === "none" || detail === "boundary" || detail === "all") { + setRenderWhitespace(detail); + } + }; + window.addEventListener(EDITOR_FONT_CHANGED_EVENT, onFont); + window.addEventListener(THEME_CHANGED_EVENT, onTheme); + window.addEventListener(ACCENT_CHANGED_EVENT, onAccent); + window.addEventListener(EDITOR_LINE_NUMBERS_CHANGED_EVENT, onLineNumbers); + window.addEventListener(EDITOR_RENDER_WHITESPACE_CHANGED_EVENT, onWhitespace); + return () => { + window.removeEventListener(EDITOR_FONT_CHANGED_EVENT, onFont); + window.removeEventListener(THEME_CHANGED_EVENT, onTheme); + window.removeEventListener(ACCENT_CHANGED_EVENT, onAccent); + window.removeEventListener(EDITOR_LINE_NUMBERS_CHANGED_EVENT, onLineNumbers); + window.removeEventListener( + EDITOR_RENDER_WHITESPACE_CHANGED_EVENT, + onWhitespace, + ); + }; + }, []); const activeTab: EditorTab | undefined = settingsActive ? undefined : tabs[activeIndex]; // Close the image context menu whenever the user switches tabs or // toggles into the settings view, so it never lingers on the wrong file. @@ -131,59 +209,25 @@ export function EditorPane({ [onOpenFile], ); + const saveActiveTab = useCallback(() => { + const path = currentPathRef.current; + if (!path) return; + const idx = tabsRef.current.findIndex((t) => t.relativePath === path); + if (idx >= 0) onSaveRef.current(idx); + }, []); + const handleMount: OnMount = (editor, monaco) => { editorRef.current = editor; setEditorReadySeq((value) => value + 1); - monaco.editor.defineTheme("sinew-cool", { - base: "vs-dark", - inherit: true, - rules: [ - { token: "comment", foreground: "52555c" }, - { token: "keyword", foreground: "c4b5fd" }, - { token: "string", foreground: "86efac" }, - { token: "number", foreground: "f5a683" }, - { token: "type", foreground: "e8bb6a" }, - { token: "function", foreground: "9fc2ff" }, - { token: "variable", foreground: "e8e9ec" }, - { token: "constant", foreground: "f5a683" }, - { token: "regexp", foreground: "86efac" }, - { token: "tag", foreground: "f5a1ab" }, - { token: "attribute.name", foreground: "c4b5fd" }, - ], - colors: { - "editor.background": "#0b0b0d", - "editor.foreground": "#e8e9ec", - "editor.lineHighlightBackground": "#0f1013", - "editorLineNumber.foreground": "#3a3d44", - "editorLineNumber.activeForeground": "#9aa0a8", - "editorCursor.foreground": "#3b82f6", - "editor.selectionBackground": "#1e2b4a", - "editor.inactiveSelectionBackground": "#141518", - "editorIndentGuide.background1": "#141518", - "editorIndentGuide.activeBackground1": "#23252b", - "editorGutter.background": "#0b0b0d", - "editorWidget.background": "#0f1013", - "editorWidget.border": "#23252b", - "editorHoverWidget.background": "#0f1013", - "editorHoverWidget.border": "#23252b", - "editorSuggestWidget.background": "#0f1013", - "editorSuggestWidget.border": "#23252b", - "editorSuggestWidget.selectedBackground": "#1e2b4a", - "editorSuggestWidget.highlightForeground": "#5b8cff", - "editorBracketMatch.background": "#1e2b4a", - "editorBracketMatch.border": "#3b82f6", - "scrollbarSlider.background": "#23252bcc", - "scrollbarSlider.hoverBackground": "#2b2e35cc", - "scrollbarSlider.activeBackground": "#3a3d44cc", - }, - }); - monaco.editor.setTheme("sinew-cool"); + defineMonacoThemes(monaco, getAccent()); + monaco.editor.setTheme(monacoThemeName(getEffectiveTheme())); editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { - const path = currentPathRef.current; - if (!path) return; - const idx = tabsRef.current.findIndex((t) => t.relativePath === path); - if (idx >= 0) onSaveRef.current(idx); + saveActiveTab(); + }); + + editor.onDidBlurEditorWidget(() => { + if (autosaveRef.current.mode === "onBlur") saveActiveTab(); }); window.requestAnimationFrame(() => { @@ -302,10 +346,44 @@ export function EditorPane({ showTextEditor, ]); + const autosaveRef = useRef({ + mode: getEditorAutosaveMode(), + delayMs: getEditorAutosaveDelay(), + }); + const autosaveTimerRef = useRef(null); + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail) autosaveRef.current = detail; + }; + window.addEventListener(EDITOR_AUTOSAVE_CHANGED_EVENT, handler); + return () => { + window.removeEventListener(EDITOR_AUTOSAVE_CHANGED_EVENT, handler); + if (autosaveTimerRef.current !== null) { + window.clearTimeout(autosaveTimerRef.current); + autosaveTimerRef.current = null; + } + }; + }, []); + const onEditorChange = useCallback( (value: string | undefined) => { if (!activeTab) return; onChange(activeIndex, value ?? ""); + if (autosaveRef.current.mode !== "afterDelay") return; + if (autosaveTimerRef.current !== null) { + window.clearTimeout(autosaveTimerRef.current); + } + // Capture the path at scheduling time so a tab switch before the + // timer fires doesn't accidentally save a different file. + const pathAtSchedule = activeTab.relativePath; + autosaveTimerRef.current = window.setTimeout(() => { + autosaveTimerRef.current = null; + const idx = tabsRef.current.findIndex( + (t) => t.relativePath === pathAtSchedule, + ); + if (idx >= 0) onSaveRef.current(idx); + }, autosaveRef.current.delayMs); }, [activeTab, activeIndex, onChange], ); @@ -445,7 +523,7 @@ export function EditorPane({ !entry.name.startsWith(".")); +} + type NodeState = { entry: WorkspaceEntry; expanded: boolean; @@ -111,6 +124,16 @@ export const FileTree = forwardRef(function FileTree( ref, ) { const [roots, setRoots] = useState([]); + const [showHidden, setShowHidden] = useState(() => getFileTreeShowHidden()); + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail === "boolean") setShowHidden(detail); + }; + window.addEventListener(FILETREE_SHOW_HIDDEN_CHANGED_EVENT, handler); + return () => + window.removeEventListener(FILETREE_SHOW_HIDDEN_CHANGED_EVENT, handler); + }, []); const [expanded, setExpanded] = useState>({}); const [rootError, setRootError] = useState(null); const [actionError, setActionError] = useState(null); @@ -1160,7 +1183,7 @@ export const FileTree = forwardRef(function FileTree( onCancel={cancelEdit} /> )} - {roots.map((entry) => ( + {filterHiddenEntries(roots, showHidden).map((entry) => ( (function FileTree( cutRelativePaths={cutRelativePaths} toggle={toggle} activeFile={activeFile} + showHidden={showHidden} onOpenFile={onOpenFile} onDragStart={handleEntryDragStart} onDragEnd={handleEntryDragEnd} @@ -1254,6 +1278,7 @@ type NodeProps = { cutRelativePaths: Set; toggle: (entry: WorkspaceEntry) => void; activeFile: string | null; + showHidden: boolean; onOpenFile: (entry: WorkspaceEntry) => void; onDragStart: (entry: WorkspaceEntry, event: React.DragEvent) => void; onDragEnd: () => void; @@ -1279,6 +1304,7 @@ const TreeNode = memo(function TreeNode({ cutRelativePaths, toggle, activeFile, + showHidden, onOpenFile, onDragStart, onDragEnd, @@ -1425,7 +1451,7 @@ const TreeNode = memo(function TreeNode({ )} - {state.children?.map((child) => ( + {filterHiddenEntries(state.children, showHidden).map((child) => ( ("about"); + const [monacoTheme, setMonacoTheme] = useState(() => + getEffectiveTheme(), + ); + useEffect(() => { + const onTheme = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail === "dark" || detail === "light") setMonacoTheme(detail); + }; + const onAccent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + defineMonacoThemes(monacoNs, detail); + monacoNs.editor.setTheme(monacoThemeName(getEffectiveTheme())); + }; + window.addEventListener(THEME_CHANGED_EVENT, onTheme); + window.addEventListener(ACCENT_CHANGED_EVENT, onAccent); + return () => { + window.removeEventListener(THEME_CHANGED_EVENT, onTheme); + window.removeEventListener(ACCENT_CHANGED_EVENT, onAccent); + }; + }, []); const [settings, setSettings] = useState(EMPTY_SETTINGS); const [savedJson, setSavedJson] = useState(""); const [jsonText, setJsonText] = useState(""); @@ -930,49 +1036,7 @@ export function SettingsPane({ workspacePath }: Props) { }, []); const handleEditorMount: OnMount = useCallback((editor, monaco) => { - monaco.editor.defineTheme("sinew-cool", { - base: "vs-dark", - inherit: true, - rules: [ - { token: "comment", foreground: "52555c" }, - { token: "keyword", foreground: "c4b5fd" }, - { token: "string", foreground: "86efac" }, - { token: "number", foreground: "f5a683" }, - { token: "type", foreground: "e8bb6a" }, - { token: "function", foreground: "9fc2ff" }, - { token: "variable", foreground: "e8e9ec" }, - { token: "constant", foreground: "f5a683" }, - { token: "regexp", foreground: "86efac" }, - { token: "tag", foreground: "f5a1ab" }, - { token: "attribute.name", foreground: "c4b5fd" }, - ], - colors: { - "editor.background": "#08090b", - "editor.foreground": "#e8e9ec", - "editor.lineHighlightBackground": "#0f1013", - "editorLineNumber.foreground": "#3a3d44", - "editorLineNumber.activeForeground": "#9aa0a8", - "editorCursor.foreground": "#3b82f6", - "editor.selectionBackground": "#1e2b4a", - "editor.inactiveSelectionBackground": "#141518", - "editorIndentGuide.background1": "#141518", - "editorIndentGuide.activeBackground1": "#23252b", - "editorGutter.background": "#08090b", - "editorWidget.background": "#0f1013", - "editorWidget.border": "#23252b", - "editorHoverWidget.background": "#0f1013", - "editorHoverWidget.border": "#23252b", - "editorSuggestWidget.background": "#0f1013", - "editorSuggestWidget.border": "#23252b", - "editorSuggestWidget.selectedBackground": "#1e2b4a", - "editorBracketMatch.background": "#1e2b4a", - "editorBracketMatch.border": "#3b82f6", - "scrollbarSlider.background": "#23252bcc", - "scrollbarSlider.hoverBackground": "#2b2e35cc", - "scrollbarSlider.activeBackground": "#3a3d44cc", - }, - }); - monaco.editor.setTheme("sinew-cool"); + defineMonacoThemes(monaco, getAccent()); editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { void saveAndDetectRef.current(); }); @@ -1002,6 +1066,21 @@ export function SettingsPane({ workspacePath }: Props) { About + + + + + +
+
+ Accent color + + Affects buttons, links, and selection highlights. + +
+
+ {ACCENT_SWATCHES.map((swatch) => ( +
+
+ + +
+
+

Window & fonts

+ + {PRIMARY_MOD_LABEL} = / {PRIMARY_MOD_LABEL} - / {PRIMARY_MOD_LABEL} 0 + +
+
+ + + +
+
+ +
+
+

Terminal

+
+
+ +
+
+ + Cursor style + + + Shape of the xterm caret. + +
+
+ {CURSOR_STYLES.map((opt) => ( + + ))} +
+
+
+
+ + Cursor blink + + + Blink the caret while the terminal is idle. + +
+ +
+ { + setTerminalCopyOnSelect(next); + setCopyOnSelectState(next); + }} + /> + { + setShellState(setTerminalShell(next)); + }} + /> +
+
+ +
+
+

Editor

+
+
+ { + setEditorLineNumbers(next); + setEditorLineNumbersState(next); + }} + /> + { + setEditorRenderWhitespace(next); + setEditorRenderWhitespaceState(next); + }} + /> + { + setEditorAutosaveMode(next); + setAutosaveModeState(next); + }} + /> + {autosaveMode === "afterDelay" && ( + { + setAutosaveDelayState(setEditorAutosaveDelay(next)); + }} + onReset={() => { + const next = setEditorAutosaveDelay(EDITOR_AUTOSAVE_DELAY_DEFAULT); + setAutosaveDelayState(next); + }} + isDefault={autosaveDelay === EDITOR_AUTOSAVE_DELAY_DEFAULT} + suffix="ms" + /> + )} +
+
+ +
+
+

Chat

+
+
+ { + setChatShowTimestamps(next); + setChatTimestampsState(next); + }} + /> +
+
+ +
+
+

File tree

+
+
+ { + setFileTreeShowHidden(next); + setShowHiddenFilesState(next); + }} + /> +
+
+ +
+
+

Workspace

+ {importStatus && {importStatus}} +
+
+ { + setWorkspaceConfirmClose(next); + setConfirmCloseState(next); + }} + /> + +
+
+ + + + ); +} + +const EDITOR_LINE_NUMBERS_OPTIONS: { value: EditorLineNumbers; label: string }[] = [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, + { value: "relative", label: "Relative" }, +]; + +const EDITOR_RENDER_WHITESPACE_OPTIONS: { + value: EditorRenderWhitespace; + label: string; +}[] = [ + { value: "none", label: "None" }, + { value: "boundary", label: "Boundary" }, + { value: "all", label: "All" }, +]; + +const EDITOR_AUTOSAVE_OPTIONS: { value: EditorAutosaveMode; label: string }[] = [ + { value: "off", label: "Off" }, + { value: "afterDelay", label: "Delay" }, + { value: "onBlur", label: "On blur" }, +]; + +function SettingsBackupRow({ + onStatus, +}: { + onStatus: (status: string | null) => void; +}) { + const importInputRef = useRef(null); + return ( +
+
+ Backup settings + + Export or import every Sinew preference as JSON. + +
+
+ + + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = typeof reader.result === "string" ? reader.result : ""; + const applied = importSettings(text); + onStatus( + applied > 0 + ? `Imported ${applied} setting${applied === 1 ? "" : "s"} — restart to apply.` + : "No settings imported", + ); + }; + reader.onerror = () => onStatus("Import failed"); + reader.readAsText(file); + }} + /> +
+
+ ); +} + +function ToggleRow({ + label, + hint, + value, + onChange, +}: { + label: string; + hint: string; + value: boolean; + onChange: (next: boolean) => void; +}) { + return ( +
+
+ {label} + {hint} +
+ +
+ ); +} + +function SegmentedRow({ + label, + hint, + value, + options, + onChange, +}: { + label: string; + hint: string; + value: T; + options: { value: T; label: string }[]; + onChange: (next: T) => void; +}) { + const cols = + options.length === 3 + ? "settings-pane__tool-provider-switch--three" + : ""; + return ( +
+
+ {label} + {hint} +
+
+ {options.map((opt) => ( + + ))} +
+
+ ); +} + +function TerminalShellRow({ + value, + detected, + onChange, +}: { + value: string; + detected: { label: string; path: string }[] | null; + onChange: (path: string) => void; +}) { + const knownPaths = useMemo( + () => new Set((detected ?? []).map((s) => s.path)), + [detected], + ); + const valueIsCustom = value !== "" && !knownPaths.has(value); + const [customMode, setCustomMode] = useState(valueIsCustom); + const [draft, setDraft] = useState(value); + useEffect(() => { + setDraft(value); + if (valueIsCustom) setCustomMode(true); + }, [value, valueIsCustom]); + + const selectValue = + customMode || valueIsCustom ? "__custom" : value === "" ? "" : value; + + const commitDraft = () => { + const next = draft.trim(); + if (next === value) return; + onChange(next); + if (next === "") setCustomMode(false); + }; + + return ( +
+
+ Shell + + {detected === null + ? "Detecting installed shells…" + : "Applied to new terminal sessions."} + +
+
+ + {(customMode || valueIsCustom) && ( + setDraft(event.target.value)} + onBlur={commitDraft} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + aria-label="Custom shell path" + /> + )} +
+
+ ); +} + +type AppearanceItemProps = { + label: string; + hint: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + onReset: () => void; + isDefault: boolean; + suffix?: string; +}; + +function AppearanceItem({ + label, + hint, + value, + min, + max, + step, + onChange, + onReset, + isDefault, + suffix, +}: AppearanceItemProps) { + const commit = (raw: number) => { + if (!Number.isFinite(raw)) return; + const clamped = Math.min(max, Math.max(min, raw)); + if (clamped !== value) onChange(clamped); + }; + return ( +
+
+ {label} + {hint} +
+
+
+ + { + const parsed = parseFloat(event.target.value); + if (Number.isFinite(parsed)) commit(parsed); + }} + aria-label={label} + /> + {suffix && ( + {suffix} + )} + +
+ +
+
+ ); +} + + // ---- Providers section ------------------------------------------------- type ProvidersSectionProps = { @@ -2250,6 +3135,7 @@ type McpSectionProps = { selectedProbe: McpServerProbe | null; onToggleEnabled: (id: string) => void; onMount: OnMount; + monacoTheme: EffectiveTheme; }; function McpSection({ @@ -2270,6 +3156,7 @@ function McpSection({ selectedProbe, onToggleEnabled, onMount, + monacoTheme, }: McpSectionProps) { const detailOpen = Boolean(selectedServer); @@ -2412,7 +3299,7 @@ function McpSection({ onJsonChange(value ?? "")} onMount={onMount} options={{ diff --git a/src/components/TerminalPanel.tsx b/src/components/TerminalPanel.tsx index 5b3e4c91..aea5401d 100644 --- a/src/components/TerminalPanel.tsx +++ b/src/components/TerminalPanel.tsx @@ -14,6 +14,18 @@ import { } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { api } from "../lib/ipc"; +import { + ACCENT_CHANGED_EVENT, + getTerminalCopyOnSelect, + getTerminalCursorBlink, + getTerminalCursorStyle, + getTerminalFontSize, + getTerminalShell, + TERMINAL_CURSOR_CHANGED_EVENT, + TERMINAL_FONT_CHANGED_EVENT, + THEME_CHANGED_EVENT, + type TerminalCursorState, +} from "../lib/appearance"; import type { TerminalDataPayload, TerminalExitPayload } from "../types"; type TerminalStatus = "idle" | "starting" | "running" | "exited" | "error"; @@ -277,14 +289,13 @@ function TerminalSurface({ "--font-mono", '"Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', ); - const fontSize = - parseFloat(cssVar("--fs-mono", "12")) || 12; + const fontSize = getTerminalFontSize(); const terminal = new Terminal({ // Required for `unicode11` and a few other addons we register below. allowProposedApi: true, - cursorBlink: true, - cursorStyle: "block", + cursorBlink: getTerminalCursorBlink(), + cursorStyle: getTerminalCursorStyle(), fontFamily, fontSize, letterSpacing: 0, @@ -355,6 +366,14 @@ function TerminalSurface({ disposablesRef.current = [ linkProviderDisposable, + terminal.onSelectionChange(() => { + if (!getTerminalCopyOnSelect()) return; + const text = terminal.getSelection(); + if (!text) return; + void navigator.clipboard.writeText(text).catch((err) => + console.error("[terminal] copy-on-select failed", err), + ); + }), terminal.onData((data) => { const currentToken = tokenRef.current; if (!currentToken) return; @@ -430,6 +449,7 @@ function TerminalSurface({ Math.max(terminal.rows, 4), Math.max(0, Math.round(rect.width)), Math.max(0, Math.round(rect.height)), + getTerminalShell() || undefined, ) .then(() => { if (disposedRef.current || tokenRef.current !== token) return; @@ -464,6 +484,44 @@ function TerminalSurface({ return () => cancelAnimationFrame(id); }, [active, fitTerminal]); + useEffect(() => { + const onFontSize = (event: Event) => { + const terminal = terminalRef.current; + if (!terminal) return; + const detail = (event as CustomEvent).detail; + if (typeof detail === "number") { + terminal.options.fontSize = detail; + } + requestAnimationFrame(() => fitTerminal()); + }; + const onCursor = (event: Event) => { + const terminal = terminalRef.current; + if (!terminal) return; + const detail = (event as CustomEvent).detail; + if (!detail) return; + terminal.options.cursorStyle = detail.style; + terminal.options.cursorBlink = detail.blink; + }; + const onTheme = () => { + const terminal = terminalRef.current; + if (!terminal) return; + // terminalTheme() reads the new CSS-var values on the next frame. + requestAnimationFrame(() => { + terminal.options.theme = terminalTheme(); + }); + }; + window.addEventListener(TERMINAL_FONT_CHANGED_EVENT, onFontSize); + window.addEventListener(TERMINAL_CURSOR_CHANGED_EVENT, onCursor); + window.addEventListener(THEME_CHANGED_EVENT, onTheme); + window.addEventListener(ACCENT_CHANGED_EVENT, onTheme); + return () => { + window.removeEventListener(TERMINAL_FONT_CHANGED_EVENT, onFontSize); + window.removeEventListener(TERMINAL_CURSOR_CHANGED_EVENT, onCursor); + window.removeEventListener(THEME_CHANGED_EVENT, onTheme); + window.removeEventListener(ACCENT_CHANGED_EVENT, onTheme); + }; + }, [fitTerminal]); + useEffect(() => { return () => { disposedRef.current = true; @@ -524,11 +582,37 @@ function terminalTheme() { const root = getComputedStyle(document.documentElement); const css = (name: string, fallback: string) => root.getPropertyValue(name).trim() || fallback; + const isLight = document.documentElement.dataset.theme === "light"; + + if (isLight) { + return { + background: css("--bg-0", "#faf8f3"), + foreground: css("--editor-fg", "#2a2620"), + cursor: css("--accent", "#2563eb"), + selectionBackground: "rgba(48, 40, 30, 0.18)", + black: "#3c3830", + red: css("--danger", "#c43232"), + green: css("--ok", "#2f8a4a"), + yellow: "#a85a1a", + blue: css("--accent", "#2563eb"), + magenta: css("--accent-2", "#7c3aed"), + cyan: "#0e7490", + white: "#5c574d", + brightBlack: "#8a8478", + brightRed: "#dc2626", + brightGreen: "#16a34a", + brightYellow: "#d97706", + brightBlue: css("--accent-hi", "#1d4ed8"), + brightMagenta: "#8b5cf6", + brightCyan: "#0891b2", + brightWhite: "#2a2620", + }; + } return { background: css("--bg-0", "#0b0b0d"), foreground: css("--editor-fg", "#e8e9ec"), - cursor: css("--text-0", "#e8e9ec"), + cursor: css("--accent", "#3b82f6"), selectionBackground: "rgba(232, 233, 236, 0.18)", black: "#111318", red: css("--danger", "#f5737f"), diff --git a/src/components/Workspace.tsx b/src/components/Workspace.tsx index 7267bb8d..37577ee7 100644 --- a/src/components/Workspace.tsx +++ b/src/components/Workspace.tsx @@ -8,8 +8,11 @@ import { } from "react"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { ask } from "@tauri-apps/plugin-dialog"; import { Icon } from "@iconify/react"; import { api } from "../lib/ipc"; +import { getWorkspaceConfirmClose } from "../lib/appearance"; import { modelRefWithThinking, thinkingFromRef } from "../lib/models"; import { Splitter } from "./Splitter"; import { FileTree, type FileTreeHandle } from "./FileTree"; @@ -57,6 +60,9 @@ const INITIAL_TERMINAL_HEIGHT = 240; const MIN_TERMINAL_HEIGHT = 140; const MAX_TERMINAL_RATIO = 0.92; const TERMINAL_OPEN_EVENT = "terminal-open-requested"; +const SETTINGS_OPEN_EVENT = "settings-open-requested"; +const NEW_CONVERSATION_EVENT = "new-conversation-requested"; +const OPEN_WORKSPACE_EVENT = "open-workspace-requested"; const SEND_BUSY_RETRY_DELAYS_MS = [160, 320, 640, 1000, 1400]; const COMPACTION_CONTINUATION_PROMPT = "Continue from the compacted context. Do not repeat completed work. Pick up exactly where you left off and proceed with the next useful step."; @@ -1539,23 +1545,46 @@ export function Workspace({ useEffect(() => { let disposed = false; - let unlisten: UnlistenFn | null = null; - - void listen(TERMINAL_OPEN_EVENT, () => { - showTerminal(); - }).then((nextUnlisten) => { - if (disposed) { - nextUnlisten(); - } else { - unlisten = nextUnlisten; - } + const unlisteners: UnlistenFn[] = []; + const subscribe = async (name: string, handler: () => void) => { + const off = await listen(name, handler); + if (disposed) off(); + else unlisteners.push(off); + }; + void subscribe(TERMINAL_OPEN_EVENT, showTerminal); + void subscribe(SETTINGS_OPEN_EVENT, () => openSettings()); + void subscribe(NEW_CONVERSATION_EVENT, () => { + void createConversation(); }); + return () => { + disposed = true; + for (const off of unlisteners) off(); + }; + }, [showTerminal, createConversation, openSettings]); + useEffect(() => { + let disposed = false; + let unlisten: UnlistenFn | null = null; + void getCurrentWindow() + .onCloseRequested(async (event) => { + if (!getWorkspaceConfirmClose()) return; + const dirty = tabsRef.current.some((tab) => tab.dirty && !tab.external); + if (!dirty) return; + const proceed = await ask("Discard unsaved changes and close?", { + title: "Sinew", + kind: "warning", + }); + if (!proceed) event.preventDefault(); + }) + .then((off) => { + if (disposed) off(); + else unlisten = off; + }); return () => { disposed = true; unlisten?.(); }; - }, [showTerminal]); + }, []); const sidebarHeightRef = useRef(null); const applyTopDelta = useCallback((delta: number) => { diff --git a/src/components/chat/ChatPane.tsx b/src/components/chat/ChatPane.tsx index 25823d71..1176819a 100644 --- a/src/components/chat/ChatPane.tsx +++ b/src/components/chat/ChatPane.tsx @@ -27,6 +27,13 @@ import { import { TodoStrip, type QueuedPromptStripItem } from "./TodoStrip"; import { fileIcon } from "../../lib/fileIcon"; import { api } from "../../lib/ipc"; +import { + CHAT_FONT_CHANGED_EVENT, + CHAT_FONT_DEFAULT, + CHAT_SHOW_TIMESTAMPS_CHANGED_EVENT, + getChatFontSize, + getChatShowTimestamps, +} from "../../lib/appearance"; import { MODELS, PROVIDERS, @@ -373,6 +380,28 @@ export function ChatPane({ }: Props) { const conversationViewsRef = useRef>(new Map()); const composerDraftsRef = useRef>(new Map()); + const [chatFontSize, setChatFontSize] = useState(() => + getChatFontSize(), + ); + const [chatShowTimestamps, setChatShowTimestamps] = useState(() => + getChatShowTimestamps(), + ); + useEffect(() => { + const onFont = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail === "number") setChatFontSize(detail); + }; + const onTimestamps = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail === "boolean") setChatShowTimestamps(detail); + }; + window.addEventListener(CHAT_FONT_CHANGED_EVENT, onFont); + window.addEventListener(CHAT_SHOW_TIMESTAMPS_CHANGED_EVENT, onTimestamps); + return () => { + window.removeEventListener(CHAT_FONT_CHANGED_EVENT, onFont); + window.removeEventListener(CHAT_SHOW_TIMESTAMPS_CHANGED_EVENT, onTimestamps); + }; + }, []); const [view, setView] = useState(() => { const initial = initialStateFromHistory(history); return isStreaming ? beginTurn(initial) : initial; @@ -2594,6 +2623,8 @@ export function ChatPane({ onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} + style={{ zoom: chatFontSize / CHAT_FONT_DEFAULT }} + data-show-timestamps={chatShowTimestamps ? "true" : "false"} > {previewImage && (
(); +function rememberUserTextTime(id: string, ts: number): void { + if (userTextTimestamps.has(id)) { + userTextTimestamps.delete(id); + } else if (userTextTimestamps.size >= USER_TEXT_TIMESTAMP_CAP) { + const oldest = userTextTimestamps.keys().next().value; + if (oldest !== undefined) userTextTimestamps.delete(oldest); + } + userTextTimestamps.set(id, ts); +} +function resolveUserTextTime( + block: Extract, +): number { + if (typeof block.createdAtMs === "number") { + rememberUserTextTime(block.id, block.createdAtMs); + return block.createdAtMs; + } + const cached = userTextTimestamps.get(block.id); + if (typeof cached === "number") return cached; + const next = Date.now(); + rememberUserTextTime(block.id, next); + return next; +} + function BlockView({ block, onPreviewImage, @@ -5322,8 +5385,18 @@ function BlockView({ activeAgentName, ); if (teamMessages && visibleTeamMessages.length === 0) return null; + const userTime = resolveUserTextTime(block); return (
+
0 ? attachments : undefined, }, diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts new file mode 100644 index 00000000..335d836a --- /dev/null +++ b/src/lib/appearance.ts @@ -0,0 +1,622 @@ +import { getCurrentWebview } from "@tauri-apps/api/webview"; + +// Persisted appearance settings (window zoom, Monaco / xterm / chat font, +// terminal cursor). Each setter dispatches an event so live components +// pick up the change without prop drilling. + +const ZOOM_KEY = "sinew.zoomLevel"; +const EDITOR_FONT_KEY = "sinew.editorFontSize"; +const TERMINAL_FONT_KEY = "sinew.terminalFontSize"; +const CHAT_FONT_KEY = "sinew.chatFontSize"; +const TERMINAL_CURSOR_STYLE_KEY = "sinew.terminalCursorStyle"; +const TERMINAL_CURSOR_BLINK_KEY = "sinew.terminalCursorBlink"; +const TERMINAL_SHELL_KEY = "sinew.terminalShell"; +const TERMINAL_COPY_ON_SELECT_KEY = "sinew.terminalCopyOnSelect"; +const THEME_KEY = "sinew.theme"; +const ACCENT_KEY = "sinew.accent"; +const EDITOR_AUTOSAVE_MODE_KEY = "sinew.editorAutosaveMode"; +const EDITOR_AUTOSAVE_DELAY_KEY = "sinew.editorAutosaveDelayMs"; +const EDITOR_LINE_NUMBERS_KEY = "sinew.editorLineNumbers"; +const EDITOR_RENDER_WHITESPACE_KEY = "sinew.editorRenderWhitespace"; +const CHAT_SHOW_TIMESTAMPS_KEY = "sinew.chatShowTimestamps"; +const FILETREE_SHOW_HIDDEN_KEY = "sinew.fileTreeShowHidden"; +const WORKSPACE_CONFIRM_CLOSE_KEY = "sinew.confirmCloseUnsaved"; +const LEGACY_UI_SCALE_KEY = "sinew.uiScale"; + +export const ZOOM_CHANGED_EVENT = "sinew:zoom-changed"; +export const EDITOR_FONT_CHANGED_EVENT = "sinew:editor-font-changed"; +export const TERMINAL_FONT_CHANGED_EVENT = "sinew:terminal-font-changed"; +export const CHAT_FONT_CHANGED_EVENT = "sinew:chat-font-changed"; +export const TERMINAL_CURSOR_CHANGED_EVENT = "sinew:terminal-cursor-changed"; +export const TERMINAL_SHELL_CHANGED_EVENT = "sinew:terminal-shell-changed"; +export const TERMINAL_COPY_ON_SELECT_CHANGED_EVENT = + "sinew:terminal-copy-on-select-changed"; +export const THEME_CHANGED_EVENT = "sinew:theme-changed"; +export const ACCENT_CHANGED_EVENT = "sinew:accent-changed"; +export const EDITOR_AUTOSAVE_CHANGED_EVENT = "sinew:editor-autosave-changed"; +export const EDITOR_LINE_NUMBERS_CHANGED_EVENT = + "sinew:editor-line-numbers-changed"; +export const EDITOR_RENDER_WHITESPACE_CHANGED_EVENT = + "sinew:editor-render-whitespace-changed"; +export const CHAT_SHOW_TIMESTAMPS_CHANGED_EVENT = + "sinew:chat-show-timestamps-changed"; +export const FILETREE_SHOW_HIDDEN_CHANGED_EVENT = + "sinew:filetree-show-hidden-changed"; +export const WORKSPACE_CONFIRM_CLOSE_CHANGED_EVENT = + "sinew:workspace-confirm-close-changed"; + +// Matches Chromium / VSCode: each zoom level steps by 1.2x. +const ZOOM_FACTOR_BASE = 1.2; +export const ZOOM_LEVEL_MIN = -5; +export const ZOOM_LEVEL_MAX = 8; +export const ZOOM_LEVEL_DEFAULT = 0; + +export const EDITOR_FONT_MIN = 6; +export const EDITOR_FONT_MAX = 32; +export const EDITOR_FONT_DEFAULT = 12; + +export const TERMINAL_FONT_MIN = 6; +export const TERMINAL_FONT_MAX = 32; +export const TERMINAL_FONT_DEFAULT = 12; + +export const CHAT_FONT_MIN = 10; +export const CHAT_FONT_MAX = 22; +export const CHAT_FONT_DEFAULT = 13; + +export type TerminalCursorStyle = "block" | "underline" | "bar"; +export type TerminalCursorState = { + style: TerminalCursorStyle; + blink: boolean; +}; +export const TERMINAL_CURSOR_STYLE_DEFAULT: TerminalCursorStyle = "block"; +export const TERMINAL_CURSOR_BLINK_DEFAULT = true; + +export type Theme = "dark" | "light" | "system"; +export type EffectiveTheme = "dark" | "light"; +export const THEME_DEFAULT: Theme = "system"; + +export type EditorAutosaveMode = "off" | "afterDelay" | "onBlur"; +export const EDITOR_AUTOSAVE_MODE_DEFAULT: EditorAutosaveMode = "off"; +export const EDITOR_AUTOSAVE_DELAY_DEFAULT = 1000; +export const EDITOR_AUTOSAVE_DELAY_MIN = 200; +export const EDITOR_AUTOSAVE_DELAY_MAX = 30_000; + +export type EditorLineNumbers = "on" | "off" | "relative"; +export const EDITOR_LINE_NUMBERS_DEFAULT: EditorLineNumbers = "on"; + +export type EditorRenderWhitespace = "none" | "boundary" | "all"; +export const EDITOR_RENDER_WHITESPACE_DEFAULT: EditorRenderWhitespace = "none"; + +export const CHAT_SHOW_TIMESTAMPS_DEFAULT = false; +export const TERMINAL_COPY_ON_SELECT_DEFAULT = false; +export const FILETREE_SHOW_HIDDEN_DEFAULT = false; +export const WORKSPACE_CONFIRM_CLOSE_DEFAULT = true; + +export type Accent = "blue" | "lavender" | "green" | "orange" | "pink"; +export const ACCENT_DEFAULT: Accent = "blue"; +export const ACCENT_SWATCHES: { value: Accent; label: string; color: string }[] = [ + { value: "blue", label: "Blue", color: "#3b82f6" }, + { value: "lavender", label: "Lavender", color: "#8b5cf6" }, + { value: "green", label: "Green", color: "#22c55e" }, + { value: "orange", label: "Orange", color: "#fb923c" }, + { value: "pink", label: "Pink", color: "#f43f5e" }, +]; + +// Mirrors WindowControls.tsx's platform sniff. +export const IS_MAC: boolean = (() => { + if (typeof navigator === "undefined") return false; + const uaData = (navigator as Navigator & { + userAgentData?: { platform?: string }; + }).userAgentData; + const raw = uaData?.platform ?? navigator.platform ?? navigator.userAgent ?? ""; + return raw.toLowerCase().includes("mac"); +})(); + +export const PRIMARY_MOD_LABEL: string = IS_MAC ? "⌘" : "Ctrl"; + +function clamp(n: number, min: number, max: number): number { + if (!Number.isFinite(n)) return min; + return Math.min(max, Math.max(min, n)); +} + +function readNumber(key: string, fallback: number, min: number, max: number): number { + try { + const raw = localStorage.getItem(key); + if (!raw) return fallback; + const n = parseFloat(raw); + return Number.isFinite(n) ? clamp(n, min, max) : fallback; + } catch { + return fallback; + } +} + +function writeNumber(key: string, value: number): void { + try { + localStorage.setItem(key, String(value)); + } catch { + // ignore quota errors + } +} + +function readBoolean(key: string, fallback: boolean): boolean { + try { + const raw = localStorage.getItem(key); + if (raw === null) return fallback; + return raw === "true"; + } catch { + return fallback; + } +} + +function writeBoolean(key: string, value: boolean): void { + try { + localStorage.setItem(key, value ? "true" : "false"); + } catch { + // ignore quota errors + } +} + +function writeString(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // ignore quota errors + } +} + +export function zoomLevelToFactor(level: number): number { + return Math.pow(ZOOM_FACTOR_BASE, level); +} + +export function zoomLevelToPercent(level: number): number { + return Math.round(zoomLevelToFactor(level) * 100); +} + +function applyZoom(level: number): void { + void getCurrentWebview() + .setZoom(zoomLevelToFactor(level)) + .catch((err) => console.error("[appearance] setZoom failed", err)); +} + +export function getZoomLevel(): number { + return Math.round(readNumber(ZOOM_KEY, ZOOM_LEVEL_DEFAULT, ZOOM_LEVEL_MIN, ZOOM_LEVEL_MAX)); +} + +export function setZoomLevel(level: number): number { + const next = Math.round(clamp(level, ZOOM_LEVEL_MIN, ZOOM_LEVEL_MAX)); + writeNumber(ZOOM_KEY, next); + applyZoom(next); + window.dispatchEvent(new CustomEvent(ZOOM_CHANGED_EVENT, { detail: next })); + return next; +} + +export function bumpZoomLevel(delta: number): number { + return setZoomLevel(getZoomLevel() + delta); +} + +export function resetZoomLevel(): number { + return setZoomLevel(ZOOM_LEVEL_DEFAULT); +} + +export function getEditorFontSize(): number { + return readNumber(EDITOR_FONT_KEY, EDITOR_FONT_DEFAULT, EDITOR_FONT_MIN, EDITOR_FONT_MAX); +} + +export function setEditorFontSize(size: number): number { + const next = clamp(size, EDITOR_FONT_MIN, EDITOR_FONT_MAX); + writeNumber(EDITOR_FONT_KEY, next); + window.dispatchEvent( + new CustomEvent(EDITOR_FONT_CHANGED_EVENT, { detail: next }), + ); + return next; +} + +export function resetEditorFontSize(): number { + return setEditorFontSize(EDITOR_FONT_DEFAULT); +} + +export function getTerminalFontSize(): number { + return readNumber(TERMINAL_FONT_KEY, TERMINAL_FONT_DEFAULT, TERMINAL_FONT_MIN, TERMINAL_FONT_MAX); +} + +export function setTerminalFontSize(size: number): number { + const next = clamp(size, TERMINAL_FONT_MIN, TERMINAL_FONT_MAX); + writeNumber(TERMINAL_FONT_KEY, next); + window.dispatchEvent( + new CustomEvent(TERMINAL_FONT_CHANGED_EVENT, { detail: next }), + ); + return next; +} + +export function resetTerminalFontSize(): number { + return setTerminalFontSize(TERMINAL_FONT_DEFAULT); +} + +export function getChatFontSize(): number { + return readNumber(CHAT_FONT_KEY, CHAT_FONT_DEFAULT, CHAT_FONT_MIN, CHAT_FONT_MAX); +} + +export function setChatFontSize(size: number): number { + const next = clamp(size, CHAT_FONT_MIN, CHAT_FONT_MAX); + writeNumber(CHAT_FONT_KEY, next); + window.dispatchEvent( + new CustomEvent(CHAT_FONT_CHANGED_EVENT, { detail: next }), + ); + return next; +} + +export function resetChatFontSize(): number { + return setChatFontSize(CHAT_FONT_DEFAULT); +} + +function parseCursorStyle(raw: string | null): TerminalCursorStyle { + if (raw === "underline" || raw === "bar") return raw; + return "block"; +} + +export function getTerminalCursorStyle(): TerminalCursorStyle { + try { + return parseCursorStyle(localStorage.getItem(TERMINAL_CURSOR_STYLE_KEY)); + } catch { + return TERMINAL_CURSOR_STYLE_DEFAULT; + } +} + +export function setTerminalCursorStyle(style: TerminalCursorStyle): TerminalCursorStyle { + writeString(TERMINAL_CURSOR_STYLE_KEY, style); + window.dispatchEvent( + new CustomEvent(TERMINAL_CURSOR_CHANGED_EVENT, { + detail: { style, blink: getTerminalCursorBlink() }, + }), + ); + return style; +} + +export function getTerminalCursorBlink(): boolean { + return readBoolean(TERMINAL_CURSOR_BLINK_KEY, TERMINAL_CURSOR_BLINK_DEFAULT); +} + +export function setTerminalCursorBlink(blink: boolean): boolean { + writeBoolean(TERMINAL_CURSOR_BLINK_KEY, blink); + window.dispatchEvent( + new CustomEvent(TERMINAL_CURSOR_CHANGED_EVENT, { + detail: { style: getTerminalCursorStyle(), blink }, + }), + ); + return blink; +} + +function parseAccent(raw: string | null): Accent { + switch (raw) { + case "blue": + case "lavender": + case "green": + case "orange": + case "pink": + return raw; + default: + return ACCENT_DEFAULT; + } +} + +export function getAccent(): Accent { + try { + return parseAccent(localStorage.getItem(ACCENT_KEY)); + } catch { + return ACCENT_DEFAULT; + } +} + +function applyAccent(accent: Accent): void { + document.documentElement.dataset.accent = accent; +} + +export function setAccent(accent: Accent): Accent { + writeString(ACCENT_KEY, accent); + applyAccent(accent); + window.dispatchEvent( + new CustomEvent(ACCENT_CHANGED_EVENT, { detail: accent }), + ); + return accent; +} + +export function getTerminalShell(): string { + try { + return localStorage.getItem(TERMINAL_SHELL_KEY) ?? ""; + } catch { + return ""; + } +} + +export function setTerminalShell(path: string): string { + const next = path.trim(); + if (next) writeString(TERMINAL_SHELL_KEY, next); + else + try { + localStorage.removeItem(TERMINAL_SHELL_KEY); + } catch { + // ignore + } + window.dispatchEvent( + new CustomEvent(TERMINAL_SHELL_CHANGED_EVENT, { detail: next }), + ); + return next; +} + +function parseTheme(raw: string | null): Theme { + if (raw === "light" || raw === "dark" || raw === "system") return raw; + return THEME_DEFAULT; +} + +export function getTheme(): Theme { + try { + return parseTheme(localStorage.getItem(THEME_KEY)); + } catch { + return THEME_DEFAULT; + } +} + +export function getEffectiveTheme(): EffectiveTheme { + const theme = getTheme(); + if (theme === "system") { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return theme; +} + +function applyTheme(theme: Theme): EffectiveTheme { + const effective: EffectiveTheme = + theme === "system" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : theme; + document.documentElement.dataset.theme = effective; + return effective; +} + +export function setTheme(theme: Theme): Theme { + writeString(THEME_KEY, theme); + const effective = applyTheme(theme); + window.dispatchEvent( + new CustomEvent(THEME_CHANGED_EVENT, { detail: effective }), + ); + return theme; +} + +export function resetTheme(): Theme { + return setTheme(THEME_DEFAULT); +} + +// Reapply the effective theme whenever the OS preference changes — only +// matters while the user has the "system" option selected. +let systemThemeBound = false; +function bindSystemThemeListener(): void { + if (systemThemeBound || typeof window.matchMedia !== "function") return; + systemThemeBound = true; + window.matchMedia("(prefers-color-scheme: dark)").addEventListener( + "change", + () => { + if (getTheme() !== "system") return; + const effective = applyTheme("system"); + window.dispatchEvent( + new CustomEvent(THEME_CHANGED_EVENT, { + detail: effective, + }), + ); + }, + ); +} + +function parseAutosaveMode(raw: string | null): EditorAutosaveMode { + return raw === "afterDelay" || raw === "onBlur" ? raw : "off"; +} + +export function getEditorAutosaveMode(): EditorAutosaveMode { + try { + return parseAutosaveMode(localStorage.getItem(EDITOR_AUTOSAVE_MODE_KEY)); + } catch { + return EDITOR_AUTOSAVE_MODE_DEFAULT; + } +} + +export function getEditorAutosaveDelay(): number { + return readNumber( + EDITOR_AUTOSAVE_DELAY_KEY, + EDITOR_AUTOSAVE_DELAY_DEFAULT, + EDITOR_AUTOSAVE_DELAY_MIN, + EDITOR_AUTOSAVE_DELAY_MAX, + ); +} + +export type EditorAutosaveState = { + mode: EditorAutosaveMode; + delayMs: number; +}; + +function emitAutosave(): void { + window.dispatchEvent( + new CustomEvent(EDITOR_AUTOSAVE_CHANGED_EVENT, { + detail: { mode: getEditorAutosaveMode(), delayMs: getEditorAutosaveDelay() }, + }), + ); +} + +export function setEditorAutosaveMode(mode: EditorAutosaveMode): EditorAutosaveMode { + writeString(EDITOR_AUTOSAVE_MODE_KEY, mode); + emitAutosave(); + return mode; +} + +export function setEditorAutosaveDelay(delayMs: number): number { + const next = Math.round( + clamp(delayMs, EDITOR_AUTOSAVE_DELAY_MIN, EDITOR_AUTOSAVE_DELAY_MAX), + ); + writeNumber(EDITOR_AUTOSAVE_DELAY_KEY, next); + emitAutosave(); + return next; +} + +function parseLineNumbers(raw: string | null): EditorLineNumbers { + return raw === "off" || raw === "relative" ? raw : "on"; +} + +export function getEditorLineNumbers(): EditorLineNumbers { + try { + return parseLineNumbers(localStorage.getItem(EDITOR_LINE_NUMBERS_KEY)); + } catch { + return EDITOR_LINE_NUMBERS_DEFAULT; + } +} + +export function setEditorLineNumbers(mode: EditorLineNumbers): EditorLineNumbers { + writeString(EDITOR_LINE_NUMBERS_KEY, mode); + window.dispatchEvent( + new CustomEvent(EDITOR_LINE_NUMBERS_CHANGED_EVENT, { + detail: mode, + }), + ); + return mode; +} + +function parseRenderWhitespace(raw: string | null): EditorRenderWhitespace { + return raw === "boundary" || raw === "all" ? raw : "none"; +} + +export function getEditorRenderWhitespace(): EditorRenderWhitespace { + try { + return parseRenderWhitespace(localStorage.getItem(EDITOR_RENDER_WHITESPACE_KEY)); + } catch { + return EDITOR_RENDER_WHITESPACE_DEFAULT; + } +} + +export function setEditorRenderWhitespace( + mode: EditorRenderWhitespace, +): EditorRenderWhitespace { + writeString(EDITOR_RENDER_WHITESPACE_KEY, mode); + window.dispatchEvent( + new CustomEvent(EDITOR_RENDER_WHITESPACE_CHANGED_EVENT, { + detail: mode, + }), + ); + return mode; +} + +export function getChatShowTimestamps(): boolean { + return readBoolean(CHAT_SHOW_TIMESTAMPS_KEY, CHAT_SHOW_TIMESTAMPS_DEFAULT); +} + +export function setChatShowTimestamps(value: boolean): boolean { + writeBoolean(CHAT_SHOW_TIMESTAMPS_KEY, value); + window.dispatchEvent( + new CustomEvent(CHAT_SHOW_TIMESTAMPS_CHANGED_EVENT, { detail: value }), + ); + return value; +} + +export function getTerminalCopyOnSelect(): boolean { + return readBoolean(TERMINAL_COPY_ON_SELECT_KEY, TERMINAL_COPY_ON_SELECT_DEFAULT); +} + +export function setTerminalCopyOnSelect(value: boolean): boolean { + writeBoolean(TERMINAL_COPY_ON_SELECT_KEY, value); + window.dispatchEvent( + new CustomEvent(TERMINAL_COPY_ON_SELECT_CHANGED_EVENT, { detail: value }), + ); + return value; +} + +export function getFileTreeShowHidden(): boolean { + return readBoolean(FILETREE_SHOW_HIDDEN_KEY, FILETREE_SHOW_HIDDEN_DEFAULT); +} + +export function setFileTreeShowHidden(value: boolean): boolean { + writeBoolean(FILETREE_SHOW_HIDDEN_KEY, value); + window.dispatchEvent( + new CustomEvent(FILETREE_SHOW_HIDDEN_CHANGED_EVENT, { detail: value }), + ); + return value; +} + +export function getWorkspaceConfirmClose(): boolean { + return readBoolean(WORKSPACE_CONFIRM_CLOSE_KEY, WORKSPACE_CONFIRM_CLOSE_DEFAULT); +} + +export function setWorkspaceConfirmClose(value: boolean): boolean { + writeBoolean(WORKSPACE_CONFIRM_CLOSE_KEY, value); + window.dispatchEvent( + new CustomEvent(WORKSPACE_CONFIRM_CLOSE_CHANGED_EVENT, { + detail: value, + }), + ); + return value; +} + +// Collect every persisted appearance / preference key under the +// `sinew.*` namespace into a JSON blob — used by Settings → export. +export function exportSettings(): string { + const out: Record = {}; + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith("sinew.")) { + const value = localStorage.getItem(key); + if (value !== null) out[key] = value; + } + } + } catch { + // ignore + } + return JSON.stringify(out, null, 2); +} + +export function importSettings(json: string): number { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return 0; + } + if (!parsed || typeof parsed !== "object") return 0; + let applied = 0; + for (const [key, value] of Object.entries(parsed as Record)) { + if (!key.startsWith("sinew.")) continue; + if (typeof value !== "string") continue; + try { + localStorage.setItem(key, value); + applied += 1; + } catch { + // ignore quota / storage errors + } + } + return applied; +} + +// Carry users forward from the earlier factor-based slider. +function migrateLegacy(): void { + try { + const legacy = localStorage.getItem(LEGACY_UI_SCALE_KEY); + if (!legacy) return; + if (localStorage.getItem(ZOOM_KEY) === null) { + const factor = parseFloat(legacy); + if (Number.isFinite(factor) && factor > 0) { + const level = Math.round(Math.log(factor) / Math.log(ZOOM_FACTOR_BASE)); + writeNumber(ZOOM_KEY, clamp(level, ZOOM_LEVEL_MIN, ZOOM_LEVEL_MAX)); + } + } + localStorage.removeItem(LEGACY_UI_SCALE_KEY); + } catch { + // ignore + } +} + +export function applyStoredAppearance(): void { + migrateLegacy(); + applyTheme(getTheme()); + applyAccent(getAccent()); + bindSystemThemeListener(); + applyZoom(getZoomLevel()); +} diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 8d2d0be6..b2b8318a 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -491,6 +491,7 @@ export const api = { rows: number, pixelWidth?: number, pixelHeight?: number, + shell?: string, ) { return invoke("spawn_terminal", { input: { @@ -501,9 +502,13 @@ export const api = { rows, pixelWidth, pixelHeight, + shell, }, }); }, + listTerminalShells() { + return invoke<{ label: string; path: string }[]>("list_terminal_shells"); + }, writeTerminal(sessionId: string, token: string, data: string) { return invoke("write_terminal", { input: { sessionId, token, data }, diff --git a/src/lib/monacoTheme.ts b/src/lib/monacoTheme.ts new file mode 100644 index 00000000..2f8ea8f0 --- /dev/null +++ b/src/lib/monacoTheme.ts @@ -0,0 +1,112 @@ +import type * as Monaco from "monaco-editor"; + +import { ACCENT_SWATCHES, type Accent, type EffectiveTheme } from "./appearance"; + +export const SINEW_DARK_THEME = "sinew-cool"; +export const SINEW_LIGHT_THEME = "sinew-light"; + +export function monacoThemeName(theme: EffectiveTheme): string { + return theme === "light" ? SINEW_LIGHT_THEME : SINEW_DARK_THEME; +} + +function accentHex(accent: Accent): string { + return ( + ACCENT_SWATCHES.find((swatch) => swatch.value === accent)?.color ?? "#3b82f6" + ); +} + +// Both Monaco theme variants share the same syntax token rules; only the +// cursor / bracket-match / suggest highlight pick up the user-selected +// accent so live changes show up in the editor. +export function defineMonacoThemes( + monaco: typeof Monaco, + accent: Accent, +): void { + const cursor = accentHex(accent); + monaco.editor.defineTheme(SINEW_DARK_THEME, { + base: "vs-dark", + inherit: true, + rules: [ + { token: "comment", foreground: "52555c" }, + { token: "keyword", foreground: "c4b5fd" }, + { token: "string", foreground: "86efac" }, + { token: "number", foreground: "f5a683" }, + { token: "type", foreground: "e8bb6a" }, + { token: "function", foreground: "9fc2ff" }, + { token: "variable", foreground: "e8e9ec" }, + { token: "constant", foreground: "f5a683" }, + { token: "regexp", foreground: "86efac" }, + { token: "tag", foreground: "f5a1ab" }, + { token: "attribute.name", foreground: "c4b5fd" }, + ], + colors: { + "editor.background": "#0b0b0d", + "editor.foreground": "#e8e9ec", + "editor.lineHighlightBackground": "#0f1013", + "editorLineNumber.foreground": "#3a3d44", + "editorLineNumber.activeForeground": "#9aa0a8", + "editorCursor.foreground": cursor, + "editor.selectionBackground": "#1e2b4a", + "editor.inactiveSelectionBackground": "#141518", + "editorIndentGuide.background1": "#141518", + "editorIndentGuide.activeBackground1": "#23252b", + "editorGutter.background": "#0b0b0d", + "editorWidget.background": "#0f1013", + "editorWidget.border": "#23252b", + "editorHoverWidget.background": "#0f1013", + "editorHoverWidget.border": "#23252b", + "editorSuggestWidget.background": "#0f1013", + "editorSuggestWidget.border": "#23252b", + "editorSuggestWidget.selectedBackground": "#1e2b4a", + "editorSuggestWidget.highlightForeground": cursor, + "editorBracketMatch.background": "#1e2b4a", + "editorBracketMatch.border": cursor, + "scrollbarSlider.background": "#23252bcc", + "scrollbarSlider.hoverBackground": "#2b2e35cc", + "scrollbarSlider.activeBackground": "#3a3d44cc", + }, + }); + monaco.editor.defineTheme(SINEW_LIGHT_THEME, { + base: "vs", + inherit: true, + rules: [ + { token: "comment", foreground: "8a8478" }, + { token: "keyword", foreground: "7c3aed" }, + { token: "string", foreground: "1f6b39" }, + { token: "number", foreground: "a85a1a" }, + { token: "type", foreground: "a85a1a" }, + { token: "function", foreground: "1d4ed8" }, + { token: "variable", foreground: "2a2620" }, + { token: "constant", foreground: "a85a1a" }, + { token: "regexp", foreground: "1f6b39" }, + { token: "tag", foreground: "a32424" }, + { token: "attribute.name", foreground: "7c3aed" }, + ], + colors: { + "editor.background": "#fbf9f4", + "editor.foreground": "#2a2620", + "editor.lineHighlightBackground": "#f3efe7", + "editorLineNumber.foreground": "#c2bcaf", + "editorLineNumber.activeForeground": "#5c574d", + "editorCursor.foreground": cursor, + "editor.selectionBackground": "#dde7ff", + "editor.inactiveSelectionBackground": "#ece7dc", + "editorIndentGuide.background1": "#ece7dc", + "editorIndentGuide.activeBackground1": "#d8d1c0", + "editorGutter.background": "#fbf9f4", + "editorWidget.background": "#f6f2ea", + "editorWidget.border": "#d8d1c0", + "editorHoverWidget.background": "#f6f2ea", + "editorHoverWidget.border": "#d8d1c0", + "editorSuggestWidget.background": "#f6f2ea", + "editorSuggestWidget.border": "#d8d1c0", + "editorSuggestWidget.selectedBackground": "#dde7ff", + "editorSuggestWidget.highlightForeground": cursor, + "editorBracketMatch.background": "#dde7ff", + "editorBracketMatch.border": cursor, + "scrollbarSlider.background": "#cbc3afcc", + "scrollbarSlider.hoverBackground": "#b8af99cc", + "scrollbarSlider.activeBackground": "#a39880cc", + }, + }); +} diff --git a/src/main.tsx b/src/main.tsx index 19d5cd4f..3afa00d7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,41 @@ import App from "./App"; import "./styles.css"; import "./lib/customIcons"; import { api } from "./lib/ipc"; +import { + applyStoredAppearance, + bumpZoomLevel, + IS_MAC, + resetZoomLevel, +} from "./lib/appearance"; + +// Apply persisted zoom before React mounts to avoid a flash at the +// default factor. +applyStoredAppearance(); + +// Cmd/Ctrl + "=" / "-" / "0" zoom shortcuts. We match on `event.key` so +// the same physical keys work on AZERTY layouts (where `event.code` +// points at the wrong character), and gate on the platform's primary +// modifier only so Ctrl-= on macOS — used by Terminal — still passes +// through. Capture phase wins against Monaco / xterm. +window.addEventListener( + "keydown", + (event) => { + const primary = IS_MAC ? event.metaKey : event.ctrlKey; + const wrong = IS_MAC ? event.ctrlKey : event.metaKey; + if (!primary || wrong) return; + const key = event.key; + const isPlus = key === "=" || key === "+"; + const isMinus = key === "-" || key === "_"; + const isZero = key === "0"; + if (!isPlus && !isMinus && !isZero) return; + event.preventDefault(); + event.stopPropagation(); + if (isPlus) bumpZoomLevel(1); + else if (isMinus) bumpZoomLevel(-1); + else resetZoomLevel(); + }, + { capture: true }, +); // Suppress the native WebKit context menu everywhere except inside text // inputs (where the OS-level copy/paste menu is still useful). Components diff --git a/src/styles.css b/src/styles.css index ea57bdb6..0dbe732e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -106,6 +106,114 @@ --shadow-focus: 0 4px 14px rgba(0, 0, 0, 0.45); } +/* ------------------------------------------------------------------ + Light theme — warm off-white, low-contrast surfaces. Driven by + `document.documentElement.dataset.theme = "light"`. Only re-skins + the tokens defined in `:root`; everything else inherits. + ------------------------------------------------------------------ */ + +:root[data-theme="light"] { + /* Warm paper cream — softer than pure white, easier on the eyes. */ + --bg-canvas: #faf8f3; + --bg-0: #faf8f3; + --bg-1: #f3efe7; + --bg-2: #ece7dc; + --bg-3: #e3ddd0; + --bg-4: #d8d1c0; + --bg-5: #cbc3af; + --bg-hi: #ece7dc; + --bg-pure: #ffffff; + + --editor-bg: #fbf9f4; + --editor-bg-2: #f6f2ea; + --editor-bg-3: #ece7dc; + --editor-fg: #2a2620; + --editor-fg-dim: #5c574d; + + /* Warm dark on cream — never pure black, never cold gray. */ + --line-soft: rgba(48, 40, 30, 0.05); + --line: rgba(48, 40, 30, 0.09); + --line-strong: rgba(48, 40, 30, 0.16); + --line-bold: rgba(48, 40, 30, 0.30); + --divider: rgba(48, 40, 30, 0.09); + + --text-0: #2a2620; + --text-1: #3c3830; + --text-2: #5c574d; + --text-3: #8a8478; + --text-muted: #a8a195; + --text-faint: #c2bcaf; + + --accent-glow: rgba(59, 130, 246, 0.28); + --accent-soft: rgba(59, 130, 246, 0.10); + + --accent-2: #7c3aed; + --accent-2-soft: rgba(124, 58, 237, 0.12); + + --signal: #7c3aed; + --danger: #c43232; + --ok: #2f8a4a; + + --tone-thinking: #d96a2c; + --tone-grep: #2f8a4a; + --tone-read: #2563eb; + --tone-edit: #7c3aed; + + --diff-add-bg: rgba(47, 138, 74, 0.12); + --diff-add-fg: #1f6b39; + --diff-rem-bg: rgba(196, 50, 50, 0.10); + --diff-rem-fg: #a32424; + + --shadow-ambient: 0 0 16px rgba(48, 40, 30, 0.06), + 0 0 8px rgba(48, 40, 30, 0.04); + --shadow-elevated: 0 32px 72px rgba(48, 40, 30, 0.14), + 0 14px 32px rgba(48, 40, 30, 0.10), + 0 0 0 1px var(--line); + --shadow-focus: 0 4px 14px rgba(48, 40, 30, 0.12); +} + +:root[data-theme="light"] ::-webkit-scrollbar-thumb { + background: rgba(48, 40, 30, 0.14); +} +:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover { + background: rgba(48, 40, 30, 0.22); +} + +/* ---- Accent palettes — picked via [data-accent] on --------- */ + +:root[data-accent="lavender"] { + --accent: #8b5cf6; + --accent-hi: #a78bfa; + --accent-dim: #7c3aed; + --accent-deep: #5b21b6; + --accent-glow: rgba(139, 92, 246, 0.35); + --accent-soft: rgba(139, 92, 246, 0.12); +} +:root[data-accent="green"] { + --accent: #22c55e; + --accent-hi: #4ade80; + --accent-dim: #16a34a; + --accent-deep: #14532d; + --accent-glow: rgba(34, 197, 94, 0.35); + --accent-soft: rgba(34, 197, 94, 0.12); +} +:root[data-accent="orange"] { + --accent: #fb923c; + --accent-hi: #fdba74; + --accent-dim: #ea580c; + --accent-deep: #9a3412; + --accent-glow: rgba(251, 146, 60, 0.35); + --accent-soft: rgba(251, 146, 60, 0.12); +} +:root[data-accent="pink"] { + --accent: #f43f5e; + --accent-hi: #fb7185; + --accent-dim: #e11d48; + --accent-deep: #881337; + --accent-glow: rgba(244, 63, 94, 0.35); + --accent-soft: rgba(244, 63, 94, 0.12); +} + * { box-sizing: border-box; } @@ -1773,8 +1881,8 @@ textarea { } .settings-pane__nav-item[data-active="true"] { - background: var(--bg-3); - color: var(--text-0); + background: var(--accent-soft); + color: var(--accent-hi); } .settings-pane__nav-icon { @@ -2007,6 +2115,173 @@ textarea { color: var(--text-0); } +/* ---- Appearance section ------------------------------------------- */ + +.settings-pane__body--appearance { + align-content: start; + gap: 14px; + max-width: 620px; + overflow-y: auto; + padding-bottom: 22px; +} + +.settings-pane__appearance-control { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.settings-pane__appearance-stepper { + display: inline-flex; + align-items: center; + justify-content: space-between; + width: 110px; + height: 26px; + padding: 0 2px; + border-radius: var(--r-med); + background: var(--bg-1); + color: var(--text-0); + font-family: var(--font-mono); + font-size: var(--fs-mono-sm); + font-variant-numeric: tabular-nums; +} +.settings-pane__appearance-stepper button { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--r-small); + background: transparent; + color: var(--text-2); + font-family: var(--font-ui); + font-size: var(--fs-sm); + font-weight: 500; + line-height: 1; + cursor: pointer; + transition: background 140ms ease, color 140ms ease; +} +.settings-pane__appearance-stepper button:hover:not(:disabled) { + background: var(--bg-3); + color: var(--text-0); +} +.settings-pane__appearance-stepper button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.settings-pane__appearance-input { + width: 28px; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: inherit; + font-family: inherit; + font-size: inherit; + text-align: center; + -moz-appearance: textfield; + appearance: textfield; +} +.settings-pane__appearance-input::-webkit-outer-spin-button, +.settings-pane__appearance-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.settings-pane__appearance-suffix { + margin-left: 3px; + margin-right: 2px; + color: var(--text-3); + font-size: var(--fs-xs); +} + +.settings-pane__appearance-select { + height: 26px; + min-width: 160px; + padding: 0 26px 0 10px; + border: 0; + outline: none; + border-radius: var(--r-med); + background: var(--bg-1); + color: var(--text-0); + font-family: var(--font-ui); + font-size: var(--fs-xs); + font-weight: 500; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 8px center; + cursor: pointer; + transition: background-color 140ms ease; +} +.settings-pane__appearance-select:hover { + background-color: var(--bg-3); +} +:root[data-theme="light"] .settings-pane__appearance-select { + background-image: url("data:image/svg+xml;utf8,"); +} + +.settings-pane__appearance-shell-input { + width: 220px; + height: 26px; + padding: 0 8px; + border: 0; + outline: none; + border-radius: var(--r-med); + background: var(--bg-1); + color: var(--text-0); + font-family: var(--font-mono); + font-size: var(--fs-mono-sm); + transition: background 140ms ease; +} +.settings-pane__appearance-shell-input:focus { + background: var(--bg-3); +} +.settings-pane__appearance-shell-input::placeholder { + color: var(--text-3); +} + +.settings-pane__appearance-action { + height: 26px; + padding: 0 12px; + border-radius: var(--r-med); + background: var(--bg-2); + color: var(--text-1); + font-family: var(--font-ui); + font-size: var(--fs-xs); + font-weight: 500; + transition: background 140ms ease, color 140ms ease; +} +.settings-pane__appearance-action:hover { + background: var(--bg-3); + color: var(--text-0); +} + +.settings-pane__accent-swatches { + display: inline-flex; + align-items: center; + gap: 6px; +} +.settings-pane__accent-swatch { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid transparent; + padding: 0; + cursor: pointer; + transition: transform 120ms ease, border-color 140ms ease; +} +.settings-pane__accent-swatch:hover { + transform: scale(1.08); +} +.settings-pane__accent-swatch[data-active="true"] { + border-color: var(--text-0); +} + .settings-pane__provider-card { min-width: 0; display: flex; @@ -2721,6 +2996,9 @@ textarea { border-radius: var(--r-card); background: var(--bg-2); } +.settings-pane__tool-provider-switch--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} .settings-pane__tool-provider-switch button { min-width: 0; height: 28px; @@ -4163,6 +4441,18 @@ textarea { text-transform: uppercase; } +.msg__time { + display: none; + align-self: flex-end; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-3); + font-variant-numeric: tabular-nums; +} +.chat-col[data-show-timestamps="true"] .msg__time { + display: inline; +} + .msg__body { font-family: var(--font-ui); font-size: var(--fs-sm); @@ -6678,11 +6968,11 @@ textarea { transition: background 140ms ease, color 140ms ease; } .composer__connect-cta:hover { - background: rgba(59, 130, 246, 0.22); + background: var(--accent-soft); color: var(--text-0); } .composer__connect-cta:active { - background: rgba(59, 130, 246, 0.28); + background: var(--accent-glow); } .composer__connect-cta svg { color: inherit;