Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20617bc18a | ||
|
|
f4ce36cd47 | ||
|
|
c47b9d126f | ||
|
|
ac76bbe21f | ||
|
|
31c40c72c0 | ||
|
|
79bfddd37c | ||
|
|
c2050183a5 | ||
|
|
b34ee80741 | ||
|
|
bb0619dbce |
@@ -1891,6 +1891,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
# via `hermes auth openai-codex`.
|
||||
if isinstance(tokens, dict) and tokens.get("access_token"):
|
||||
active_sources.add("device_code")
|
||||
custom_label = str(state.get("label") or "").strip()
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
@@ -1902,7 +1903,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"refresh_token": tokens.get("refresh_token"),
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": state.get("last_refresh"),
|
||||
"label": label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||
"label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+34
-5
@@ -6,16 +6,42 @@ gateway/cron startup). The local-CLI backend deliberately leaves it unset and
|
||||
relies on the launch dir. Reading it in one place keeps the system prompt, the
|
||||
tool surfaces, and context-file discovery agreeing on where the agent lives.
|
||||
|
||||
The #29531 per-session extension point is this function: a future PR adds a
|
||||
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
|
||||
`set_session_vars` seam — by design, not a reopening hazard.
|
||||
Multi-session gateways can pin a logical cwd via the `_SESSION_CWD`
|
||||
contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_UNSET: Any = object()
|
||||
|
||||
_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET)
|
||||
|
||||
|
||||
def set_session_cwd(cwd: str | None) -> Token:
|
||||
"""Pin the logical cwd for the current context."""
|
||||
return _SESSION_CWD.set((cwd or "").strip())
|
||||
|
||||
|
||||
def clear_session_cwd() -> None:
|
||||
_SESSION_CWD.set("")
|
||||
|
||||
|
||||
def _session_cwd_override() -> str:
|
||||
value = _SESSION_CWD.get()
|
||||
if value is _UNSET:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def resolve_agent_cwd() -> Path:
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
p = Path(override).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
@@ -27,7 +53,10 @@ def resolve_agent_cwd() -> Path:
|
||||
def resolve_context_cwd() -> Path | None:
|
||||
# None means "no configured cwd": build_context_files_prompt then falls back
|
||||
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
|
||||
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py)
|
||||
# or, per session, the _SESSION_CWD contextvar above.
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
return Path(raw).expanduser() if raw else None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Setup</title>
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body class="h-full antialiased">
|
||||
<div id="root" class="h-full"></div>
|
||||
|
||||
@@ -208,7 +208,7 @@ pub async fn launch_hermes_desktop(
|
||||
/// Walks the well-known electron-builder unpacked-app paths under
|
||||
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
|
||||
/// <os>-unpacked/<exe>).
|
||||
fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let release_dir = install_root.join("apps").join("desktop").join("release");
|
||||
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
|
||||
&[
|
||||
@@ -232,6 +232,35 @@ fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf>
|
||||
None
|
||||
}
|
||||
|
||||
/// True when a prior install completed (bootstrap-complete marker present) AND a
|
||||
/// launchable desktop app exists on disk. Used by the installer's launcher fast
|
||||
/// path so a bare re-open just opens Hermes instead of re-running setup.
|
||||
pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
||||
install_root.join(".hermes-bootstrap-complete").exists()
|
||||
&& resolve_hermes_desktop_exe(install_root).is_some()
|
||||
}
|
||||
|
||||
/// Spawn the already-built desktop app, detached. Returns Err if no built app
|
||||
/// exists or the spawn fails, so the caller can fall back to showing the
|
||||
/// installer UI.
|
||||
pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io::Result<()> {
|
||||
let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app")
|
||||
})?;
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
cmd.current_dir(exe.parent().unwrap_or(install_root));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008 — keep the desktop alive after the
|
||||
// installer exits, mirroring launch_hermes_desktop. Kept correct here
|
||||
// even though the only caller is macOS-gated today, so future reuse on
|
||||
// Windows doesn't reintroduce the relaunch race.
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
cmd.spawn().map(|_child| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -50,6 +50,20 @@ impl AppMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the args request a forced installer UI (repair/reinstall)
|
||||
/// via `--reinstall` or `--repair`, which overrides the macOS launcher
|
||||
/// fast-path so a broken install can be repaired. Arg-iterator generic so it's
|
||||
/// unit-testable, mirroring `AppMode::from_args`. Independent of mode selection:
|
||||
/// these flags never flip Install<->Update.
|
||||
pub fn force_setup_from_args<I, S>(args: I) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
args.into_iter()
|
||||
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
|
||||
}
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
@@ -85,7 +99,11 @@ pub fn run() {
|
||||
let _guard = paths::init_logging();
|
||||
|
||||
let mode = AppMode::from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, "Hermes Setup starting");
|
||||
// Escape hatch: `--reinstall`/`--repair` forces the installer UI even when
|
||||
// Hermes is already installed, so users can re-run setup to repair a broken
|
||||
// install instead of the launcher fast path silently relaunching the app.
|
||||
let force_setup = force_setup_from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, force_setup, "Hermes installer starting");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -93,6 +111,60 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Arc::new(AppState::new(mode)))
|
||||
.setup(move |app| {
|
||||
use tauri::Manager;
|
||||
// Launcher fast path (macOS only): a bare ("Install") launch when
|
||||
// Hermes is already installed should NOT show the installer or
|
||||
// rebuild — it should just open the app, so the /Applications
|
||||
// "Hermes" doubles as a normal launcher (first run installs, every
|
||||
// later run launches instantly). The window is kept hidden until
|
||||
// here via `"visible": false` so this path never flashes a window.
|
||||
//
|
||||
// Gated to macOS deliberately: on Windows/Linux the installer keeps
|
||||
// its existing behavior (Windows users relaunch via the Start
|
||||
// Menu/Desktop "Hermes" shortcuts that install.ps1 creates, and a
|
||||
// reliable detached relaunch there needs the DETACHED_PROCESS +
|
||||
// startup-grace handling used by launch_hermes_desktop — out of
|
||||
// scope here). So this is a pure no-op on non-macOS.
|
||||
//
|
||||
// `--reinstall`/`--repair` opts out so a broken install can be
|
||||
// repaired by re-running setup instead of launching the bad app.
|
||||
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
|
||||
let install_root = paths::hermes_home().join("hermes-agent");
|
||||
if bootstrap::hermes_is_installed(&install_root) {
|
||||
match bootstrap::spawn_installed_desktop(&install_root) {
|
||||
Ok(()) => {
|
||||
// Brief grace so the spawned app is registered
|
||||
// before we exit (mirrors launch_hermes_desktop).
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
tracing::info!(
|
||||
"hermes already installed — relaunched desktop; exiting installer"
|
||||
);
|
||||
app.handle().exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"relaunch of installed desktop failed; showing installer UI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// First run / repair install, or Update mode: reveal the UI.
|
||||
match app.get_webview_window("main") {
|
||||
Some(win) => {
|
||||
if let Err(err) = win.show() {
|
||||
tracing::error!(?err, "failed to show main installer window");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("main installer window not found; installer UI will not appear");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Mode (install vs update)
|
||||
get_mode,
|
||||
@@ -115,7 +187,7 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppMode;
|
||||
use super::{force_setup_from_args, AppMode};
|
||||
|
||||
#[test]
|
||||
fn bare_args_are_install() {
|
||||
@@ -131,4 +203,30 @@ mod tests {
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstall_and_repair_flags_force_setup() {
|
||||
assert!(force_setup_from_args(["--reinstall"]));
|
||||
assert!(force_setup_from_args(["--repair"]));
|
||||
assert!(force_setup_from_args(["--foo", "--repair", "--bar"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_or_unrelated_args_do_not_force_setup() {
|
||||
assert!(!force_setup_from_args(Vec::<String>::new()));
|
||||
assert!(!force_setup_from_args(["--foo", "bar"]));
|
||||
// --update must not be mistaken for a force-setup flag.
|
||||
assert!(!force_setup_from_args(["--update"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_setup_flags_do_not_affect_mode_selection() {
|
||||
// The repair flags must never flip Install<->Update.
|
||||
assert_eq!(AppMode::from_args(["--reinstall"]), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--repair"]), AppMode::Install);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--update", "--reinstall"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes Setup",
|
||||
"productName": "Hermes",
|
||||
"version": "0.0.1",
|
||||
"identifier": "com.nousresearch.hermes.setup",
|
||||
"build": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermes Setup",
|
||||
"title": "Hermes",
|
||||
"width": 880,
|
||||
"height": 620,
|
||||
"minWidth": 720,
|
||||
@@ -22,7 +22,8 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"center": true
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -33,7 +34,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Hermes Setup",
|
||||
"shortDescription": "Hermes",
|
||||
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
|
||||
"publisher": "Nous Research",
|
||||
"copyright": "Copyright © 2026 Nous Research",
|
||||
|
||||
+16
-3
@@ -111,15 +111,28 @@ npm run test:desktop:all
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
# Force a clean first-launch setup
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete" # macOS/Linux
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv" # macOS/Linux
|
||||
# Reset a stuck macOS microphone prompt
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv"
|
||||
# Reset a stuck macOS microphone prompt (macOS only)
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
# Force a clean first-launch setup
|
||||
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
|
||||
```
|
||||
|
||||
> The default Hermes home on Windows is `%LOCALAPPDATA%\hermes`. Set the `HERMES_HOME` env var if you've relocated it.
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
@@ -429,6 +429,13 @@ function registerMediaProtocol() {
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
// Auto-reload budget for renderer crashes. A deterministic startup crash would
|
||||
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
|
||||
// logs. Allow a few reloads per rolling window, then stop and leave the dead
|
||||
// window so the user can read the error / quit.
|
||||
const RENDERER_RELOAD_WINDOW_MS = 60_000
|
||||
const RENDERER_RELOAD_MAX = 3
|
||||
let rendererReloadTimes = []
|
||||
// Latched bootstrap failure: when the first-launch install fails, we hold
|
||||
// onto the error so subsequent startHermes() calls (e.g. the renderer's
|
||||
// ensureGatewayOpen retrying after the WS won't open) return the same error
|
||||
@@ -528,6 +535,39 @@ function openExternalUrl(rawUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
// `file://` URLs come from the artifacts panel (the renderer can't open
|
||||
// them itself because Chromium blocks file:// navigation from the app
|
||||
// origin). Hand them to `shell.openPath`, which dispatches to the OS
|
||||
// file association. If the OS can't open it (`error` is a non-empty
|
||||
// string), fall back to revealing the file in the system file manager.
|
||||
if (parsed.protocol === 'file:') {
|
||||
let localPath
|
||||
try {
|
||||
localPath = fileURLToPath(parsed.toString())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
void shell
|
||||
.openPath(localPath)
|
||||
.then(error => {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`)
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(localPath)
|
||||
} catch (revealError) {
|
||||
rememberLog(`[file] showItemInFolder failed: ${revealError.message}`)
|
||||
}
|
||||
})
|
||||
.catch(error => rememberLog(`[file] openPath rejected: ${error.message}`))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
|
||||
return false
|
||||
}
|
||||
@@ -1519,10 +1559,18 @@ function resolveRendererIndex() {
|
||||
}
|
||||
|
||||
function resolveHermesCwd() {
|
||||
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
||||
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
||||
// on macOS). Sessions spawned there leave files inside the app bundle
|
||||
// and bewilder users when "where did my files go?" is the install dir.
|
||||
// The user-configurable default project directory wins over everything,
|
||||
// followed by env hints (only honored when packaged if they point at a
|
||||
// real directory), then the home dir.
|
||||
const candidates = [
|
||||
readDefaultProjectDir(),
|
||||
process.env.HERMES_DESKTOP_CWD,
|
||||
process.env.INIT_CWD,
|
||||
process.cwd(),
|
||||
IS_PACKAGED ? null : process.cwd(),
|
||||
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
||||
app.getPath('home')
|
||||
]
|
||||
@@ -1536,6 +1584,48 @@ function resolveHermesCwd() {
|
||||
return app.getPath('home')
|
||||
}
|
||||
|
||||
// Persisted "Default project directory" — surfaced as a setting in the
|
||||
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
||||
// userData so it survives self-updates without bleeding into the new
|
||||
// install. `null` means "no preference, fall back to the usual chain".
|
||||
const DEFAULT_PROJECT_DIR_CONFIG_FILENAME = 'project-dir.json'
|
||||
|
||||
function defaultProjectDirConfigPath() {
|
||||
return path.join(app.getPath('userData'), DEFAULT_PROJECT_DIR_CONFIG_FILENAME)
|
||||
}
|
||||
|
||||
function readDefaultProjectDir() {
|
||||
try {
|
||||
const raw = fs.readFileSync(defaultProjectDirConfigPath(), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (parsed && typeof parsed.dir === 'string' && parsed.dir.trim()) {
|
||||
const resolved = path.resolve(parsed.dir)
|
||||
|
||||
if (directoryExists(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing / unreadable / malformed → fall through to the rest of the
|
||||
// candidate chain.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function writeDefaultProjectDir(dir) {
|
||||
const target = defaultProjectDirConfigPath()
|
||||
const payload = dir ? JSON.stringify({ dir: path.resolve(dir) }, null, 2) : JSON.stringify({}, null, 2)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(target, payload, 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[settings] write default project dir failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
const python = findPythonForRoot(root)
|
||||
if (!python) return null
|
||||
@@ -2695,6 +2785,28 @@ function installContextMenu(window) {
|
||||
)
|
||||
}
|
||||
|
||||
// Spell-check suggestions for the misspelled word under the caret.
|
||||
// Chromium surfaces them on `params.dictionarySuggestions`; we offer the
|
||||
// top 5 plus a "Add to dictionary" affordance.
|
||||
const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : []
|
||||
|
||||
if (isEditable && params.misspelledWord && suggestions.length > 0) {
|
||||
if (template.length) template.push({ type: 'separator' })
|
||||
|
||||
for (const suggestion of suggestions.slice(0, 5)) {
|
||||
template.push({
|
||||
label: suggestion,
|
||||
click: () => window.webContents.replaceMisspelling(suggestion)
|
||||
})
|
||||
}
|
||||
|
||||
template.push({ type: 'separator' })
|
||||
template.push({
|
||||
label: 'Add to dictionary',
|
||||
click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||
})
|
||||
}
|
||||
|
||||
if (hasSelection || isEditable) {
|
||||
if (template.length) template.push({ type: 'separator' })
|
||||
if (isEditable) {
|
||||
@@ -3222,6 +3334,51 @@ function createWindow() {
|
||||
openExternalUrl(url)
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||||
|
||||
if (details?.reason === 'crashed' || details?.reason === 'oom') {
|
||||
const now = Date.now()
|
||||
rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS)
|
||||
|
||||
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
|
||||
rememberLog(
|
||||
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rendererReloadTimes.push(now)
|
||||
setImmediate(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
try {
|
||||
mainWindow.webContents.reload()
|
||||
} catch (err) {
|
||||
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))
|
||||
|
||||
// Electron always passes the event first. The canonical (Electron 36+) shape
|
||||
// is (event, messageDetails); the deprecated positional shape is
|
||||
// (event, level, message, line, sourceId). Handle both. `level` is numeric
|
||||
// (0..3), where 3 === error.
|
||||
mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => {
|
||||
const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null
|
||||
const level = details ? details.level : detailsOrLevel
|
||||
|
||||
if (level !== 3) return
|
||||
|
||||
const text = details ? details.message : message
|
||||
const src = details ? details.sourceUrl : sourceId
|
||||
const lineNo = details ? details.lineNumber : line
|
||||
rememberLog(`[renderer console] ${text} (${src}:${lineNo})`)
|
||||
})
|
||||
|
||||
if (DEV_SERVER) {
|
||||
mainWindow.loadURL(DEV_SERVER)
|
||||
} else {
|
||||
@@ -3372,13 +3529,21 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
||||
const properties = ['openFile']
|
||||
if (options?.directories) properties.push('openDirectory')
|
||||
const properties = options?.directories ? ['openDirectory'] : ['openFile']
|
||||
if (options?.multiple !== false) properties.push('multiSelections')
|
||||
|
||||
let resolvedDefaultPath
|
||||
if (options?.defaultPath) {
|
||||
try {
|
||||
resolvedDefaultPath = path.resolve(String(options.defaultPath))
|
||||
} catch {
|
||||
resolvedDefaultPath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: options?.title || 'Add context',
|
||||
defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
|
||||
defaultPath: resolvedDefaultPath,
|
||||
properties,
|
||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||
})
|
||||
@@ -3437,6 +3602,45 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
}
|
||||
})
|
||||
|
||||
// User-configurable default project directory. The renderer reads this on
|
||||
// settings mount and seeds the value into the picker; writing back persists
|
||||
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
|
||||
// session spawn (no app restart needed).
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
||||
dir: readDefaultProjectDir(),
|
||||
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
|
||||
}))
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
||||
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
||||
|
||||
if (next) {
|
||||
try {
|
||||
fs.mkdirSync(next, { recursive: true })
|
||||
} catch (error) {
|
||||
throw new Error(`Could not create directory: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
writeDefaultProjectDir(next)
|
||||
|
||||
return { dir: next }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Choose default project directory',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { canceled: true, dir: null }
|
||||
}
|
||||
|
||||
return { canceled: false, dir: result.filePaths[0] }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
||||
|
||||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
@@ -3746,6 +3950,7 @@ app.whenReady().then(() => {
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -3753,6 +3958,29 @@ app.whenReady().then(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Seed Chromium's spellchecker with the system locale (falling back to en-US).
|
||||
// On macOS Electron uses the native spellchecker which ignores this list, but
|
||||
// on Windows/Linux Chromium downloads Hunspell dictionaries on demand and
|
||||
// won't enable any without an explicit language.
|
||||
function configureSpellChecker() {
|
||||
try {
|
||||
const defaultSession = session.defaultSession
|
||||
|
||||
if (!defaultSession || typeof defaultSession.setSpellCheckerLanguages !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const available = defaultSession.availableSpellCheckerLanguages || []
|
||||
const locale = (app.getLocale && app.getLocale()) || 'en-US'
|
||||
const candidates = [locale, locale.split('-')[0], 'en-US', 'en']
|
||||
const chosen = candidates.find(lang => available.includes(lang)) || 'en-US'
|
||||
|
||||
defaultSession.setSpellCheckerLanguages([chosen])
|
||||
} catch (error) {
|
||||
rememberLog(`Spellchecker setup failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
|
||||
@@ -31,6 +31,11 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
settings: {
|
||||
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
||||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
|
||||
},
|
||||
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="icon" type="image/png" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="shortcut icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.13.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
@@ -17,6 +23,24 @@ import { cn } from '@/lib/utils'
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
|
||||
{
|
||||
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
|
||||
label: 'Code review',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
{
|
||||
description: 'Outline an approach before touching code so the diff stays focused.',
|
||||
label: 'Implementation plan',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
{
|
||||
description: 'Walk through how the selected code works and link to the key files.',
|
||||
label: 'Explain this',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
]
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
onInsertText,
|
||||
@@ -25,81 +49,114 @@ export function ContextMenu({
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages
|
||||
}: {
|
||||
state: ChatBarState
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
}) {
|
||||
}: ContextMenuProps) {
|
||||
// Prompt snippets used to be a Radix submenu. That submenu didn't open
|
||||
// reliably when the parent menu was positioned at the bottom of the
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
// easier to grow with search / descriptions, and no positioning math.
|
||||
const [snippetsOpen, setSnippetsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MessageSquareText />
|
||||
<span>Prompt snippets</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{[
|
||||
{ label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' },
|
||||
{ label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' },
|
||||
{ label: 'Explain this', text: 'Please explain how this works and point me to the key files.' }
|
||||
].map(snippet => (
|
||||
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
|
||||
{snippet.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
|
||||
Prompt snippets…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
|
||||
inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
|
||||
inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptSnippetsDialog
|
||||
onInsertText={onInsertText}
|
||||
onOpenChange={setSnippetsOpen}
|
||||
open={snippetsOpen}
|
||||
snippets={PROMPT_SNIPPETS}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({
|
||||
onInsertText,
|
||||
onOpenChange,
|
||||
open,
|
||||
snippets
|
||||
}: PromptSnippetsDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prompt snippets</DialogTitle>
|
||||
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="grid gap-1">
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{snippet.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,12 +165,7 @@ export function ContextMenuItem({
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
@@ -121,3 +173,33 @@ export function ContextMenuItem({
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
state: ChatBarState
|
||||
}
|
||||
|
||||
interface PromptSnippet {
|
||||
description: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PromptSnippetsDialogProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
snippets: readonly PromptSnippet[]
|
||||
}
|
||||
|
||||
@@ -1024,6 +1024,8 @@ export function ChatBar({
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label="Message"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
@@ -1045,6 +1047,7 @@ export function ChatBar({
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck="true"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
|
||||
@@ -97,6 +97,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
|
||||
@@ -67,6 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
@@ -438,7 +444,7 @@ export function ChatSidebar({
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -540,23 +546,28 @@ export function ChatSidebar({
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
// Grouping operates on unpinned recents; if everything is
|
||||
// pinned the toggle does nothing visible, so hide it to avoid
|
||||
// a phantom click target.
|
||||
agentSessions.length > 0 ? (
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
@@ -633,7 +644,7 @@ function SidebarPinnedEmptyState() {
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift click to pin a chat</span>
|
||||
<span>Shift-click a chat to pin · drag to reorder</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -428,14 +428,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search cron jobs..."
|
||||
searchTrailingAction={
|
||||
@@ -457,6 +449,10 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
@@ -469,6 +465,19 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
@@ -484,8 +493,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
|
||||
@@ -372,14 +372,22 @@ export function DesktopController() {
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
|
||||
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey && event.code === 'KeyN') {
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
// Two accelerators for "new session":
|
||||
// - Cmd/Ctrl+N (browser-like, works while typing in any input)
|
||||
// - Shift+N (single-key, only when no input is focused)
|
||||
const accelerator = event.metaKey || event.ctrlKey
|
||||
const singleKey = !accelerator && !editing && event.shiftKey
|
||||
|
||||
if (!accelerator && !singleKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { PlatformAvatar } from './platform-icon'
|
||||
|
||||
interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
@@ -39,29 +41,6 @@ const STATE_LABELS: Record<string, string> = {
|
||||
startup_failed: 'Startup failed'
|
||||
}
|
||||
|
||||
const PLATFORM_TINTS: Record<string, string> = {
|
||||
telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300',
|
||||
discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300',
|
||||
slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300',
|
||||
mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300',
|
||||
email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300',
|
||||
dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
weixin: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300',
|
||||
api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300',
|
||||
webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
@@ -442,19 +421,6 @@ function PlatformRow({
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformDetail({
|
||||
edits,
|
||||
onClear,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import {
|
||||
SiApple,
|
||||
SiBilibili,
|
||||
SiDiscord,
|
||||
SiGmail,
|
||||
SiHomeassistant,
|
||||
SiMatrix,
|
||||
SiMattermost,
|
||||
SiQq,
|
||||
SiSignal,
|
||||
SiTelegram,
|
||||
SiWechat,
|
||||
SiWhatsapp
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
|
||||
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// We render simpleicons.org brand glyphs for platforms whose owners publish a
|
||||
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
|
||||
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
|
||||
// request, so we fall back to a colored letter monogram for those.
|
||||
//
|
||||
// `iconColor` is the brand's hex from simpleicons.org so we can paint each
|
||||
// glyph in its native color on top of a soft tint. The fallback monogram uses
|
||||
// the same hex to keep visual consistency.
|
||||
type IconKind = 'brand' | 'generic'
|
||||
|
||||
interface PlatformIconSpec {
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
color: string
|
||||
kind: IconKind
|
||||
}
|
||||
|
||||
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
|
||||
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
|
||||
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
|
||||
// Slack removed from Simple Icons by Salesforce request — letter monogram.
|
||||
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
|
||||
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
|
||||
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
|
||||
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
|
||||
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
|
||||
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
|
||||
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
|
||||
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
|
||||
webhook: { Icon: LinkIcon, color: '#71717A', kind: 'generic' },
|
||||
api_server: { Icon: Globe, color: '#64748B', kind: 'generic' },
|
||||
weixin: { Icon: SiWechat, color: '#07C160', kind: 'brand' },
|
||||
qqbot: { Icon: SiQq, color: '#EB1923', kind: 'brand' },
|
||||
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
|
||||
}
|
||||
|
||||
interface PlatformAvatarProps {
|
||||
platformId: string
|
||||
platformName: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlatformAvatar({ className, platformId, platformName }: PlatformAvatarProps) {
|
||||
const spec = PLATFORM_ICONS[platformId]
|
||||
|
||||
const baseClass = cn(
|
||||
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
className
|
||||
)
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const { Icon, color } = spec
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={baseClass}
|
||||
style={{
|
||||
// 16% tint of the brand color so the glyph reads against any surface
|
||||
// without the avatar dominating the row.
|
||||
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
|
||||
color
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -28,10 +28,16 @@ export function PageSearchShell({
|
||||
{...props}
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
|
||||
>
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
|
||||
{/*
|
||||
This header sits in the titlebar row, so it overlaps the OS window-drag
|
||||
region painted by the shell. Without `-webkit-app-region: no-drag` on
|
||||
the search row, mousedown on the input gets intercepted as a window-
|
||||
drag start and the input never receives focus (visible as "I can't
|
||||
click the search box" on the messaging/cron/etc pages).
|
||||
*/}
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
|
||||
{/* Reserve the top-right titlebar tools + native window-controls
|
||||
footprint so the full-width search input never slides under them
|
||||
(this header sits in the titlebar row at the window top). */}
|
||||
footprint so the full-width search input never slides under them. */}
|
||||
<div
|
||||
style={{
|
||||
paddingRight:
|
||||
|
||||
@@ -11,6 +11,8 @@ const ROW_HEIGHT = 22
|
||||
const INDENT = 10
|
||||
|
||||
interface ProjectTreeProps {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: TreeNode[]
|
||||
onActivateFile: (path: string) => void
|
||||
onActivateFolder: (path: string) => void
|
||||
@@ -21,6 +23,8 @@ interface ProjectTreeProps {
|
||||
}
|
||||
|
||||
export function ProjectTree({
|
||||
collapseNonce,
|
||||
cwd,
|
||||
data,
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
@@ -63,7 +67,7 @@ export function ProjectTree({
|
||||
|
||||
onNodeOpenChange(id, node.isOpen)
|
||||
|
||||
if (node.isOpen && node.data.children === undefined) {
|
||||
if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) {
|
||||
void onLoadChildren(id)
|
||||
}
|
||||
},
|
||||
@@ -72,7 +76,7 @@ export function ProjectTree({
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(node: NodeApi<TreeNode>) => {
|
||||
if (!node.data.isDirectory) {
|
||||
if (node.data && !node.data.isDirectory) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
},
|
||||
@@ -83,7 +87,7 @@ export function ProjectTree({
|
||||
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
|
||||
{size.height > 0 && size.width > 0 ? (
|
||||
<Tree<TreeNode>
|
||||
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
|
||||
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
|
||||
data={data}
|
||||
disableDrag
|
||||
disableDrop
|
||||
@@ -91,6 +95,7 @@ export function ProjectTree({
|
||||
height={size.height}
|
||||
indent={INDENT}
|
||||
initialOpenState={openState}
|
||||
key={`${cwd}:${collapseNonce}`}
|
||||
onActivate={handleActivate}
|
||||
onToggle={handleToggle}
|
||||
openByDefault={false}
|
||||
@@ -135,6 +140,10 @@ function ProjectTreeRow({
|
||||
onAttachFolder: (path: string) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
}) {
|
||||
if (!node.data) {
|
||||
return <div style={style} />
|
||||
}
|
||||
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
|
||||
|
||||
@@ -47,16 +47,20 @@ function placeholderChild(parentId: string): TreeNode {
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
|
||||
collapseNonce: number
|
||||
data: TreeNode[]
|
||||
openState: Record<string, boolean>
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
collapseAll: () => void
|
||||
loadChildren: (id: string) => Promise<void>
|
||||
refreshRoot: () => Promise<void>
|
||||
setNodeOpen: (id: string, open: boolean) => void
|
||||
}
|
||||
|
||||
interface ProjectTreeState {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: TreeNode[]
|
||||
loaded: boolean
|
||||
@@ -67,6 +71,7 @@ interface ProjectTreeState {
|
||||
}
|
||||
|
||||
const initialState: ProjectTreeState = {
|
||||
collapseNonce: 0,
|
||||
cwd: '',
|
||||
data: [],
|
||||
loaded: false,
|
||||
@@ -112,6 +117,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
}
|
||||
|
||||
$projectTree.set({
|
||||
collapseNonce: current.collapseNonce,
|
||||
cwd,
|
||||
data: [],
|
||||
loaded: false,
|
||||
@@ -174,6 +180,19 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
[cwd]
|
||||
)
|
||||
|
||||
// Clears the recorded open state and bumps the nonce; the tree is keyed on
|
||||
// the nonce so it remounts with everything collapsed (loaded children stay
|
||||
// cached in `data`, just hidden).
|
||||
const collapseAll = useCallback(() => {
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {
|
||||
return current
|
||||
}
|
||||
|
||||
return { ...current, collapseNonce: current.collapseNonce + 1, openState: {} }
|
||||
})
|
||||
}, [cwd])
|
||||
|
||||
const loadChildren = useCallback(
|
||||
async (id: string) => {
|
||||
if (!cwd || inflight.has(id)) {
|
||||
@@ -222,6 +241,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
collapseAll,
|
||||
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
|
||||
data: state.cwd === cwd ? state.data : [],
|
||||
loadChildren,
|
||||
openState: state.cwd === cwd ? state.openState : {},
|
||||
@@ -231,10 +252,12 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
setNodeOpen
|
||||
}),
|
||||
[
|
||||
collapseAll,
|
||||
cwd,
|
||||
loadChildren,
|
||||
refreshRoot,
|
||||
setNodeOpen,
|
||||
state.collapseNonce,
|
||||
state.cwd,
|
||||
state.data,
|
||||
state.openState,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
@@ -52,7 +53,10 @@ export function RightSidebarPane({
|
||||
.pop() ?? currentCwd)
|
||||
: 'No folder selected'
|
||||
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } =
|
||||
useProjectTree(currentCwd)
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
@@ -97,6 +101,8 @@ export function RightSidebarPane({
|
||||
<TerminalSlot />
|
||||
) : (
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
@@ -106,6 +112,7 @@ export function RightSidebarPane({
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
@@ -160,13 +167,22 @@ function RightSidebarChrome({
|
||||
}
|
||||
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
canCollapse: boolean
|
||||
cwdName: string
|
||||
hasCwd: boolean
|
||||
onChangeFolder: () => Promise<void> | void
|
||||
onCollapseAll: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const HEADER_ACTION_CLASS =
|
||||
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
|
||||
function FilesystemTab({
|
||||
canCollapse,
|
||||
collapseNonce,
|
||||
cwd,
|
||||
cwdName,
|
||||
data,
|
||||
@@ -176,6 +192,7 @@ function FilesystemTab({
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onChangeFolder,
|
||||
onCollapseAll,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
@@ -188,14 +205,35 @@ function FilesystemTab({
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
title={hasCwd ? cwd : 'No folder selected'}
|
||||
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<Button
|
||||
aria-label="Open folder"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon"
|
||||
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Collapse all folders"
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon"
|
||||
title="Collapse all folders"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon"
|
||||
@@ -206,6 +244,7 @@ function FilesystemTab({
|
||||
</Button>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={cwd}
|
||||
data={data}
|
||||
error={error}
|
||||
@@ -226,6 +265,7 @@ export function RightSidebarSectionHeader({ children }: { children: ReactNode })
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: ReturnType<typeof useProjectTree>['data']
|
||||
error: string | null
|
||||
@@ -239,6 +279,7 @@ interface FileTreeBodyProps {
|
||||
}
|
||||
|
||||
function FileTreeBody({
|
||||
collapseNonce,
|
||||
cwd,
|
||||
data,
|
||||
error,
|
||||
@@ -267,15 +308,34 @@ function FileTreeBody({
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectTree
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
<ErrorBoundary
|
||||
fallback={({ reset }) => (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<EmptyState body="The file tree hit an error rendering this folder." title="Tree error" />
|
||||
<button
|
||||
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={reset}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
key={cwd}
|
||||
label="file-tree"
|
||||
>
|
||||
<ProjectTree
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={cwd}
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,16 +50,23 @@ export function useCwdActions({
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
setCurrentCwd(trimmed)
|
||||
|
||||
try {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
|
||||
key: 'project',
|
||||
cwd: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
// Adopt the backend's normalized cwd so the persisted workspace and
|
||||
// branch stay consistent with what the agent will use.
|
||||
if (info.cwd) {
|
||||
setCurrentCwd(info.cwd)
|
||||
}
|
||||
|
||||
setCurrentBranch(info.branch || '')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
} catch {
|
||||
setCurrentBranch('')
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
$sessions,
|
||||
getRememberedWorkspaceCwd,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionsTotal,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
@@ -291,7 +293,8 @@ export function useSessionActions({
|
||||
})
|
||||
setSessionStartedAt(null)
|
||||
setTurnStartedAt(null)
|
||||
setCurrentCwd('')
|
||||
// New chats inherit the current workspace.
|
||||
setCurrentCwd(getRememberedWorkspaceCwd())
|
||||
setCurrentBranch('')
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
@@ -308,7 +311,7 @@ export function useSessionActions({
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const cwd = $currentCwd.get().trim()
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
@@ -687,6 +690,9 @@ export function useSessionActions({
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
// Tear down before awaiting so the route effect can't resume the
|
||||
@@ -709,6 +715,7 @@ export function useSessionActions({
|
||||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
@@ -761,6 +768,10 @@ export function useSessionActions({
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
@@ -773,6 +784,7 @@ export function useSessionActions({
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $busy, $messages, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -140,6 +140,13 @@ export function useSessionStateCache({
|
||||
}
|
||||
|
||||
setSessionWorking(next.storedSessionId, next.busy)
|
||||
// Every state update is effectively a "still alive" heartbeat for
|
||||
// streaming events. The session-store watchdog uses this to keep the
|
||||
// working flag alive during long-running turns and to clear it once
|
||||
// the stream goes silent.
|
||||
if (next.busy) {
|
||||
noteSessionActivity(next.storedSessionId)
|
||||
}
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
$updateChecking,
|
||||
$updateStatus,
|
||||
checkUpdates,
|
||||
openUpdatesWindow
|
||||
openUpdatesWindow,
|
||||
refreshDesktopVersion
|
||||
} from '@/store/updates'
|
||||
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
@@ -46,6 +47,14 @@ export function AboutSettings() {
|
||||
const checking = useStore($updateChecking)
|
||||
const [justChecked, setJustChecked] = useState(false)
|
||||
|
||||
// The version atom is loaded once at app boot, which makes About show a
|
||||
// stale number after a self-update (the running binary is current, the
|
||||
// displayed string is not). Re-read on mount so opening About always
|
||||
// reflects the running build.
|
||||
useEffect(() => {
|
||||
void refreshDesktopVersion()
|
||||
}, [])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
const supported = status?.supported !== false
|
||||
const applying = apply.applying || apply.stage === 'restart'
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
|
||||
const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
@@ -186,8 +184,11 @@ function EnvProviderGroup({
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
@@ -222,27 +223,17 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY)
|
||||
|
||||
if (stored === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return stored === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
// default-collapsed-unless-set keep the surface manageable.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false')
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore persistence failures and keep in-memory preference.
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [showAdvanced])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -262,28 +253,21 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const filterEnv = useCallback(
|
||||
(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!showAdvanced && Boolean(info.advanced)) {
|
||||
return false
|
||||
}
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
},
|
||||
[showAdvanced]
|
||||
)
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
if (!vars) {
|
||||
@@ -415,12 +399,6 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline">
|
||||
{showAdvanced ? 'Hide advanced' : 'Show advanced'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Zap}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
@@ -105,6 +105,8 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<DefaultProjectDirSetting />
|
||||
|
||||
<SectionHeading
|
||||
icon={Archive}
|
||||
meta={sessions.length ? String(sessions.length) : undefined}
|
||||
@@ -166,3 +168,104 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
// Lets the user pin the default cwd for new sessions. Without this, packaged
|
||||
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
|
||||
// / Program Files), which buried any files Hermes wrote there.
|
||||
function DefaultProjectDirSetting() {
|
||||
const [dir, setDir] = useState<null | string>(null)
|
||||
const [fallback, setFallback] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// The bridge is only present when running inside Electron. In a Vitest
|
||||
// / Storybook / non-Electron context `window.hermesDesktop` is
|
||||
// undefined, so guard the WHOLE call chain rather than chaining
|
||||
// `?.settings.getDefaultProjectDir().then(...)` (the latter would
|
||||
// short-circuit to `undefined.then(...)` and throw at runtime).
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
|
||||
let alive = true
|
||||
|
||||
void settings.getDefaultProjectDir().then(result => {
|
||||
if (!alive) return
|
||||
setDir(result.dir)
|
||||
setFallback(result.defaultLabel)
|
||||
})
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const choose = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
const picked = await settings.pickDefaultProjectDir()
|
||||
|
||||
if (picked.canceled || !picked.dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await settings.setDefaultProjectDir(picked.dir)
|
||||
setDir(result.dir)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Default project directory updated' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not update default directory')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
await settings.setDefaultProjectDir(null)
|
||||
setDir(null)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not clear default directory')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<SectionHeading icon={FolderOpen} title="Default project directory" />
|
||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
New sessions start in this folder unless you pick another. Leave it unset to use your home directory.
|
||||
</p>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
|
||||
<FolderOpen className="size-3.5" />
|
||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
||||
</Button>
|
||||
{dir && (
|
||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={dir || `Defaults to ${fallback || '~/hermes-projects'}.`}
|
||||
title={dir ? dir : 'Not set'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,14 +54,18 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
|
||||
example "Connecting…" on a fresh/untitled session — can't paint a
|
||||
horizontal scrollbar across the bottom of the window. Items already
|
||||
`truncate` their labels, so clipping is the right behavior. */}
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{leftItems
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
|
||||
@@ -13,7 +13,7 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
|
||||
export const TITLEBAR_EDGE_INSET = 14
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { ansiColorClass, hasAnsiCodes, parseAnsi } from '@/lib/ansi'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AnsiTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Renders text with embedded ANSI SGR codes as colored / bold spans. Falls
|
||||
* back to a plain string node when no codes are present so the parser cost
|
||||
* is paid only when there's something to colorize. */
|
||||
export const AnsiText: FC<AnsiTextProps> = ({ className, text }) => {
|
||||
const segments = useMemo(() => (hasAnsiCodes(text) ? parseAnsi(text) : null), [text])
|
||||
|
||||
if (!segments) {
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{segments.map((segment, index) => (
|
||||
<span
|
||||
className={cn(segment.bold && 'font-semibold', segment.fg && ansiColorClass(segment.fg))}
|
||||
key={`ansi-${index}`}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,8 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/co
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
@@ -703,9 +704,10 @@ const UserMessage: FC<{
|
||||
</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</span>
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -35,7 +35,18 @@ export interface ToolView {
|
||||
previewTarget?: string
|
||||
rawArgs: string
|
||||
rawResult: string
|
||||
/** Set for tools whose output naturally contains ANSI escape codes
|
||||
* (terminal/execute_code) so the renderer knows to run them through
|
||||
* the ANSI parser instead of printing them as literals. */
|
||||
rendersAnsi?: boolean
|
||||
searchHits?: SearchResultRow[]
|
||||
/** When the backend reports stderr as a separate stream (terminal /
|
||||
* execute_code), the renderer shows it as its own labeled, neutrally
|
||||
* tinted block under stdout — distinct from an error tone. */
|
||||
stderr?: string
|
||||
/** When set, the renderer uses stdout+stderr as separate sections and
|
||||
* ignores the merged `detail`. */
|
||||
stdout?: string
|
||||
status: ToolStatus
|
||||
subtitle: string
|
||||
title: string
|
||||
@@ -1002,6 +1013,10 @@ function toolDetailText(
|
||||
}
|
||||
|
||||
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
||||
// Streams are split out into ToolView.stdout / ToolView.stderr by
|
||||
// buildToolView so the renderer can label them separately. The merged
|
||||
// fallback here is only used when the backend doesn't expose either
|
||||
// stream individually.
|
||||
const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
|
||||
|
||||
const lines = Array.isArray(resultRecord.lines)
|
||||
@@ -1209,6 +1224,18 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
|
||||
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
|
||||
|
||||
// For shell/code tools we surface stdout and stderr as separate labeled
|
||||
// streams in the renderer. Many CLIs use stderr for informational
|
||||
// messages (npm progress, git hints), so we deliberately don't paint
|
||||
// stderr destructively even though it's tagged.
|
||||
const rendersAnsi = part.toolName === 'terminal' || part.toolName === 'execute_code'
|
||||
const stdout = rendersAnsi ? firstStringField(resultRecord, ['stdout']) : ''
|
||||
const stderrRaw = rendersAnsi ? firstStringField(resultRecord, ['stderr']) : ''
|
||||
// Only attach stderr when the backend actually returned it as its own
|
||||
// field — otherwise the merged `detail` already covers it and double-
|
||||
// rendering would duplicate output.
|
||||
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
|
||||
|
||||
return {
|
||||
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
|
||||
detail,
|
||||
@@ -1220,7 +1247,10 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
|
||||
rawArgs: prettyJson(part.args),
|
||||
rawResult: prettyJson(part.result),
|
||||
rendersAnsi: rendersAnsi || undefined,
|
||||
searchHits: searchHits?.length ? searchHits : undefined,
|
||||
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
|
||||
stdout: hasSplitStreams ? stdout || undefined : undefined,
|
||||
status,
|
||||
subtitle,
|
||||
title,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/shallow'
|
||||
|
||||
import { AnsiText } from '@/components/assistant-ui/ansi-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
||||
@@ -344,11 +345,41 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
) : view.stdout || view.stderr ? (
|
||||
// Stdout + stderr split: render both as labeled blocks. stderr
|
||||
// is intentionally NOT painted destructive — many CLIs log
|
||||
// informational output there.
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{view.stdout && (
|
||||
<div className="space-y-0.5">
|
||||
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{view.stderr && (
|
||||
<div className={cn('space-y-0.5', view.stdout && 'mt-1.5')}>
|
||||
<p className={TOOL_SECTION_LABEL_CLASS}>stderr</p>
|
||||
<pre
|
||||
className={cn(
|
||||
TOOL_SECTION_PRE_CLASS,
|
||||
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{renderDetailAsCode ? (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
|
||||
</pre>
|
||||
) : (
|
||||
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// User messages should render the bare-minimum of markdown: backtick `code`
|
||||
// spans and ``` fenced blocks. We deliberately don't pull in the full
|
||||
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
|
||||
// because user input rarely contains structured docs and the heavy pipeline
|
||||
// adds a lot of runtime cost per bubble.
|
||||
//
|
||||
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
|
||||
// inside the plain-text segments.
|
||||
|
||||
interface FenceSegment {
|
||||
kind: 'fence'
|
||||
code: string
|
||||
lang: string | null
|
||||
}
|
||||
|
||||
interface InlineSegment {
|
||||
kind: 'inline'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface InlineCodeSegment {
|
||||
kind: 'inline-code'
|
||||
code: string
|
||||
}
|
||||
|
||||
interface InlineTextSegment {
|
||||
kind: 'inline-text'
|
||||
text: string
|
||||
}
|
||||
|
||||
type TopSegment = FenceSegment | InlineSegment
|
||||
type InlineNode = InlineCodeSegment | InlineTextSegment
|
||||
|
||||
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
|
||||
|
||||
// Greedy backtick run length so ``code with `backticks` inside`` works.
|
||||
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
|
||||
|
||||
function splitFences(text: string): TopSegment[] {
|
||||
const segments: TopSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(FENCE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: 'fence',
|
||||
lang: (match[1] || '').trim() || null,
|
||||
code: match[2] ?? ''
|
||||
})
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function splitInlineCode(text: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(INLINE_CODE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
nodes.push({ kind: 'inline-code', code: match[2] })
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
interface UserMessageTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
|
||||
const top = useMemo(() => splitFences(text), [text])
|
||||
|
||||
return (
|
||||
<span className={cn('block', className)} data-slot="aui_user-message-text">
|
||||
{top.map((segment, segmentIndex) => {
|
||||
if (segment.kind === 'fence') {
|
||||
return (
|
||||
<pre
|
||||
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-border/45 bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
|
||||
data-slot="aui_user-fence"
|
||||
key={`fence-${segmentIndex}`}
|
||||
>
|
||||
<code className="block whitespace-pre">{segment.code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`inline-${segmentIndex}`}>
|
||||
<InlineSegmentView text={segment.text} />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
|
||||
const nodes = useMemo(() => splitInlineCode(text), [text])
|
||||
|
||||
return (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
{nodes.map((node, nodeIndex) =>
|
||||
node.kind === 'inline-code' ? (
|
||||
<code
|
||||
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
|
||||
data-slot="aui_user-inline-code"
|
||||
key={`code-${nodeIndex}`}
|
||||
>
|
||||
{node.code}
|
||||
</code>
|
||||
) : (
|
||||
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
|
||||
// still render. DirectiveContent already preserves whitespace.
|
||||
<Fragment key={`text-${nodeIndex}`}>
|
||||
<DirectiveContent text={node.text} />
|
||||
</Fragment>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, RefreshCw } from '@/lib/icons'
|
||||
|
||||
export interface ErrorBoundaryFallbackProps {
|
||||
error: Error
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode
|
||||
label?: string
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
|
||||
console.error(tag, error, info.componentStack)
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.setState({ error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state
|
||||
|
||||
if (!error) {
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback({ error, reset: this.reset })
|
||||
}
|
||||
|
||||
return <RootErrorFallback error={error} reset={this.reset} />
|
||||
}
|
||||
}
|
||||
|
||||
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1500] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
|
||||
{error.message || String(error)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="size-4" />
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Reload window
|
||||
</Button>
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -46,7 +46,10 @@ function DialogContent({
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
// Cap height at 85vh and let long content scroll inside the dialog
|
||||
// instead of overflowing off-screen (long cron titles, tool detail
|
||||
// dumps, etc.). Individual dialogs can still override via className.
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -24,8 +24,11 @@ function DropdownMenuContent({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
// `dt-portal-scrollbar` reproduces the thin themed scrollbar from
|
||||
// `.scrollbar-dt` for portaled overlays (Radix renders this under
|
||||
// document.body, outside #root's scope). See styles.css.
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
@@ -188,8 +191,13 @@ function DropdownMenuSubContent({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
// SubContent inherits the same portal/scrollbar issue as Content (Radix
|
||||
// renders it under document.body), so apply `dt-portal-scrollbar`. Use
|
||||
// a fixed `max-h-80` rather than the Radix available-height variable:
|
||||
// that variable is only published on Content, NOT SubContent — using
|
||||
// it here collapses the submenu to 0px height.
|
||||
className={cn(
|
||||
'z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
|
||||
Vendored
+5
@@ -27,6 +27,11 @@ declare global {
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
settings: {
|
||||
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
|
||||
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
|
||||
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
|
||||
}
|
||||
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
|
||||
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ansiColorClass, hasAnsiCodes, parseAnsi } from './ansi'
|
||||
|
||||
const ESC = '\x1b'
|
||||
|
||||
describe('parseAnsi', () => {
|
||||
it('returns a single default segment for plain text', () => {
|
||||
expect(parseAnsi('hello world')).toEqual([{ bold: false, fg: null, text: 'hello world' }])
|
||||
})
|
||||
|
||||
it('returns nothing for an empty string', () => {
|
||||
expect(parseAnsi('')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses a basic foreground color sequence and resets', () => {
|
||||
const input = `${ESC}[31merror${ESC}[0m ok`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: false, fg: 'red', text: 'error' },
|
||||
{ bold: false, fg: null, text: ' ok' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats bold (1) and bold-off (22) as toggles without affecting fg', () => {
|
||||
const input = `${ESC}[1mloud${ESC}[22m quiet`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: null, text: 'loud' },
|
||||
{ bold: false, fg: null, text: ' quiet' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats default-fg (39) as a foreground-only reset (keeps bold)', () => {
|
||||
const input = `${ESC}[1;31mboth${ESC}[39mbold-only`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: 'red', text: 'both' },
|
||||
{ bold: true, fg: null, text: 'bold-only' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles bright colors via the 90-97 range', () => {
|
||||
expect(parseAnsi(`${ESC}[92mgreen`)).toEqual([{ bold: false, fg: 'bright-green', text: 'green' }])
|
||||
})
|
||||
|
||||
it('coalesces adjacent runs with the same style', () => {
|
||||
const input = `${ESC}[31ma${ESC}[31mb${ESC}[31mc`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([{ bold: false, fg: 'red', text: 'abc' }])
|
||||
})
|
||||
|
||||
it('skips 256-color (38;5) trailing args without painting fg or leaking the params as text', () => {
|
||||
// 256-color and truecolor aren't rendered (FG_BY_CODE doesn't cover them),
|
||||
// but the parser must consume the trailing `;5;<n>` / `;2;r;g;b` args so
|
||||
// they never bleed into the visible segment text.
|
||||
const segments = parseAnsi(`${ESC}[38;5;208morange${ESC}[0m`)
|
||||
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0].fg).toBe(null)
|
||||
expect(segments[0].text).toBe('orange')
|
||||
})
|
||||
|
||||
it('skips truecolor (38;2;r;g;b) trailing args', () => {
|
||||
const segments = parseAnsi(`${ESC}[38;2;10;20;30mrgb${ESC}[0m`)
|
||||
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0].fg).toBe(null)
|
||||
expect(segments[0].text).toBe('rgb')
|
||||
})
|
||||
|
||||
it('drops non-SGR CSI sequences (cursor motion, erase) without consuming surrounding text', () => {
|
||||
const input = `before${ESC}[2Jmiddle${ESC}[10;5Hafter`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([{ bold: false, fg: null, text: 'beforemiddleafter' }])
|
||||
})
|
||||
|
||||
it('treats an empty SGR parameter (ESC[m) as a full reset', () => {
|
||||
const input = `${ESC}[1;31mfoo${ESC}[mbar`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: 'red', text: 'foo' },
|
||||
{ bold: false, fg: null, text: 'bar' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAnsiCodes', () => {
|
||||
it('returns false for plain text', () => {
|
||||
expect(hasAnsiCodes('hello world')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when any CSI introducer is present', () => {
|
||||
expect(hasAnsiCodes(`${ESC}[31mred`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ansiColorClass', () => {
|
||||
it('returns a non-empty Tailwind class string for every supported color', () => {
|
||||
const colors = [
|
||||
'black',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white',
|
||||
'bright-black',
|
||||
'bright-red',
|
||||
'bright-green',
|
||||
'bright-yellow',
|
||||
'bright-blue',
|
||||
'bright-magenta',
|
||||
'bright-cyan',
|
||||
'bright-white'
|
||||
] as const
|
||||
|
||||
for (const color of colors) {
|
||||
expect(ansiColorClass(color)).toMatch(/\S/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
// Minimal ANSI SGR parser for rendering terminal output inside chat tool
|
||||
// cards. Only handles the SGR codes that show up in practice (color, bold,
|
||||
// reset); cursor motions and other CSI sequences are dropped silently.
|
||||
//
|
||||
// Returns a flat array of styled segments so callers can render them as
|
||||
// React spans without each consumer having to re-implement the parser.
|
||||
|
||||
export interface AnsiSegment {
|
||||
bold: boolean
|
||||
/** Tailwind text-color class or null for the default foreground. */
|
||||
fg: AnsiColor | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export type AnsiColor =
|
||||
| 'black'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'magenta'
|
||||
| 'cyan'
|
||||
| 'white'
|
||||
| 'bright-black'
|
||||
| 'bright-red'
|
||||
| 'bright-green'
|
||||
| 'bright-yellow'
|
||||
| 'bright-blue'
|
||||
| 'bright-magenta'
|
||||
| 'bright-cyan'
|
||||
| 'bright-white'
|
||||
|
||||
const FG_BY_CODE: Record<number, AnsiColor> = {
|
||||
30: 'black',
|
||||
31: 'red',
|
||||
32: 'green',
|
||||
33: 'yellow',
|
||||
34: 'blue',
|
||||
35: 'magenta',
|
||||
36: 'cyan',
|
||||
37: 'white',
|
||||
90: 'bright-black',
|
||||
91: 'bright-red',
|
||||
92: 'bright-green',
|
||||
93: 'bright-yellow',
|
||||
94: 'bright-blue',
|
||||
95: 'bright-magenta',
|
||||
96: 'bright-cyan',
|
||||
97: 'bright-white'
|
||||
}
|
||||
|
||||
// CSI = ESC '[' params 'final'. We only care about SGR (final == 'm'); other
|
||||
// final bytes are matched and consumed so they don't leak into the rendered
|
||||
// text. Range covers the common CSI command set (A-Z / a-z / @).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CSI_RE = /\x1b\[([\d;]*)([\x40-\x7e])/g
|
||||
// Other escape sequences (single-char OSC/SS3/etc.) — strip silently.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const OTHER_ESCAPE_RE = /\x1b[@-Z\\-_]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
|
||||
|
||||
export function parseAnsi(input: string): AnsiSegment[] {
|
||||
if (!input) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Strip non-CSI escapes upfront — none of them carry text we want to keep
|
||||
// and CSI_RE wouldn't match them.
|
||||
const cleaned = input.replace(OTHER_ESCAPE_RE, '')
|
||||
|
||||
const segments: AnsiSegment[] = []
|
||||
let cursor = 0
|
||||
let bold = false
|
||||
let fg: AnsiColor | null = null
|
||||
|
||||
const pushText = (text: string) => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const last = segments.at(-1)
|
||||
|
||||
if (last && last.bold === bold && last.fg === fg) {
|
||||
last.text += text
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
segments.push({ bold, fg, text })
|
||||
}
|
||||
|
||||
CSI_RE.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = CSI_RE.exec(cleaned)) !== null) {
|
||||
const start = match.index
|
||||
|
||||
if (start > cursor) {
|
||||
pushText(cleaned.slice(cursor, start))
|
||||
}
|
||||
|
||||
if (match[2] === 'm') {
|
||||
const codes = match[1]
|
||||
.split(';')
|
||||
.map(part => (part === '' ? 0 : Number(part)))
|
||||
.filter(value => Number.isFinite(value))
|
||||
|
||||
for (let i = 0; i < codes.length; i += 1) {
|
||||
const code = codes[i]
|
||||
|
||||
if (code === 0) {
|
||||
bold = false
|
||||
fg = null
|
||||
} else if (code === 1) {
|
||||
bold = true
|
||||
} else if (code === 22) {
|
||||
bold = false
|
||||
} else if (code === 39) {
|
||||
fg = null
|
||||
} else if (code in FG_BY_CODE) {
|
||||
fg = FG_BY_CODE[code]
|
||||
} else if (code === 38) {
|
||||
// 256-color / truecolor — skip the trailing args we don't render.
|
||||
if (codes[i + 1] === 5) {
|
||||
i += 2
|
||||
} else if (codes[i + 1] === 2) {
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
// Background colors (40-47, 100-107) and effects we don't render are
|
||||
// intentionally ignored — the segment keeps the prior bold/fg state.
|
||||
}
|
||||
}
|
||||
|
||||
cursor = CSI_RE.lastIndex
|
||||
}
|
||||
|
||||
if (cursor < cleaned.length) {
|
||||
pushText(cleaned.slice(cursor))
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
const TAILWIND_BY_COLOR: Record<AnsiColor, string> = {
|
||||
// Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used
|
||||
// in tool cards. We don't paint pure ANSI colors (#000, #fff) because they
|
||||
// disappear into the surface.
|
||||
'black': 'text-zinc-700 dark:text-zinc-300',
|
||||
'red': 'text-red-700 dark:text-red-300',
|
||||
'green': 'text-emerald-700 dark:text-emerald-300',
|
||||
'yellow': 'text-amber-700 dark:text-amber-300',
|
||||
'blue': 'text-blue-700 dark:text-blue-300',
|
||||
'magenta': 'text-fuchsia-700 dark:text-fuchsia-300',
|
||||
'cyan': 'text-cyan-700 dark:text-cyan-300',
|
||||
'white': 'text-zinc-600 dark:text-zinc-200',
|
||||
'bright-black': 'text-zinc-500 dark:text-zinc-400',
|
||||
'bright-red': 'text-rose-600 dark:text-rose-300',
|
||||
'bright-green': 'text-emerald-600 dark:text-emerald-200',
|
||||
'bright-yellow': 'text-amber-600 dark:text-amber-200',
|
||||
'bright-blue': 'text-sky-600 dark:text-sky-300',
|
||||
'bright-magenta': 'text-pink-600 dark:text-pink-300',
|
||||
'bright-cyan': 'text-teal-600 dark:text-teal-200',
|
||||
'bright-white': 'text-zinc-500 dark:text-zinc-100'
|
||||
}
|
||||
|
||||
export function ansiColorClass(color: AnsiColor): string {
|
||||
return TAILWIND_BY_COLOR[color]
|
||||
}
|
||||
|
||||
/** Returns true if the input contains at least one CSI sequence. Cheap check
|
||||
* so callers can skip the parser for plain-ASCII output. */
|
||||
export function hasAnsiCodes(input: string): boolean {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return /\x1b\[/.test(input)
|
||||
}
|
||||
@@ -20,7 +20,11 @@ const PRIORITY_KEYS = [
|
||||
] as const
|
||||
|
||||
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
|
||||
// 'stderr' deliberately excluded: many CLIs emit informational lines on
|
||||
// stderr (npm progress, git's hint:, gcc's `In file included from`) that
|
||||
// aren't errors. Treating those as error signal flipped tool cards into
|
||||
// destructive styling for healthy commands.
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail'] as const
|
||||
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
|
||||
|
||||
type Json = Record<string, unknown>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
import App from './app'
|
||||
import { ErrorBoundary } from './components/error-boundary'
|
||||
import { HapticsProvider } from './components/haptics-provider'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
@@ -32,14 +33,16 @@ const queryClient = new QueryClient({
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -3,10 +3,15 @@ import { atom } from 'nanostores'
|
||||
import type { ContextSuggestion } from '@/app/types'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import type { SessionInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
|
||||
|
||||
interface AppAtom<T> {
|
||||
get: () => T
|
||||
set: (value: T) => void
|
||||
@@ -39,7 +44,7 @@ export const $currentProvider = atom('')
|
||||
export const $currentReasoningEffort = atom('')
|
||||
export const $currentServiceTier = atom('')
|
||||
export const $currentFastMode = atom(false)
|
||||
export const $currentCwd = atom('')
|
||||
export const $currentCwd = atom(getRememberedWorkspaceCwd())
|
||||
export const $currentBranch = atom('')
|
||||
export const $currentUsage = atom<UsageStats>({
|
||||
calls: 0,
|
||||
@@ -73,7 +78,14 @@ export const setCurrentProvider = (next: Updater<string>) => updateAtom($current
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
|
||||
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
||||
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
|
||||
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => {
|
||||
updateAtom($currentCwd, next)
|
||||
// Keep localStorage in sync with the atom: a real folder is remembered, an
|
||||
// empty cwd clears the key (|| null → removeItem).
|
||||
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
|
||||
}
|
||||
|
||||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||||
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
|
||||
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
|
||||
@@ -85,6 +97,53 @@ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, ne
|
||||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||||
|
||||
// Watchdog tracking — when does a "working" session count as stuck?
|
||||
// Long-running tool calls (LLM inference, long shell commands, web fetches)
|
||||
// can take a few minutes legitimately. We allow 8 minutes of complete
|
||||
// silence on the stream before clearing the working flag; in practice this
|
||||
// catches gateway hangs and dropped streams without false-positive-clearing
|
||||
// real long turns.
|
||||
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function armSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
// Re-check the latest state at fire-time. If the user already navigated
|
||||
// away or the session genuinely finished, the timer is a no-op.
|
||||
if ($workingSessionIds.get().includes(sessionId)) {
|
||||
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
|
||||
}
|
||||
}, SESSION_WATCHDOG_TIMEOUT_MS)
|
||||
|
||||
sessionWatchdogTimers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
function clearSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Call when a streaming event for a session lands. Refreshes the watchdog
|
||||
* so the session keeps its "working" status as long as data keeps coming. */
|
||||
export function noteSessionActivity(sessionId: string | null | undefined) {
|
||||
if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
armSessionWatchdog(sessionId)
|
||||
}
|
||||
|
||||
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
|
||||
if (!sessionId) {
|
||||
return
|
||||
@@ -99,4 +158,13 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
|
||||
|
||||
return alreadyWorking ? current.filter(id => id !== sessionId) : current
|
||||
})
|
||||
|
||||
// Bookend the watchdog: arm it whenever a session enters "working",
|
||||
// disarm it whenever it leaves. A subsequent noteSessionActivity() from
|
||||
// a streaming event will refresh the timer.
|
||||
if (working) {
|
||||
armSessionWatchdog(sessionId)
|
||||
} else {
|
||||
clearSessionWatchdog(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,34 @@ export function openUpdatesWindow(): void {
|
||||
void checkUpdates()
|
||||
}
|
||||
|
||||
/** Re-read the running app's version from the Electron main process and
|
||||
* publish it on `$desktopVersion`. Called when the About panel mounts, the
|
||||
* update flow finishes, and the window regains focus, so the About text
|
||||
* stays in sync with the just-installed binary instead of frozen at the
|
||||
* value captured at first-load. */
|
||||
export async function refreshDesktopVersion(): Promise<DesktopVersionInfo | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Best-effort UI sync: callers (checkUpdates, startUpdatePoller, window
|
||||
// focus handler) all kick this off with `void refreshDesktopVersion()`,
|
||||
// so any rejection from the IPC bridge (e.g. main process shutting down
|
||||
// mid-reload, or the bridge not yet ready on first paint) would surface
|
||||
// as an unhandled promise rejection in the renderer. Swallow it.
|
||||
try {
|
||||
const next = await window.hermesDesktop?.getVersion?.()
|
||||
|
||||
if (next) {
|
||||
$desktopVersion.set(next)
|
||||
}
|
||||
|
||||
return next ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const bridge = window.hermesDesktop?.updates
|
||||
|
||||
@@ -158,6 +186,10 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const status = await bridge.check()
|
||||
$updateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
// The update check pulls the latest hermes_cli + bundled package metadata
|
||||
// into place. Re-read the running version so About reflects the now-fresh
|
||||
// checkout rather than the one captured at process start.
|
||||
void refreshDesktopVersion()
|
||||
|
||||
return status
|
||||
} catch (error) {
|
||||
@@ -249,7 +281,7 @@ export function startUpdatePoller(): void {
|
||||
|
||||
pollerStarted = true
|
||||
void checkUpdates()
|
||||
void window.hermesDesktop?.getVersion?.().then(info => $desktopVersion.set(info))
|
||||
void refreshDesktopVersion()
|
||||
bridge.onProgress(ingestProgress)
|
||||
|
||||
window.addEventListener('focus', onFocus)
|
||||
@@ -275,4 +307,8 @@ function onFocus() {
|
||||
|
||||
lastFocusAt = now
|
||||
void checkUpdates()
|
||||
// Cheap and safe to re-read on every (throttled) focus: the user may have
|
||||
// updated Hermes from another window/CLI between focuses, and About should
|
||||
// catch up without forcing a restart.
|
||||
void refreshDesktopVersion()
|
||||
}
|
||||
|
||||
@@ -641,6 +641,41 @@ canvas {
|
||||
.scrollbar-dt *::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that
|
||||
render under document.body, outside the `.scrollbar-dt` scope on
|
||||
#root. Same visual treatment, applied directly to the overlay
|
||||
container so its (and only its) internal scrollbar is themed. */
|
||||
.dt-portal-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--dt-midground) 28%, transparent) transparent;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-track,
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-midground) 28%, transparent);
|
||||
border-radius: 9999rem;
|
||||
border: 0.0625rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--dt-midground) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom clearance lives on [data-slot='aui_composer-clearance'] —
|
||||
|
||||
@@ -107,14 +107,17 @@ def set_session_vars(
|
||||
user_name: str = "",
|
||||
session_key: str = "",
|
||||
message_id: str = "",
|
||||
cwd: str = "",
|
||||
) -> list:
|
||||
"""Set all session context variables and return reset tokens.
|
||||
|
||||
Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
|
||||
the previous values when the handler exits.
|
||||
Call ``clear_session_vars(tokens)`` in a ``finally`` block when the handler
|
||||
exits. Note ``clear_session_vars`` resets every var to ``""`` (to suppress
|
||||
the ``os.environ`` fallback) rather than restoring prior values — these
|
||||
helpers are not nestable/stack-safe, and the returned tokens are accepted
|
||||
only for API compatibility.
|
||||
|
||||
Returns a list of ``Token`` objects (one per variable) that can be
|
||||
passed to ``clear_session_vars``.
|
||||
``cwd`` pins the logical working directory for this context.
|
||||
"""
|
||||
tokens = [
|
||||
_SESSION_PLATFORM.set(platform),
|
||||
@@ -126,6 +129,12 @@ def set_session_vars(
|
||||
_SESSION_KEY.set(session_key),
|
||||
_SESSION_MESSAGE_ID.set(message_id),
|
||||
]
|
||||
try:
|
||||
from agent.runtime_cwd import set_session_cwd
|
||||
|
||||
set_session_cwd(cwd)
|
||||
except Exception:
|
||||
pass
|
||||
return tokens
|
||||
|
||||
|
||||
@@ -151,6 +160,12 @@ def clear_session_vars(tokens: list) -> None:
|
||||
_SESSION_MESSAGE_ID,
|
||||
):
|
||||
var.set("")
|
||||
try:
|
||||
from agent.runtime_cwd import clear_session_cwd
|
||||
|
||||
clear_session_cwd()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_session_env(name: str, default: str = "") -> str:
|
||||
|
||||
+3
-1
@@ -3370,7 +3370,7 @@ def _sync_codex_pool_entries(
|
||||
entry["last_error_reset_at"] = None
|
||||
|
||||
|
||||
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
|
||||
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None:
|
||||
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
|
||||
if last_refresh is None:
|
||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -3380,6 +3380,8 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None
|
||||
state["tokens"] = tokens
|
||||
state["last_refresh"] = last_refresh
|
||||
state["auth_mode"] = "chatgpt"
|
||||
if label and str(label).strip():
|
||||
state["label"] = str(label).strip()
|
||||
_save_provider_state(auth_store, "openai-codex", state)
|
||||
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
@@ -307,28 +307,20 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "openai-codex":
|
||||
# Clear any existing suppression marker so a re-link after `hermes auth
|
||||
# remove openai-codex` works without the new tokens being skipped.
|
||||
auth_mod.unsuppress_credential_source(provider, "device_code")
|
||||
creds = auth_mod._codex_device_code_login()
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["tokens"]["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:device_code",
|
||||
access_token=creds["tokens"]["access_token"],
|
||||
refresh_token=creds["tokens"].get("refresh_token"),
|
||||
base_url=creds.get("base_url"),
|
||||
auth_mod._save_codex_tokens(
|
||||
creds["tokens"],
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
label=label,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
pool = load_pool(provider)
|
||||
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
|
||||
shown_label = entry.label if entry is not None else label
|
||||
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
|
||||
return
|
||||
|
||||
if provider == "xai-oauth":
|
||||
|
||||
+210
-29
@@ -201,6 +201,7 @@ if _try_termux_ultrafast_version():
|
||||
raise SystemExit(0)
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -6869,6 +6870,147 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool:
|
||||
return (desktop_dir / "dist" / "index.html").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop build stamp — content-hash based skip logic
|
||||
# ---------------------------------------------------------------------------
|
||||
# The desktop Electron build is expensive.
|
||||
# Unlike the web UI (which uses mtime comparison), the desktop uses a
|
||||
# SHA-256 content hash of the source tree so that:
|
||||
# - ``git checkout`` / ``git pull`` that touch mtimes but not content
|
||||
# don't trigger a rebuild
|
||||
# - ``hermes update`` can unconditionally call ``hermes desktop --build-only``
|
||||
# and it will skip if nothing actually changed
|
||||
# - ``hermes desktop`` (interactive launch) skips the build when the
|
||||
# stamp matches, making repeated launches fast
|
||||
#
|
||||
# Stamp file: $HERMES_HOME/desktop-build-stamp.json
|
||||
# Schema:
|
||||
# {
|
||||
# "contentHash": "<sha256 hex of source files>",
|
||||
# "sourceMode": true | false,
|
||||
# "builtAt": "<ISO 8601>"
|
||||
# }
|
||||
|
||||
def _compute_desktop_content_hash(project_root: Path) -> str:
|
||||
"""Return a SHA-256 hex digest of all source files that feed the desktop build.
|
||||
|
||||
Covers ``apps/desktop/`` (excluding anything matched by .gitignore)
|
||||
plus the root ``package.json`` / ``package-lock.json`` (workspace config
|
||||
that determines dependency resolution for the desktop workspace).
|
||||
|
||||
Parses the repo-root ``.gitignore`` via *pathspec* so we automatically
|
||||
skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining
|
||||
a hardcoded skip-list.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
|
||||
def _hash_file(path: Path) -> None:
|
||||
rel = str(path.relative_to(project_root))
|
||||
h.update(rel.encode())
|
||||
h.update(b"\0")
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
h.update(b"\0")
|
||||
|
||||
|
||||
from pathspec import PathSpec
|
||||
|
||||
gitignore = project_root / ".gitignore"
|
||||
lines: list[str] = []
|
||||
if gitignore.is_file():
|
||||
lines = gitignore.read_text(encoding="utf-8").splitlines()
|
||||
spec = PathSpec.from_lines("gitignore", lines)
|
||||
|
||||
# Root workspace config
|
||||
for name in ("package.json", "package-lock.json"):
|
||||
p = project_root / name
|
||||
if p.is_file():
|
||||
rel = str(p.relative_to(project_root))
|
||||
if not spec.match_file(rel):
|
||||
_hash_file(p)
|
||||
|
||||
# Walk apps/desktop/ — prune ignored directories in-place
|
||||
desktop_dir = project_root / "apps" / "desktop"
|
||||
for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True):
|
||||
# Prune ignored directories so we never descend into them
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root)))
|
||||
]
|
||||
|
||||
for fn in sorted(filenames):
|
||||
fp = Path(dirpath) / fn
|
||||
rel = str(fp.relative_to(project_root))
|
||||
if not spec.match_file(rel):
|
||||
_hash_file(fp)
|
||||
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _desktop_stamp_path() -> Path:
|
||||
"""Return the path to the desktop build stamp file under $HERMES_HOME."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "desktop-build-stamp.json"
|
||||
|
||||
|
||||
def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
|
||||
"""Return True when the desktop build output is stale or missing.
|
||||
|
||||
Compares the current content hash against the saved stamp. Also returns
|
||||
True if the expected build artifact doesn't exist (e.g. first run after
|
||||
``hermes update`` that pulled new source but hasn't built yet).
|
||||
"""
|
||||
# If there's no build output at all, we definitely need to build
|
||||
if source_mode:
|
||||
if not _desktop_dist_exists(desktop_dir):
|
||||
return True
|
||||
else:
|
||||
if _desktop_packaged_executable(desktop_dir) is None:
|
||||
return True
|
||||
|
||||
stamp_file = _desktop_stamp_path()
|
||||
if not stamp_file.is_file():
|
||||
return True
|
||||
|
||||
try:
|
||||
stamp_data = json.loads(stamp_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
return True
|
||||
|
||||
# If the mode changed (source vs packaged), force a rebuild
|
||||
if stamp_data.get("sourceMode") != source_mode:
|
||||
return True
|
||||
|
||||
saved_hash = stamp_data.get("contentHash")
|
||||
if not saved_hash:
|
||||
return True
|
||||
|
||||
current_hash = _compute_desktop_content_hash(project_root)
|
||||
return current_hash != saved_hash
|
||||
|
||||
|
||||
def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None:
|
||||
"""Write the desktop build stamp after a successful build."""
|
||||
stamp_file = _desktop_stamp_path()
|
||||
try:
|
||||
stamp_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
content_hash = _compute_desktop_content_hash(project_root)
|
||||
from datetime import datetime, timezone
|
||||
stamp_data = {
|
||||
"contentHash": content_hash,
|
||||
"sourceMode": source_mode,
|
||||
"builtAt": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8")
|
||||
except Exception as exc:
|
||||
# Never let stamp-writing block or fail a build
|
||||
logger.debug("Failed to write desktop build stamp: %s", exc)
|
||||
|
||||
|
||||
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
|
||||
"""Return the current platform's unpacked Electron app executable."""
|
||||
release_dir = desktop_dir / "release"
|
||||
@@ -6928,8 +7070,7 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
|
||||
except Exception as exc:
|
||||
print(f" (warning: macOS relaunch fixup skipped: {exc})")
|
||||
|
||||
|
||||
def cmd_gui(args):
|
||||
def cmd_gui(args: argparse.Namespace):
|
||||
"""Build and launch the native Electron desktop GUI."""
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
if not (desktop_dir / "package.json").exists():
|
||||
@@ -6954,6 +7095,8 @@ def cmd_gui(args):
|
||||
|
||||
source_mode = getattr(args, "source", False)
|
||||
skip_build = getattr(args, "skip_build", False)
|
||||
force_build = getattr(args, "force_build", False)
|
||||
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
|
||||
if source_mode or not skip_build:
|
||||
@@ -6965,7 +7108,7 @@ def cmd_gui(args):
|
||||
else:
|
||||
npm = None
|
||||
|
||||
if getattr(args, "skip_build", False):
|
||||
if skip_build:
|
||||
if source_mode:
|
||||
if not _desktop_dist_exists(desktop_dir):
|
||||
print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}")
|
||||
@@ -6986,27 +7129,41 @@ def cmd_gui(args):
|
||||
else:
|
||||
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
|
||||
else:
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
# Check the content-hash stamp before doing any build work.
|
||||
# If the source tree hasn't changed since the last successful build,
|
||||
# skip the npm install + build entirely (saves a ton of useless work).
|
||||
# --force-build overrides the stamp and always rebuilds.
|
||||
build_needed = force_build or _desktop_build_needed(
|
||||
desktop_dir, PROJECT_ROOT, source_mode=source_mode
|
||||
)
|
||||
if not build_needed:
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
|
||||
else:
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
build_script = "build" if source_mode else "pack"
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
build_script = "build" if source_mode else "pack"
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
|
||||
# Build succeeded — write the stamp so next run can skip
|
||||
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
|
||||
|
||||
# --build-only: produce the artifact but do NOT launch. The installer's
|
||||
# --update flow drives the rebuild headlessly and then launches the desktop
|
||||
@@ -9697,6 +9854,25 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_update_node_dependencies()
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
|
||||
# Rebuild the desktop app if the source tree changed since the last
|
||||
# build. ``hermes desktop --build-only`` uses the content-hash stamp
|
||||
# internally, so this is effectively a no-op when nothing changed.
|
||||
# Only bother if the user has a desktop app installed (indicated by
|
||||
# an existing packaged executable or desktop dist); people who have
|
||||
# never run ``hermes desktop`` shouldn't be forced into a full
|
||||
# Electron build by ``hermes update``.
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
|
||||
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
|
||||
print("→ Checking if desktop app needs rebuilding...")
|
||||
build_result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
)
|
||||
if build_result.returncode != 0:
|
||||
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
|
||||
|
||||
print()
|
||||
print("✓ Code updated!")
|
||||
|
||||
@@ -11364,7 +11540,7 @@ def cmd_dashboard(args):
|
||||
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
|
||||
sys.exit(1)
|
||||
elif getattr(args, "skip_build", False):
|
||||
# --skip-build trusts the caller to have pre-built the web UI.
|
||||
# --build-mode skip trusts the caller to have pre-built the web UI.
|
||||
# Verify the dist actually exists; otherwise the server will start
|
||||
# and serve 404s with no obvious cause (issue #23817).
|
||||
_dist_root = (
|
||||
@@ -14733,11 +14909,6 @@ Examples:
|
||||
"Electron app, then launches that packaged artifact."
|
||||
),
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--source",
|
||||
action="store_true",
|
||||
@@ -14766,6 +14937,16 @@ Examples:
|
||||
"--cwd",
|
||||
help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--force-build",
|
||||
action="store_true",
|
||||
help="Force a full rebuild even if the content stamp matches",
|
||||
)
|
||||
gui_parser.set_defaults(func=cmd_gui)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -241,6 +241,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"google-gemini-cli": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
# Code Assist serves two flash slugs with different access gates
|
||||
# (gemini-cli models.ts): gemini-3-flash-preview is the preview flash
|
||||
# that subscription/free-tier OAuth users actually reach, while
|
||||
# gemini-3.5-flash is GA-channel-gated. Offer both so non-GA users
|
||||
# aren't stuck with a slug cloudcode-pa 404s for them.
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
],
|
||||
"zai": [
|
||||
|
||||
@@ -3729,31 +3729,12 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
if not access_token:
|
||||
raise RuntimeError("token exchange did not return access_token")
|
||||
|
||||
# Persist via credential pool — same shape as auth_commands.add_command
|
||||
from agent.credential_pool import (
|
||||
PooledCredential,
|
||||
load_pool,
|
||||
AUTH_TYPE_OAUTH,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
import uuid as _uuid
|
||||
pool = load_pool("openai-codex")
|
||||
base_url = (
|
||||
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
|
||||
or DEFAULT_CODEX_BASE_URL
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider="openai-codex",
|
||||
id=_uuid.uuid4().hex[:6],
|
||||
label="dashboard device_code",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:dashboard_device_code",
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
base_url=base_url,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
from hermes_cli.auth import _save_codex_tokens
|
||||
|
||||
_save_codex_tokens({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
})
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
||||
|
||||
Generated
+125
-1959
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,8 @@ dependencies = [
|
||||
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
|
||||
# `os.killpg` (which doesn't exist on Windows).
|
||||
"psutil==7.2.2",
|
||||
# .gitignore-aware file matching for desktop build stamp.
|
||||
"pathspec==1.1.1",
|
||||
"fastapi>=0.104.0,<1",
|
||||
"uvicorn[standard]>=0.24.0,<1",
|
||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||
|
||||
@@ -6,7 +6,12 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import agent.runtime_cwd as rt
|
||||
from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
|
||||
from agent.runtime_cwd import (
|
||||
clear_session_cwd,
|
||||
resolve_agent_cwd,
|
||||
resolve_context_cwd,
|
||||
set_session_cwd,
|
||||
)
|
||||
|
||||
|
||||
def _raise_oserror(*args, **kwargs):
|
||||
@@ -77,3 +82,48 @@ class TestResolveContextCwd:
|
||||
# than building Path(" ") and resolving garbage under the launch dir.
|
||||
monkeypatch.setenv("TERMINAL_CWD", " ")
|
||||
assert resolve_context_cwd() is None
|
||||
|
||||
|
||||
class TestSessionCwdOverride:
|
||||
"""The #29531 per-session arm: a contextvar cwd wins over TERMINAL_CWD so a
|
||||
multi-session gateway can pin each session to its own folder."""
|
||||
|
||||
def test_session_cwd_overrides_terminal_cwd(self, monkeypatch, tmp_path):
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
token = set_session_cwd(str(other))
|
||||
try:
|
||||
assert resolve_agent_cwd() == other
|
||||
assert resolve_context_cwd() == other
|
||||
finally:
|
||||
rt._SESSION_CWD.reset(token)
|
||||
|
||||
def test_empty_session_cwd_falls_back_to_terminal_cwd(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
token = set_session_cwd("")
|
||||
try:
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
assert resolve_context_cwd() == tmp_path
|
||||
finally:
|
||||
rt._SESSION_CWD.reset(token)
|
||||
|
||||
def test_clear_session_cwd_restores_terminal_cwd(self, monkeypatch, tmp_path):
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
token = set_session_cwd(str(other))
|
||||
try:
|
||||
clear_session_cwd()
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
finally:
|
||||
rt._SESSION_CWD.reset(token)
|
||||
|
||||
def test_nonexistent_session_cwd_falls_back(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
token = set_session_cwd(str(tmp_path / "gone"))
|
||||
try:
|
||||
# resolve_agent_cwd guards on isdir; a missing session cwd must not win.
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
finally:
|
||||
rt._SESSION_CWD.reset(token)
|
||||
|
||||
@@ -303,9 +303,11 @@ def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:device_code")
|
||||
entry = next(item for item in entries if item["source"] == "device_code")
|
||||
assert payload["active_provider"] == "openai-codex"
|
||||
assert payload["providers"]["openai-codex"]["tokens"]["access_token"] == token
|
||||
assert entry["label"] == "codex@example.com"
|
||||
assert entry["source"] == "manual:device_code"
|
||||
assert entry["source"] == "device_code"
|
||||
assert entry["refresh_token"] == "refresh-token"
|
||||
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
@@ -1129,10 +1131,6 @@ def test_auth_remove_codex_manual_source_suppresses_reseed(tmp_path, monkeypatch
|
||||
def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||
"""Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1171,7 +1169,8 @@ def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||
assert "openai-codex" not in payload.get("suppressed_sources", {})
|
||||
# New pool entry must be present
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
assert any(e["source"] == "manual:device_code" for e in entries)
|
||||
assert any(e["source"] == "device_code" for e in entries)
|
||||
assert payload["active_provider"] == "openai-codex"
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
|
||||
|
||||
@@ -16,6 +16,8 @@ from hermes_cli import main as cli_main
|
||||
def _ns(**kw):
|
||||
defaults = dict(
|
||||
skip_build=False,
|
||||
build_only=False,
|
||||
force_build=False,
|
||||
source=False,
|
||||
fake_boot=False,
|
||||
ignore_existing=False,
|
||||
@@ -60,6 +62,8 @@ def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch):
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
@@ -86,6 +90,8 @@ def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch):
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \
|
||||
pytest.raises(SystemExit):
|
||||
@@ -158,6 +164,8 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(source=True))
|
||||
@@ -179,3 +187,158 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
|
||||
def test_gui_is_known_builtin_for_plugin_gating(argv):
|
||||
with patch.object(sys, "argv", argv):
|
||||
assert cli_main._plugin_cli_discovery_needed() is False
|
||||
|
||||
|
||||
# ── Content-hash stamp tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_desktop_build_stamp_skips_build_when_up_to_date(tmp_path, monkeypatch):
|
||||
"""When the stamp matches and the artifact exists, build is skipped entirely."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
|
||||
launch_ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_install.assert_not_called()
|
||||
mock_run.assert_called_once() # only the launch call, no build
|
||||
|
||||
|
||||
def test_desktop_force_build_overrides_stamp(tmp_path, monkeypatch):
|
||||
"""--force-build forces a rebuild even when the stamp says up-to-date."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
||||
launch_ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=False), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(force_build=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_install.assert_called_once()
|
||||
mock_stamp.assert_called_once()
|
||||
# pack + launch = 2 calls
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_stable(tmp_path, monkeypatch):
|
||||
"""_compute_desktop_content_hash returns the same digest for identical trees."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("console.log('hi')", encoding="utf-8")
|
||||
(root / "package.json").write_text('{"name":"hermes"}', encoding="utf-8")
|
||||
(root / "package-lock.json").write_text('{}', encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 == h2
|
||||
assert len(h1) == 64 # sha256 hex
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_changes_on_edit(tmp_path, monkeypatch):
|
||||
"""Editing a file under apps/desktop/ changes the hash."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 != h2
|
||||
|
||||
|
||||
def test_desktop_build_needed_detects_missing_artifact(tmp_path, monkeypatch):
|
||||
"""Even with a valid stamp, missing artifact means build is needed."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
# Write a stamp that matches current content
|
||||
cli_main._write_desktop_build_stamp(root, source_mode=False)
|
||||
# No packaged executable exists → build needed
|
||||
assert cli_main._desktop_build_needed(
|
||||
root / "apps" / "desktop", root, source_mode=False
|
||||
) is True
|
||||
|
||||
|
||||
def test_desktop_build_stamp_round_trip(tmp_path, monkeypatch):
|
||||
"""Write stamp, then _desktop_build_needed returns False when artifact exists."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
# Create the artifact so the "artifact exists" check passes
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
# Write stamp
|
||||
cli_main._write_desktop_build_stamp(root, source_mode=False)
|
||||
# Build should NOT be needed
|
||||
assert cli_main._desktop_build_needed(
|
||||
root / "apps" / "desktop", root, source_mode=False
|
||||
) is False
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_works_without_gitignore(tmp_path, monkeypatch):
|
||||
"""When no .gitignore exists, _compute_desktop_content_hash still works (matches everything)."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
# No .gitignore → pathspec matches nothing → all files hashed
|
||||
h = cli_main._compute_desktop_content_hash(root)
|
||||
assert len(h) == 64 # valid sha256 hex
|
||||
|
||||
# Edit a file → hash changes
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h != h2
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):
|
||||
"""Files matched by .gitignore are excluded from the hash."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("hello", encoding="utf-8")
|
||||
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=xxx", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
(root / ".gitignore").write_text("*.env\n", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
# Reset cached spec
|
||||
cli_main._DESKTOP_STAMP_SPEC = None
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
|
||||
# Change the .env file (ignored) — hash should NOT change
|
||||
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=yyy", encoding="utf-8")
|
||||
cli_main._DESKTOP_STAMP_SPEC = None # reset since gitignore hasn't changed
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 == h2, "changing an ignored file should not change the hash"
|
||||
|
||||
# Change the .js file (not ignored) — hash SHOULD change
|
||||
(root / "apps" / "desktop" / "main.js").write_text("world", encoding="utf-8")
|
||||
cli_main._DESKTOP_STAMP_SPEC = None
|
||||
h3 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 != h3, "changing a tracked file should change the hash"
|
||||
|
||||
@@ -146,6 +146,67 @@ def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusa
|
||||
assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE]
|
||||
|
||||
|
||||
def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch):
|
||||
from hermes_cli import web_server as ws
|
||||
from hermes_cli.auth import get_active_provider
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
access_token = "h.eyJleHAiOjk5OTk5OTk5OTl9.s"
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code, payload):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
if url.endswith("/deviceauth/usercode"):
|
||||
return _Resp(200, {
|
||||
"device_auth_id": "device-auth-id",
|
||||
"interval": 3,
|
||||
"user_code": "CODEX-1234",
|
||||
})
|
||||
if url.endswith("/deviceauth/token"):
|
||||
return _Resp(200, {
|
||||
"authorization_code": "authorization-code",
|
||||
"code_verifier": "code-verifier",
|
||||
})
|
||||
return _Resp(200, {
|
||||
"access_token": access_token,
|
||||
"refresh_token": "codex-refresh",
|
||||
})
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(httpx, "Client", _Client)
|
||||
monkeypatch.setattr(ws.time, "sleep", lambda _: None)
|
||||
|
||||
sid, _ = ws._new_oauth_session("openai-codex", "device_code")
|
||||
try:
|
||||
ws._codex_full_login_worker(sid)
|
||||
|
||||
assert ws._oauth_sessions[sid]["status"] == "approved"
|
||||
assert get_active_provider() == "openai-codex"
|
||||
|
||||
runtime = resolve_runtime_provider(requested=None)
|
||||
assert runtime["provider"] == "openai-codex"
|
||||
assert runtime["api_key"] == access_token
|
||||
assert runtime["api_mode"] == "codex_responses"
|
||||
finally:
|
||||
ws._oauth_sessions.pop(sid, None)
|
||||
|
||||
|
||||
def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
@@ -10,6 +10,55 @@ from unittest.mock import patch
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
def test_session_context_uses_session_cwd(monkeypatch, tmp_path):
|
||||
"""Desktop/TUI sessions must pin the agent cwd per session.
|
||||
|
||||
The gateway process itself is often launched from apps/desktop in dev, so
|
||||
falling back to os.getcwd() makes agents answer from the desktop app folder
|
||||
even when the sidebar/session cwd is a real project.
|
||||
"""
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
|
||||
sid = "cwd-sid"
|
||||
session_key = "cwd-key"
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
launcher = tmp_path / "apps" / "desktop"
|
||||
launcher.mkdir(parents=True)
|
||||
|
||||
server._sessions[sid] = {"session_key": session_key, "cwd": str(project)}
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.chdir(launcher)
|
||||
|
||||
tokens = server._set_session_context(session_key)
|
||||
try:
|
||||
assert resolve_agent_cwd() == project
|
||||
finally:
|
||||
server._clear_session_context(tokens)
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
|
||||
def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path):
|
||||
"""Background/preview tasks use ephemeral ids absent from `_sessions`, so the
|
||||
parent workspace is passed explicitly; it must pin instead of clearing back
|
||||
to the gateway launch dir."""
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
launcher = tmp_path / "apps" / "desktop"
|
||||
launcher.mkdir(parents=True)
|
||||
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.chdir(launcher)
|
||||
|
||||
tokens = server._set_session_context("bg_deadbe", cwd=str(project))
|
||||
try:
|
||||
assert resolve_agent_cwd() == project
|
||||
finally:
|
||||
server._clear_session_context(tokens)
|
||||
|
||||
|
||||
class _ChunkyStdout:
|
||||
def __init__(self):
|
||||
self.parts: list[str] = []
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.mcp_tool import MCPServerTask, _format_connect_error, _resolve_stdio_command, _MCP_AVAILABLE
|
||||
|
||||
@@ -126,3 +127,83 @@ def test_run_stdio_uses_resolved_command_and_prepended_path(tmp_path):
|
||||
await server.shutdown()
|
||||
|
||||
asyncio.run(_test())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression tests for #37589: Desktop/launchd processes inherit a minimal
|
||||
# PATH on macOS that does not include ~/.local/bin, /opt/homebrew/bin, or
|
||||
# /usr/local/bin. The resolver must locate uv/uvx (the dominant MCP server
|
||||
# runtime for Python projects) under those locations.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_stdio_command_finds_uvx_in_user_local_bin(tmp_path):
|
||||
"""uv's official installer drops uv/uvx at ``~/.local/bin/uvx`` on
|
||||
macOS and Linux. The resolver must pick it up when the GUI PATH
|
||||
doesn't include that directory (#37589)."""
|
||||
local_bin = tmp_path / ".local" / "bin"
|
||||
local_bin.mkdir(parents=True)
|
||||
uvx_path = local_bin / "uvx"
|
||||
uvx_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
uvx_path.chmod(0o755)
|
||||
|
||||
with patch("tools.mcp_tool.shutil.which", return_value=None), \
|
||||
patch("os.path.expanduser", lambda p: p.replace("~", str(tmp_path)) if p == "~" else p):
|
||||
command, env = _resolve_stdio_command("uvx", {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"})
|
||||
|
||||
assert command == str(uvx_path)
|
||||
# The resolver prepended the chosen bin so uvx's shebang-resolved
|
||||
# children (uv itself, python) can be found in the same directory.
|
||||
assert env["PATH"].split(os.pathsep)[0] == str(local_bin)
|
||||
|
||||
|
||||
def test_resolve_stdio_command_uvx_unchanged_when_already_on_path():
|
||||
"""shutil.which hit must still take precedence — don't double-resolve
|
||||
a working bare command on PATH into something else."""
|
||||
resolved_path = "/some/path/uvx"
|
||||
with patch("tools.mcp_tool.shutil.which", return_value=resolved_path):
|
||||
command, _env = _resolve_stdio_command("uvx", {"PATH": "/usr/bin"})
|
||||
|
||||
assert command == resolved_path
|
||||
|
||||
|
||||
def test_resolve_stdio_command_skips_unknown_commands():
|
||||
"""Bare command names outside the npx/npm/node/uv/uvx allowlist must
|
||||
NOT be matched against the candidate fallback paths — that would
|
||||
produce false positives like rewriting ``command: my-tool`` into a
|
||||
coincidentally-named file at ``/opt/homebrew/bin/my-tool`` (#37589)."""
|
||||
with patch("tools.mcp_tool.shutil.which", return_value=None), \
|
||||
patch("tools.mcp_tool.os.path.expanduser", lambda p: p), \
|
||||
patch("tools.mcp_tool.os.path.isfile", return_value=True), \
|
||||
patch("tools.mcp_tool.os.access", return_value=True):
|
||||
# A command like 'foo' or 'python' must be left alone even if
|
||||
# the test is faking every candidate as present.
|
||||
command, _env = _resolve_stdio_command("foo", {"PATH": "/usr/bin:/bin"})
|
||||
|
||||
assert command == "foo"
|
||||
|
||||
|
||||
def test_resolve_stdio_command_prefers_managed_uv(tmp_path):
|
||||
"""Managed uv at $HERMES_HOME/bin/uv should be preferred over
|
||||
~/.local/bin, /opt/homebrew/bin, and /usr/local/bin — MCP servers
|
||||
must use the same uv as the CLI update path (managed_uv.py)."""
|
||||
hermes_bin = tmp_path / "bin"
|
||||
hermes_bin.mkdir()
|
||||
uv_path = hermes_bin / "uv"
|
||||
uv_path.write_text("#!/bin/sh\necho uv 0.1.2\n", encoding="utf-8")
|
||||
uv_path.chmod(0o755)
|
||||
|
||||
# Also create a stale uv at ~/.local/bin to verify ordering.
|
||||
local_bin = tmp_path / ".local" / "bin"
|
||||
local_bin.mkdir(parents=True)
|
||||
stale_uv = local_bin / "uv"
|
||||
stale_uv.write_text("#!/bin/sh\necho stale\n", encoding="utf-8")
|
||||
stale_uv.chmod(0o755)
|
||||
|
||||
with patch("tools.mcp_tool.shutil.which", return_value=None), \
|
||||
patch("os.path.expanduser", lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p), \
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
command, env = _resolve_stdio_command("uv", {"PATH": "/usr/bin:/bin"})
|
||||
|
||||
assert command == str(uv_path)
|
||||
assert env["PATH"].split(os.pathsep)[0] == str(hermes_bin)
|
||||
|
||||
@@ -353,6 +353,16 @@ class FileOperations(ABC):
|
||||
"""Delete a file. Returns WriteResult with .error set on failure."""
|
||||
...
|
||||
|
||||
def delete_path(self, path: str, recursive: bool = False) -> WriteResult:
|
||||
"""Cross-platform delete that handles files and (with recursive=True)
|
||||
directory trees. Default implementation delegates to ``delete_file``
|
||||
for the non-recursive case; backends with native recursive support
|
||||
should override.
|
||||
"""
|
||||
if recursive:
|
||||
return WriteResult(error="Recursive delete not implemented for this backend")
|
||||
return self.delete_file(path)
|
||||
|
||||
@abstractmethod
|
||||
def move_file(self, src: str, dst: str) -> WriteResult:
|
||||
"""Move/rename a file from src to dst. Returns WriteResult with .error set on failure."""
|
||||
@@ -1065,13 +1075,64 @@ class ShellFileOperations(FileOperations):
|
||||
)
|
||||
|
||||
def delete_file(self, path: str) -> WriteResult:
|
||||
"""Delete a file via rm."""
|
||||
"""Delete a single file.
|
||||
|
||||
Cross-platform: runs via ``python -c`` against the terminal env's
|
||||
Python so it works on Windows shells (``cmd.exe``/PowerShell) that
|
||||
don't ship ``rm``. Directories are rejected here — use
|
||||
``delete_path(recursive=True)`` for trees.
|
||||
"""
|
||||
return self._python_delete(path, recursive=False)
|
||||
|
||||
def delete_path(self, path: str, recursive: bool = False) -> WriteResult:
|
||||
"""Cross-platform delete that handles files and (with recursive=True)
|
||||
directory trees. Always preferred over emitting ``rm -rf`` /
|
||||
``Remove-Item -Recurse`` directly so the same tool call works on
|
||||
every backend (local / docker / ssh / Windows).
|
||||
"""
|
||||
return self._python_delete(path, recursive=recursive)
|
||||
|
||||
def _python_delete(self, path: str, recursive: bool) -> WriteResult:
|
||||
path = self._expand_path(path)
|
||||
if _is_write_denied(path):
|
||||
return WriteResult(error=f"Delete denied: {path} is a protected path")
|
||||
result = self._exec(f"rm -f {self._escape_shell_arg(path)}")
|
||||
|
||||
# We can't shell out to ``rm`` here — it doesn't exist on Windows
|
||||
# ``cmd.exe`` or PowerShell, so this code path is what's left when
|
||||
# the backend's terminal is a Windows shell. Path is baked into the
|
||||
# snippet via ``repr()`` so quoting is correct on every shell.
|
||||
snippet = (
|
||||
"import shutil, pathlib, sys\n"
|
||||
f"p = pathlib.Path({path!r})\n"
|
||||
f"recursive = {bool(recursive)!r}\n"
|
||||
"try:\n"
|
||||
" if p.is_dir() and not p.is_symlink():\n"
|
||||
" if recursive:\n"
|
||||
" shutil.rmtree(p)\n"
|
||||
" else:\n"
|
||||
" print('is a directory: ' + str(p), file=sys.stderr); sys.exit(2)\n"
|
||||
" else:\n"
|
||||
# NOTE: avoid ``unlink(missing_ok=True)`` — that kwarg lands in
|
||||
# Python 3.8 and the remote interpreter (docker/ssh) may still
|
||||
# be 3.7 on older distros. The FileNotFoundError handler below
|
||||
# covers the same case and works back to 3.4.
|
||||
" p.unlink()\n"
|
||||
"except FileNotFoundError:\n"
|
||||
" pass\n"
|
||||
"except Exception as exc:\n"
|
||||
" print(str(exc), file=sys.stderr); sys.exit(1)\n"
|
||||
)
|
||||
|
||||
result = self._exec(f"python3 -c {self._escape_shell_arg(snippet)}")
|
||||
|
||||
# Fall back to ``python`` (Windows / older systems where there's no
|
||||
# ``python3`` symlink but a ``python`` binary is on PATH).
|
||||
if result.exit_code != 0 and "python3" in (result.stdout or ""):
|
||||
result = self._exec(f"python -c {self._escape_shell_arg(snippet)}")
|
||||
|
||||
if result.exit_code != 0:
|
||||
return WriteResult(error=f"Failed to delete {path}: {result.stdout}")
|
||||
return WriteResult(error=f"Failed to delete {path}: {(result.stdout or '').strip() or 'unknown error'}")
|
||||
|
||||
return WriteResult()
|
||||
|
||||
def move_file(self, src: str, dst: str) -> WriteResult:
|
||||
|
||||
+37
-13
@@ -402,8 +402,15 @@ def _prepend_path(env: dict, directory: str) -> dict:
|
||||
def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
|
||||
"""Resolve a stdio MCP command against the exact subprocess environment.
|
||||
|
||||
This primarily exists to make bare ``npx``/``npm``/``node`` commands work
|
||||
reliably even when MCP subprocesses run under a filtered PATH.
|
||||
This primarily exists to make bare ``npx``/``npm``/``node`` and
|
||||
``uv``/``uvx`` commands work reliably even when MCP subprocesses run
|
||||
under a filtered PATH (#37589). On macOS, processes launched from the
|
||||
GUI / LaunchAgents inherit a minimal PATH (``/usr/bin:/bin:/usr/sbin:/sbin``)
|
||||
that does not include ``~/.local/bin`` (uv user install), ``/opt/homebrew/bin``
|
||||
(Apple Silicon Homebrew), or ``/usr/local/bin`` (Intel Homebrew / Linux
|
||||
from-source), so a bare ``command: uvx`` MCP server fails with ENOENT
|
||||
at ``execvp`` from Hermes Desktop even though it works from an
|
||||
interactive Terminal.
|
||||
"""
|
||||
resolved_command = os.path.expanduser(str(command).strip())
|
||||
resolved_env = dict(env or {})
|
||||
@@ -413,25 +420,42 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
|
||||
which_hit = shutil.which(resolved_command, path=path_arg)
|
||||
if which_hit:
|
||||
resolved_command = which_hit
|
||||
elif resolved_command in {"npx", "npm", "node"}:
|
||||
elif resolved_command in {"npx", "npm", "node", "uv", "uvx"}:
|
||||
hermes_home = os.path.expanduser(
|
||||
os.getenv(
|
||||
"HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes")
|
||||
)
|
||||
)
|
||||
candidates = [
|
||||
# Hermes-bundled Node first so the desktop app's pinned
|
||||
# runtime wins over a system Node that may be incompatible.
|
||||
os.path.join(hermes_home, "node", "bin", resolved_command),
|
||||
# Managed uv: Hermes owns its own uv/uvx at
|
||||
# $HERMES_HOME/bin/ (see hermes_cli/managed_uv.py).
|
||||
# Always prefer this over PATH/brew/pip installs so MCP
|
||||
# servers use the same uv as the CLI update path.
|
||||
os.path.join(hermes_home, "bin", resolved_command),
|
||||
# uv's official macOS/Linux installer drops uv/uvx here.
|
||||
# This is the dominant install location for developers
|
||||
# on Apple Silicon and Ubuntu. The ``uv`` user installer
|
||||
# is documented to land here.
|
||||
os.path.join(os.path.expanduser("~"), ".local", "bin", resolved_command),
|
||||
# /usr/local/bin is the canonical install location for Node on
|
||||
# Linux from-source builds, the upstream node:bookworm-slim
|
||||
# image (which the Hermes Docker image copies node + npm +
|
||||
# corepack from since #4977), and macOS Homebrew on Intel.
|
||||
# Without this candidate, any MCP server configured with an
|
||||
# env.PATH that omits /usr/local/bin (a common pattern when
|
||||
# users hand-author PATH for sandboxing) fails with ENOENT
|
||||
# at execvp, and a naive symlink workaround into the user's
|
||||
# PATH only fails one layer deeper because npx's shebang
|
||||
# re-execs /usr/bin/env node which needs the same directory.
|
||||
# /opt/homebrew/bin — Apple Silicon Homebrew. uv is
|
||||
# installable via `brew install uv`, and a non-trivial
|
||||
# share of macOS users (including the original bug
|
||||
# reporter) have it here.
|
||||
os.path.join(os.sep, "opt", "homebrew", "bin", resolved_command),
|
||||
# /usr/local/bin — the canonical install location for
|
||||
# Node on Linux from-source builds, the upstream
|
||||
# node:bookworm-slim image (which the Hermes Docker
|
||||
# image copies node + npm + corepack from since #4977),
|
||||
# and macOS Homebrew on Intel. Without this candidate,
|
||||
# any MCP server configured with an env.PATH that omits
|
||||
# /usr/local/bin (a common pattern when users hand-author
|
||||
# PATH for sandboxing) fails with ENOENT at execvp, and
|
||||
# a naive symlink workaround into the user's PATH only
|
||||
# fails one layer deeper because npx's shebang re-execs
|
||||
# /usr/bin/env node which needs the same directory.
|
||||
os.path.join(os.sep, "usr", "local", "bin", resolved_command),
|
||||
]
|
||||
for candidate in candidates:
|
||||
|
||||
+39
-7
@@ -809,11 +809,31 @@ def _save_cfg(cfg: dict):
|
||||
_cfg_mtime = None
|
||||
|
||||
|
||||
def _set_session_context(session_key: str) -> list:
|
||||
def _cwd_for_session_key(session_key: str) -> str:
|
||||
"""Reverse-map session_key to the session's logical cwd.
|
||||
|
||||
Snapshots ``_sessions`` first: concurrent RPC handlers mutate it from the
|
||||
thread pool, so iterating the live view risks ``RuntimeError: dictionary
|
||||
changed size during iteration``.
|
||||
"""
|
||||
if not session_key:
|
||||
return ""
|
||||
for sess in list(_sessions.values()):
|
||||
if sess.get("session_key") == session_key:
|
||||
return str(sess.get("cwd") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _set_session_context(session_key: str, cwd: str | None = None) -> list:
|
||||
try:
|
||||
from gateway.session_context import set_session_vars
|
||||
|
||||
return set_session_vars(session_key=session_key)
|
||||
# Ephemeral task IDs (background, preview) aren't in `_sessions`, so the
|
||||
# reverse-map returns "" and would clear the cwd override. Callers that
|
||||
# know the parent workspace pass it explicitly so spawned agents inherit
|
||||
# it instead of falling back to the gateway launch dir.
|
||||
resolved = cwd if cwd is not None else _cwd_for_session_key(session_key)
|
||||
return set_session_vars(session_key=session_key, cwd=resolved)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -2764,6 +2784,7 @@ def _(rid, params: dict) -> dict:
|
||||
explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd)))
|
||||
except Exception:
|
||||
explicit_cwd = False
|
||||
resolved_cwd = _completion_cwd(params)
|
||||
_enable_gateway_prompts()
|
||||
|
||||
ready = threading.Event()
|
||||
@@ -2782,7 +2803,7 @@ def _(rid, params: dict) -> dict:
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"image_counter": 0,
|
||||
"cwd": _completion_cwd(params),
|
||||
"cwd": resolved_cwd,
|
||||
"inflight_turn": None,
|
||||
"last_active": now,
|
||||
"pending_title": title or None,
|
||||
@@ -4624,7 +4645,7 @@ def _(rid, params: dict) -> dict:
|
||||
task_id = f"bg_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
def run():
|
||||
session_tokens = _set_session_context(task_id)
|
||||
session_tokens = _set_session_context(task_id, cwd=_session_cwd(session))
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
|
||||
@@ -4709,14 +4730,25 @@ def _(rid, params: dict) -> dict:
|
||||
if line
|
||||
)
|
||||
|
||||
# Normalize defensively: a malformed client path (embedded NUL, etc.) must
|
||||
# not blow up the whole restart — treat it as "no validated cwd".
|
||||
try:
|
||||
preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else ""
|
||||
if preview_cwd and not os.path.isdir(preview_cwd):
|
||||
preview_cwd = ""
|
||||
except Exception:
|
||||
preview_cwd = ""
|
||||
|
||||
def run():
|
||||
session_tokens = _set_session_context(task_id)
|
||||
# Pin the validated preview cwd, else the parent workspace — never an
|
||||
# invalid client path, which would silently fall back to the launch dir.
|
||||
session_tokens = _set_session_context(task_id, cwd=(preview_cwd or _session_cwd(session)))
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
from tools.terminal_tool import register_task_env_overrides
|
||||
|
||||
if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))):
|
||||
register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))})
|
||||
if preview_cwd:
|
||||
register_task_env_overrides(task_id, {"cwd": preview_cwd})
|
||||
|
||||
history_note = (
|
||||
f" (with {len(parent_history)} parent-session messages of context)"
|
||||
|
||||
@@ -1602,21 +1602,26 @@ version = "0.15.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "croniter" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fire" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "jinja2" },
|
||||
{ name = "openai" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "prompt-toolkit" },
|
||||
{ name = "psutil" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "ruamel-yaml" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1632,11 +1637,9 @@ all = [
|
||||
{ name = "google-auth-httplib2" },
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "mcp" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "ruff" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "simple-term-menu" },
|
||||
@@ -1739,10 +1742,6 @@ modal = [
|
||||
parallel-web = [
|
||||
{ name = "parallel-web" },
|
||||
]
|
||||
pty = [
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
slack = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "slack-bolt" },
|
||||
@@ -1755,9 +1754,7 @@ termux = [
|
||||
{ name = "agent-client-protocol" },
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "mcp" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
@@ -1770,9 +1767,7 @@ termux-all = [
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "mcp" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@@ -1822,6 +1817,7 @@ requires-dist = [
|
||||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" },
|
||||
{ name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" },
|
||||
{ name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" },
|
||||
{ name = "fastapi", specifier = ">=0.104.0,<1" },
|
||||
{ name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" },
|
||||
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" },
|
||||
{ name = "fire", specifier = "==0.7.1" },
|
||||
@@ -1866,9 +1862,10 @@ requires-dist = [
|
||||
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
|
||||
{ name = "openai", specifier = "==2.24.0" },
|
||||
{ name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
|
||||
{ name = "pathspec", specifier = "==1.1.1" },
|
||||
{ name = "prompt-toolkit", specifier = "==3.0.52" },
|
||||
{ name = "psutil", specifier = "==7.2.2" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||
@@ -1877,7 +1874,7 @@ requires-dist = [
|
||||
{ name = "python-dotenv", specifier = "==1.2.2" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" },
|
||||
{ name = "pyyaml", specifier = "==6.0.3" },
|
||||
{ name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" },
|
||||
{ name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" },
|
||||
@@ -1900,6 +1897,7 @@ requires-dist = [
|
||||
{ name = "tenacity", specifier = "==9.1.4" },
|
||||
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" },
|
||||
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
|
||||
]
|
||||
@@ -3057,6 +3055,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
||||
@@ -92,8 +92,9 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works
|
||||
| Flag | Description |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `--skip-build` | Skip npm install/package and launch the existing unpacked app from `apps/desktop/release` |
|
||||
| `--force-build` | Force a full rebuild even if the content stamp matches |
|
||||
| `--build-only` | Build the desktop app but do not launch it (used by `hermes update`) |
|
||||
| `--source` | Launch via `electron .` against `apps/desktop/dist` instead of the packaged app |
|
||||
| `--build-only` | Build the desktop app but do not launch it (used by the installer's `--update` flow) |
|
||||
| `--cwd PATH` | Initial project directory for desktop chat sessions (sets `HERMES_DESKTOP_CWD`) |
|
||||
| `--hermes-root PATH` | Override the Hermes source root the app uses (sets `HERMES_DESKTOP_HERMES_ROOT`) |
|
||||
| `--ignore-existing` | Force the app to ignore any `hermes` CLI already on `PATH` during backend resolution |
|
||||
|
||||
Reference in New Issue
Block a user