refactor: archive GUI clients; keep minimal TUI with hooks/API
ci / tcc-check (push) Successful in 1m8s
ci / linux (aarch64, 0) (push) Failing after 1h0m3s
ci / linux (x86, 1) (push) Failing after 1h0m3s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 50s

Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus
libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-10-30 22:49:23 +08:00
co-authored by Cursor
parent ea38247161
commit 5538ed3412
184 changed files with 14098 additions and 1858 deletions
+6 -6
View File
@@ -5,8 +5,8 @@ set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD_REQUIRED ON)
option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON) option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
option(CST_BUILD_WEBUI "Build the HTTP/SSE WebUI (--web)" ON) option(CST_BUILD_WEBUI "Build the HTTP/SSE engine API (--web, cst-webui/1)" ON)
option(CST_BUILD_APP "Build the Qt front end in app/ (in-process engine, no port)" OFF) option(CST_BUILD_APP "Build archived Qt front end in archive/qt-app (experimental)" OFF)
option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF) option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON) option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
@@ -218,9 +218,8 @@ if(WIN32)
endif() endif()
# --- the engine as a library --------------------------------------------- # --- the engine as a library ---------------------------------------------
# cstcore is the core without main()/TUI: the CLI links it and the Qt front # cstcore is the core without main()/TUI: the CLI links it; archived clients
# end in app/ (CST_BUILD_APP) embeds the very same objects in-process, so the # (archive/qt-app, archive/desktop-tauri) can embed the same objects in-process.
# engine is compiled once per build instead of once per front end.
add_library(cstcore STATIC ${CST_CORE_SOURCES}) add_library(cstcore STATIC ${CST_CORE_SOURCES})
add_dependencies(cstcore sashidown) add_dependencies(cstcore sashidown)
target_include_directories(cstcore PUBLIC target_include_directories(cstcore PUBLIC
@@ -246,7 +245,8 @@ else()
endif() endif()
if(CST_BUILD_APP) if(CST_BUILD_APP)
add_subdirectory(app) # Frozen reference GUI; not part of the default Hermit delivery.
add_subdirectory(archive/qt-app)
endif() endif()
set(CST_SOURCES src/main.c) set(CST_SOURCES src/main.c)
+7 -4
View File
@@ -4,9 +4,11 @@
> 本仓库 `CST-agent` 仅作迁移兼容入口。换远程:`./scripts/migrate_remote_to_hermit.sh` > 本仓库 `CST-agent` 仅作迁移兼容入口。换远程:`./scripts/migrate_remote_to_hermit.sh`
> 完整步骤:[MIGRATION.md](MIGRATION.md)。 > 完整步骤:[MIGRATION.md](MIGRATION.md)。
本地 coding agent(原 CST-Agent):终端 TUI、可选 WebUI / 桌面 GUI,在工作区里读改文件、跑命令、管会话。 本地 **极简 TUI** coding agent(原 CST-Agent):在工作区里读改文件、跑命令、管会话。
C11 实现,OpenAI 兼容网关(DeepSeek / Kimi / MiniMax / OpenAI 等)。 C11 实现,OpenAI 兼容网关(DeepSeek / Kimi / MiniMax / OpenAI 等)。
本仓交付:`hermit` CLI/TUI、`libcstcore`、可替换 plugins/MCP/skills,以及 `hermit --web` 提供的 HTTP/SSE 引擎 API(`cst-webui/1`)。完整浏览器 SPA / 桌面 GUI 参考实现在 [`archive/`](archive/)(不编入默认产物)。
**适合**:有监督的本地改动与仓库探索(权限确认、diff、`/undo`、沙箱)。 **适合**:有监督的本地改动与仓库探索(权限确认、diff、`/undo`、沙箱)。
**慎用**:完全无人值守的长任务;headless 默认自动批准。 **慎用**:完全无人值守的长任务;headless 默认自动批准。
@@ -58,7 +60,7 @@ make hermit-font # 烘焙 PNG 帧 + hermit-icons.ttf(Pillow + fonttool
make install PREFIX=$HOME/.local # 同时安装字体到 share/fonts/hermit 与 ~/.local/share/fonts make install PREFIX=$HOME/.local # 同时安装字体到 share/fonts/hermit 与 ~/.local/share/fonts
``` ```
桌面 GUI(Tauri 2 + React,DeepSeek Harness 风格布局):见 [`apps/desktop`](apps/desktop/) 与 [docs/desktop.md](docs/desktop.md)。旧 Qt App(`app/`)已冻结。 外置客户端契约:hooks(`cst_agent_hooks_t`)与 [`docs/webui.md`](docs/webui.md) / [`docs/webui-schema.md`](docs/webui-schema.md)。归档 GUI:[`archive/desktop-tauri`](archive/desktop-tauri/)、[`archive/qt-app`](archive/qt-app/)、[`archive/web-spa`](archive/web-spa/)。
TUI 图标:`HERMIT_ICONS=auto|mini|font`(默认色块动画帧,状态与 `icons/agent-motion` HTML 套件一致;`font` 输出 PUA `U+E700+`,需终端加载已安装的 Hermit Icons)。环境变量 **`HERMIT_*` 优先,`CST_*` 仍接受**。 TUI 图标:`HERMIT_ICONS=auto|mini|font`(默认色块动画帧,状态与 `icons/agent-motion` HTML 套件一致;`font` 输出 PUA `U+E700+`,需终端加载已安装的 Hermit Icons)。环境变量 **`HERMIT_*` 优先,`CST_*` 仍接受**。
@@ -68,13 +70,14 @@ TUI 图标:`HERMIT_ICONS=auto|mini|font`(默认色块动画帧,状态与 `
```bash ```bash
hermit # TUI hermit # TUI
hermit --web # http://127.0.0.1:8787/ hermit --web # HTTP/SSE API + health page
hermit --web --web-dev archive/web-spa # archived full SPA
hermit --headless --prompt '…' hermit --headless --prompt '…'
``` ```
首页品牌 **HERMIT** + 寄居蟹色块动效;色板:brand `#277F94`、crab `#F68042`、eye `#9957AD`。 首页品牌 **HERMIT** + 寄居蟹色块动效;色板:brand `#277F94`、crab `#F68042`、eye `#9957AD`。
更多:Provider / 工具 / 议会 / WebUI 见 [docs/](docs/)。 更多:Provider / 工具 / 议会 / 引擎 API 见 [docs/](docs/)。
--- ---
-10
View File
@@ -1,10 +0,0 @@
# Deprecated: Qt front end
This directory is the **legacy Qt 6 / QML** Hermit GUI (`cst-agent-app`).
The supported desktop shell is **`apps/desktop`** (Tauri 2 + React), with a
DeepSeek-Harness-inspired layout and an in-process C engine bridge.
Do not add features here. Once Tauri reaches feature parity, this tree will be
moved to `archive/qt-app` or removed. Android APK scripts that target this tree
are transitional.
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
fn main() {
// Optional: link a prebuilt libcstcore when HERMIT_CORE_LIB is set.
// Example:
// HERMIT_CORE_LIB=/path/to/libcstcore.a HERMIT_CORE_INCLUDE=/path/to/include
// cargo build --features engine
if std::env::var("CARGO_FEATURE_ENGINE").is_ok() {
if let Ok(lib) = std::env::var("HERMIT_CORE_LIB") {
let path = std::path::PathBuf::from(&lib);
if let Some(dir) = path.parent() {
println!("cargo:rustc-link-search=native={}", dir.display());
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("cstcore")
.trim_start_matches("lib");
println!("cargo:rustc-link-lib=static={}", stem);
println!("cargo:rustc-link-lib=curl");
println!("cargo:rustc-link-lib=pthread");
println!("cargo:rustc-link-lib=m");
println!("cargo:rerun-if-changed={}", lib);
}
if let Ok(inc) = std::env::var("HERMIT_CORE_INCLUDE") {
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", inc);
}
}
tauri_build::build()
}
-173
View File
@@ -1,173 +0,0 @@
//! Engine façade: mock by default; with `--features engine` + HERMIT_CORE_LIB
//! this module will grow real FFI around `cst_agent.h` (AgentCore parity).
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub title: String,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum ChatItem {
#[serde(rename = "user")]
User { id: String, text: String },
#[serde(rename = "assistant")]
Assistant { id: String, text: String },
#[serde(rename = "thought")]
Thought { id: String, text: String },
#[serde(rename = "tool")]
Tool {
id: String,
name: String,
status: String,
detail: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub provider: String,
pub model: String,
pub approval: String,
pub workspace: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum EngineEvent {
#[serde(rename = "assistant_delta")]
AssistantDelta { text: String },
#[serde(rename = "thought_delta")]
ThoughtDelta { text: String },
#[serde(rename = "tool_start")]
ToolStart {
id: String,
name: String,
detail: String,
},
#[serde(rename = "tool_end")]
ToolEnd {
id: String,
status: String,
detail: String,
},
#[serde(rename = "consent")]
Consent {
req: ConsentRequest,
},
#[serde(rename = "turn_end")]
TurnEnd { ok: bool },
#[serde(rename = "error")]
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsentRequest {
pub id: String,
pub tool: String,
pub detail: String,
}
pub struct EngineState {
pub settings: Settings,
pub sessions: Vec<SessionInfo>,
busy: bool,
}
impl Default for EngineState {
fn default() -> Self {
let workspace = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| ".".into());
Self {
settings: Settings {
provider: "deepseek".into(),
model: "deepseek-flash".into(),
approval: "auto".into(),
workspace,
},
sessions: vec![SessionInfo {
id: "welcome".into(),
title: "Welcome".into(),
updated_at: chrono_now(),
}],
busy: false,
}
}
}
fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub type SharedEngine = Mutex<EngineState>;
pub fn list_sessions(state: &SharedEngine) -> Vec<SessionInfo> {
state.lock().unwrap().sessions.clone()
}
pub fn new_session(state: &SharedEngine) -> SessionInfo {
let mut g = state.lock().unwrap();
let s = SessionInfo {
id: format!("s{}", chrono_now()),
title: "New session".into(),
updated_at: chrono_now(),
};
g.sessions.insert(0, s.clone());
s
}
pub fn open_session(_state: &SharedEngine, _id: &str) -> Vec<ChatItem> {
// Real FFI will replay transcript from cst_agent_store_*.
Vec::new()
}
pub fn settings_get(state: &SharedEngine) -> Settings {
state.lock().unwrap().settings.clone()
}
pub async fn send_turn(app: AppHandle, state: &SharedEngine, text: String) {
{
let mut g = state.lock().unwrap();
if g.busy {
return;
}
g.busy = true;
}
// Placeholder stream until libcstcore FFI is wired (feature = "engine").
let _ = app.emit(
"hermit://event",
EngineEvent::ThoughtDelta {
text: "Rust host received turn; FFI bridge pending full AgentCore port."
.into(),
},
);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let _ = app.emit(
"hermit://event",
EngineEvent::AssistantDelta {
text: format!("Echo (host): {}", text),
},
);
let _ = app.emit("hermit://event", EngineEvent::TurnEnd { ok: true });
state.lock().unwrap().busy = false;
}
pub fn cancel_turn(state: &SharedEngine) {
state.lock().unwrap().busy = false;
}
pub fn consent_answer(_id: &str, _answer: &str) {
// Real path: wake the worker blocked in consent hook.
}
-31
View File
@@ -1,31 +0,0 @@
//! Future FFI surface against `include/cst_agent.h` (AgentCore parity).
//!
//! Enable with:
//! make -C ../.. libcstcore.a # or cmake cstcore
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
//! cargo build --features engine
//!
//! Mapping (Qt AgentCore → Tauri commands/events):
//! open / send / cancel → session_* / send_turn / cancel_turn
//! hooks.assistant_delta → hermit://event AssistantDelta
//! hooks.tool_* → ToolStart / ToolEnd
//! hooks.consent (blocking) → Consent event + consent_answer wakes waiter
//! settings / creds → settings_get (+ later settings_set)
#![allow(dead_code)]
#[cfg(feature = "engine")]
mod live {
// Bindings land here (cbindgen or manual extern "C").
}
#[cfg(not(feature = "engine"))]
pub fn engine_linked() -> bool {
false
}
#[cfg(feature = "engine")]
pub fn engine_linked() -> bool {
true
}
-73
View File
@@ -1,73 +0,0 @@
mod engine;
mod ffi;
use engine::{SharedEngine, EngineState};
use std::sync::Mutex;
use tauri::Manager;
#[tauri::command]
fn session_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::SessionInfo> {
engine::list_sessions(&state)
}
#[tauri::command]
fn session_new(state: tauri::State<'_, SharedEngine>) -> engine::SessionInfo {
engine::new_session(&state)
}
#[tauri::command]
fn session_open(
state: tauri::State<'_, SharedEngine>,
id: String,
) -> Vec<engine::ChatItem> {
engine::open_session(&state, &id)
}
#[tauri::command]
fn settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
engine::settings_get(&state)
}
#[tauri::command]
async fn send_turn(
app: tauri::AppHandle,
state: tauri::State<'_, SharedEngine>,
text: String,
) -> Result<(), String> {
engine::send_turn(app, &state, text).await;
Ok(())
}
#[tauri::command]
fn cancel_turn(state: tauri::State<'_, SharedEngine>) {
engine::cancel_turn(&state);
}
#[tauri::command]
fn consent_answer(id: String, answer: String) {
engine::consent_answer(&id, &answer);
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(Mutex::new(EngineState::default()))
.invoke_handler(tauri::generate_handler![
session_list,
session_new,
session_open,
settings_get,
send_turn,
cancel_turn,
consent_answer
])
.setup(|app| {
if let Some(w) = app.get_webview_window("main") {
let _ = w.set_title("Hermit");
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running Hermit desktop");
}
-209
View File
@@ -1,209 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Rail } from "@/components/Rail";
import { Transcript } from "@/components/Transcript";
import { Composer } from "@/components/Composer";
import { ConsentModal } from "@/components/ConsentModal";
import {
answerConsent,
cancelTurn,
getSettings,
listSessions,
newSession,
onEngineEvent,
openSession,
sendTurn,
type ChatItem,
type ConsentRequest,
type SessionInfo,
type Settings,
} from "@/lib/bridge";
import "@/styles/app.css";
export default function App() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState<ChatItem[]>([]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const [settings, setSettings] = useState<Settings>({
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: ".",
});
const [consent, setConsent] = useState<ConsentRequest | null>(null);
const [showSettings, setShowSettings] = useState(false);
const refreshSessions = useCallback(async () => {
setSessions(await listSessions());
}, []);
useEffect(() => {
void (async () => {
setSettings(await getSettings());
await refreshSessions();
})();
}, [refreshSessions]);
useEffect(() => {
let un: (() => void) | undefined;
void onEngineEvent((ev) => {
if (ev.type === "assistant_delta") {
setItems((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "assistant") {
return [
...prev.slice(0, -1),
{ ...last, text: last.text + ev.text },
];
}
return [
...prev,
{ kind: "assistant", id: `a${Date.now()}`, text: ev.text },
];
});
} else if (ev.type === "thought_delta") {
setItems((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "thought") {
return [
...prev.slice(0, -1),
{ ...last, text: last.text + "\n" + ev.text },
];
}
return [
...prev,
{ kind: "thought", id: `th${Date.now()}`, text: ev.text },
];
});
} else if (ev.type === "tool_start") {
setItems((prev) => [
...prev,
{
kind: "tool",
id: ev.id,
name: ev.name,
status: "running",
detail: ev.detail,
},
]);
} else if (ev.type === "tool_end") {
setItems((prev) =>
prev.map((it) =>
it.kind === "tool" && it.id === ev.id
? { ...it, status: ev.status, detail: ev.detail || it.detail }
: it,
),
);
} else if (ev.type === "consent") {
setConsent(ev.req);
} else if (ev.type === "turn_end") {
setBusy(false);
} else if (ev.type === "error") {
setBusy(false);
setItems((prev) => [
...prev,
{
kind: "assistant",
id: `err${Date.now()}`,
text: `Error: ${ev.message}`,
},
]);
}
}).then((u) => {
un = u;
});
return () => un?.();
}, []);
const open = async (id: string) => {
setActiveId(id);
setItems(await openSession(id));
};
const onNew = async () => {
const s = await newSession();
await refreshSessions();
setActiveId(s.id);
setItems([]);
};
const onSend = async () => {
const text = draft.trim();
if (!text || busy) return;
setDraft("");
setBusy(true);
setItems((prev) => [
...prev,
{ kind: "user", id: `u${Date.now()}`, text },
]);
await sendTurn(text);
};
return (
<div className="app">
<Rail
sessions={sessions}
activeId={activeId}
workspace={settings.workspace}
query={query}
onQuery={setQuery}
onNew={() => void onNew()}
onOpen={(id) => void open(id)}
onSettings={() => setShowSettings(true)}
/>
<section className="main">
<header className="topbar">
<h1>
{sessions.find((s) => s.id === activeId)?.title ?? "New session"}
</h1>
<div className="chips">
<span className="chip ok">engine ready</span>
<span className="chip">
<strong>{settings.model}</strong>
</span>
</div>
</header>
<Transcript items={items} />
<Composer
value={draft}
busy={busy}
settings={settings}
onChange={setDraft}
onSend={() => void onSend()}
onStop={() => void cancelTurn()}
/>
</section>
{consent ? (
<ConsentModal
req={consent}
onAnswer={(a) => {
void answerConsent(consent.id, a);
setConsent(null);
}}
/>
) : null}
{showSettings ? (
<div className="consent" onClick={() => setShowSettings(false)}>
<div
className="consent-card"
onClick={(e) => e.stopPropagation()}
>
<h3>Settings</h3>
<pre>{JSON.stringify(settings, null, 2)}</pre>
<div className="consent-actions">
<button
type="button"
className="primary"
onClick={() => setShowSettings(false)}
>
Close
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
import { useEffect, useRef } from "react";
import type { Settings } from "@/lib/bridge";
type Props = {
value: string;
busy: boolean;
settings: Settings;
onChange: (v: string) => void;
onSend: () => void;
onStop: () => void;
};
export function Composer({
value,
busy,
settings,
onChange,
onSend,
onStop,
}: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "0px";
el.style.height = Math.min(200, el.scrollHeight) + "px";
}, [value]);
return (
<div className="composer-wrap">
<div className="composer">
<textarea
ref={ref}
rows={2}
placeholder="Describe what you want to build, / commands, @ files…"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy && value.trim()) onSend();
}
}}
/>
<div className="composer-bar">
<div className="composer-left">
<span className="chip">
<strong>{settings.model}</strong>
</span>
<span className="chip">
{settings.provider} · {settings.approval}
</span>
</div>
{busy ? (
<button type="button" className="send stop" onClick={onStop}>
Stop
</button>
) : (
<button
type="button"
className="send"
disabled={!value.trim()}
onClick={onSend}
>
Send
</button>
)}
</div>
</div>
</div>
);
}
@@ -1,31 +0,0 @@
import type { ConsentRequest } from "@/lib/bridge";
type Props = {
req: ConsentRequest;
onAnswer: (a: "deny" | "once" | "always_tool" | "always_prefix") => void;
};
export function ConsentModal({ req, onAnswer }: Props) {
return (
<div className="consent">
<div className="consent-card">
<h3>Allow {req.tool}?</h3>
<pre>{req.detail}</pre>
<div className="consent-actions">
<button type="button" onClick={() => onAnswer("deny")}>
Deny
</button>
<button type="button" onClick={() => onAnswer("always_prefix")}>
Always prefix
</button>
<button type="button" onClick={() => onAnswer("always_tool")}>
Always tool
</button>
<button type="button" className="primary" onClick={() => onAnswer("once")}>
Allow once
</button>
</div>
</div>
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
import type { SessionInfo } from "@/lib/bridge";
type Props = {
sessions: SessionInfo[];
activeId: string | null;
workspace: string;
query: string;
onQuery: (q: string) => void;
onNew: () => void;
onOpen: (id: string) => void;
onSettings: () => void;
};
export function Rail({
sessions,
activeId,
workspace,
query,
onQuery,
onNew,
onOpen,
onSettings,
}: Props) {
const filtered = sessions.filter(
(s) => !query || s.title.toLowerCase().includes(query.toLowerCase()),
);
return (
<aside className="rail">
<div className="brand">
<div className="brand-mark">H</div>
<div>
<div className="brand-name">Hermit</div>
<div className="brand-sub">local coding agent</div>
</div>
</div>
<button type="button" className="btn-new" onClick={onNew}>
+ New session
</button>
<div className="rail-sec">
<span>Sessions</span>
</div>
<input
className="sess-search"
type="search"
placeholder="Search sessions"
value={query}
onChange={(e) => onQuery(e.target.value)}
/>
<div className="sessions">
{filtered.map((s) => (
<button
key={s.id}
type="button"
className={`sess${s.id === activeId ? " active" : ""}`}
onClick={() => onOpen(s.id)}
>
<div className="sess-title">{s.title}</div>
<div className="sess-meta">
{new Date(s.updated_at).toLocaleString()}
</div>
</button>
))}
</div>
<div className="rail-foot">
<div className="workspace" title={workspace}>
{workspace}
</div>
<div className="rail-actions">
<button type="button" className="icon-btn" onClick={onSettings}>
Settings
</button>
</div>
</div>
</aside>
);
}
@@ -1,63 +0,0 @@
import type { ChatItem } from "@/lib/bridge";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
type Props = { items: ChatItem[] };
export function Transcript({ items }: Props) {
if (items.length === 0) {
return (
<div className="transcript">
<div className="empty">
<h2>Meet Hermit</h2>
<p>
Local coding agent — read and edit your workspace, run commands with
approval, keep sessions on disk. Describe a task to begin.
</p>
</div>
</div>
);
}
return (
<div className="transcript">
{items.map((it) => {
if (it.kind === "user") {
return (
<div className="msg user" key={it.id}>
<div className="msg-role">You</div>
<div className="bubble">{it.text}</div>
</div>
);
}
if (it.kind === "assistant") {
return (
<div className="msg assistant" key={it.id}>
<div className="msg-role">Hermit</div>
<div className="bubble">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{it.text}</ReactMarkdown>
</div>
</div>
);
}
if (it.kind === "thought") {
return (
<details className="thought" key={it.id}>
<summary>Thought for a while</summary>
<div className="thought-body">{it.text}</div>
</details>
);
}
return (
<div className="tool-card" key={it.id}>
<div className="tool-head">
<span className="dot" />
<span className="name">{it.name}</span>
<span className="status">{it.status}</span>
</div>
{it.detail ? <div className="tool-body">{it.detail}</div> : null}
</div>
);
})}
</div>
);
}
-191
View File
@@ -1,191 +0,0 @@
/** IPC / mock bridge for Hermit desktop. */
export type SessionInfo = {
id: string;
title: string;
updated_at: number;
};
export type ConsentRequest = {
id: string;
tool: string;
detail: string;
};
export type ChatItem =
| { kind: "user"; id: string; text: string }
| { kind: "assistant"; id: string; text: string }
| { kind: "thought"; id: string; text: string }
| {
kind: "tool";
id: string;
name: string;
status: "running" | "ok" | "denied" | "error";
detail: string;
};
export type EngineEvent =
| { type: "assistant_delta"; text: string }
| { type: "thought_delta"; text: string }
| { type: "tool_start"; id: string; name: string; detail: string }
| { type: "tool_end"; id: string; status: "ok" | "denied" | "error"; detail: string }
| { type: "consent"; req: ConsentRequest }
| { type: "turn_end"; ok: boolean }
| { type: "error"; message: string };
export type Settings = {
provider: string;
model: string;
approval: "ask" | "auto" | "yolo";
workspace: string;
};
type Unlisten = () => void;
async function tryInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> {
try {
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<T>(cmd, args);
} catch {
return null;
}
}
const mockSessions: SessionInfo[] = [
{
id: "s1",
title: "Fix home TUI layout",
updated_at: Date.now() - 3600_000,
},
{
id: "s2",
title: "Ship hermit-update",
updated_at: Date.now() - 86400_000,
},
];
let mockBusy = false;
const mockListeners = new Set<(e: EngineEvent) => void>();
function emitMock(e: EngineEvent) {
mockListeners.forEach((fn) => fn(e));
}
export async function listSessions(): Promise<SessionInfo[]> {
const real = await tryInvoke<SessionInfo[]>("session_list");
return real ?? mockSessions;
}
export async function newSession(): Promise<SessionInfo> {
const real = await tryInvoke<SessionInfo>("session_new");
if (real) return real;
const s: SessionInfo = {
id: `s${Date.now()}`,
title: "New session",
updated_at: Date.now(),
};
mockSessions.unshift(s);
return s;
}
export async function openSession(id: string): Promise<ChatItem[]> {
const real = await tryInvoke<ChatItem[]>("session_open", { id });
if (real) return real;
if (id === "s1") {
return [
{ kind: "user", id: "u1", text: "右上角布局不对,帮我看看。" },
{
kind: "thought",
id: "t1",
text: "Sidebar starts at row 0 while topbar only covers the main pane…",
},
{
kind: "tool",
id: "tool1",
name: "read_file",
status: "ok",
detail: "src/tui.c · draw_side / layout",
},
{
kind: "assistant",
id: "a1",
text: "顶栏只盖主区、侧栏从第 0 行铺满,右上角会空出一块。已把 topbar 拉满并把 seat deck 对齐到顶栏下方。",
},
];
}
return [];
}
export async function getSettings(): Promise<Settings> {
const real = await tryInvoke<Settings>("settings_get");
return (
real ?? {
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: "~/hermit",
}
);
}
export async function sendTurn(text: string): Promise<void> {
const real = await tryInvoke<void>("send_turn", { text });
if (real !== null) return;
if (mockBusy) return;
mockBusy = true;
emitMock({ type: "thought_delta", text: "Planning a short reply…" });
await delay(400);
emitMock({
type: "tool_start",
id: "m1",
name: "shell",
detail: "hermit --version",
});
await delay(500);
emitMock({
type: "tool_end",
id: "m1",
status: "ok",
detail: "hermit 1.1.0",
});
const reply =
"这是桌面壳的本地演示流(未链上引擎时)。接上 libcstcore 后,这里会变成真实 turn。";
for (const ch of reply) {
emitMock({ type: "assistant_delta", text: ch });
await delay(12);
}
emitMock({ type: "turn_end", ok: true });
mockBusy = false;
}
export async function cancelTurn(): Promise<void> {
await tryInvoke("cancel_turn");
mockBusy = false;
emitMock({ type: "turn_end", ok: false });
}
export async function answerConsent(
id: string,
answer: "deny" | "once" | "always_tool" | "always_prefix",
): Promise<void> {
await tryInvoke("consent_answer", { id, answer });
}
export async function onEngineEvent(fn: (e: EngineEvent) => void): Promise<Unlisten> {
try {
const { listen } = await import("@tauri-apps/api/event");
const un = await listen<EngineEvent>("hermit://event", (ev) => fn(ev.payload));
return () => {
un();
};
} catch {
mockListeners.add(fn);
return () => {
mockListeners.delete(fn);
};
}
}
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
-425
View File
@@ -1,425 +0,0 @@
@import "./theme.css";
.app {
display: grid;
grid-template-columns: var(--rail-w) 1fr;
height: 100%;
overflow: hidden;
}
/* —— left rail (DSH-like) —— */
.rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 12px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px;
}
.brand-mark {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(145deg, var(--brand), var(--brand-dim));
box-shadow: inset 0 0 0 1px rgba(169, 217, 215, 0.25);
display: grid;
place-items: center;
color: var(--accent-text);
font-weight: 700;
font-size: 13px;
}
.brand-name {
font-weight: 650;
letter-spacing: 0.02em;
}
.brand-sub {
color: var(--text-faint);
font-size: 11px;
}
.btn-new {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
border-radius: var(--radius-sm);
background: var(--brand);
color: #fff;
font-weight: 600;
transition: background 0.15s ease;
}
.btn-new:hover {
background: #2f94ab;
}
.rail-sec {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 0;
color: var(--text-faint);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sess-search {
width: 100%;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg);
outline: none;
}
.sess-search:focus {
border-color: var(--brand);
}
.sessions {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 2px;
}
.sess {
text-align: left;
padding: 10px 10px;
border-radius: var(--radius-sm);
color: var(--text-muted);
border: 1px solid transparent;
}
.sess:hover {
background: var(--bg-hover);
color: var(--text);
}
.sess.active {
background: var(--bg-card);
border-color: var(--border-strong);
color: var(--text);
}
.sess-title {
font-weight: 550;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sess-meta {
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.rail-foot {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.workspace {
padding: 8px 10px;
border-radius: var(--radius-sm);
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
font-family: var(--mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-actions {
display: flex;
gap: 6px;
}
.icon-btn {
flex: 1;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
color: var(--text-muted);
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
/* —— main column —— */
.main {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px var(--pad);
border-bottom: 1px solid var(--border);
}
.topbar h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-muted);
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chip strong {
color: var(--text);
font-weight: 550;
}
.chip.ok {
border-color: rgba(61, 154, 106, 0.35);
color: var(--ok);
}
.transcript {
flex: 1;
overflow: auto;
padding: 20px var(--pad) 8px;
display: flex;
flex-direction: column;
gap: 14px;
}
.empty {
margin: auto;
max-width: 420px;
text-align: center;
color: var(--text-muted);
}
.empty h2 {
margin: 0 0 8px;
color: var(--text);
font-weight: 600;
font-size: 20px;
}
.empty p {
margin: 0;
color: var(--text-faint);
}
.msg {
max-width: 820px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.msg.user {
align-items: flex-end;
}
.msg-role {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.bubble {
padding: 12px 14px;
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: var(--shadow);
white-space: pre-wrap;
word-break: break-word;
}
.msg.user .bubble {
background: linear-gradient(160deg, var(--brand-dim), #1a4a56);
border-color: transparent;
}
.msg.assistant .bubble {
background: var(--bg-elev);
}
.thought,
.tool-card {
max-width: 820px;
width: 100%;
margin: 0 auto;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-elev);
overflow: hidden;
}
.thought summary,
.tool-head {
list-style: none;
cursor: pointer;
padding: 10px 12px;
color: var(--text-muted);
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.thought summary::-webkit-details-marker {
display: none;
}
.thought[open] summary {
border-bottom: 1px solid var(--border);
}
.thought-body,
.tool-body {
padding: 10px 12px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
white-space: pre-wrap;
}
.tool-head .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--crab);
}
.tool-head .name {
color: var(--text);
font-weight: 550;
}
.tool-head .status {
margin-left: auto;
color: var(--text-faint);
}
.composer-wrap {
padding: 10px var(--pad) 16px;
}
.composer {
max-width: 820px;
margin: 0 auto;
border: 1px solid var(--border-strong);
background: var(--bg-card);
border-radius: 16px;
padding: 10px 12px 8px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 8px;
}
.composer textarea {
width: 100%;
min-height: 56px;
max-height: 200px;
resize: none;
border: none;
outline: none;
background: transparent;
padding: 4px 2px;
}
.composer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.composer-left {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.send {
height: 34px;
min-width: 72px;
padding: 0 14px;
border-radius: 999px;
background: var(--crab);
color: #1a100c;
font-weight: 650;
}
.send:disabled {
opacity: 0.45;
cursor: default;
}
.send.stop {
background: var(--danger);
color: #fff;
}
.consent {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
z-index: 40;
}
.consent-card {
width: min(480px, calc(100% - 32px));
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
}
.consent-card h3 {
margin: 0 0 8px;
}
.consent-card pre {
margin: 0 0 14px;
padding: 10px;
background: var(--bg);
border-radius: var(--radius-sm);
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
max-height: 180px;
overflow: auto;
}
.consent-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.consent-actions button {
height: 34px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.consent-actions .primary {
background: var(--brand);
border-color: transparent;
color: #fff;
}
@media (max-width: 880px) {
.app {
grid-template-columns: 1fr;
}
.rail {
display: none;
}
}
-72
View File
@@ -1,72 +0,0 @@
:root {
/* Hermit brand + DSH-like rhythm */
--brand: #277f94;
--brand-dim: #1d5f6e;
--crab: #f68042;
--eye: #9957ad;
--ok: #3d9a6a;
--warn: #e9ba47;
--danger: #c45c4a;
--bg: #0f1114;
--bg-elev: #161a1f;
--bg-card: #1b2128;
--bg-hover: #222933;
--border: rgba(255, 255, 255, 0.06);
--border-strong: rgba(255, 255, 255, 0.12);
--text: #e8ecf0;
--text-muted: #8b949e;
--text-faint: #5c6570;
--accent-text: #a9d9d7;
--radius: 12px;
--radius-sm: 8px;
--pad: 16px;
--rail-w: 260px;
--font: "IBM Plex Sans", "Segoe UI", "Noto Sans SC", system-ui, sans-serif;
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, monospace;
--shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
a {
color: var(--accent-text);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-1
View File
@@ -1 +0,0 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/Composer.tsx","./src/components/ConsentModal.tsx","./src/components/Rail.tsx","./src/components/Transcript.tsx","./src/lib/bridge.ts"],"version":"5.9.3"}
+22
View File
@@ -0,0 +1,22 @@
# archive/ — frozen clients (not shipped with Hermit)
This tree holds **reference front ends** that used to live in the main repo.
The Hermit product surface is now:
- **CLI + minimal TUI** (`hermit`)
- **Engine library** (`libcstcore.a` + `include/`)
- **Replaceable extensions**: plugins / MCP / skills / agents (not core tools)
- **Client contracts**: `cst_agent_hooks_t` and HTTP/SSE `cst-webui/1` (`hermit --web`)
| Path | Former path | Notes |
|------|-------------|--------|
| [`qt-app/`](qt-app/) | `app/` | Qt6/QML GUI (deprecated). Experimental CMake: `-DCST_BUILD_APP=ON`. |
| [`desktop-tauri/`](desktop-tauri/) | `apps/desktop/` | Tauri 2 + React shell (FFI into `libcstcore`). |
| [`web-spa/`](web-spa/) | `web/` | Full browser SPA. Load with `hermit --web --web-dev archive/web-spa`. |
**Do not expect** these trees to stay buildable without manual path fixes.
Default `make` / CI does **not** compile them.
For a new external client, implement against [`docs/webui.md`](../docs/webui.md) and
[`docs/webui-schema.md`](../docs/webui-schema.md), or embed via `cst_agent.h` hooks / the
archived FFI bridge under `desktop-tauri/src-tauri/src/ffi_bridge.*`.
@@ -3,3 +3,7 @@ dist/
src-tauri/target/ src-tauri/target/
.DS_Store .DS_Store
*.log *.log
.layout-check.png
.layout-dsh.png
.layout-dsh-qa/
@@ -28,7 +28,7 @@ nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl
## Develop ## Develop
```bash ```bash
cd apps/desktop cd archive/desktop-tauri
npm install npm install
npm run tauri dev npm run tauri dev
``` ```
@@ -47,11 +47,12 @@ Artifacts land in `src-tauri/target/release/bundle/`.
## Layout ## Layout
``` ```
apps/desktop/ archive/desktop-tauri/
src/ React UI (DSH-like) src/ React UI (DSH-like)
src-tauri/ Rust + Tauri + FFI src-tauri/ Rust + Tauri + FFI
README.md README.md
``` ```
The legacy Qt front end under `/app` is frozen and will be archived once The legacy Qt front end under `/app` is frozen and will be archived once
desktop feature parity lands. desktop feature parity lands.
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN" data-ds-dark-theme>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermit</title>
<style>
/* Boot: match DSH bluish-950 before modules arrive; never flash light UA chrome */
html, body, #root { width: 100%; height: 100%; margin: 0; background: rgb(21, 21, 23); color: rgb(247, 247, 248); }
#root { display: flex; flex-direction: column; min-width: 0; }
button { appearance: none; -webkit-appearance: none; }
</style>
</head>
<body data-ds-dark-theme>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Hermit Desktop — official local dev entry (engine preferred).
#
# Required for real turns:
# - libcstcore.a at repo root (auto-enables --features engine), OR HERMIT_FEATURES=engine
# - DEEPSEEK_API_KEY in the environment, OR set the key in Settings after launch
#
# Host-only (no engine): HERMIT_FEATURES=host ./run-dev.sh
# Composer will refuse send until engine is available (no silent mock product path).
# UI mock turns (browser/host demos only): localStorage hermit.allowMock=1 or VITE_HERMIT_ALLOW_MOCK=1
#
# Hand-check: new session → type → streaming assistant → Stop → reopen session has history
set -euo pipefail
cd "$(dirname "$0")"
# 补 PKG_CONFIG_PATH(勿整文件 source set-environment:会撞 set -u)
if [[ -z "${PKG_CONFIG_PATH:-}" ]]; then
if [[ -f /etc/set-environment ]]; then
PKG_CONFIG_PATH="$(set +u; . /etc/set-environment >/dev/null; printf '%s' "${PKG_CONFIG_PATH:-}")"
export PKG_CONFIG_PATH
fi
fi
if [[ -z "${PKG_CONFIG_PATH:-}" ]]; then
echo "hermit-dev: PKG_CONFIG_PATH 为空。请用 hermit-tauri-dev 或 nixos-rebuild 后重开终端" >&2
exit 1
fi
if ! pkg-config --exists webkit2gtk-4.1 gdk-pixbuf-2.0 2>/dev/null; then
echo "hermit-dev: 缺少 webkit/gdk-pixbuf .pc" >&2
exit 1
fi
unset WEBKIT_DISABLE_COMPOSITING_MODE || true
if [[ -f /proc/driver/nvidia/version ]]; then
export __NV_DISABLE_EXPLICIT_SYNC="${__NV_DISABLE_EXPLICIT_SYNC:-1}"
# X11/XWayland 上 WebKit DMABUF →「Failed to create GBM buffer」→ 空白灰窗
# 必须关 DMABUF;Wayland 原生则可只靠 __NV_DISABLE_EXPLICIT_SYNC
if [[ -n "${WAYLAND_DISPLAY:-}" || "${XDG_SESSION_TYPE:-}" == wayland ]]; then
export GDK_BACKEND="${GDK_BACKEND:-x11}"
fi
if [[ "${GDK_BACKEND:-}" == x11 ]]; then
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
else
unset WEBKIT_DISABLE_DMABUF_RENDERER || true
fi
fi
export TAURI_DEV_HOST="${TAURI_DEV_HOST:-127.0.0.1}"
# Optional real engine (libcstcore). Example:
# HERMIT_FEATURES=engine ./run-dev.sh
# # or:
# HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
# HERMIT_CORE_INCLUDE=$PWD/../../include \
# npm run tauri -- dev -- --features engine
TAURI_EXTRA=()
ROOT="$(cd ../.. && pwd)"
# Auto-enable engine when libcstcore.a exists, unless HERMIT_FEATURES=host
if [[ "${HERMIT_FEATURES:-}" == *engine* ]] || {
[[ "${HERMIT_FEATURES:-}" != *host* ]] && [[ -f "$ROOT/libcstcore.a" ]]
}; then
export HERMIT_CORE_LIB="${HERMIT_CORE_LIB:-$ROOT/libcstcore.a}"
export HERMIT_CORE_INCLUDE="${HERMIT_CORE_INCLUDE:-$ROOT/include}"
if [[ -f "$HERMIT_CORE_LIB" ]]; then
# NixOS: -lcurl needs an explicit libdir (pkg-config or ldd).
if [[ -z "${HERMIT_CURL_LIBDIR:-}" ]]; then
if pkg-config --exists libcurl 2>/dev/null; then
HERMIT_CURL_LIBDIR="$(pkg-config --variable=libdir libcurl 2>/dev/null || true)"
fi
if [[ -z "${HERMIT_CURL_LIBDIR:-}" ]] && command -v curl >/dev/null 2>&1; then
curl_so="$(ldd "$(command -v curl)" 2>/dev/null | awk '/libcurl\.so/ { print $3; exit }' || true)"
if [[ -n "${curl_so:-}" && -e "$curl_so" ]]; then
HERMIT_CURL_LIBDIR="$(dirname "$curl_so")"
fi
fi
if [[ -n "${HERMIT_CURL_LIBDIR:-}" ]]; then
export HERMIT_CURL_LIBDIR
export LD_LIBRARY_PATH="${HERMIT_CURL_LIBDIR}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
fi
fi
echo "hermit-dev: engine feature HERMIT_CORE_LIB=$HERMIT_CORE_LIB curl=${HERMIT_CURL_LIBDIR:-default}" >&2
TAURI_EXTRA+=(--features engine)
else
echo "hermit-dev: engine requested but missing $HERMIT_CORE_LIB (host mock)" >&2
fi
fi
for p in 1420 1421; do
if command -v fuser >/dev/null 2>&1; then
fuser -k "${p}/tcp" 2>/dev/null || true
else
mapfile -t pids < <(ss -ltnp 2>/dev/null | awk -v p=":$p" '$4 ~ p {
if (match($0, /pid=[0-9]+/)) { print substr($0, RSTART+4, RLENGTH-4) }
}' | sort -u)
for pid in "${pids[@]:-}"; do
[[ -n "${pid:-}" ]] && kill "$pid" 2>/dev/null || true
done
fi
done
sleep 0.3
if [[ -z "${DEEPSEEK_API_KEY:-}" && -z "${OPENAI_API_KEY:-}" ]]; then
echo "hermit-dev: note — no DEEPSEEK_API_KEY/OPENAI_API_KEY in env; set one or paste key in Settings" >&2
fi
if printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -qx -- '--features' && printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -qx engine; then
echo "hermit-dev: mode=engine (libcstcore)" >&2
elif printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -q engine; then
echo "hermit-dev: mode=engine (libcstcore)" >&2
else
echo "hermit-dev: mode=host (Composer send disabled until engine)" >&2
fi
echo "hermit-dev: GDK_BACKEND=${GDK_BACKEND:-none} DMABUF_OFF=${WEBKIT_DISABLE_DMABUF_RENDERER:-0} NV_SYNC=${__NV_DISABLE_EXPLICIT_SYNC:-0} host=${TAURI_DEV_HOST}" >&2
exec npm run tauri -- dev "${TAURI_EXTRA[@]}" "$@"
@@ -1220,6 +1220,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-shell", "tauri-plugin-shell",
"tokio", "tokio",
] ]
@@ -2059,6 +2060,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2545,6 +2547,32 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"libc",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"percent-encoding",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -3241,6 +3269,48 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-dialog"
version = "2.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce0fb9c2b6d34a9720627ed930ebc8af26f61e648695aecaeb03d5c33c5896f5"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.21",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.21",
"toml 1.1.8+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-shell" name = "tauri-plugin-shell"
version = "2.6.0" version = "2.6.0"
@@ -16,6 +16,7 @@ cc = "1.2"
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] }
+175
View File
@@ -0,0 +1,175 @@
fn main() {
// Optional: link a prebuilt libcstcore when HERMIT_CORE_LIB is set.
// Example:
// HERMIT_CORE_LIB=/path/to/libcstcore.a HERMIT_CORE_INCLUDE=/path/to/include
// cargo build --features engine
if std::env::var("CARGO_FEATURE_ENGINE").is_ok() {
let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
// src-tauri → desktop → apps → repo root
let repo_root = manifest_dir.join("../../..");
let include = std::env::var("HERMIT_CORE_INCLUDE").unwrap_or_else(|_| {
repo_root
.join("include")
.canonicalize()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| repo_root.join("include").display().to_string())
});
let mut build = cc::Build::new();
build
.file("src/ffi_bridge.c")
.include(&include)
.include("src")
.warnings(false);
// libcstcore pulls C11 + POSIX.
build.std("c11");
build.compile("hermit_ffi_bridge");
println!("cargo:rerun-if-changed=src/ffi_bridge.c");
println!("cargo:rerun-if-changed=src/ffi_bridge.h");
let core = std::env::var("HERMIT_CORE_LIB")
.ok()
.map(std::path::PathBuf::from)
.filter(|p| p.exists())
.or_else(|| {
let fallback = repo_root.join("libcstcore.a");
fallback.exists().then_some(fallback)
});
if let Some(path) = core {
if let Some(dir) = path.parent() {
println!("cargo:rustc-link-search=native={}", dir.display());
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("cstcore")
.trim_start_matches("lib");
println!("cargo:rustc-link-lib=static={}", stem);
// libcstcore leaves sashidown symbols unresolved (same as Makefile LDLIBS).
let sashidown_dir = repo_root.join("vendor/sashidown");
let sashidown_a = sashidown_dir.join("libsashidown.a");
if sashidown_a.exists() {
println!(
"cargo:rustc-link-search=native={}",
sashidown_dir
.canonicalize()
.unwrap_or_else(|_| sashidown_dir.clone())
.display()
);
println!("cargo:rustc-link-lib=static=sashidown");
println!("cargo:rerun-if-changed={}", sashidown_a.display());
} else {
println!(
"cargo:warning=missing {} (make -C vendor/sashidown libsashidown.a)",
sashidown_a.display()
);
}
link_curl_and_sys();
println!("cargo:rerun-if-changed={}", path.display());
} else {
println!(
"cargo:warning=feature engine enabled but HERMIT_CORE_LIB unset and libcstcore.a missing"
);
}
println!("cargo:rerun-if-env-changed=HERMIT_CORE_LIB");
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
println!("cargo:rerun-if-env-changed=HERMIT_CURL_LIBDIR");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", include);
}
tauri_build::build()
}
/// libcstcore needs libcurl (+ pthread/m/dl). On NixOS `-lcurl` alone fails
/// because curl is not on the default linker path — resolve via pkg-config,
/// HERMIT_CURL_LIBDIR, or `ldd $(command -v curl)`.
fn link_curl_and_sys() {
if link_curl_via_pkg_config() || link_curl_via_env() || link_curl_via_ldd() {
// ok
} else {
println!("cargo:warning=libcurl path not found; falling back to -lcurl");
println!("cargo:rustc-link-lib=curl");
}
println!("cargo:rustc-link-lib=pthread");
println!("cargo:rustc-link-lib=m");
println!("cargo:rustc-link-lib=dl");
}
fn apply_pkg_libs(libs: &str) -> bool {
let mut saw_curl = false;
for tok in libs.split_whitespace() {
if let Some(dir) = tok.strip_prefix("-L") {
if !dir.is_empty() {
println!("cargo:rustc-link-search=native={dir}");
}
} else if let Some(lib) = tok.strip_prefix("-l") {
if lib == "curl" {
saw_curl = true;
}
println!("cargo:rustc-link-lib={lib}");
}
}
saw_curl
}
fn link_curl_via_pkg_config() -> bool {
let Ok(out) = std::process::Command::new("pkg-config")
.args(["--libs", "libcurl"])
.output()
else {
return false;
};
if !out.status.success() {
return false;
}
let libs = String::from_utf8_lossy(&out.stdout);
apply_pkg_libs(libs.trim())
}
fn link_curl_via_env() -> bool {
let Ok(dir) = std::env::var("HERMIT_CURL_LIBDIR") else {
return false;
};
if dir.is_empty() || !std::path::Path::new(&dir).is_dir() {
return false;
}
println!("cargo:rustc-link-search=native={dir}");
println!("cargo:rustc-link-lib=curl");
true
}
fn link_curl_via_ldd() -> bool {
// Parse: libcurl.so.4 => /nix/store/.../lib/libcurl.so.4 (0x...)
let script = r#"
set -e
bin=$(command -v curl 2>/dev/null || true)
[ -n "$bin" ] || exit 1
ldd "$bin" 2>/dev/null | awk '/libcurl\.so/ { print $3; exit }'
"#;
let Ok(out) = std::process::Command::new("sh")
.arg("-c")
.arg(script)
.output()
else {
return false;
};
if !out.status.success() {
return false;
}
let so = String::from_utf8_lossy(&out.stdout);
let so = so.trim();
if so.is_empty() || so == "not" {
return false;
}
let path = std::path::Path::new(so);
let Some(dir) = path.parent() else {
return false;
};
if !dir.is_dir() {
return false;
}
println!("cargo:rustc-link-search=native={}", dir.display());
println!("cargo:rustc-link-lib=curl");
true
}
@@ -5,6 +5,7 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"shell:allow-open" "shell:allow-open",
"dialog:default"
] ]
} }
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default Hermit desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}} {"default":{"identifier":"default","description":"Default Hermit desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:default"]}}
@@ -2426,6 +2426,72 @@
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string", "type": "string",
@@ -2426,6 +2426,72 @@
"const": "core:window:deny-unminimize", "const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string", "type": "string",

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 682 B

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 233 B

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because it is too large Load Diff
+780
View File
@@ -0,0 +1,780 @@
//! FFI surface against `include/cst_agent.h` via `ffi_bridge.c`.
//!
//! Enable with:
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
//! cargo build --features engine
#![allow(dead_code)]
use std::sync::{Condvar, Mutex};
#[cfg(not(feature = "engine"))]
pub fn engine_linked() -> bool {
false
}
#[cfg(feature = "engine")]
pub fn engine_linked() -> bool {
true
}
/// Shared waiter: UI answers via `consent_answer` / `question_answer`.
#[derive(Default)]
pub struct ConsentGate {
inner: Mutex<ConsentGateInner>,
cv: Condvar,
}
#[derive(Default)]
struct ConsentGateInner {
pending_id: Option<String>,
pending_tool: String,
pending_detail: String,
answer: Option<String>,
answer_text: String,
cancel: bool,
}
impl ConsentGate {
pub fn request(&self, id: &str, tool: &str, detail: &str) -> i32 {
let mut g = self.inner.lock().unwrap();
g.pending_id = Some(id.to_string());
g.pending_tool = tool.to_string();
g.pending_detail = detail.to_string();
g.answer = None;
g.answer_text.clear();
g.cancel = false;
loop {
if g.cancel {
g.pending_id = None;
return 0;
}
if let Some(ans) = g.answer.take() {
g.pending_id = None;
g.answer_text = ans.clone();
return match ans.as_str() {
"once" | "always_tool" | "always_prefix" => {
if ans == "once" {
1
} else if ans == "always_tool" {
2
} else {
3
}
}
_ if !ans.is_empty() && ans != "deny" => 1,
_ => 0,
};
}
g = self.cv.wait(g).unwrap();
}
}
pub fn request_text(&self, id: &str, tool: &str, detail: &str) -> Option<String> {
let code = self.request(id, tool, detail);
if code == 0 {
return None;
}
let g = self.inner.lock().unwrap();
if g.answer_text.is_empty() || g.answer_text == "once" {
Some(String::new())
} else {
Some(g.answer_text.clone())
}
}
pub fn answer(&self, id: &str, answer: &str) -> bool {
let mut g = self.inner.lock().unwrap();
if g.pending_id.as_deref() != Some(id) {
return false;
}
g.answer = Some(answer.to_string());
g.answer_text = answer.to_string();
self.cv.notify_all();
true
}
pub fn cancel(&self) {
let mut g = self.inner.lock().unwrap();
g.cancel = true;
self.cv.notify_all();
}
}
#[cfg(feature = "engine")]
mod live {
use super::*;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int, c_void};
use std::ptr;
use std::sync::Arc;
#[repr(C)]
struct HermitHooks {
on_assistant: Option<extern "C" fn(*const c_char, usize, *mut c_void)>,
on_thought: Option<extern "C" fn(*const c_char, usize, *mut c_void)>,
on_tool: Option<
extern "C" fn(
*const c_char,
*const c_char,
*const c_char,
*const c_char,
*mut c_void,
),
>,
on_tool_result: Option<
extern "C" fn(*const c_char, *const c_char, *const c_char, usize, c_int, *mut c_void),
>,
on_todo: Option<extern "C" fn(*const c_char, *mut c_void)>,
on_status: Option<extern "C" fn(*const c_char, *mut c_void)>,
on_perm: Option<
extern "C" fn(*const c_char, *const c_char, *const c_char, *mut c_void) -> c_int,
>,
on_ask: Option<
extern "C" fn(
*const c_char,
*const c_char,
*mut c_char,
usize,
*mut c_void,
) -> c_int,
>,
ud: *mut c_void,
}
extern "C" {
fn hermit_agent_open(
workspace: *const c_char,
provider: *const c_char,
model: *const c_char,
approval: *const c_char,
api_key: *const c_char,
effort: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> *mut c_void;
fn hermit_agent_close(agent: *mut c_void);
fn hermit_agent_turn(
agent: *mut c_void,
user_text: *const c_char,
hooks: *const HermitHooks,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_cancel(agent: *mut c_void);
fn hermit_agent_set_workspace(
agent: *mut c_void,
path: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_new_session(
agent: *mut c_void,
id_out: *mut c_char,
id_sz: usize,
) -> c_int;
fn hermit_agent_switch_session(agent: *mut c_void, id: *const c_char) -> c_int;
fn hermit_agent_session_id(
agent: *mut c_void,
out: *mut c_char,
out_sz: usize,
) -> c_int;
fn hermit_agent_set_plan_mode(agent: *mut c_void, on: c_int) -> c_int;
fn hermit_agent_set_approval(agent: *mut c_void, approval: *const c_char) -> c_int;
fn hermit_agent_set_effort(agent: *mut c_void, effort: *const c_char) -> c_int;
fn hermit_agent_attach_file(
agent: *mut c_void,
path: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_pending_clear(agent: *mut c_void);
fn hermit_agent_compact(
agent: *mut c_void,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_usage(
agent: *mut c_void,
prompt: *mut i64,
out_tokens: *mut i64,
cost: *mut f64,
priced: *mut c_int,
);
fn hermit_agent_history_json(agent: *mut c_void) -> *mut c_char;
fn hermit_agent_todo(agent: *mut c_void) -> *mut c_char;
fn hermit_agent_export_md(agent: *mut c_void, title: *const c_char) -> *mut c_char;
fn hermit_creds_has(provider: *const c_char) -> c_int;
fn free(p: *mut c_void);
fn hermit_store_list(
workspace: *const c_char,
ids: *mut [c_char; 128],
max_ids: c_int,
) -> c_int;
fn hermit_store_info(
workspace: *const c_char,
id: *const c_char,
title: *mut c_char,
title_sz: usize,
mtime_ms: *mut i64,
) -> c_int;
fn hermit_store_title_set(
workspace: *const c_char,
id: *const c_char,
title: *const c_char,
) -> c_int;
fn hermit_store_delete(workspace: *const c_char, id: *const c_char) -> c_int;
fn hermit_creds_save(provider: *const c_char, key: *const c_char) -> c_int;
}
pub struct LiveAgent {
ptr: *mut c_void,
}
unsafe impl Send for LiveAgent {}
impl Drop for LiveAgent {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe { hermit_agent_close(self.ptr) };
self.ptr = ptr::null_mut();
}
}
}
fn cstr_lossy(buf: &[u8]) -> String {
CStr::from_bytes_until_nul(buf)
.ok()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub fn open(
workspace: &str,
provider: &str,
model: &str,
approval: &str,
api_key: &str,
effort: &str,
) -> Result<LiveAgent, String> {
let ws = CString::new(workspace).map_err(|e| e.to_string())?;
let prov = CString::new(provider).map_err(|e| e.to_string())?;
let modl = CString::new(model).map_err(|e| e.to_string())?;
let appr = CString::new(approval).map_err(|e| e.to_string())?;
let key = CString::new(api_key).map_err(|e| e.to_string())?;
let eff = CString::new(effort).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let ptr = unsafe {
hermit_agent_open(
ws.as_ptr(),
prov.as_ptr(),
modl.as_ptr(),
appr.as_ptr(),
key.as_ptr(),
eff.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if ptr.is_null() {
return Err(cstr_lossy(&err));
}
Ok(LiveAgent { ptr })
}
pub fn cancel(agent: &LiveAgent) {
unsafe { hermit_agent_cancel(agent.ptr) };
}
pub fn set_workspace(agent: &LiveAgent, path: &str) -> Result<(), String> {
let p = CString::new(path).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_set_workspace(
agent.ptr,
p.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn set_plan_mode(agent: &LiveAgent, on: bool) -> Result<(), String> {
let rc = unsafe { hermit_agent_set_plan_mode(agent.ptr, if on { 1 } else { 0 }) };
if rc != 0 {
return Err("set_plan_mode failed".into());
}
Ok(())
}
pub fn set_approval(agent: &LiveAgent, approval: &str) -> Result<(), String> {
let a = CString::new(approval).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_set_approval(agent.ptr, a.as_ptr()) };
if rc != 0 {
return Err("set_approval failed".into());
}
Ok(())
}
pub fn set_effort(agent: &LiveAgent, effort: &str) -> Result<(), String> {
let e = CString::new(effort).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_set_effort(agent.ptr, e.as_ptr()) };
if rc != 0 {
return Err("set_effort failed".into());
}
Ok(())
}
pub fn attach_file(agent: &LiveAgent, path: &str) -> Result<(), String> {
let p = CString::new(path).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_attach_file(
agent.ptr,
p.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn pending_clear(agent: &LiveAgent) {
unsafe { hermit_agent_pending_clear(agent.ptr) };
}
pub fn compact(agent: &LiveAgent) -> Result<(), String> {
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_compact(agent.ptr, err.as_mut_ptr() as *mut c_char, err.len())
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub struct UsageSnap {
pub prompt: i64,
pub out: i64,
pub cost: f64,
pub priced: bool,
pub pct: f64,
}
pub fn usage(agent: &LiveAgent) -> Result<UsageSnap, String> {
let mut prompt = 0i64;
let mut out = 0i64;
let mut cost = 0f64;
let mut priced = 0i32;
unsafe {
hermit_agent_usage(agent.ptr, &mut prompt, &mut out, &mut cost, &mut priced);
}
let pct = ((prompt + out) as f64 / 128_000.0 * 100.0).min(99.0);
Ok(UsageSnap {
prompt,
out,
cost,
priced: priced != 0,
pct,
})
}
pub fn new_session(agent: &LiveAgent) -> Result<String, String> {
let mut buf = vec![0u8; 64];
let rc = unsafe {
hermit_agent_new_session(agent.ptr, buf.as_mut_ptr() as *mut c_char, buf.len())
};
if rc != 0 {
return Err("new_session failed".into());
}
Ok(cstr_lossy(&buf))
}
pub fn switch_session(agent: &LiveAgent, id: &str) -> Result<(), String> {
let i = CString::new(id).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_switch_session(agent.ptr, i.as_ptr()) };
if rc != 0 {
return Err("switch_session failed".into());
}
Ok(())
}
pub fn session_history_json(agent: &LiveAgent) -> Result<String, String> {
let ptr = unsafe { hermit_agent_history_json(agent.ptr) };
if ptr.is_null() {
return Ok("[]".into());
}
let json = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
Ok(json)
}
pub fn todo_text(agent: &LiveAgent) -> String {
let ptr = unsafe { hermit_agent_todo(agent.ptr) };
if ptr.is_null() {
return String::new();
}
let s = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
s
}
pub fn export_md(agent: &LiveAgent, title: &str) -> Result<String, String> {
let t = CString::new(title).map_err(|e| e.to_string())?;
let ptr = unsafe { hermit_agent_export_md(agent.ptr, t.as_ptr()) };
if ptr.is_null() {
return Err("export failed".into());
}
let s = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
Ok(s)
}
pub fn creds_has(provider: &str) -> bool {
let Ok(p) = CString::new(provider) else {
return false;
};
unsafe { hermit_creds_has(p.as_ptr()) != 0 }
}
pub struct TurnCallbacks {
pub on_assistant: Box<dyn FnMut(&str) + Send>,
pub on_thought: Box<dyn FnMut(&str) + Send>,
pub on_tool: Box<dyn FnMut(&str, &str, &str, &str) + Send>,
pub on_tool_result: Box<dyn FnMut(&str, &str, &str, i32) + Send>,
pub on_todo: Box<dyn FnMut(&str) + Send>,
pub on_status: Box<dyn FnMut(&str) + Send>,
pub gate: Arc<ConsentGate>,
pub question_gate: Arc<ConsentGate>,
pub emit_consent: Box<dyn FnMut(String, String, String, Option<String>) + Send>,
pub emit_question: Box<dyn FnMut(String, String, Vec<String>) + Send>,
}
struct TurnUd {
cb: *mut TurnCallbacks,
}
unsafe fn cstr(p: *const c_char) -> String {
if p.is_null() {
return String::new();
}
CStr::from_ptr(p).to_string_lossy().into_owned()
}
extern "C" fn c_assistant(text: *const c_char, len: usize, ud: *mut c_void) {
if text.is_null() || ud.is_null() {
return;
}
let s = unsafe { std::slice::from_raw_parts(text as *const u8, len) };
let t = String::from_utf8_lossy(s);
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_assistant)(&t);
}
extern "C" fn c_thought(text: *const c_char, len: usize, ud: *mut c_void) {
if text.is_null() || ud.is_null() {
return;
}
let s = unsafe { std::slice::from_raw_parts(text as *const u8, len) };
let t = String::from_utf8_lossy(s);
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_thought)(&t);
}
extern "C" fn c_tool(
phase: *const c_char,
id: *const c_char,
name: *const c_char,
detail: *const c_char,
ud: *mut c_void,
) {
if ud.is_null() {
return;
}
let phase = unsafe { cstr(phase) };
let id = unsafe { cstr(id) };
let name = unsafe { cstr(name) };
let detail = unsafe { cstr(detail) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_tool)(&phase, &id, &name, &detail);
}
extern "C" fn c_tool_result(
id: *const c_char,
name: *const c_char,
out: *const c_char,
len: usize,
exit: c_int,
ud: *mut c_void,
) {
if ud.is_null() {
return;
}
let id = unsafe { cstr(id) };
let name = unsafe { cstr(name) };
let out = if out.is_null() {
String::new()
} else {
let s = unsafe { std::slice::from_raw_parts(out as *const u8, len) };
String::from_utf8_lossy(s).into_owned()
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_tool_result)(&id, &name, &out, exit);
}
extern "C" fn c_todo(text: *const c_char, ud: *mut c_void) {
if ud.is_null() {
return;
}
let t = unsafe { cstr(text) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_todo)(&t);
}
extern "C" fn c_status(text: *const c_char, ud: *mut c_void) {
if ud.is_null() {
return;
}
let t = unsafe { cstr(text) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_status)(&t);
}
extern "C" fn c_perm(
tool: *const c_char,
detail: *const c_char,
preview: *const c_char,
ud: *mut c_void,
) -> c_int {
if ud.is_null() {
return 0;
}
let tool = unsafe { cstr(tool) };
let detail = unsafe { cstr(detail) };
let preview = if preview.is_null() {
None
} else {
Some(unsafe { cstr(preview) })
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
let id = format!(
"c{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
(cb.emit_consent)(id.clone(), tool.clone(), detail.clone(), preview);
cb.gate.request(&id, &tool, &detail)
}
extern "C" fn c_ask(
question: *const c_char,
options: *const c_char,
ans: *mut c_char,
ans_sz: usize,
ud: *mut c_void,
) -> c_int {
if ud.is_null() || ans.is_null() || ans_sz == 0 {
return -1;
}
let question = unsafe { cstr(question) };
let options_raw = unsafe { cstr(options) };
let opts: Vec<String> = if options_raw.is_empty() {
Vec::new()
} else if options_raw.trim_start().starts_with('[') {
serde_json::from_str(&options_raw).unwrap_or_default()
} else {
options_raw
.split(|c| c == '\n' || c == '|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
let id = format!(
"q{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
(cb.emit_question)(id.clone(), question.clone(), opts);
match cb.question_gate.request_text(&id, "ask_user", &question) {
None => -1,
Some(text) => {
let bytes = text.as_bytes();
let n = bytes.len().min(ans_sz - 1);
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ans as *mut u8, n);
*ans.add(n) = 0;
}
0
}
}
}
pub fn run_turn(agent: &LiveAgent, text: &str, mut cb: TurnCallbacks) -> Result<(), String> {
let prompt = CString::new(text).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 1024];
let mut turn_ud = TurnUd {
cb: &mut cb as *mut TurnCallbacks,
};
let hooks = HermitHooks {
on_assistant: Some(c_assistant),
on_thought: Some(c_thought),
on_tool: Some(c_tool),
on_tool_result: Some(c_tool_result),
on_todo: Some(c_todo),
on_status: Some(c_status),
on_perm: Some(c_perm),
on_ask: Some(c_ask),
ud: &mut turn_ud as *mut TurnUd as *mut c_void,
};
let rc = unsafe {
hermit_agent_turn(
agent.ptr,
prompt.as_ptr(),
&hooks,
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn store_list(workspace: &str) -> Vec<(String, String, i64)> {
let Ok(ws) = CString::new(workspace) else {
return Vec::new();
};
let mut ids = [[0i8; 128]; 200];
let n = unsafe { hermit_store_list(ws.as_ptr(), ids.as_mut_ptr(), 200) };
if n <= 0 {
return Vec::new();
}
let mut out = Vec::new();
for i in 0..(n as usize) {
let id = unsafe { CStr::from_ptr(ids[i].as_ptr()) }
.to_string_lossy()
.into_owned();
if id.is_empty() {
continue;
}
let Ok(id_c) = CString::new(id.as_str()) else {
continue;
};
let mut title_buf = vec![0u8; 128];
let mut mtime: i64 = 0;
let rc = unsafe {
hermit_store_info(
ws.as_ptr(),
id_c.as_ptr(),
title_buf.as_mut_ptr() as *mut c_char,
title_buf.len(),
&mut mtime,
)
};
let title = if rc == 0 {
cstr_lossy(&title_buf)
} else {
id.clone()
};
out.push((id, title, mtime));
}
out
}
pub fn store_title_set(workspace: &str, id: &str, title: &str) -> bool {
let Ok(ws) = CString::new(workspace) else {
return false;
};
let Ok(i) = CString::new(id) else {
return false;
};
let Ok(t) = CString::new(title) else {
return false;
};
unsafe { hermit_store_title_set(ws.as_ptr(), i.as_ptr(), t.as_ptr()) == 0 }
}
pub fn store_delete(workspace: &str, id: &str) -> bool {
let Ok(ws) = CString::new(workspace) else {
return false;
};
let Ok(i) = CString::new(id) else {
return false;
};
unsafe { hermit_store_delete(ws.as_ptr(), i.as_ptr()) == 0 }
}
pub fn creds_save(provider: &str, key: &str) -> bool {
let Ok(p) = CString::new(provider) else {
return false;
};
let Ok(k) = CString::new(key) else {
return false;
};
unsafe { hermit_creds_save(p.as_ptr(), k.as_ptr()) == 0 }
}
pub struct ProviderSnap {
pub name: String,
pub model: String,
pub has_key: bool,
pub key_from: Option<String>,
}
pub fn providers_list() -> Result<Vec<ProviderSnap>, String> {
let presets = [
("deepseek", "deepseek-flash"),
("openai", "gpt-4.1"),
("anthropic", "claude-sonnet-4"),
];
Ok(presets
.into_iter()
.map(|(name, model)| {
let has = creds_has(name)
|| std::env::var(format!("{}_API_KEY", name.to_ascii_uppercase())).is_ok()
|| (name == "openai" && std::env::var("OPENAI_API_KEY").is_ok())
|| (name == "deepseek"
&& (std::env::var("DEEPSEEK_API_KEY").is_ok()
|| std::env::var("OPENAI_API_KEY").is_ok()));
ProviderSnap {
name: name.into(),
model: model.into(),
has_key: has,
key_from: if has { Some("creds/env".into()) } else { None },
}
})
.collect())
}
}
#[cfg(feature = "engine")]
pub use live::*;
@@ -0,0 +1,517 @@
#include "ffi_bridge.h"
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
const hermit_hooks_t *h;
} bridge_ud_t;
static void hook_assistant(const char *text, size_t len, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_assistant)
b->h->on_assistant(text, len, b->h->ud);
}
static void hook_think(const char *text, size_t len, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_thought)
b->h->on_thought(text, len, b->h->ud);
}
static void hook_tool(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_tool || !call)
return;
b->h->on_tool(phase ? phase : "", call->id, call->name,
detail ? detail : "", b->h->ud);
}
static void hook_tool_result(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_tool_result || !call)
return;
b->h->on_tool_result(call->id, call->name, out ? out : "", len, exit_code,
b->h->ud);
}
static void hook_todo(const char *text, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_todo)
b->h->on_todo(text ? text : "", b->h->ud);
}
static void hook_status(const char *msg, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_status)
b->h->on_status(msg ? msg : "", b->h->ud);
}
static int hook_perm(const char *tool_name, const char *detail,
const char *preview, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_perm)
return CST_PERM_DENY;
return b->h->on_perm(tool_name ? tool_name : "", detail ? detail : "",
preview, b->h->ud);
}
static int hook_ask(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_ask)
return -1;
return b->h->on_ask(question ? question : "", options ? options : "", ans,
ans_sz, b->h->ud);
}
static void hook_turn_begin(const char *user_text, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_turn_begin)
b->h->on_turn_begin(user_text ? user_text : "", b->h->ud);
}
static void hook_turn_end(int rc, const char *err, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_turn_end)
b->h->on_turn_end(rc, err, b->h->ud);
}
static void hook_error(const char *kind, const char *message, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_error)
b->h->on_error(kind ? kind : "", message ? message : "", b->h->ud);
}
void *hermit_agent_open(const char *workspace, const char *provider,
const char *model, const char *approval,
const char *api_key, const char *effort, char *err,
size_t err_sz) {
if (err && err_sz)
err[0] = '\0';
if (!workspace || !workspace[0]) {
if (err && err_sz)
snprintf(err, err_sz, "workspace required");
return NULL;
}
if (cst_agent_store_ensure(workspace) != 0) {
if (err && err_sz)
snprintf(err, err_sz, "store ensure failed");
return NULL;
}
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", workspace);
if (provider && provider[0])
snprintf(cfg.provider, sizeof(cfg.provider), "%s", provider);
else
snprintf(cfg.provider, sizeof(cfg.provider), "%s", "deepseek");
if (model && model[0])
snprintf(cfg.model, sizeof(cfg.model), "%s", model);
else
snprintf(cfg.model, sizeof(cfg.model), "%s", "deepseek-flash");
snprintf(cfg.mode, sizeof(cfg.mode), "%s", CST_DEFAULT_MODE);
cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = CST_DEFAULT_MAX_STEPS;
cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
cfg.approval = CST_DEFAULT_APPROVAL;
if (approval && approval[0]) {
int a = CST_DEFAULT_APPROVAL;
if (cst_approval_parse(approval, &a) == 0)
cfg.approval = a;
}
if (effort && effort[0] && cst_effort_valid(effort))
snprintf(cfg.effort, sizeof(cfg.effort), "%s", effort);
char id[64];
if (cst_agent_store_resolve_default(cfg.workspace_root, cfg.session_path,
sizeof(cfg.session_path), id,
sizeof(id)) != 0) {
if (cst_agent_store_new_id(id, sizeof(id)) != 0 ||
cst_agent_store_path_for_id(cfg.workspace_root, id, cfg.session_path,
sizeof(cfg.session_path)) != 0) {
if (err && err_sz)
snprintf(err, err_sz, "session path failed");
return NULL;
}
cst_agent_store_set_current(cfg.workspace_root, id);
}
if (api_key && api_key[0]) {
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", api_key);
} else {
char key[512];
const char *from = NULL;
if (cst_creds_resolve(cfg.provider, key, sizeof(key), &from) == 0)
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", key);
}
cst_agent_t *a = cst_agent_create(&cfg);
if (!a && err && err_sz)
snprintf(err, err_sz, "cst_agent_create failed");
return a;
}
void hermit_agent_close(void *agent) {
if (agent)
cst_agent_destroy((cst_agent_t *)agent);
}
int hermit_agent_turn(void *agent, const char *user_text,
const hermit_hooks_t *hooks, char *err, size_t err_sz) {
if (!agent || !user_text)
return -1;
bridge_ud_t bud = {.h = hooks};
cst_agent_hooks_t h;
memset(&h, 0, sizeof(h));
h.on_assistant_delta = hook_assistant;
h.on_think_delta = hook_think;
h.on_tool_event = hook_tool;
h.on_tool_result = hook_tool_result;
h.on_todo = hook_todo;
h.on_status = hook_status;
h.on_permission = hook_perm;
h.on_ask_user = hook_ask;
h.on_turn_begin = hook_turn_begin;
h.on_turn_end = hook_turn_end;
h.on_error = hook_error;
h.userdata = &bud;
return cst_agent_run_turn_ex((cst_agent_t *)agent, user_text, &h, err,
err_sz);
}
void hermit_agent_cancel(void *agent) {
if (agent)
cst_agent_request_cancel((cst_agent_t *)agent);
}
int hermit_agent_set_workspace(void *agent, const char *path, char *err,
size_t err_sz) {
if (!agent || !path)
return -1;
return cst_agent_set_workspace((cst_agent_t *)agent, path, err, err_sz);
}
int hermit_agent_new_session(void *agent, char *id_out, size_t id_sz) {
if (!agent)
return -1;
return cst_agent_new_session((cst_agent_t *)agent, id_out, id_sz);
}
int hermit_agent_switch_session(void *agent, const char *id) {
if (!agent || !id)
return -1;
return cst_agent_switch_session((cst_agent_t *)agent, id);
}
int hermit_agent_session_id(void *agent, char *out, size_t out_sz) {
if (!agent || !out || out_sz == 0)
return -1;
cst_agent_session_id((cst_agent_t *)agent, out, out_sz);
return 0;
}
int hermit_agent_set_plan_mode(void *agent, int on) {
if (!agent)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
cfg->plan_mode = on ? 1 : 0;
return 0;
}
int hermit_agent_set_approval(void *agent, const char *approval) {
if (!agent || !approval)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
int a = cfg->approval;
if (cst_approval_parse(approval, &a) == 0)
cfg->approval = a;
return 0;
}
int hermit_agent_set_effort(void *agent, const char *effort) {
if (!agent)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
if (!effort || !effort[0]) {
cfg->effort[0] = '\0';
return 0;
}
if (!cst_effort_valid(effort))
return -1;
snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort);
return 0;
}
int hermit_agent_attach_file(void *agent, const char *path, char *err,
size_t err_sz) {
if (!agent || !path)
return -1;
return cst_agent_attach_file((cst_agent_t *)agent, path, err, err_sz);
}
void hermit_agent_pending_clear(void *agent) {
if (agent)
cst_agent_pending_clear((cst_agent_t *)agent);
}
int hermit_agent_compact(void *agent, char *err, size_t err_sz) {
if (!agent)
return -1;
return cst_agent_compact((cst_agent_t *)agent, err, err_sz);
}
void hermit_agent_usage(void *agent, long *prompt, long *out_tokens,
double *cost, int *priced) {
if (!agent)
return;
cst_usage_t s, t;
memset(&s, 0, sizeof(s));
memset(&t, 0, sizeof(t));
cst_agent_usage((cst_agent_t *)agent, &s, &t);
if (prompt)
*prompt = s.prompt;
if (out_tokens)
*out_tokens = s.out;
if (cost)
*cost = s.cost;
if (priced)
*priced = s.priced;
}
static void json_escape_append(char **buf, size_t *len, size_t *cap,
const char *s) {
if (!s)
s = "";
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
char tmp[8];
const char *chunk;
size_t n;
if (*p == '"' || *p == '\\') {
tmp[0] = '\\';
tmp[1] = (char)*p;
tmp[2] = 0;
chunk = tmp;
n = 2;
} else if (*p < 0x20) {
n = (size_t)snprintf(tmp, sizeof(tmp), "\\u%04x", *p);
chunk = tmp;
} else {
tmp[0] = (char)*p;
tmp[1] = 0;
chunk = tmp;
n = 1;
}
if (*len + n + 1 >= *cap) {
*cap = (*cap + n + 1) * 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return;
*buf = nb;
}
memcpy(*buf + *len, chunk, n);
*len += n;
(*buf)[*len] = 0;
}
}
static int hist_append_obj(char **buf, size_t *len, size_t *cap, int *first,
const char *head, const char *body) {
if (!*first) {
if (*len + 2 >= *cap) {
*cap *= 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
(*buf)[(*len)++] = ',';
(*buf)[*len] = 0;
}
*first = 0;
size_t hn = strlen(head);
if (*len + hn + 8 >= *cap) {
*cap = (*cap + hn) * 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
memcpy(*buf + *len, head, hn);
*len += hn;
(*buf)[*len] = 0;
json_escape_append(buf, len, cap, body ? body : "");
if (*len + 3 >= *cap) {
*cap *= 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
(*buf)[(*len)++] = '"';
(*buf)[(*len)++] = '}';
(*buf)[*len] = 0;
return 0;
}
char *hermit_agent_history_json(void *agent) {
if (!agent)
return NULL;
cst_session_t *sess = cst_agent_session((cst_agent_t *)agent);
if (!sess)
return NULL;
cst_session_lock(sess);
int n = cst_session_count(sess);
size_t cap = 4096, len = 0;
char *buf = (char *)malloc(cap);
if (!buf) {
cst_session_unlock(sess);
return NULL;
}
buf[0] = '[';
len = 1;
int first = 1;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(sess, i);
if (!m)
continue;
if (m->role == CST_ROLE_SYSTEM)
continue;
char idbuf[64], head[288];
snprintf(idbuf, sizeof(idbuf), "h%d", i);
if (m->role == CST_ROLE_ASSISTANT && m->reasoning && m->reasoning[0]) {
snprintf(head, sizeof(head),
"{\"kind\":\"thought\",\"id\":\"%s-r\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->reasoning) != 0)
break;
}
if (m->role == CST_ROLE_TOOL) {
const char *tname =
(m->tool_name[0] && !strpbrk(m->tool_name, "\\\"\n\r"))
? m->tool_name
: "tool";
snprintf(head, sizeof(head),
"{\"kind\":\"tool\",\"id\":\"%s\",\"name\":\"%s\",\"status\":"
"\"ok\",\"detail\":\"%s\",\"output\":\"",
idbuf, tname, tname);
if (hist_append_obj(&buf, &len, &cap, &first, head,
m->content ? m->content : "") != 0)
break;
continue;
}
if (m->role == CST_ROLE_USER) {
if (!m->content || !m->content[0])
continue;
snprintf(head, sizeof(head),
"{\"kind\":\"user\",\"id\":\"%s\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->content) != 0)
break;
continue;
}
if (m->role == CST_ROLE_ASSISTANT) {
if (!m->content || !m->content[0])
continue;
snprintf(head, sizeof(head),
"{\"kind\":\"assistant\",\"id\":\"%s\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->content) != 0)
break;
}
}
if (len + 2 >= cap) {
char *nb = (char *)realloc(buf, len + 2);
if (nb)
buf = nb;
}
buf[len++] = ']';
buf[len] = 0;
cst_session_unlock(sess);
return buf;
}
char *hermit_agent_todo(void *agent) {
if (!agent)
return NULL;
const char *t = cst_agent_todo((cst_agent_t *)agent);
if (!t || !t[0])
return NULL;
size_t n = strlen(t);
char *out = (char *)malloc(n + 1);
if (!out)
return NULL;
memcpy(out, t, n + 1);
return out;
}
char *hermit_agent_export_md(void *agent, const char *title) {
if (!agent)
return NULL;
cst_session_t *sess = cst_agent_session((cst_agent_t *)agent);
if (!sess)
return NULL;
char sid[128];
cst_agent_session_id((cst_agent_t *)agent, sid, sizeof(sid));
const cst_agent_config_t *cfg = cst_agent_config((cst_agent_t *)agent);
const char *model = cfg && cfg->model[0] ? cfg->model : "";
return cst_session_export_md(sess, title ? title : "Session", sid, model);
}
int hermit_creds_has(const char *provider) {
char key[512];
const char *from = NULL;
if (!provider || !provider[0])
return 0;
return cst_creds_resolve(provider, key, sizeof(key), &from) == 0 ? 1 : 0;
}
int hermit_store_list(const char *workspace, char ids[][128], int max_ids) {
return cst_agent_store_list(workspace, ids, max_ids);
}
int hermit_store_info(const char *workspace, const char *id, char *title,
size_t title_sz, long long *mtime_ms) {
cst_session_info_t info;
memset(&info, 0, sizeof(info));
if (cst_agent_store_info(workspace, id, &info) != 0)
return -1;
if (title && title_sz) {
if (info.title[0])
snprintf(title, title_sz, "%s", info.title);
else if (info.first_user[0])
snprintf(title, title_sz, "%s", info.first_user);
else
snprintf(title, title_sz, "%s", id);
}
/* cst_session_info_t.mtime is seconds since epoch (os_lstat); JS Date wants ms. */
if (mtime_ms)
*mtime_ms = info.mtime > 0 ? info.mtime * 1000LL : 0;
return 0;
}
int hermit_store_title_set(const char *workspace, const char *id,
const char *title) {
return cst_agent_store_title_set(workspace, id, title);
}
int hermit_store_delete(const char *workspace, const char *id) {
return cst_agent_store_delete(workspace, id);
}
int hermit_creds_save(const char *provider, const char *key) {
return cst_creds_save(provider, key);
}
@@ -0,0 +1,88 @@
/* Thin C façade over cst_agent.h for the Tauri desktop host. */
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef void (*hermit_on_text_fn)(const char *text, size_t len, void *ud);
typedef void (*hermit_on_tool_fn)(const char *phase, const char *id,
const char *name, const char *detail,
void *ud);
typedef void (*hermit_on_tool_result_fn)(const char *id, const char *name,
const char *out, size_t out_len,
int exit_code, void *ud);
typedef void (*hermit_on_todo_fn)(const char *text, void *ud);
typedef void (*hermit_on_status_fn)(const char *msg, void *ud);
/* Return CST_PERM_* . preview may be NULL. */
typedef int (*hermit_on_perm_fn)(const char *tool, const char *detail,
const char *preview, void *ud);
/* Fill ans; return 0 ok, -1 dismiss. options is CSV or JSON-ish string. */
typedef int (*hermit_on_ask_fn)(const char *question, const char *options,
char *ans, size_t ans_sz, void *ud);
typedef void (*hermit_on_turn_begin_fn)(const char *user_text, void *ud);
typedef void (*hermit_on_turn_end_fn)(int rc, const char *err, void *ud);
typedef void (*hermit_on_error_fn)(const char *kind, const char *message,
void *ud);
typedef struct {
hermit_on_text_fn on_assistant;
hermit_on_text_fn on_thought;
hermit_on_tool_fn on_tool;
hermit_on_tool_result_fn on_tool_result;
hermit_on_todo_fn on_todo;
hermit_on_status_fn on_status;
hermit_on_perm_fn on_perm;
hermit_on_ask_fn on_ask;
hermit_on_turn_begin_fn on_turn_begin;
hermit_on_turn_end_fn on_turn_end;
hermit_on_error_fn on_error;
void *ud;
} hermit_hooks_t;
void *hermit_agent_open(const char *workspace, const char *provider,
const char *model, const char *approval,
const char *api_key, const char *effort, char *err,
size_t err_sz);
void hermit_agent_close(void *agent);
int hermit_agent_turn(void *agent, const char *user_text,
const hermit_hooks_t *hooks, char *err, size_t err_sz);
void hermit_agent_cancel(void *agent);
int hermit_agent_set_workspace(void *agent, const char *path, char *err,
size_t err_sz);
int hermit_agent_new_session(void *agent, char *id_out, size_t id_sz);
int hermit_agent_switch_session(void *agent, const char *id);
int hermit_agent_session_id(void *agent, char *out, size_t out_sz);
int hermit_agent_set_plan_mode(void *agent, int on);
int hermit_agent_set_approval(void *agent, const char *approval);
int hermit_agent_set_effort(void *agent, const char *effort);
int hermit_agent_attach_file(void *agent, const char *path, char *err,
size_t err_sz);
void hermit_agent_pending_clear(void *agent);
int hermit_agent_compact(void *agent, char *err, size_t err_sz);
void hermit_agent_usage(void *agent, long *prompt, long *out_tokens,
double *cost, int *priced);
/* Malloc'd JSON array of chat items; caller frees. NULL on failure. */
char *hermit_agent_history_json(void *agent);
/* Malloc'd todo text or NULL. */
char *hermit_agent_todo(void *agent);
/* Malloc'd markdown export; caller frees. */
char *hermit_agent_export_md(void *agent, const char *title);
/* 1 if a saved/env key exists for provider. */
int hermit_creds_has(const char *provider);
int hermit_store_list(const char *workspace, char ids[][128], int max_ids);
int hermit_store_info(const char *workspace, const char *id, char *title,
size_t title_sz, long long *mtime_ms);
int hermit_store_title_set(const char *workspace, const char *id,
const char *title);
int hermit_store_delete(const char *workspace, const char *id);
int hermit_creds_save(const char *provider, const char *key);
#ifdef __cplusplus
}
#endif
+291
View File
@@ -0,0 +1,291 @@
mod engine;
mod ffi;
use engine::{SharedEngine, EngineState};
use std::sync::Mutex;
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
Emitter, Manager,
};
#[tauri::command]
fn session_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::SessionInfo> {
engine::list_sessions(&state)
}
#[tauri::command]
fn session_new(state: tauri::State<'_, SharedEngine>) -> engine::SessionInfo {
engine::new_session(&state)
}
#[tauri::command]
fn session_open(
state: tauri::State<'_, SharedEngine>,
id: String,
) -> Vec<engine::ChatItem> {
engine::open_session(&state, &id)
}
#[tauri::command]
fn session_rename(
state: tauri::State<'_, SharedEngine>,
id: String,
title: String,
) -> bool {
engine::rename_session(&state, &id, &title)
}
#[tauri::command]
fn session_delete(state: tauri::State<'_, SharedEngine>, id: String) -> bool {
engine::delete_session(&state, &id)
}
#[tauri::command]
fn session_pin(state: tauri::State<'_, SharedEngine>, id: String) -> bool {
engine::pin_session(&state, &id)
}
#[tauri::command]
fn session_set_folder(
state: tauri::State<'_, SharedEngine>,
id: String,
folder: Option<String>,
) -> bool {
engine::session_set_folder(&state, &id, folder)
}
#[tauri::command]
fn session_compact(state: tauri::State<'_, SharedEngine>) -> String {
engine::session_compact(&state)
}
#[tauri::command]
fn session_export(state: tauri::State<'_, SharedEngine>) -> String {
engine::session_export(&state)
}
#[tauri::command]
fn settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
engine::settings_get(&state)
}
#[tauri::command]
fn settings_set(
state: tauri::State<'_, SharedEngine>,
settings: engine::Settings,
) -> engine::Settings {
engine::settings_set(&state, settings)
}
#[tauri::command]
fn attach_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::AttachmentInfo> {
engine::attach_list(&state)
}
#[tauri::command]
fn attach_file(
state: tauri::State<'_, SharedEngine>,
path: String,
) -> Result<engine::AttachmentInfo, String> {
engine::attach_file(&state, path)
}
#[tauri::command]
fn attach_clear(state: tauri::State<'_, SharedEngine>) {
engine::attach_clear(&state)
}
#[tauri::command]
fn attach_remove(state: tauri::State<'_, SharedEngine>, id: String) {
engine::attach_remove(&state, &id)
}
#[tauri::command]
fn providers_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::ProviderInfo> {
engine::providers_list(&state)
}
#[tauri::command]
fn providers_use(
state: tauri::State<'_, SharedEngine>,
provider: String,
model: String,
key: String,
) -> Result<String, String> {
engine::providers_use(&state, provider, model, key)
}
#[tauri::command]
fn usage_get(state: tauri::State<'_, SharedEngine>) -> engine::UsageInfo {
engine::usage_get(&state)
}
#[tauri::command]
fn todo_get(state: tauri::State<'_, SharedEngine>) -> String {
engine::todo_get(&state)
}
#[tauri::command]
fn bash_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::BashRun> {
engine::bash_list(&state)
}
#[tauri::command]
fn workspace_files(
state: tauri::State<'_, SharedEngine>,
query: String,
) -> Vec<String> {
engine::workspace_files(&state, query)
}
#[tauri::command]
fn engine_status() -> String {
engine::engine_status()
}
#[tauri::command]
async fn send_turn(
app: tauri::AppHandle,
state: tauri::State<'_, SharedEngine>,
text: String,
) -> Result<(), String> {
engine::send_turn(app, &state, text).await;
Ok(())
}
#[tauri::command]
fn cancel_turn(state: tauri::State<'_, SharedEngine>) {
engine::cancel_turn(&state);
}
#[tauri::command]
fn consent_answer(state: tauri::State<'_, SharedEngine>, id: String, answer: String) {
engine::consent_answer(&state, &id, &answer);
}
#[tauri::command]
fn question_answer(state: tauri::State<'_, SharedEngine>, id: String, answer: String) {
engine::question_answer(&state, &id, &answer);
}
#[cfg(target_os = "linux")]
fn apply_linux_webview_workarounds() {
let nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists();
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|| std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland");
unsafe {
if nvidia && std::env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() {
std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1");
}
if nvidia && wayland && std::env::var_os("GDK_BACKEND").is_none() {
std::env::set_var("GDK_BACKEND", "x11");
}
let backend = std::env::var("GDK_BACKEND").unwrap_or_default();
let on_x11 = backend == "x11" || (!wayland && std::env::var_os("DISPLAY").is_some());
if nvidia && on_x11 && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
}
fn build_menu(app: &tauri::App) -> tauri::Result<Menu<tauri::Wry>> {
let new_session = MenuItem::with_id(app, "new_session", "New Session", true, Some("CmdOrCtrl+N"))?;
let open_ws = MenuItem::with_id(app, "open_workspace", "Open Workspace…", true, Some("CmdOrCtrl+O"))?;
let settings = MenuItem::with_id(app, "settings", "Settings…", true, Some("CmdOrCtrl+Comma"))?;
let quit = PredefinedMenuItem::quit(app, Some("Quit Hermit"))?;
let file = Submenu::with_items(
app,
"File",
true,
&[&new_session, &open_ws, &PredefinedMenuItem::separator(app)?, &settings, &quit],
)?;
let edit = Submenu::with_items(
app,
"Edit",
true,
&[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
],
)?;
let view_plan = MenuItem::with_id(app, "view_plan", "Toggle Plan", true, None::<&str>)?;
let view_bash = MenuItem::with_id(app, "view_bash", "Toggle Background Bash", true, Some("CmdOrCtrl+`"))?;
let view = Submenu::with_items(app, "View", true, &[&view_plan, &view_bash])?;
let about = MenuItem::with_id(app, "about", "About Hermit", true, None::<&str>)?;
let help = Submenu::with_items(app, "Help", true, &[&about])?;
Menu::with_items(app, &[&file, &edit, &view, &help])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(target_os = "linux")]
apply_linux_webview_workarounds();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(Mutex::new(EngineState::default()))
.invoke_handler(tauri::generate_handler![
session_list,
session_new,
session_open,
session_rename,
session_delete,
session_pin,
session_set_folder,
session_compact,
session_export,
settings_get,
settings_set,
attach_list,
attach_file,
attach_clear,
attach_remove,
providers_list,
providers_use,
usage_get,
todo_get,
bash_list,
workspace_files,
engine_status,
send_turn,
cancel_turn,
consent_answer,
question_answer
])
.setup(|app| {
if let Ok(menu) = build_menu(app) {
let _ = app.set_menu(menu);
}
if let Some(w) = app.get_webview_window("main") {
let _ = w.set_title("Hermit");
let _ = w.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: 1280.0,
height: 840.0,
}));
#[cfg(debug_assertions)]
{
let _ = w.navigate(
tauri::Url::parse("http://127.0.0.1:1420/").expect("dev url"),
);
}
}
Ok(())
})
.on_menu_event(|app, event| {
let id = event.id().as_ref();
let _ = app.emit("hermit://menu", id);
})
.run(tauri::generate_context!())
.expect("error while running Hermit desktop");
}
@@ -5,7 +5,7 @@
"identifier": "xyz.whcself.hermit", "identifier": "xyz.whcself.hermit",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build", "beforeBuildCommand": "npm run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
+789
View File
@@ -0,0 +1,789 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Rail } from "@/components/Rail";
import { Transcript } from "@/components/Transcript";
import { Composer, type MainView, type SlashCommand } from "@/components/Composer";
import { Hero } from "@/components/Hero";
import { WorkspaceChip } from "@/components/WorkspaceChip";
import { ApprovalCard } from "@/components/ApprovalCard";
import { QuestionCard } from "@/components/QuestionCard";
import { SessionHeader } from "@/components/SessionHeader";
import { SettingsPanel } from "@/components/SettingsPanel";
import { PlanPanel } from "@/components/PlanPanel";
import { BashPanel } from "@/components/BashPanel";
import type { HermitMood } from "@/components/Icons";
import {
answerConsent,
answerQuestion,
attachFile,
cancelTurn,
clearPending,
compactSession,
deleteSession,
engineStatus,
exportSession,
getSettings,
getTodo,
getUsage,
isShellTool,
listBashRuns,
listPending,
listProviders,
listSessions,
listWorkspaceFiles,
mockTurnsAllowed,
newSession,
onEngineEvent,
onMenuEvent,
openSession,
pickFiles,
pickWorkspace,
pinSession,
removePending,
renameSession,
sendTurn,
setSessionFolder,
setSettings,
useProvider,
type AttachmentInfo,
type BashRun,
type ChatItem,
type ConsentRequest,
type ProviderInfo,
type QuestionRequest,
type SessionInfo,
type Settings,
type UsageInfo,
} from "@/lib/bridge";
import frame from "@/dsh/layout/AppFrame.module.css";
import conv from "@/dsh/conversation/ConversationRoot.module.css";
const SIDEBAR_WIDTH = 280;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function displaySessionTitle(title?: string, id?: string): string {
const t = (title || "").trim();
if (!t || UUID_RE.test(t) || t === id) return "New session";
return t;
}
export default function App() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState<ChatItem[]>([]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const [settings, setSettingsState] = useState<Settings>({
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: ".",
plan_mode: false,
reasoning_effort: "high",
font_size: "m",
language: "zh",
});
const [consent, setConsent] = useState<ConsentRequest | null>(null);
const [question, setQuestion] = useState<QuestionRequest | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [status, setStatus] = useState("");
const [todo, setTodo] = useState("");
const [bashRuns, setBashRuns] = useState<BashRun[]>([]);
const [view, setView] = useState<MainView>("chat");
const [attachments, setAttachments] = useState<AttachmentInfo[]>([]);
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [usage, setUsage] = useState<UsageInfo>({
prompt: 0,
out: 0,
cost: 0,
priced: false,
pct: 0,
});
const [fileSuggestions, setFileSuggestions] = useState<string[]>([]);
const [dragging, setDragging] = useState(false);
const [engine, setEngine] = useState("host");
const convColRef = useRef<HTMLDivElement>(null);
const hero = items.length === 0 && !busy && view === "chat";
useEffect(() => {
const el = convColRef.current;
if (!el) return;
const sync = () => {
el.style.setProperty(
"--dsh-conversation-column-width",
`${el.clientWidth}px`,
);
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, []);
const agentMood: HermitMood = busy
? consent || question
? "thinking"
: "coding"
: "idle";
const engineReady = engine === "engine";
const sendBlocked = !engineReady && !mockTurnsAllowed();
const sendBlockedReason = sendBlocked
? engine === "browser"
? "当前在浏览器预览(无 Tauri)。请用 apps/desktop/run-dev.sh 启动真引擎。"
: "当前为 host(未链接 libcstcore)。请用 run-dev.sh(自动 --features engine),并在环境或设置中配置 DEEPSEEK_API_KEY。"
: engineReady
? undefined
: "演示 mock 已开启(hermit.allowMock)— 非真实引擎。";
const refreshSessions = useCallback(async () => {
const list = await listSessions();
list.sort(
(a, b) =>
Number(!!b.pinned) - Number(!!a.pinned) || b.updated_at - a.updated_at,
);
setSessions(list);
}, []);
const refreshPending = useCallback(async () => {
setAttachments(await listPending());
}, []);
const refreshBash = useCallback(async () => {
setBashRuns(await listBashRuns());
}, []);
const applySettings = useCallback(async (next: Settings) => {
const saved = await setSettings(next);
setSettingsState(saved);
document.documentElement.dataset.font = saved.font_size || "m";
document.documentElement.dataset.lang = saved.language || "zh";
return saved;
}, []);
const onPickWorkspace = useCallback(async () => {
const path = await pickWorkspace();
if (!path) return;
await applySettings({ ...settings, workspace: path });
await refreshSessions();
}, [applySettings, refreshSessions, settings]);
useEffect(() => {
void (async () => {
const s = await getSettings();
setSettingsState(s);
document.documentElement.dataset.font = s.font_size || "m";
document.documentElement.dataset.lang = s.language || "zh";
const st = await engineStatus();
setEngine(st);
setStatus(st === "engine" ? "engine ready" : st);
setProviders(await listProviders());
setUsage(await getUsage());
const list = await listSessions();
list.sort(
(a, b) =>
Number(!!b.pinned) - Number(!!a.pinned) || b.updated_at - a.updated_at,
);
setSessions(list);
await refreshPending();
// Never block first paint on ensure_live/open_session (can freeze GTK).
// Start on empty hero; open the latest session after a tick.
const forceHero =
typeof location !== "undefined" &&
new URLSearchParams(location.search).get("hero") === "1";
setActiveId(null);
setItems([]);
if (forceHero) return;
const first = list[0];
if (!first) return;
window.setTimeout(() => {
void (async () => {
try {
setActiveId(first.id);
setItems(await openSession(first.id));
setTodo(await getTodo());
setBashRuns(await listBashRuns());
} catch (e) {
console.error("openSession deferred failed", e);
setActiveId(null);
setItems([]);
}
})();
}, 0);
})();
}, [refreshPending]);
useEffect(() => {
let un: (() => void) | undefined;
void onEngineEvent((ev) => {
if (ev.type === "assistant_delta") {
setItems((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "assistant") {
return [
...prev.slice(0, -1),
{ ...last, text: last.text + ev.text },
];
}
return [
...prev,
{
kind: "assistant",
id: `a${Date.now()}`,
text: ev.text,
ts: Date.now(),
},
];
});
} else if (ev.type === "thought_delta") {
setItems((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "thought") {
return [
...prev.slice(0, -1),
{ ...last, text: last.text + "\n" + ev.text },
];
}
return [
...prev,
{
kind: "thought",
id: `th${Date.now()}`,
text: ev.text,
ts: Date.now(),
},
];
});
} else if (ev.type === "tool_start") {
setItems((prev) => [
...prev,
{
kind: "tool",
id: ev.id,
name: ev.name,
status: "running",
detail: ev.detail,
ts: Date.now(),
},
]);
if (isShellTool(ev.name)) {
setBashRuns((prev) => {
if (prev.some((b) => b.id === ev.id)) return prev;
return [
...prev,
{
id: ev.id,
command: ev.detail || ev.name,
status: "running",
started_at: Date.now(),
},
];
});
}
} else if (ev.type === "tool_end") {
setItems((prev) =>
prev.map((it) =>
it.kind === "tool" && it.id === ev.id
? {
...it,
status: ev.status,
detail: ev.detail || it.detail,
output: ev.output ?? it.output,
exit_code: ev.exit_code ?? it.exit_code,
}
: it,
),
);
setBashRuns((prev) =>
prev.map((b) =>
b.id === ev.id
? {
...b,
status: ev.status,
output: ev.output ?? b.output,
exit_code: ev.exit_code ?? b.exit_code,
ended_at: Date.now(),
}
: b,
),
);
} else if (ev.type === "consent") {
setConsent(ev.req);
} else if (ev.type === "question") {
setQuestion(ev.req);
} else if (ev.type === "todo") {
setTodo(ev.text);
} else if (ev.type === "status") {
setStatus(ev.message);
} else if (ev.type === "turn_end") {
setBusy(false);
setConsent(null);
setQuestion(null);
void refreshSessions();
void refreshPending();
void getUsage().then(setUsage);
void getTodo().then(setTodo);
void refreshBash();
setActiveId((id) => {
if (id && ev.ok) {
void openSession(id).then(setItems);
}
return id;
});
} else if (ev.type === "error") {
setBusy(false);
setItems((prev) => [
...prev,
{
kind: "assistant",
id: `err${Date.now()}`,
text: `Error: ${ev.message}`,
ts: Date.now(),
},
]);
}
}).then((u) => {
un = u;
});
return () => un?.();
}, [refreshBash, refreshPending, refreshSessions]);
useEffect(() => {
let un: (() => void) | undefined;
void onMenuEvent((id) => {
if (id === "new_session") void onNew();
else if (id === "open_workspace") void onPickWorkspace();
else if (id === "settings") setShowSettings(true);
else if (id === "view_plan") setView((v) => (v === "plan" ? "chat" : "plan"));
else if (id === "view_bash") setView((v) => (v === "bash" ? "chat" : "bash"));
else if (id === "about") setShowSettings(true);
}).then((u) => {
un = u;
});
return () => un?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onPickWorkspace]);
const open = async (id: string) => {
setActiveId(id);
setItems(await openSession(id));
setTodo(await getTodo());
setBashRuns(await listBashRuns());
setConsent(null);
setQuestion(null);
setView("chat");
};
const onNew = async () => {
const s = await newSession();
await refreshSessions();
setActiveId(s.id);
setItems([]);
setConsent(null);
setQuestion(null);
setTodo("");
setBashRuns([]);
setView("chat");
};
const onAttach = async () => {
const paths = await pickFiles();
for (const p of paths) await attachFile(p);
await refreshPending();
};
const onSend = async () => {
const text = draft.trim();
if ((!text && attachments.length === 0) || busy || consent || question) return;
if (sendBlocked) return;
setDraft("");
setBusy(true);
setView("chat");
setItems((prev) => [
...prev,
{
kind: "user",
id: `u${Date.now()}`,
text: text || "(attachments)",
ts: Date.now(),
},
]);
if (!activeId) {
const s = await newSession();
setActiveId(s.id);
await refreshSessions();
} else {
setSessions((prev) =>
prev.map((s) =>
s.id === activeId &&
(s.title === "New session" || s.title === "Welcome")
? {
...s,
title: (text || "Attachments").slice(0, 48),
updated_at: Date.now(),
}
: s,
),
);
}
await sendTurn(text || "Please look at the attached files.");
};
const models = useMemo(() => {
const base = ["deepseek-flash", "deepseek-chat", "deepseek-reasoner"];
if (!base.includes(settings.model)) return [settings.model, ...base];
return base;
}, [settings.model]);
const slashCommands: SlashCommand[] = useMemo(
() => [
{ cmd: "/new", desc: "Start a new session", run: () => void onNew() },
{
cmd: "/model",
desc: "Cycle model",
run: () => {
const i = models.indexOf(settings.model);
const next = models[(i + 1) % models.length];
void applySettings({ ...settings, model: next });
},
},
{
cmd: "/approval",
desc: "Cycle approval mode",
run: () => {
const order: Settings["approval"][] = ["ask", "auto", "yolo"];
const i = order.indexOf(settings.approval);
void applySettings({
...settings,
approval: order[(i + 1) % order.length],
});
},
},
{
cmd: "/plan",
desc: "Toggle plan panel",
run: () => setView((v) => (v === "plan" ? "chat" : "plan")),
},
{
cmd: "/bash",
desc: "Toggle bash panel",
run: () => setView((v) => (v === "bash" ? "chat" : "bash")),
},
{
cmd: "/compact",
desc: "Compact session context",
run: async () => {
const msg = await compactSession();
const hist = activeId ? await openSession(activeId) : [];
setItems([
...hist,
{
kind: "assistant",
id: `c${Date.now()}`,
text: msg,
ts: Date.now(),
},
]);
setTodo(await getTodo());
},
},
{
cmd: "/todo",
desc: "Refresh todo checklist",
run: async () => {
const t = await getTodo();
setTodo(t);
setView("plan");
},
},
{
cmd: "/provider",
desc: "Cycle provider",
run: async () => {
const list =
providers.length > 0 ? providers : await listProviders();
if (list.length === 0) return;
const i = list.findIndex((p) => p.name === settings.provider);
const next = list[(i + 1) % list.length];
await useProvider(next.name, next.model);
setProviders(await listProviders());
setSettingsState(await getSettings());
setStatus(`provider · ${next.name}`);
},
},
{
cmd: "/clear",
desc: "Clear pending attachments",
run: async () => {
await clearPending();
await refreshPending();
},
},
{
cmd: "/rename",
desc: "Rename current session",
run: () => {
if (!activeId) return;
const cur = sessions.find((s) => s.id === activeId)?.title ?? "";
const title = window.prompt("Rename session", cur);
if (title?.trim()) {
void renameSession(activeId, title.trim()).then(refreshSessions);
}
},
},
{
cmd: "/export",
desc: "Export transcript as Markdown",
run: async () => {
const md = await exportSession();
await navigator.clipboard?.writeText(md).catch(() => undefined);
setItems((prev) => [
...prev,
{
kind: "assistant",
id: `e${Date.now()}`,
text: "Exported Markdown copied to clipboard.",
ts: Date.now(),
},
]);
},
},
{
cmd: "/settings",
desc: "Open settings",
run: () => setShowSettings(true),
},
],
[
activeId,
applySettings,
models,
providers,
refreshPending,
refreshSessions,
sessions,
settings,
],
);
const active = sessions.find((s) => s.id === activeId);
const sessionTitle = displaySessionTitle(active?.title, active?.id);
const wsLabel =
settings.workspace === "." || !settings.workspace
? "Choose workspace"
: settings.workspace.split(/[/\\]/).filter(Boolean).pop() ||
settings.workspace;
const inputBar = consent ? (
<ApprovalCard
req={consent}
onAnswer={(a) => {
void answerConsent(consent.id, a);
setConsent(null);
}}
/>
) : question ? (
<QuestionCard
req={question}
onAnswer={(a) => {
void answerQuestion(question.id, a);
setQuestion(null);
}}
onSkip={() => {
void answerQuestion(question.id, "");
setQuestion(null);
}}
/>
) : (
<Composer
value={draft}
busy={busy}
settings={settings}
models={models}
attachments={attachments}
usage={usage}
slashCommands={slashCommands}
fileSuggestions={fileSuggestions}
view={view}
hero={hero}
dragging={dragging}
sendBlocked={sendBlocked}
sendBlockedReason={sendBlockedReason}
onChange={setDraft}
onSend={() => void onSend()}
onStop={() => void cancelTurn()}
onSettingsPatch={(patch) => {
void applySettings({ ...settings, ...patch });
}}
onAttach={() => void onAttach()}
onRemoveAttach={(id) => {
void removePending(id).then(refreshPending);
}}
onQueryFiles={(q) => {
void listWorkspaceFiles(q).then(setFileSuggestions);
}}
onInsert={() => undefined}
onView={setView}
/>
);
/** DSH ConversationContent composerBar */
const composerBar = (
<div
className={`${conv.composerStack}${hero ? ` ${conv.composerHero}` : ""}`}
>
{hero ? <Hero mood={agentMood} /> : null}
{hero ? (
<div className={conv.heroWorkspaceRow}>
<WorkspaceChip
workspace={settings.workspace}
onPick={() => void onPickWorkspace()}
/>
<span className="hermit-engine-chip" data-mode={engine}>
{engine}
</span>
</div>
) : null}
{inputBar}
</div>
);
return (
<div
className={`${frame.frame} app-frame-fill`}
style={{
gridTemplateColumns: `${SIDEBAR_WIDTH}px minmax(0, 1fr) 0px`,
}}
data-rightbar-collapsed=""
data-font={settings.font_size || "m"}
onDragEnter={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const files = [...e.dataTransfer.files];
void (async () => {
for (const f of files) {
const path = (f as File & { path?: string }).path;
if (path) await attachFile(path);
}
await refreshPending();
})();
}}
>
<div className={frame.sidebarCol}>
<Rail
sessions={sessions}
activeId={activeId}
workspace={settings.workspace}
query={query}
engine={engine}
onQuery={setQuery}
onNew={() => void onNew()}
onOpen={(id) => void open(id)}
onRename={(id, title) => {
void renameSession(id, title).then(refreshSessions);
}}
onDelete={(id) => {
void (async () => {
await deleteSession(id);
await refreshSessions();
if (activeId === id) {
setActiveId(null);
setItems([]);
}
})();
}}
onPin={(id) => {
void pinSession(id).then(refreshSessions);
}}
onSetFolder={(id, folder) => {
void setSessionFolder(id, folder).then(refreshSessions);
}}
onSettings={() => setShowSettings(true)}
onPickWorkspace={() => void onPickWorkspace()}
/>
</div>
<div className={frame.centerCol}>
<div className={frame.mainPanel}>
<div
ref={convColRef}
className={`${conv.root} hermit-conversation-body`}
data-phase={hero ? "hero" : "active"}
>
{!hero ? (
<SessionHeader
workspaceLabel={wsLabel}
folder={active?.folder}
title={sessionTitle}
mood={agentMood}
status={status && status !== engine ? status : undefined}
engine={engine}
/>
) : null}
<div className={conv.body}>
<div className={conv.scrollBody} data-conversation-scroll="">
{!hero ? (
<div className={conv.viewArea}>
{view === "plan" ? (
<PlanPanel
text={todo}
planMode={!!settings.plan_mode}
onClose={() => setView("chat")}
onTogglePlanMode={() =>
void applySettings({
...settings,
plan_mode: !settings.plan_mode,
})
}
/>
) : view === "bash" ? (
<BashPanel
runs={bashRuns}
onClose={() => setView("chat")}
/>
) : (
<Transcript items={items} busy={busy} />
)}
</div>
) : null}
<div className={conv.composerSeat} data-composer-seat="">
{composerBar}
</div>
</div>
</div>
</div>
</div>
</div>
{/* rightbar fixed closed — 0 track */}
<div className={frame.rightbarCol} aria-hidden />
{showSettings ? (
<SettingsPanel
settings={settings}
providers={providers}
engine={engine}
onSave={(s) => {
void applySettings(s);
}}
onUseProvider={(provider, model, key) => {
void useProvider(provider, model, key).then(async () => {
setProviders(await listProviders());
setSettingsState(await getSettings());
});
}}
onClose={() => setShowSettings(false)}
/>
) : null}
</div>
);
}
@@ -0,0 +1,84 @@
import { useEffect, useRef } from "react";
import type { ConsentRequest } from "@/lib/bridge";
import appr from "@/dsh/approval/ApprovalPanel.module.css";
type Answer = "deny" | "once" | "always_tool" | "always_prefix";
type Props = {
req: ConsentRequest;
onAnswer: (a: Answer) => void;
};
export function ApprovalCard({ req, onAnswer }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
rootRef.current?.focus();
}, [req.id]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.isComposing || e.keyCode === 229) return;
if (e.key === "Enter") {
e.preventDefault();
onAnswer("once");
} else if (e.key === "Escape") {
e.preventDefault();
onAnswer("deny");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onAnswer, req.id]);
return (
<div className={appr.root}>
<div
className={appr.card}
ref={rootRef}
tabIndex={-1}
role="group"
aria-label={`Allow ${req.tool}?`}
>
<div className={appr.strip}>Waiting for approval</div>
<div className={appr.body}>
<div className={appr.headline}>Allow {req.tool}?</div>
<pre className={appr.command}>{req.detail}</pre>
{req.preview ? <pre className={appr.command}>{req.preview}</pre> : null}
</div>
<div className={appr.actionRow}>
<button
type="button"
className={`${appr.reject || ""} hermit-toolbar-btn`}
onClick={() => onAnswer("deny")}
>
Reject
</button>
<button
type="button"
className="hermit-toolbar-btn"
onClick={() => onAnswer("always_prefix")}
>
Always prefix
</button>
<button
type="button"
className="hermit-toolbar-btn"
onClick={() => onAnswer("always_tool")}
>
Always tool
</button>
<button
type="button"
className="hermit-send"
style={{ width: "auto", padding: "0 14px", borderRadius: 8 }}
onClick={() => onAnswer("once")}
>
Allow once
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import type { BashRun } from "@/lib/bridge";
type Filter = "recent" | "running" | "done" | "all";
type Props = {
runs: BashRun[];
onClose: () => void;
};
function fmtDur(r: BashRun): string {
const end = r.ended_at ?? Date.now();
const ms = Math.max(0, end - r.started_at);
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}秒`;
return `${Math.floor(ms / 60_000)}分${Math.round((ms % 60_000) / 1000)}秒`;
}
export function BashPanel({ runs, onClose }: Props) {
const [filter, setFilter] = useState<Filter>("recent");
const [openId, setOpenId] = useState<string | null>(null);
const list = useMemo(() => {
const sorted = [...runs].sort((a, b) => b.started_at - a.started_at);
if (filter === "all") return sorted;
if (filter === "running") return sorted.filter((r) => r.status === "running");
if (filter === "done")
return sorted.filter((r) => r.status !== "running");
return sorted.slice(0, 20);
}, [runs, filter]);
const running = runs.filter((r) => r.status === "running").length;
return (
<div className="work-panel bash-panel">
<div className="work-panel-head">
<strong>后台 Bash · {running} 运行中</strong>
<button type="button" onClick={onClose}>
关闭
</button>
</div>
<div className="bash-filters">
{(
[
["recent", "最近"],
["running", "进行中"],
["done", "已完成"],
["all", "全部"],
] as const
).map(([k, label]) => (
<button
key={k}
type="button"
className={filter === k ? "active" : ""}
onClick={() => setFilter(k)}
>
{label}
</button>
))}
</div>
{list.length === 0 ? (
<div className="work-empty">还没有 shell 工具记录。</div>
) : (
<ul className="bash-list">
{list.map((r) => (
<li key={r.id} className={`bash-row ${r.status}`}>
<button
type="button"
className="bash-row-main"
onClick={() => setOpenId(openId === r.id ? null : r.id)}
>
<span className="bash-status" aria-hidden>
{r.status === "ok"
? "✓"
: r.status === "running"
? "…"
: "✕"}
</span>
<code className="bash-cmd">$ {r.command}</code>
<span className="bash-dur">{fmtDur(r)}</span>
<span className="bash-chev">{openId === r.id ? "▾" : "▸"}</span>
</button>
{openId === r.id && r.output ? (
<pre className="bash-out">{r.output}</pre>
) : null}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,440 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AttachmentInfo, Settings, UsageInfo } from "@/lib/bridge";
import {
IconChevronDown,
IconPlus,
IconSend,
IconSparkle,
IconStop,
} from "@/components/Icons";
import bar from "@/dsh/conversation/InputBar.module.css";
const APPROVALS: {
value: Settings["approval"];
label: string;
desc: string;
}[] = [
{
value: "ask",
label: "始终询问",
desc: "只读自动;其余操作需确认",
},
{
value: "auto",
label: "必要时询问",
desc: "常规修改/命令自动;高风险再问",
},
{
value: "yolo",
label: "完全自动",
desc: "不打断;全部自行完成",
},
];
const EFFORTS: { value: string; label: string }[] = [
{ value: "", label: "Off" },
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
{ value: "max", label: "Max" },
];
export type SlashCommand = {
cmd: string;
desc: string;
run: () => void | Promise<void>;
};
export type MainView = "chat" | "plan" | "bash";
type Props = {
value: string;
busy: boolean;
settings: Settings;
models: string[];
attachments: AttachmentInfo[];
usage: UsageInfo;
slashCommands: SlashCommand[];
fileSuggestions: string[];
view: MainView;
hero?: boolean;
sendBlocked?: boolean;
sendBlockedReason?: string;
onChange: (v: string) => void;
onSend: () => void;
onStop: () => void;
onSettingsPatch: (patch: Partial<Settings>) => void;
onAttach: () => void;
onRemoveAttach: (id: string) => void;
onQueryFiles: (q: string) => void;
onInsert: (snippet: string) => void;
onView: (v: MainView) => void;
dragging?: boolean;
};
export function Composer({
value,
busy,
settings,
models,
attachments,
usage,
slashCommands,
fileSuggestions,
view: _view,
hero,
sendBlocked,
sendBlockedReason,
onChange,
onSend,
onStop,
onSettingsPatch,
onAttach,
onRemoveAttach,
onQueryFiles,
onInsert,
onView: _onView,
dragging,
}: Props) {
void _view;
void _onView;
const isHero = !!hero;
const ref = useRef<HTMLTextAreaElement>(null);
const [menu, setMenu] = useState<"model" | "approval" | null>(null);
const [palIdx, setPalIdx] = useState(0);
const slash = useMemo(() => {
const m = value.match(/(?:^|\n)\/([^\s]*)$/);
if (!m) return null;
const q = m[1].toLowerCase();
return slashCommands.filter(
(c) => c.cmd.slice(1).startsWith(q) || c.cmd.includes(q),
);
}, [value, slashCommands]);
const atQuery = useMemo(() => {
const m = value.match(/@([^\s]*)$/);
return m ? m[1] : null;
}, [value]);
useEffect(() => {
if (atQuery !== null) onQueryFiles(atQuery);
}, [atQuery, onQueryFiles]);
useEffect(() => {
setPalIdx(0);
}, [slash, atQuery, fileSuggestions]);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
const floor = isHero ? 52 : 36;
const next = Math.min(220, Math.max(floor, el.scrollHeight + 2));
el.style.height = `${next}px`;
}, [value, isHero]);
const replaceTrigger = (re: RegExp, insert: string) => {
const next = value.replace(re, insert);
onChange(next);
onInsert(insert);
};
const approvalLabel =
APPROVALS.find((a) => a.value === settings.approval)?.label ??
settings.approval;
const effort = settings.reasoning_effort ?? "";
const effortLabel =
EFFORTS.find((e) => e.value === effort)?.label ?? (effort || "Off");
const blocked = !!sendBlocked && !busy;
const canSend =
!blocked && !busy && (!!value.trim() || attachments.length > 0);
return (
<div className={`${bar.root}${isHero ? ` ${bar.hero}` : ""}`}>
{sendBlockedReason ? (
<div className={bar.notice}>{sendBlockedReason}</div>
) : null}
<div className={bar.card}>
{dragging ? (
<div className="hermit-drop-overlay">Drop files to attach</div>
) : null}
<div className={bar.overlayAnchor}>
{slash && slash.length > 0 ? (
<div className="hermit-palette" role="listbox">
{slash.map((c, i) => (
<button
key={c.cmd}
type="button"
className={i === palIdx ? "active" : ""}
onMouseDown={(e) => {
e.preventDefault();
void c.run();
onChange(
value.replace(/(?:^|\n)\/[^\s]*$/, "").trimStart(),
);
}}
>
<span className="pal-cmd">{c.cmd}</span>
<span className="pal-desc">{c.desc}</span>
</button>
))}
</div>
) : null}
{atQuery !== null && fileSuggestions.length > 0 ? (
<div className="hermit-palette" role="listbox">
{fileSuggestions.map((p, i) => (
<button
key={p}
type="button"
className={i === palIdx ? "active" : ""}
onMouseDown={(e) => {
e.preventDefault();
replaceTrigger(/@[^\s]*$/, `\`${p}\` `);
}}
>
<span className="pal-cmd">{p}</span>
<span className="pal-desc">Insert path</span>
</button>
))}
</div>
) : null}
</div>
{attachments.length > 0 ? (
<div className="hermit-attach-rail">
{attachments.map((a) => (
<div key={a.id} className="hermit-attach-chip" title={a.path}>
<span>{a.name}</span>
<button
type="button"
aria-label="Remove"
onClick={() => onRemoveAttach(a.id)}
>
×
</button>
</div>
))}
</div>
) : null}
<div className={bar.scroll}>
<textarea
ref={ref}
className="hermit-textarea"
rows={2}
placeholder={
blocked
? "Engine required to send…"
: isHero
? "Ask Hermit to build, fix, or explore…"
: "Describe a task, / commands, @ files…"
}
value={value}
disabled={blocked}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
const items =
slash && slash.length > 0
? slash
: atQuery !== null
? fileSuggestions
: null;
if (items && items.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPalIdx((i) => (i + 1) % items.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setPalIdx((i) => (i - 1 + items.length) % items.length);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
if (slash && slash.length > 0) {
const c = slash[palIdx];
void c.run();
onChange(
value.replace(/(?:^|\n)\/[^\s]*$/, "").trimStart(),
);
} else if (atQuery !== null) {
const p = fileSuggestions[palIdx];
if (p) replaceTrigger(/@[^\s]*$/, `\`${p}\` `);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
onChange(
value
.replace(/(?:^|\n)\/[^\s]*$/, "")
.replace(/@[^\s]*$/, ""),
);
return;
}
}
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
if (canSend) onSend();
}
}}
/>
</div>
<div className={bar.row}>
<div className={bar.tools}>
<button
type="button"
className={bar.add}
title="Attach"
onClick={onAttach}
disabled={blocked}
>
<IconPlus size={14} />
</button>
<div className={bar.modes}>
<div style={{ position: "relative" }}>
<button
type="button"
className="hermit-toolbar-btn"
disabled={blocked}
onClick={() =>
setMenu(menu === "approval" ? null : "approval")
}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 99,
background: "var(--dsw-alias-state-business-primary)",
}}
/>
{approvalLabel}
<IconChevronDown size={12} />
</button>
{menu === "approval" ? (
<div className="hermit-chip-menu">
<div className="menu-section">Autonomy</div>
{APPROVALS.map((a) => (
<button
key={a.value}
type="button"
className={
a.value === settings.approval ? "active" : ""
}
onClick={() => {
onSettingsPatch({ approval: a.value });
setMenu(null);
}}
>
<strong>{a.label}</strong>
<span className="menu-desc">{a.desc}</span>
</button>
))}
</div>
) : null}
</div>
<button
type="button"
className="hermit-toolbar-btn"
disabled={blocked}
onClick={() =>
onSettingsPatch({ plan_mode: !settings.plan_mode })
}
>
Plan{settings.plan_mode ? " · on" : ""}
</button>
</div>
</div>
<div className={bar.trailing}>
<div style={{ position: "relative" }}>
<button
type="button"
className="hermit-toolbar-btn"
disabled={blocked}
onClick={() => setMenu(menu === "model" ? null : "model")}
>
<IconSparkle size={12} />
<strong>{settings.model}</strong>
<span>· {effortLabel}</span>
<IconChevronDown size={12} />
</button>
{menu === "model" ? (
<div className="hermit-chip-menu">
<div className="menu-section">Models</div>
{models.map((m) => (
<button
key={m}
type="button"
className={m === settings.model ? "active" : ""}
onClick={() => onSettingsPatch({ model: m })}
>
{m}
</button>
))}
<div className="menu-section">Thinking</div>
<div className="hermit-effort-row">
{EFFORTS.map((e) => (
<button
key={e.label}
type="button"
className={
(settings.reasoning_effort ?? "") === e.value
? "active"
: ""
}
onClick={() => {
onSettingsPatch({ reasoning_effort: e.value });
setMenu(null);
}}
>
{e.label}
</button>
))}
</div>
</div>
) : null}
</div>
<span
className="hermit-toolbar-btn"
title="Context usage"
style={{ cursor: "default" }}
>
{usage.pct > 0 ? `${Math.round(usage.pct)}%` : "ctx"}
</span>
{busy ? (
<button type="button" className="hermit-send stop" onClick={onStop}>
<IconStop size={12} />
Stop
</button>
) : (
<button
type="button"
className={bar.primary}
disabled={!canSend}
onClick={onSend}
aria-label="Send"
>
<IconSend size={16} />
</button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { HermitMark, type HermitMood } from "@/components/Icons";
import hero from "@/dsh/conversation/HeroShell.module.css";
type Props = {
mood?: HermitMood;
};
/**
* DSH EmptyHero parity: headline only (mark 34 + title + preview badge).
* Workspace chip + composer live on InputBar / ConversationRoot, not here.
*/
export function Hero({ mood = "idle" }: Props) {
return (
<div className={hero.root}>
<div className={hero.stack}>
<div className={hero.headline}>
<span className={hero.fishHitbox}>
<HermitMark size={34} mood={mood} className={`${hero.fish} hermit-hero-mark`} />
</span>
<div className={hero.titleGroup}>
<span>Meet Hermit</span>
<span className={hero.previewBadge}>Preview</span>
</div>
</div>
<div className={hero.body} />
</div>
</div>
);
}
@@ -0,0 +1,228 @@
import type { ReactNode } from "react";
import idleShell from "@/assets/hermit/idle-shell.svg?raw";
import thinkingShell from "@/assets/hermit/thinking-shell.svg?raw";
import codingShell from "@/assets/hermit/coding-shell.svg?raw";
import successShell from "@/assets/hermit/success-shell.svg?raw";
import errorShell from "@/assets/hermit/error-shell.svg?raw";
type IconProps = {
size?: number;
className?: string;
title?: string;
};
export type HermitMood = "idle" | "thinking" | "coding" | "success" | "error";
const MOOD_SVG: Record<HermitMood, string> = {
idle: idleShell,
thinking: thinkingShell,
coding: codingShell,
success: successShell,
error: errorShell,
};
function Svg({
size = 16,
className,
title,
children,
}: IconProps & { children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden={title ? undefined : true}
role={title ? "img" : undefined}
>
{title ? <title>{title}</title> : null}
{children}
</svg>
);
}
/** Designed agent-motion hermit crab (shell + body + eyes). */
export function HermitMark({
size = 24,
className,
mood = "idle",
}: IconProps & { mood?: HermitMood }) {
const h = Math.round(size * (80 / 96));
return (
<span
className={`hermit-mark${className ? ` ${className}` : ""}`}
style={{ width: size, height: h, display: "inline-flex" }}
aria-hidden
dangerouslySetInnerHTML={{ __html: scaleSvg(MOOD_SVG[mood], size, h) }}
/>
);
}
function scaleSvg(raw: string, w: number, h: number): string {
// WebKitGTK + NVIDIA: infinite CSS animations inside injected SVGs freeze the
// compositor ("没有响应"). Strip <style> so marks stay static glyphs.
const stripped = raw.replace(/<style[\s\S]*?<\/style>/gi, "");
return stripped.replace(
/<svg\b([^>]*)>/,
(_m, attrs: string) => {
let next = String(attrs)
.replace(/\swidth="[^"]*"/g, "")
.replace(/\sheight="[^"]*"/g, "");
return `<svg${next} width="${w}" height="${h}">`;
},
);
}
export function IconPlus(p: IconProps) {
return (
<Svg {...p}>
<path
d="M8 3.2v9.6M3.2 8h9.6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</Svg>
);
}
export function IconSearch(p: IconProps) {
return (
<Svg {...p}>
<circle cx="7" cy="7" r="4.2" stroke="currentColor" strokeWidth="1.4" />
<path
d="M10.2 10.2L13.2 13.2"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</Svg>
);
}
export function IconFolder(p: IconProps) {
return (
<Svg {...p}>
<path
d="M2.5 5.5h4l1.2 1.5H13.5a1 1 0 011 1v5a1 1 0 01-1 1h-11a1 1 0 01-1-1v-6.5a1 1 0 011-1z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconChevronDown(p: IconProps) {
return (
<Svg {...p}>
<path
d="M4 6.5L8 10.5L12 6.5"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconSend(p: IconProps) {
return (
<Svg {...p}>
<path
d="M8 12.5V3.5M8 3.5L4.5 7M8 3.5L11.5 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconGear(p: IconProps) {
return (
<Svg {...p}>
<circle cx="8" cy="8" r="2.2" stroke="currentColor" strokeWidth="1.3" />
<path
d="M8 2.2v1.4M8 12.4v1.4M2.2 8h1.4M12.4 8h1.4M3.9 3.9l1 1M11.1 11.1l1 1M12.1 3.9l-1 1M4.9 11.1l-1 1"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</Svg>
);
}
export function IconPencil(p: IconProps) {
return (
<Svg {...p}>
<path
d="M10.5 2.8l2.7 2.7L5.8 13H3.1v-2.7L10.5 2.8z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconTrash(p: IconProps) {
return (
<Svg {...p}>
<path
d="M3.5 5h9M6 5V3.5h4V5M5 5l.5 8h5l.5-8"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconPin(p: IconProps) {
return (
<Svg {...p}>
<path
d="M9.8 2.8l3.4 3.4-2.2.6-2.8 2.8-.8 2.8-2.8-.8 2.8-2.8.6-2.2zM6.2 9.8L3.5 12.5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconStop(p: IconProps) {
return (
<Svg {...p}>
<rect
x="4"
y="4"
width="8"
height="8"
rx="1.2"
fill="currentColor"
/>
</Svg>
);
}
export function IconSparkle(p: IconProps) {
return (
<Svg {...p}>
<path
d="M8 2.5l1.1 3.4L12.5 7 9.1 8.1 8 11.5 6.9 8.1 3.5 7l3.4-1.1L8 2.5z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</Svg>
);
}
@@ -0,0 +1,76 @@
export type PlanStep = {
text: string;
status: "pending" | "done" | "running";
};
export function parseTodo(text: string): PlanStep[] {
if (!text.trim()) return [];
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const m = line.match(/^[-*]\s*\[([ xX~])\]\s*(.*)$/);
if (m) {
const mark = m[1].toLowerCase();
return {
text: m[2] || line,
status: mark === "x" ? "done" : mark === "~" ? "running" : "pending",
} as PlanStep;
}
if (/^(done|✓|✔)/i.test(line)) {
return { text: line.replace(/^(done|✓|✔)\s*/i, ""), status: "done" as const };
}
if (/^(wip|running|\.\.\.)/i.test(line)) {
return {
text: line.replace(/^(wip|running|\.\.\.)\s*/i, ""),
status: "running" as const,
};
}
return { text: line.replace(/^[-*]\s*/, ""), status: "pending" as const };
});
}
type Props = {
text: string;
planMode: boolean;
onClose: () => void;
onTogglePlanMode: () => void;
};
export function PlanPanel({ text, planMode, onClose, onTogglePlanMode }: Props) {
const steps = parseTodo(text);
return (
<div className="work-panel plan-panel">
<div className="work-panel-head">
<strong>计划</strong>
<div className="work-panel-actions">
<button
type="button"
className={planMode ? "active" : ""}
onClick={onTogglePlanMode}
>
Plan mode {planMode ? "on" : "off"}
</button>
<button type="button" onClick={onClose}>
关闭
</button>
</div>
</div>
{steps.length === 0 ? (
<div className="work-empty">暂无计划步骤。Agent 写入 todo 后会出现在这里。</div>
) : (
<ol className="plan-steps">
{steps.map((s, i) => (
<li key={`${i}-${s.text}`} className={`plan-step ${s.status}`}>
<span className="plan-mark" aria-hidden>
{s.status === "done" ? "✓" : s.status === "running" ? "…" : "○"}
</span>
<span className="plan-text">{s.text}</span>
</li>
))}
</ol>
)}
</div>
);
}
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from "react";
import type { QuestionRequest } from "@/lib/bridge";
import appr from "@/dsh/approval/ApprovalPanel.module.css";
type Props = {
req: QuestionRequest;
onAnswer: (answer: string) => void;
onSkip: () => void;
};
export function QuestionCard({ req, onAnswer, onSkip }: Props) {
const [text, setText] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
rootRef.current?.focus();
}, [req.id]);
return (
<div className={appr.root}>
<div
className={appr.card}
ref={rootRef}
tabIndex={-1}
role="group"
aria-label="Question"
>
<div className={appr.strip}>Waiting for your answer</div>
<div className={appr.body}>
<div className={appr.headline}>{req.question}</div>
{req.options.length > 0 ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{req.options.map((opt) => (
<button
key={opt}
type="button"
className="hermit-toolbar-btn"
onClick={() => onAnswer(opt)}
>
{opt}
</button>
))}
</div>
) : null}
<input
className="question-input"
style={{
marginTop: 8,
height: 32,
width: "100%",
boxSizing: "border-box",
padding: "0 10px",
borderRadius: "var(--dsw-radius-sm)",
border: "0.5px solid var(--dsw-alias-border-l3)",
background: "var(--dsw-alias-bg-layer-1)",
color: "var(--dsw-alias-label-primary)",
font: "inherit",
}}
value={text}
placeholder={
req.options.length > 0
? "Or type a custom answer…"
: "Type your answer…"
}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && text.trim()) onAnswer(text.trim());
}}
/>
</div>
<div className={appr.actionRow}>
<button type="button" className="hermit-toolbar-btn" onClick={onSkip}>
Skip
</button>
<button
type="button"
className="hermit-send"
style={{ width: "auto", padding: "0 14px", borderRadius: 8 }}
disabled={!text.trim() && req.options.length === 0}
onClick={() => onAnswer(text.trim())}
>
Submit
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,300 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { SessionInfo } from "@/lib/bridge";
import {
HermitMark,
IconFolder,
IconGear,
IconPencil,
IconPin,
IconPlus,
IconSearch,
IconTrash,
} from "@/components/Icons";
import browser from "@/dsh/workspace/WorkspaceBrowser.module.css";
import rows from "@/dsh/workspace/Rows.module.css";
import sidebar from "@/dsh/workspace/SidebarRoot.module.css";
type Props = {
sessions: SessionInfo[];
activeId: string | null;
workspace: string;
query: string;
engine: string;
onQuery: (q: string) => void;
onNew: () => void;
onOpen: (id: string) => void;
onRename: (id: string, title: string) => void;
onDelete: (id: string) => void;
onPin?: (id: string) => void;
onSetFolder?: (id: string, folder: string | null) => void;
onSettings: () => void;
onPickWorkspace: () => void;
};
function normalizeTs(ts: number): number {
if (ts > 0 && ts < 1e12) return ts * 1000;
return ts;
}
function relTime(ts: number): string {
const t = normalizeTs(ts);
const d = Date.now() - t;
if (d < 0 || Number.isNaN(d)) return "";
if (d < 60_000) return "just now";
if (d < 3600_000) return `${Math.floor(d / 60_000)}m`;
if (d < 86400_000) return `${Math.floor(d / 3600_000)}h`;
if (d < 7 * 86400_000) return `${Math.floor(d / 86400_000)}d`;
return new Date(t).toLocaleDateString();
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function sessionLabel(s: SessionInfo): string {
const t = (s.title || "").trim();
if (!t || UUID_RE.test(t) || t === s.id) return "New session";
return t;
}
export function Rail({
sessions,
activeId,
workspace,
query,
engine,
onQuery,
onNew,
onOpen,
onRename,
onDelete,
onPin,
onSetFolder,
onSettings,
onPickWorkspace,
}: Props) {
const [searchOpen, setSearchOpen] = useState(false);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const searchRef = useRef<HTMLInputElement>(null);
const filtered = sessions.filter(
(s) =>
!query ||
sessionLabel(s).toLowerCase().includes(query.toLowerCase()) ||
s.title.toLowerCase().includes(query.toLowerCase()),
);
const groups = useMemo(() => {
const map = new Map<string, SessionInfo[]>();
for (const s of filtered) {
const key = s.folder?.trim() || "";
const list = map.get(key) ?? [];
list.push(s);
map.set(key, list);
}
return [...map.entries()].sort(([a], [b]) => {
if (!a) return 1;
if (!b) return -1;
return a.localeCompare(b);
});
}, [filtered]);
const wsLabel =
workspace === "." || !workspace
? "workspace"
: workspace.split(/[/\\]/).filter(Boolean).pop() || workspace;
useEffect(() => {
if (searchOpen) searchRef.current?.focus();
}, [searchOpen]);
return (
<aside className={sidebar.root}>
<div className={sidebar.logoRow}>
<button type="button" className={sidebar.brand} onClick={onNew}>
<span className={sidebar.brandIdentity}>
<span className={sidebar.brandMark}>
<HermitMark size={22} />
</span>
<span className={sidebar.brandName}>
<span className={sidebar.fallbackBrandName}>Hermit</span>
</span>
</span>
</button>
<button
type="button"
className={sidebar.iconButton}
aria-label="Settings"
title={`engine · ${engine}`}
onClick={onSettings}
>
<IconGear size={16} />
</button>
</div>
<button type="button" className={sidebar.newSession} onClick={onNew}>
<span className={sidebar.newSessionContent}>
<IconPlus size={16} />
<span className={sidebar.newSessionLabel}>New session</span>
</span>
</button>
<div className={browser.root}>
<div className={browser.sectionHeader}>
<button
type="button"
className={browser.iconButton}
title={workspace}
onClick={onPickWorkspace}
>
<IconFolder size={16} />
</button>
<button
type="button"
className={browser.iconButton}
aria-label="Search"
onClick={() => setSearchOpen((v) => !v)}
>
<IconSearch size={16} />
</button>
</div>
{searchOpen ? (
<input
ref={searchRef}
className={browser.searchInput}
value={query}
placeholder="Search sessions"
onChange={(e) => onQuery(e.target.value)}
/>
) : null}
<button
type="button"
className={rows.projectRow}
title={workspace}
onClick={onPickWorkspace}
>
<span className={rows.slot}>
<IconFolder size={16} />
</span>
<span className={rows.title}>{wsLabel}</span>
<span className={rows.time}>{engine}</span>
</button>
<div className={browser.listArea}>
<div className={browser.list}>
{groups.map(([folder, list]) => {
const key = folder || "__ungrouped";
const isCollapsed = !!collapsed[key];
return (
<div key={key} className={browser.groupSection}>
{folder ? (
<button
type="button"
className={rows.projectRow}
onClick={() =>
setCollapsed((c) => ({ ...c, [key]: !c[key] }))
}
>
<span className={rows.title}>{folder}</span>
<span className={rows.time}>
{isCollapsed ? "▸" : "▾"}
</span>
</button>
) : null}
{!isCollapsed
? list.map((s) => {
const selected = s.id === activeId;
return (
<div
key={s.id}
className={`${rows.sessionRow}${selected ? ` ${rows.selected}` : ""}`}
onClick={() => onOpen(s.id)}
onKeyDown={(e) => {
if (e.key === "Enter") onOpen(s.id);
}}
role="button"
tabIndex={0}
>
<span className={rows.title}>{sessionLabel(s)}</span>
<span className={rows.rowResting}>
{s.pinned ? (
<span className={rows.pinIndicator}>📌</span>
) : null}
<span className={rows.time}>
{relTime(s.updated_at)}
</span>
</span>
<div
className={`${rows.rowActions} hermit-row-actions`}
>
{onPin ? (
<button
type="button"
className={browser.iconButton}
title="Pin"
onClick={(e) => {
e.stopPropagation();
onPin(s.id);
}}
>
<IconPin size={14} />
</button>
) : null}
<button
type="button"
className={browser.iconButton}
title="Rename"
onClick={(e) => {
e.stopPropagation();
const title = window.prompt(
"Rename",
sessionLabel(s),
);
if (title?.trim())
onRename(s.id, title.trim());
}}
>
<IconPencil size={14} />
</button>
{onSetFolder ? (
<button
type="button"
className={browser.iconButton}
title="Folder"
onClick={(e) => {
e.stopPropagation();
const next = window.prompt(
"Folder (empty to clear)",
s.folder ?? "",
);
if (next === null) return;
onSetFolder(s.id, next.trim() || null);
}}
>
<IconFolder size={14} />
</button>
) : null}
<button
type="button"
className={browser.iconButton}
title="Delete"
onClick={(e) => {
e.stopPropagation();
onDelete(s.id);
}}
>
<IconTrash size={14} />
</button>
</div>
</div>
);
})
: null}
</div>
);
})}
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,67 @@
import { HermitMark, type HermitMood } from "@/components/Icons";
import conv from "@/dsh/conversation/ConversationRoot.module.css";
type Props = {
workspaceLabel: string;
folder?: string | null;
title: string;
mood: HermitMood;
status?: string;
engine: string;
};
export function SessionHeader({
workspaceLabel,
folder,
title,
mood,
status,
engine,
}: Props) {
return (
<header className={conv.header}>
<div className={conv.titleRow}>
<HermitMark size={18} mood={mood} />
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
minWidth: 0,
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: 14,
fontWeight: 500,
color: "var(--dsw-alias-label-primary)",
}}
>
{title}
</span>
<span className="hermit-engine-chip" data-mode={engine}>
{engine}
</span>
</div>
<div
style={{
fontSize: 12,
color: "var(--dsw-alias-label-tertiary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{workspaceLabel}
{folder ? ` / ${folder}` : ""}
{status ? ` · ${status}` : ""}
</div>
</div>
</div>
</header>
);
}
@@ -0,0 +1,242 @@
import { useState } from "react";
import type { ProviderInfo, Settings } from "@/lib/bridge";
type Tab = "general" | "model" | "agent" | "workspace" | "about";
type Props = {
settings: Settings;
providers: ProviderInfo[];
engine: string;
onSave: (s: Settings) => void;
onUseProvider: (provider: string, model: string, key?: string) => void;
onClose: () => void;
};
export function SettingsPanel({
settings,
providers,
engine,
onSave,
onUseProvider,
onClose,
}: Props) {
const [tab, setTab] = useState<Tab>("general");
const [draft, setDraft] = useState<Settings>({
reasoning_effort: "high",
font_size: "m",
language: "zh",
native_titlebar: false,
...settings,
});
const tabs: { id: Tab; label: string }[] = [
{ id: "general", label: "通用" },
{ id: "model", label: "模型" },
{ id: "agent", label: "智能体" },
{ id: "workspace", label: "工作区" },
{ id: "about", label: "关于" },
];
return (
<div className="settings-scrim" onClick={onClose}>
<div
className="settings-card"
style={{ width: "min(720px, calc(100% - 48px))", display: "flex", gap: 16 }}
onClick={(e) => e.stopPropagation()}
>
<div className="settings-nav">
<h3>设置</h3>
{tabs.map((t) => (
<button
key={t.id}
type="button"
className={tab === t.id ? "active" : ""}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div className="settings-body">
{tab === "general" ? (
<>
<label className="field">
<span>字号</span>
<div className="seg">
{(["s", "m", "l", "xl"] as const).map((f) => (
<button
key={f}
type="button"
className={draft.font_size === f ? "active" : ""}
onClick={() => setDraft({ ...draft, font_size: f })}
>
{f.toUpperCase()}
</button>
))}
</div>
</label>
<label className="field">
<span>语言</span>
<div className="seg">
<button
type="button"
className={draft.language === "zh" ? "active" : ""}
onClick={() => setDraft({ ...draft, language: "zh" })}
>
简体中文
</button>
<button
type="button"
className={draft.language === "en" ? "active" : ""}
onClick={() => setDraft({ ...draft, language: "en" })}
>
English
</button>
</div>
</label>
<label className="field field-row">
<span>使用系统标题栏</span>
<input
type="checkbox"
checked={!!draft.native_titlebar}
onChange={(e) =>
setDraft({ ...draft, native_titlebar: e.target.checked })
}
/>
</label>
</>
) : null}
{tab === "model" ? (
<>
<label className="field">
<span>Provider</span>
<select
value={draft.provider}
onChange={(e) => setDraft({ ...draft, provider: e.target.value })}
>
{providers.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
{p.has_key ? "" : " (no key)"}
</option>
))}
{!providers.find((p) => p.name === draft.provider) ? (
<option value={draft.provider}>{draft.provider}</option>
) : null}
</select>
</label>
<label className="field">
<span>Model</span>
<input
value={draft.model}
onChange={(e) => setDraft({ ...draft, model: e.target.value })}
/>
</label>
<label className="field">
<span>Thinking / effort</span>
<select
value={draft.reasoning_effort ?? ""}
onChange={(e) =>
setDraft({ ...draft, reasoning_effort: e.target.value })
}
>
<option value="">Off</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="max">Max</option>
</select>
</label>
<label className="field">
<span>API key</span>
<input
type="password"
value={draft.api_key ?? ""}
placeholder="sk-…"
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
/>
</label>
</>
) : null}
{tab === "agent" ? (
<>
<label className="field">
<span>Approval</span>
<select
value={draft.approval}
onChange={(e) =>
setDraft({
...draft,
approval: e.target.value as Settings["approval"],
})
}
>
<option value="ask">始终询问 (ask)</option>
<option value="auto">必要时询问 (auto)</option>
<option value="yolo">完全自动 (yolo)</option>
</select>
</label>
<label className="field field-row">
<span>Plan mode 默认</span>
<input
type="checkbox"
checked={!!draft.plan_mode}
onChange={(e) =>
setDraft({ ...draft, plan_mode: e.target.checked })
}
/>
</label>
</>
) : null}
{tab === "workspace" ? (
<label className="field">
<span>Workspace</span>
<input
value={draft.workspace}
onChange={(e) => setDraft({ ...draft, workspace: e.target.value })}
/>
</label>
) : null}
{tab === "about" ? (
<div className="about-block">
<p>
<strong>Hermit</strong> desktop 1.1.0
</p>
<p>
Engine: <code>{engine}</code>
</p>
<p className="muted">暗色色卡 · 本地 agent 工作台</p>
</div>
) : null}
<div className="modal-actions">
<button type="button" onClick={onClose}>
Cancel
</button>
{tab !== "about" ? (
<button
type="button"
className="primary"
onClick={() => {
onUseProvider(draft.provider, draft.model, draft.api_key);
onSave(draft);
onClose();
}}
>
Save
</button>
) : (
<button type="button" className="primary" onClick={onClose}>
Close
</button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import type { ChatItem } from "@/lib/bridge";
import { isShellTool } from "@/lib/bridge";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import chat from "@/dsh/chat/ChatView.module.css";
import msg from "@/dsh/chat/MessageItem.module.css";
import md from "@/dsh/chat/AssistantMarkdown.module.css";
import reason from "@/dsh/chat/ReasoningRow.module.css";
import tool from "@/dsh/chat/GenericCommandCard.module.css";
type Props = { items: ChatItem[]; busy: boolean };
function fmtTs(ts?: number): string {
if (!ts) return "";
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
export function Transcript({ items, busy }: Props) {
const scroller = useRef<HTMLDivElement>(null);
const [follow, setFollow] = useState(true);
const [showJump, setShowJump] = useState(false);
useEffect(() => {
if (!follow) return;
const el = scroller.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [items, busy, follow]);
return (
<div className={chat.frame}>
<div className={chat.root} data-chat-following-tail={follow || undefined}>
<div
className={chat.scroll}
ref={scroller}
onScroll={() => {
const el = scroller.current;
if (!el) return;
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 48;
setFollow(atBottom);
setShowJump(!atBottom);
}}
>
<div className={chat.column}>
{items.map((it) => {
if (it.kind === "user") {
return (
<div className={msg.userRow} key={it.id}>
<div className={msg.userStack}>
<div className="hermit-user-bubble">{it.text}</div>
{it.ts ? (
<div className={msg.referenceSummary}>{fmtTs(it.ts)}</div>
) : null}
</div>
</div>
);
}
if (it.kind === "assistant") {
return (
<div className={md.root} key={it.id}>
<div className={md.body}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{it.text}
</ReactMarkdown>
{busy && items[items.length - 1]?.id === it.id ? (
<span className="hermit-cursor" aria-hidden />
) : null}
</div>
<div className={md.actions}>
{it.ts ? <span>{fmtTs(it.ts)}</span> : null}
<button
type="button"
className="hermit-toolbar-btn"
onClick={() =>
void navigator.clipboard?.writeText(it.text)
}
>
复制
</button>
</div>
</div>
);
}
if (it.kind === "thought") {
return (
<details
className={reason.root}
key={it.id}
open={busy}
data-expanded={busy || undefined}
data-preview
>
<summary className={reason.row}>
<span className={reason.leading}>✦</span>
<span className={reason.title}>Thought</span>
<span className={reason.separator} />
<span className={reason.summary}>
<span className={reason.summaryText}>
{firstLine(it.text)}
</span>
</span>
</summary>
<div className={reason.thinkBody}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{it.text}
</pre>
</div>
</details>
);
}
return <ToolCard key={it.id} item={it} />;
})}
</div>
</div>
</div>
{showJump ? (
<button
type="button"
className="hermit-toolbar-btn"
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
background: "var(--dsw-alias-bg-layer-2)",
boxShadow: "var(--dsw-elevation-soft)",
}}
onClick={() => {
setFollow(true);
setShowJump(false);
const el = scroller.current;
if (el) el.scrollTop = el.scrollHeight;
}}
>
Jump to latest
</button>
) : null}
</div>
);
}
function firstLine(s: string) {
return s.split("\n").find((l) => l.trim())?.slice(0, 80) ?? "";
}
function ToolCard({ item }: { item: Extract<ChatItem, { kind: "tool" }> }) {
const shell = isShellTool(item.name);
return (
<details
className={tool.root}
open={item.status === "running"}
data-error={
item.status === "error" || item.status === "denied" || undefined
}
>
<summary
style={{ display: "flex", alignItems: "center", cursor: "pointer" }}
>
<span className={tool.leading}>{shell ? "$" : "⚙"}</span>
<span className={tool.title}>{item.name}</span>
<span className={tool.separator} />
<span
className={tool.summary}
data-error={
item.status === "error" || item.status === "denied" || undefined
}
>
{item.detail || item.status}
</span>
</summary>
{(item.output || item.detail) && (
<pre
className={tool.body}
data-error={
item.status === "error" || item.status === "denied" || undefined
}
>
{item.output || item.detail}
</pre>
)}
</details>
);
}
@@ -0,0 +1,35 @@
import {
IconChevronDown,
IconFolder,
} from "@/components/Icons";
import hero from "@/dsh/conversation/HeroShell.module.css";
type Props = {
workspace: string;
onPick: () => void;
};
/** DSH EmptyHero WorkspaceChip — folder + label + chevron. */
export function WorkspaceChip({ workspace, onPick }: Props) {
const has =
workspace !== "." && workspace !== "" && workspace.trim().length > 0;
const label = has
? workspace.split(/[/\\]/).filter(Boolean).pop() || workspace
: undefined;
return (
<button
type="button"
className={hero.workspace}
title={has ? workspace : undefined}
aria-label="Choose workspace"
onClick={onPick}
>
<IconFolder size={16} className={hero.folder} />
<span className={hero.workspaceLabel}>
{label ?? "Choose workspace"}
</span>
<IconChevronDown size={12} className={hero.chevron} />
</button>
);
}
@@ -0,0 +1,92 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px;
}
.card {
overflow: hidden;
width: 100%;
max-width: var(--dsh-chat-content-width);
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: var(--dsw-radius-xl);
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.strip {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
}
.body {
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
padding: 12px 16px 0;
}
.headline {
color: var(--dsw-alias-label-primary);
font-size: 15px;
font-weight: 500;
line-height: 24px;
}
.command {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 13px;
line-height: 20px;
word-break: break-all;
}
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 16px;
}
@media (hover: hover) {
.reject:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
--dsh-button-outline-color: transparent;
}
}
.subject {
display: flex;
align-items: center;
gap: 12px;
color: var(--dsw-alias-label-primary);
font-size: 16px;
}
.subject img {
flex-shrink: 0;
object-fit: contain;
}
.description {
margin: 0;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
}
.withSubject .body { text-align: left; }
.withSubject .actionRow { flex-wrap: wrap; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,78 @@
/* Assistant flow body: full-width narration (figma 16/28 scaled 0.875 → 14/24
with the markdown token ladder), block gap 16. The Settings font-size
preference rides the body-published axis: size follows
--dsh-content-font-size and the line height keeps the 10px leading via the
shared px delta. IconActions sit below the body with an explicit 16px top
margin (figma 43:32997) — separate from the body's internal gap so the
footer spacing stays fixed when the body is a single block. */
.root {
display: flex;
flex-direction: column;
font-size: var(--dsh-content-font-size, 14px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span
the whole transcript width instead of clipping at the 748px message
column, while the table content keeps starting at its normal x (the lead
padding compensates the negative margin) — deepsuite chat TableWrapper
parity, with 100cqw (the ChatView scroll container) standing in for its
JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero
when the transcript is narrower than the message column, so narrow
viewports keep the plain in-column scroll with no sideways shift.
Percentages resolve against the wrapper's containing block, so a table
indented inside a list still reaches the same transcript edges. */
.body :global(.md-table-wide) {
--dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2));
--dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%);
box-sizing: border-box;
width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare));
/* The base .tableScroll caps at the column (max-width: 100%); the breakout
is exactly the case that must exceed it. */
max-width: none;
margin-left: calc(-1 * var(--dsh-table-lead));
padding-left: var(--dsh-table-lead);
}
/* Only the painted table outranks Conversation's z-index 0 width handle. The
full-width scroll wrapper and its transparent alignment padding stay below,
so a fitting four-column table does not disable the surrounding gutter. */
.body :global(.md-table-wide) > table {
position: relative;
z-index: 1;
}
/* hidden="until-found" keeps a zero-height reasoning box in flex layout.
Cancel the one gap that box would otherwise leave before the visible reply;
visible Assistant blocks retain the ordinary 16px rhythm. */
.body > [data-turn-process-inline][hidden] {
margin-bottom: -16px;
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed
size like the small/code token variants — 11px is dense secondary text
that would fall to an illegible 9px at the 12px floor. */
.stopped {
align-self: flex-start;
padding: 0 6px;
border-radius: var(--dsw-radius-sm);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 18px;
}
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
.actions {
margin-top: 16px;
/* Optical align with the 28px action hit targets. */
margin-left: -6px;
}
@@ -0,0 +1,409 @@
.frame {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
/* Floating controls query this frame outside the clipped transcript. */
container-type: inline-size;
}
.root {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
overflow-x: visible;
overflow-y: clip;
}
.scroll {
flex: 1 1 auto;
min-height: 0;
/* Never expose horizontal scrollable overflow: the absolutely positioned
text-interaction surface adds it transiently, and the changing scroll range
makes the transcript jitter while the tail is pinned. */
overflow-x: clip;
overflow-y: auto;
/* Sides = composer clearance + 16px: on narrow viewports the transcript
stays exactly 32px narrower than the input card (the shared width rule). */
padding: 16px calc(var(--dsh-composer-side-clearance) + 16px);
/* Inline-size query container: wide markdown tables size their breakout
against this box's content width (100cqw in AssistantMarkdown's
md-table-wide rule) — the CSS stand-in for deepsuite chat's JS-measured
--dsl-virtual-list-width. */
container-type: inline-size;
}
.root[data-chat-following-tail] .scroll,
:global([data-conversation-scroll]):has(.root[data-chat-following-tail]) {
overflow-anchor: none;
}
:global([data-conversation-scroll]) .frame,
:global([data-conversation-scroll]) .root {
flex: 0 0 auto;
min-height: auto;
height: auto;
}
:global([data-conversation-scroll]) .scroll {
/* A host scrollport still owns vertical overflow; horizontal overflow is
clipped for the same reason as the self-scrolled case above. */
overflow-x: clip;
overflow-y: visible;
flex: 0 0 auto;
min-height: auto;
}
/* Message column: shared chat width (ConversationRoot --dsh-chat-content-width),
centered on the same axis as the input box (which caps at chat + 16px); the
scroller itself stays full-bleed. */
.column {
position: relative;
max-width: var(--dsh-chat-content-width);
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
}
/* The flow slot's display:contents anchor preserves layout but not selector ancestry.
`hidden="until-found"` retains a searchable zero-height box; hidden and empty
Seats do not contribute spacing. The older-history control precedes the anchor;
an ancestor :has() here also invalidates the history on unrelated descendant edits. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))
~ :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))),
.column > .older + :global([data-slot="conversation.chat.flow"])
> :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) {
margin-top: var(--dsh-chat-flow-gap, 4px);
}
/* Process rows sit 4px apart; an Assistant response is separated from its
neighbours by 12px. */
.flowItem[data-chat-group-part="response"] {
--dsh-chat-flow-gap: 12px;
}
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-group-part="response"]:not([hidden]):not(:empty)
+ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) {
--dsh-chat-flow-gap: 12px;
}
/* Settled-flow identity boundary. It is neutral until it becomes the natural
measurement/mount unit for a virtualizer without changing the column gap. */
.flowItem {
min-width: 0;
}
/* Bottom room: sized by the viewport owner while rows above the reader close,
given back only as later content grows into it. */
.turnSpacer {
height: 0;
}
/* While a Turn folds, spacing changes share the closing rows' timing. */
.column[data-chat-motion] > :not([data-chat-motion]),
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
transition: margin-top 160ms cubic-bezier(0.4, 0, 0.2, 1);
}
.column[data-input-fold] > :not([data-chat-motion]),
.column[data-input-fold] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
transition: none;
}
/* A Turn's duration control renders empty until the Turn closes; its gap appears with it in the same
frame, so the layout below changes once instead of sliding after the control's own height. */
.column[data-chat-motion] > [data-chat-flow-kind="turn-process"],
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > [data-chat-flow-kind="turn-process"] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.column[data-chat-motion] > :not([data-chat-motion]),
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
transition: none;
}
}
/* The Turn header has 16px clearance from its input and following content. A
running Turn's control renders empty until the Turn closes; an echo after it
is still an opening input of that Turn, so it keeps the input gap. */
.flowItem[data-chat-flow-kind="turn-process"],
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"]:not(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])),
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not([data-submission-echo], [data-pending-steering]):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) {
--dsh-chat-flow-gap: 16px;
}
/* Tail slot: the running status and a completed Turn's actions row both take
a 16px offset plus a 28px row. The steering renderer marks the first
steering row of each claim that Chat orders after the Turn's process
control; that row keeps the slot above it, the way a completed Turn's
actions row precedes the next user message, while rows claimed together
stay 4px apart and opening inputs keep ordinary spacing. The first waiting
echo sits directly below the running status, so admission swaps the two
without moving any bubble. Neighbour gap rules exclude the batch start so
this slot always applies. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > .flowItem:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch="start"]) {
--dsh-chat-flow-gap: calc(44px + var(--dsh-content-font-delta, 0px));
}
.running + [data-pending-steering] {
--dsh-chat-flow-gap: 0px;
}
/* A completed Turn's actions row owns the tail slot; the status can trail it
for one frame before the Session stops running, or between queued Turns. */
.flowItem[data-chat-flow-kind="turn-tail"] + .running {
display: none;
}
/* Steering rows keep their full layout from insertion. Only the status moves:
it enters its new slot below them from one slot higher, independent of how
many admitted rows it passes or how tall they are. The start therefore sits
below the status's previous position by the admitted rows' height; the
fade-in covers that difference, so this entrance approximates the move. */
@media (prefers-reduced-motion: no-preference) {
:where(.column > :global([data-slot="conversation.chat.flow"]) > .flowItem[data-chat-flow-kind="steering"]) + .running {
animation: running-enters-slot 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* An input fold runs the entrance at zero duration. The switch only changes
the duration of an entrance its structural change already created, as if
the entrance had it from its start: lifting the switch never replays an
entrance older than its duration, and setting it completes a running one.
The structural rule is wrapped in :where() so this rule outranks it. */
.column[data-input-fold] .running {
animation-duration: 0s;
}
}
@keyframes running-enters-slot {
from {
opacity: 0;
translate: 0 calc(-44px - var(--dsh-content-font-delta, 0px));
}
40% {
opacity: 1;
}
}
/* Empty Seats, including retained empty slot anchors, preserve Turn lookup
without contributing height or the sibling gap. */
.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) {
height: 0;
}
.callRow {
border-radius: var(--dsw-radius-sm);
}
/* Selection still sets data-selected for details linkage; no outline —
tool rows match Think chrome (no selected ring). */
.hint {
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.running {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
--dsh-chat-flow-gap: 12px;
--dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer);
color: var(--dsw-alias-label-deep-diving);
font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
}
/* After the Session stops running and before the Turn's actions row arrives, the status keeps its slot
unseen so the tail does not shrink for that interval. */
.running[data-holding] {
visibility: hidden;
opacity: 0;
}
/* The last visible row before the status must contain output, not an input.
Hidden and empty seats do not separate a user message from the status, and
the steering/Inbox echoes after the status are outside the count. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
[data-chat-group-key]
) ~ .running > .runningDivider {
opacity: 1;
}
/* Drawn inside the 16px offset above the status row, so showing or hiding it
never changes the tail slot's height. */
.runningDivider {
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 0.5px;
background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2));
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.runningDivider {
transition: opacity 160ms ease;
}
}
/* Same offset and height as the completed Turn's tail actions row. */
.runningContent {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 6px;
margin-top: 4px;
height: calc(28px + var(--dsh-content-font-delta, 0px));
}
.runningText {
min-width: 0;
font-variant-numeric: tabular-nums;
}
.runningIcon {
position: relative;
display: inline-flex;
flex: none;
width: calc(14px + var(--dsh-content-font-delta, 0px));
height: calc(14px + var(--dsh-content-font-delta, 0px));
/* The fixed footprint contains the animation's layout and paint. */
contain: strict;
overflow: hidden;
}
.runningWhaleAnimated {
display: none;
position: absolute;
inset: 0;
}
.runningWhaleStill {
display: initial;
}
@supports (mask-mode: alpha) and (mask-image: url('')) {
@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
.runningWhaleAnimated {
display: block;
background: currentColor;
mask: url('../assets/running-whale@2x.png') center / 100% 100% no-repeat;
mask-mode: alpha;
/* Keep animated-mask repaints on an icon-sized composited layer. */
will-change: transform;
}
.runningWhaleStill {
display: none;
}
}
}
.openError {
color: var(--dsw-alias-state-error-primary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.older {
display: flex;
justify-content: center;
}
.older button {
border: none;
border-radius: var(--dsw-radius-sm);
padding: 4px 12px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-interactive-bg-hover-solid);
cursor: pointer;
}
.older button:disabled {
cursor: default;
opacity: 0.6;
}
/* Floating controls sit outside the clipped transcript. A standalone Chat
anchors them to its frame; the shared host supplies the sticky scrollport. */
.toBottomSlot {
position: absolute;
right: 0;
bottom: 16px;
left: 0;
/* Above the sticky composer (z-index 7) so the control stays clickable and
visible over the input card. Exception: while an @/slash menu is open the
seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never
covered by this control. */
z-index: 8;
height: 0;
display: flex;
justify-content: flex-end;
padding-right: max(
calc(var(--dsh-composer-side-clearance) + 16px),
calc((100% - var(--dsh-chat-content-width)) / 2)
);
pointer-events: none;
}
:global([data-conversation-scroll]) .toBottomSlot {
position: sticky;
/* Clears the sticky composer stack (docks + input card + stats): the live
height rides --dsh-composer-height (ConversationRoot's seat observer) so
the control follows a growing textarea; the fallback covers the first
paint before the observer fires. */
bottom: calc(var(--dsh-composer-height, 152px) + 16px);
}
.toBottom {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-top: -34px;
padding: 0;
border: 0;
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
border-radius: 100px;
corner-shape: round;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-button-floating-fill);
box-shadow: var(--dsw-elevation-panel);
cursor: pointer;
pointer-events: auto;
}
@media (hover: hover) {
.toBottom:hover {
background: var(--dsw-alias-button-floating-hover);
}
}
/* Host open-path refusal: same dialog family as the workspace folder error. */
.modalAction {
min-width: 72px;
}
/* Local surfaces do not contribute flex spacing or selected message text. */
.column > .textInteraction {
position: absolute;
/* Remain below the sticky composer while scroll placement catches up. */
z-index: 6;
visibility: hidden;
margin: 0;
width: max-content;
}
@@ -0,0 +1,49 @@
.root {
display: flex;
flex-direction: column;
}
.leading {
flex-shrink: 0;
}
.title {
font-weight: 400;
}
.separator {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.summary {
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
}
.summary[data-error],
.body[data-error] {
color: var(--dsw-alias-state-error-primary);
}
.body {
max-height: 260px;
margin: 4px 0 4px 4px;
padding: 12px 16px;
overflow: auto;
border: 0.5px solid var(--dsw-alias-border-l1);
border-radius: var(--dsw-radius-lg);
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-markdown-code-block-small);
white-space: pre-wrap;
}
@@ -0,0 +1,360 @@
/* User messages align their bubble and action row to the right. */
.userRow {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.userStack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
min-width: 0;
/* The figma 525px cap held as a share of the width axis (525/748 ≈ 0.702):
resolves to the original 525px on the default column and widens with a
dragged or adaptive column. The 748px fallback covers mounts outside the
conversation column, where the axis variable is absent. */
max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%);
}
.referenceSummary {
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.contextRow {
padding: 2px 0;
}
/* Compaction marker: one 24px row with a context icon at rest and a
hover/focus disclosure for the summary body. */
.compactionRow {
/* The toggle's line, and the band a pinned toggle occupies. The button's
height and the offset that holds a summary's code banner below that band
are the same measurement, so both read it from here. */
--dsh-compaction-header-height: calc(24px + var(--dsh-content-font-delta, 0px));
padding: 2px 0;
}
.compactionButton {
display: flex;
align-items: center;
width: 100%;
height: var(--dsh-compaction-header-height);
min-width: 0;
padding: 0;
border: none;
border-radius: var(--dsw-radius-sm);
background: none;
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
font: inherit;
text-align: left;
}
/* The summary body flows uncapped with the page, so a long checkpoint summary
carries this toggle off the top of the viewport. Pin it to the conversation
scroll container's top while the row is open (the body is a sibling rendered
only when expanded); the base background masks the prose scrolling under it.
The square corners apply for as long as the row is open, pinned or not: the
fill is what masks the prose, and rounded corners would leave that prose visible
in the corners. z-index outranks the markdown code block's own sticky banner
(z-index 6 in CodeBlock.module.css); that banner itself stops below this
toggle (rule below), so a summary's fenced code block never puts a control
inside the toggle's band. */
.compactionRow:has(.compactionBody) .compactionButton {
position: sticky;
top: 0;
z-index: 7;
border-radius: 0;
background: var(--dsw-alias-bg-base);
}
/* A fenced code block in the summary pins its banner at the scrollport top,
where this pinned toggle already sits, and the toggle's band covers the
banner's Copy control. Stop the banner below the toggle instead, on the same
measurement the toggle's own height uses. This rule beats CodeBlock's
`.bannerWrap { top: 0 }` by specificity (0,2,0 against 0,1,0), not by
declaration order: the two sheets load in their own packages. */
.compactionBody :has(> [data-code-block-banner]) {
top: var(--dsh-compaction-header-height);
}
/* Pinned, the toggle needs an opaque hover fill: the default translucent hover
token below would let the scrolling prose show through the moment the pointer
lands on the toggle to collapse it. */
@media (hover: hover) {
.compactionRow:has(.compactionBody) .compactionButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
}
.compactionButton:not(:disabled) {
cursor: pointer;
}
@media (hover: hover) {
.compactionButton:hover {
color: var(--dsw-alias-label-secondary);
}
.compactionButton:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.compactionLeading {
flex: none;
display: inline-grid;
place-items: center;
width: calc(16px + var(--dsh-content-font-delta, 0px));
height: calc(16px + var(--dsh-content-font-delta, 0px));
margin-right: 6px;
color: inherit;
}
/* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides
the svg attributes so the 14px glyphs follow the font-size delta. */
.compactionLeading svg {
width: calc(14px + var(--dsh-content-font-delta, 0px));
height: calc(14px + var(--dsh-content-font-delta, 0px));
}
.compactionContextIcon,
.compactionDisclosureIcon {
display: inline-flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
}
.compactionDisclosureIcon {
opacity: 0;
}
.compactionButton:not(:disabled):focus-visible .compactionContextIcon {
opacity: 0;
}
@media (hover: hover) {
.compactionButton:not(:disabled):hover .compactionContextIcon {
opacity: 0;
}
}
.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
opacity: 1;
}
@media (hover: hover) {
.compactionButton:not(:disabled):hover .compactionDisclosureIcon {
opacity: 1;
}
}
.compactionTitle {
flex: none;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: inherit;
}
.compactionSep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.compactionSummary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: inherit;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
}
.compactionBody {
/* Indent under the title, which starts at leading (16 + delta) + gap 6. */
padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
}
.retryRow {
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
}
.retrySummary {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
padding: 2px 0;
gap: 7px;
border-radius: var(--dsw-radius-sm);
color: inherit;
cursor: pointer;
list-style: none;
user-select: none;
}
.retrySummary::-webkit-details-marker {
display: none;
}
.retrySummary::after {
flex: none;
width: 6px;
height: 6px;
margin-right: 2px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
content: '';
transform: rotate(-45deg);
transition: transform 120ms ease;
}
@media (hover: hover) {
.retrySummary:hover {
color: var(--dsw-alias-label-secondary);
}
}
@media (prefers-reduced-motion: reduce) {
.compactionButton {
transition: none;
}
}
.retrySummary:focus-visible {
outline: 1.5px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: 2px;
}
.retryText {
min-width: 0;
overflow-wrap: anywhere;
}
.retryRow[open] .retrySummary::after {
transform: rotate(45deg);
}
.retryDetails {
display: grid;
gap: 2px;
margin-top: 3px;
padding-left: 14px;
overflow-wrap: anywhere;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.retryDetailLabel {
color: var(--dsw-alias-label-secondary);
}
.turnErrorRow {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 2px 0;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
}
.turnErrorDot {
margin-top: 5px;
}
.turnErrorCopy {
min-width: 0;
overflow-wrap: anywhere;
}
.turnErrorTitle {
margin-right: 6px;
color: var(--dsw-alias-state-error-primary);
font-weight: 600;
}
.turnErrorMessage {
color: var(--dsw-alias-label-secondary);
}
.turnErrorCode {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-markdown-code-block-small);
}
.maxTokensTitle {
margin-right: 6px;
color: var(--dsw-alias-state-warn-primary);
font-weight: 600;
}
.attachmentRow {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 100%;
gap: 8px;
}
.fileCard {
display: inline-flex;
flex: 0 0 240px;
align-items: center;
gap: 10px;
width: 240px;
min-height: 64px;
padding: 8px 12px;
border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
border-radius: var(--dsw-radius-lg);
background: var(--dsw-specific-input-major, transparent);
box-sizing: border-box;
}
.fileIcon {
flex: none;
width: 28px;
height: 28px;
}
.fileContent {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.fileName {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--dsw-alias-label-primary);
font-size: 14px;
font-weight: 500;
line-height: 22px;
}
.fileMeta {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
font-size: 12px;
line-height: 15px;
}
@@ -0,0 +1,77 @@
.root {
display: flex;
flex-direction: column;
}
.root:not([data-expanded]) {
contain: size layout;
height: calc(24px + var(--dsh-content-font-delta, 0px));
}
.row {
overflow: hidden;
}
/* The uncapped body scrolls with the conversation; keep its collapse toggle
reachable. Compact Markdown code banners stay in flow below this header. */
.root[data-expanded] [data-open] [data-disclosure-header] {
position: sticky;
top: 0;
z-index: 1;
background: var(--dsw-alias-bg-base);
}
.leading {
flex-shrink: 0;
}
.title {
font-weight: 400;
}
.separator {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
/* Think text reads the secondary tier — one step under the body size
(setting −1 at ≤14, setting −2 above), shared with every flow-row title
and summary. */
.summary {
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
white-space: nowrap;
}
.summaryText {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.summary[data-streaming] {
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent);
}
.summary[data-streaming] .summaryText {
overflow: visible;
text-overflow: clip;
}
.root:not([data-preview]) .separator,
.root:not([data-preview]) .summary {
display: none;
}
.thinkBody {
/* Indent under the title, which starts at leading (16 + delta) + gap 6. */
padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
min-width: 0;
}
@@ -0,0 +1,160 @@
/* Context-occupancy control below the composer plus its click-open breakdown
panel (menu surface: elevation-prominent). */
.root {
display: inline-flex;
flex: none;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 1px 8px;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-family: inherit;
font-size: var(--dsh-content-font-size-secondary, 13px);
font-variant-numeric: tabular-nums;
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
white-space: nowrap;
cursor: pointer;
}
.trigger[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
@media (hover: hover) {
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
}
.track {
fill: none;
stroke: var(--dsw-alias-border-l3);
stroke-width: 2;
}
.fill {
fill: none;
stroke: var(--dsw-alias-label-tertiary);
stroke-width: 2;
stroke-linecap: round;
}
.panel {
position: fixed;
z-index: 1100;
box-sizing: border-box;
width: min(264px, calc(100vw - 24px));
padding: 12px;
border: 0;
border-radius: var(--dsw-radius-lg);
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
cursor: default;
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.figures {
margin-left: auto;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
.percent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.headline {
color: var(--dsw-alias-label-tertiary);
}
/* The headline brackets the reading, so the side a locale leaves empty must
drop out of the flex row rather than spend a gap. */
.headline:empty {
display: none;
}
.bar {
display: flex;
gap: 1px;
margin: 10px 0 12px;
height: 4px;
border-radius: 999px;
corner-shape: round;
background: var(--dsw-alias-interactive-bg-hover);
overflow: hidden;
}
.segment {
flex: none;
min-width: 2px;
height: 100%;
border-radius: 1px;
background: var(--meter-tint, var(--dsw-alias-label-tertiary));
}
.swatch {
display: inline-block;
margin-right: 6px;
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--meter-tint);
vertical-align: baseline;
}
.colorSystem {
--meter-tint: var(--dsw-static-neutral-bluish-400);
}
.colorTools {
/* The design platform ships no purple static token; violet-400 literal. */
--meter-tint: rgb(167, 139, 250);
}
.colorMessages {
--meter-tint: var(--dsw-static-blue-450);
}
.rows {
margin: 6px 0 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
}
.row dt {
color: var(--dsw-alias-label-secondary);
}
.row dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
@@ -0,0 +1,636 @@
.root {
position: relative; /* positioning context for slot-owned absolute chrome */
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
background: var(--dsw-alias-bg-base);
}
/* 76px with its rule: the height of the Sidebar's tab strip and header row
(ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules
meet at the column edge. The rows add up to it — 10px top inset, the 30px
title row, the tab strip's 10px margin, and a 25px view row. When the views
and the More launcher do not fit on one row, the row wraps and the header
grows past 76px. */
.header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
flex: none;
box-sizing: border-box;
min-height: 76px;
padding: 10px 28px 0 20px;
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
}
.header:where(:not(:has(.tabs))) {
min-height: 0;
padding-bottom: 10px;
}
/* Blank Sessions retain navigation controls without conversation title or tabs. */
.headerBlank {
min-height: 0;
padding-bottom: 0;
border-bottom: none;
}
.headerBlank .headerCorner {
margin-left: auto;
}
/* Only the macOS titlebar needs reserved drag space before a Session exists. */
:global(html:not([data-platform='darwin'])) .headerSessionless {
padding-top: 0;
}
:global(html:not([data-platform='darwin'])) .headerSessionless .titleRow {
min-height: 0;
}
/* macOS hiddenInset titlebar: the header block owns the window drag over its
own box — title row plus the view tab strip, so the draggable geometry is the
header's geometry and no frame band has to match its height. The header marks
itself `data-window-drag` (ConversationHeader); below it the title-row clusters
subtract their whole boxes, and individual controls are covered by the base.css
interactive-element rule. A covering overlay lives beside #root
(ui-settings-general), which puts it after this row in document order. */
:global([data-platform='darwin']) .headerLeading,
:global([data-platform='darwin']) .headerActions,
:global([data-platform='darwin']) .headerUtilities,
:global([data-platform='darwin']) .headerCorner {
-webkit-app-region: no-drag;
}
.titleRow {
grid-column: 2;
/* Anonymous inline-size container (CSS modules hash container-names per
module, so occupants match the nearest container): header occupants such
as the preset label and slot actions shed their text at row-width
breakpoints. The minmax(0, 1fr) column sizes the row, so inline-size
containment cannot collapse it. */
container-type: inline-size;
display: flex;
align-items: center;
gap: 0;
min-height: 30px;
/* Clears the frame's window-chrome band (traffic lights + shell.leading
controls, ui-layout AppFrame) when it is published; the header already
pads 20px of it. Zero everywhere else (the fallback makes the max
negative). */
padding-inline-start: max(0px, calc(var(--dsh-frame-leading-clearance, 0px) - 20px));
}
/* Header actions do not shrink, so on a narrow row they overflow the cluster.
The cluster clips them, hit testing included, so the next control keeps
every click. The trailing padding spans the gap before that control and
holds the fade: content that fits ends before it, and only overflowing
content reaches it. The 4px block and start padding, offset by negative
margins, keeps focus rings inside the clip. `clip` rather than `hidden`
keeps the cluster from becoming a scroll container that focus could scroll. */
.titleCluster {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
--title-cluster-fade: 20px;
margin: -4px calc(-1 * var(--title-cluster-fade)) -4px -4px;
padding: 4px var(--title-cluster-fade) 4px 4px;
overflow: clip;
mask-image: linear-gradient(to left, transparent calc(0.3 * var(--title-cluster-fade)), #000 var(--title-cluster-fade));
}
/* Without utilities the corner's 8px margin is the gap the fade spans. */
.titleCluster:has(+ .headerUtilities:empty) {
--title-cluster-fade: 8px;
}
/* The auto grid column collapses with an empty or CSS-hidden occupant.
The occupant owns its trailing gap so hidden controls reserve no space. */
.headerLeading {
grid-column: 1;
grid-row: 1;
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
.crumbs {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.crumbSeg {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.crumbSep {
/* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */
color: var(--dsw-alias-label-caption);
font-size: 14px;
line-height: 20px;
}
.crumb {
display: inline-block;
max-width: 220px;
overflow: hidden;
padding: 4px 8px;
border: none;
border-radius: var(--dsw-radius-md);
background: transparent;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.crumbSubagent {
font-size: 12px;
line-height: 18px;
}
@media (hover: hover) {
button.crumb:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.crumbCurrent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
cursor: default;
}
.headerActions {
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
.headerUtilities {
display: flex;
flex: none;
align-items: center;
gap: 8px;
margin-left: 20px;
}
.headerUtilities:empty {
display: none;
}
/* The far-right corner seat reaches 16px into the header's 28px right padding,
so its control sits past the utilities' edge; it is laid out only while its
occupant renders something. Its left margin equals the utilities' 8px gap so
the header's trailing controls space evenly. */
.headerCorner {
display: flex;
flex: none;
align-items: center;
margin-left: 8px;
margin-right: -16px;
}
.headerCorner:empty {
display: none;
}
/* The tab row sits above the header paint so the active bar covers its rule. */
.tabs {
grid-column: 1 / -1;
position: relative;
z-index: 1;
display: flex;
gap: 16px 20px;
width: 100%;
min-width: 0;
box-sizing: border-box;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 10px;
padding-left: 8px;
}
@media (max-width: 767px) {
:global(html:not([data-platform])) .tabs {
padding-left: 0;
}
}
.viewTabs {
display: flex;
flex-wrap: wrap;
gap: 32px;
min-width: 0;
min-height: 25px;
}
/* The 16px text line retains 9px below it. The press target extends 10px
above the text into the strip's top margin and 10px to each side, matching
the inline padding of the Sidebar tab chips (ui-dockkit `.tab`); negative
margins keep the layout box at its 25px row and text width. The active bar
spans the text and reaches 1px past the tab box to sit over the header
rule. */
.tab {
--dsh-tab-hit-inline: 10px;
position: relative;
box-sizing: border-box;
min-height: 35px;
margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0;
padding: 10px var(--dsh-tab-hit-inline) 9px;
border: none;
background: transparent;
font-size: 13px;
line-height: 16px;
font-weight: 500;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.tab::after {
content: '';
position: absolute;
right: var(--dsh-tab-hit-inline);
bottom: -1px;
left: var(--dsh-tab-hit-inline);
height: 2px;
border-radius: 2px;
background: transparent;
}
/* Selected tab is blue, not ink: brand-primary resolves to neutral black in
this token sheet, so the selected state rides the business blue — the
nearest semantic token that stays blue in both themes. */
.tabActive {
color: var(--dsw-alias-state-business-primary);
}
.tabActive::after {
background: var(--dsw-alias-state-business-primary);
}
/* The More launcher centers its 22px box on the tab's 16px text line. */
.viewsMoreAnchor {
flex: none;
}
.viewsMore {
width: 22px;
height: 22px;
margin-top: -3px;
padding: 0;
color: var(--dsw-alias-label-secondary);
}
.viewsMoreActive { color: var(--dsw-alias-state-business-primary); }
.tabLabel { display: block; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.viewsMore[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
.viewsMoreMenu {
width: 288px;
max-width: calc(100vw - 24px);
}
.viewArea {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
/* Width handles: 10px col-resize strips beside the transcript, absolute in
.body (NOT the scrollport: an absolute strip there would extend the
scrollable range; NOT .root: a strip from the column top would paint its
glow through the header's transparent background). The inner edge sits
24px outside the content column and the strip extends 10px outward; the
outer edge is clamped to keep a 24px safe zone against the
column edges (sidebar side and scrollbar side stay drag-free). When the
margin cannot fit inset + strip + safe zone the computed width goes
negative and the strip resolves to zero — no mispositioned hit area on
narrow columns. Known half-scrollbar (~4px) centering offset vs the
gutter-reserved content box is accepted: it consumes part of the 10px
strip but remains within it. */
.widthHandle {
position: absolute;
top: 0;
bottom: 0;
/* Concrete overflow content establishes z-index 1, while the sticky
composer remains above both layers. */
z-index: 0;
width: min(
10px,
calc((100% - var(--dsh-chat-content-width)) / 2 - 24px - 24px)
);
cursor: col-resize;
}
.widthHandle[data-side='left'] {
right: calc(50% + var(--dsh-chat-content-width) / 2 + 24px);
}
.widthHandle[data-side='right'] {
left: calc(50% + var(--dsh-chat-content-width) / 2 + 24px);
}
/* The glow: a short 2px bar following the captured pointer's Y, centered in the strip
(29px from the content column), not a full-height rule — a 16px solid core
centered on the pointer
(published during drag as --dsh-width-handle-pointer-y), fading to
transparent over 28px each side (72px total). A solid CORE, not a single
solid stop: a lone peak washes out over the fade. The resting scrollbar
tint keeps this secondary affordance quiet in both themes. Before the first
drag move the fallback centers the glow at 50% height; hover leaves its position unchanged. */
.widthHandle::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 2px;
border-radius: 2px;
background: linear-gradient(
to bottom,
transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),
var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),
var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),
transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)
);
opacity: 0;
pointer-events: none;
}
.widthHandle[data-side='left']::after {
right: 4px;
}
.widthHandle[data-side='right']::after {
left: 4px;
}
.widthHandle[data-dragging]::after {
opacity: 1;
}
@media (hover: hover) {
.widthHandle:hover::after {
opacity: 1;
}
}
/* A drag starts above the composer band. Pointer capture keeps it active when
crossing the footer; lift only the active handle so its indicator remains
visible without making the resting footer gutter draggable. */
.widthHandle[data-dragging] {
z-index: 8;
}
/* Views that elect a composer overlay (trajectory etc.) own their full-bleed
scrollers; the strips would sit over their content and steal clicks. */
.root:has([data-conversation-composer-overlay]) .widthHandle {
display: none;
}
/* Composer context stack (Figma 9:937): standalone dock cards share one
rhythm above the input card. */
.composerStack {
/* Horizontal geometry (card width, clearance, dock inset) rides the shared
.root variables above so takeover siblings match the stack. */
--dsh-composer-stack-gap: 6px;
display: flex;
flex-direction: column;
gap: var(--dsh-composer-stack-gap);
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
.composerSeat {
display: flex;
flex: none;
flex-direction: column;
/* One cap for every scrolling text region a composer seat can hold: the
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
takeover panels' bodies top out at the same height, so electing a
takeover never grows the footer past the card it replaces. Declared on
the seat because it is the chain's only shared ancestor — fallback and
elected overlay are siblings — and custom properties inherit down to
whichever entry is mounted. */
--dsh-composer-text-max-height: 336px;
}
/* Active phase: header is ordinary column chrome above the scrollport (not
sticky). The scroll body holds the transcript and the sticky composer seat
so wheel over the footer moves the flow. */
.root[data-phase='active'] {
overflow: hidden;
}
.root[data-phase='active'] .header {
flex: none;
}
/* Band below the header: the scrollport plus the width handles. Anchoring the
handles here keeps their full-height strips and glow under the header's
bottom edge at every header height. */
.body {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
/* Shared width axis for the transcript, dock cards, composer, and takeover
cards. Main width controls publish the live body width and optional user
preference; embedded occurrences override this adaptive axis below. */
--dsh-chat-content-width: var(
--dsh-chat-user-width,
clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px)
);
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
--dsh-composer-side-clearance: 16px;
--dsh-composer-dock-inset: 8px;
}
.embeddedBody {
overflow: hidden;
--dsh-chat-content-width: min(calc(100% - 32px), 920px);
--dsh-composer-card-max-width: min(calc(100% - 16px), 952px);
--dsh-composer-side-clearance: 8px;
--dsh-composer-dock-inset: 8px;
}
.scrollBody {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
/* The product-wide 2px scrollbar offset (ui-workspace WorkspaceBrowser
`.list`, ui-sidebar-files FilesBody `.body`): the bar sits 2px clear of
the column's edge instead of flush against it. */
margin-right: 2px;
overflow-y: auto;
/* Reserved unconditionally: the composer seat rides this box's content box in
Chat and its padding box under a view's composer overlay, so an `auto`
gutter moves the input card sideways by the bar's width whenever the two
differ ([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
scrollbar-gutter: stable;
}
/* The bar also stops 2px short of the scroller's ends, matching its 2px edge
offset. WebKit-only: the Firefox path has no track to inset. */
.scrollBody::-webkit-scrollbar-track {
margin: 2px;
}
.root[data-phase='active'] .viewArea,
.embeddedBody[data-content-phase='active'] .viewArea {
flex: 1 0 auto;
min-height: auto;
}
.root[data-phase='active'] .composerSeat,
.embeddedBody[data-content-phase='active'] .composerSeat {
position: sticky;
bottom: 0;
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
paints under a sticking code header while scrolling. The pinned compaction
header (MessageItem.module.css, ui-chat) and the turn-navigation rail slot
(TurnNavigator.module.css, ui-chat) also take 7 to clear the same banners.
Among elements of equal rank the later one in DOM order wins, so the
composer stays above the pinned header on the extreme small-viewport
overlap and the pinned header stays above the rail. */
z-index: 7;
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
band at the seat's top (the figma 24% of the resting ~150px composer),
solid below — px stops, not %, so a growing draft only widens the solid
region and the fade band never stretches. The 0px stop is bg-base at
zero alpha (not white, which the figma export hardcodes) so both themes
fade from their own base. */
background: linear-gradient(
180deg,
color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
var(--dsw-alias-bg-base) 36px
);
}
/* An open @/slash menu (data-trigger-menu, ui-input-trigger) renders inside
the input card, so the seat's stacking context caps its z-index; lift the
seat above the back-to-bottom control (z-index 8, ui-chat ChatView) while
the menu is open. The steady state stays 7 so the control keeps painting
over the card. */
.root[data-phase='active'] .composerSeat:has([data-trigger-menu]) {
z-index: 9;
}
/* Views may opt into a composer overlay while ConversationRoot retains
ownership of the seat geometry and its active-phase precedence. */
.scrollBody:has([data-conversation-composer-overlay]) {
position: relative;
/* A clipping box nothing scrolls out of, stated as a scroll container on both
axes rather than `overflow: hidden`: a single-axis scroller computes the
other axis to `auto`, and the seat's absolute positioning below needs the
box to stay a containing block in every engine. */
overflow-x: hidden;
overflow-y: auto;
/* The view owns its own scrollers (the trajectory ledger scrolls inside the
view), so this box never scrolls here; reserving a gutter would only
narrow the view's content by the bar's width. Chat keeps the reservation
on the same element, so the seat below compensates with the bar's width
([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
scrollbar-gutter: auto;
}
.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
position: absolute;
/* Chat's seat rides this box's content box, which its own scrolling shortens
by the bar's width; the overlay seat is against the padding box, so it
must give back the same width to keep the input card in place across
tabs. Reads ui-theme's --dsh-scrollbar-width, which mirrors the WebKit
bar's layout width. Chat reserves the engine-resolved width, so on an
engine where the resolved width differs from this fixed 8px the card
drifts by half the difference — accepted residual risk, recorded in
([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md)). */
right: var(--dsh-scrollbar-width);
bottom: 0;
left: 0;
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
flex-centered in the column; composer phase docks it at the bottom. Flex,
NOT absolute+transform: a transform would make this box the containing
block for position:fixed descendants (pickers/modals), shrinking them. */
.composerHero {
align-self: center;
/* figma 75:8208 drew 12 between all three rows; the workspace row now sits
8 above the card (its margin-top restores 12 under the hero chrome). */
gap: 8px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
/* Card cap + both clearances: the hero input card lands at exactly the same
width as the docked composer at every viewport. */
width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance)), 100%);
z-index: 1;
}
.heroWorkspaceRow {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
margin-top: 4px;
/* figma drew px 8; left nudged +12 so the chip's folder glyph lines up
closer to the card's inner controls below; right keeps the trailing
preset seat off the card edge. */
padding: 0 16px 0 20px;
}
/* Plugin options share the row, pushed to its right edge. */
.heroOptions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
min-width: 0;
margin-left: auto;
}
.heroOptions:empty {
display: none;
}
/* Hero: the composer sits inside the session scroll body; center there so
the tree seat matches active (sticky footer) without a Root remount. */
.root[data-phase='hero'] .scrollBody,
.embeddedBody[data-content-phase='hero'] .scrollBody {
justify-content: center;
overflow-y: auto;
}
/* Settling (session replaying, hero/docked unknown): keep the composer seat
mounted but invisible so no wrong layout flashes before the phase lands. */
.root[data-phase='settling'] .composerSeat,
.embeddedBody[data-content-phase='settling'] .composerSeat {
visibility: hidden;
}
@@ -0,0 +1,237 @@
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
above the shared InputBar card. The input itself is InputBar — only stack
geometry and the chip live here. */
.root {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-width: 0;
padding: 0 24px;
}
/* Cap matches the InputBar card. Glow may paint past the sides. */
.stack {
display: flex;
flex-direction: column;
align-items: stretch;
/* figma 75:8208: 12 between title block / workspace / card. */
gap: 12px;
width: 100%;
max-width: var(--dsh-composer-card-max-width);
overflow: visible;
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
badge is a product addition outside that source: a mono superscript pill
riding the title's top-right. */
.headline {
display: flex;
/* Overflow-driven: the fish and the title group are two wrap units sharing
one centered line while they fit; squeezed, the fish takes its own line
above the group. No breakpoint. */
flex-wrap: wrap;
column-gap: 10px;
/* Wrapped, the fish sits on its own line; more air than the 10px side gap
keeps it reading as a mark above the title, not a squeezed inline glyph. */
row-gap: 12px;
align-items: center;
justify-content: center;
font-size: 26px;
line-height: 32px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
/* Title text + badge as one wrap unit so the title never breaks mid-text with
the badge stranded beside a fragment. When the badge no longer fits beside
the title it wraps to its own centered line below; while both fit, the group
is content-sized and centering is a no-op, keeping the superscript snug. */
.titleGroup {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
/* Superscript sits 7 from the title. */
column-gap: 7px;
row-gap: 4px;
min-width: 0;
}
.previewBadge {
align-self: flex-start;
margin-top: 2px;
padding: 1px 7px 0;
border: 0.5px solid var(--dsw-alias-interactive-bg-hover);
border-radius: 999px;
corner-shape: round;
background: var(--dsw-alias-state-business-tertiary);
color: var(--dsw-alias-label-primary-bluish);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
font-weight: 500;
white-space: nowrap;
}
/* Keep hover detection on a stationary box while the mark moves within it;
hover rules target the hitbox so a slot-occupant mark swims too. */
.fishHitbox {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
}
/* The swim morph's curves poke slightly past the viewBox edges, so clipping
would slice the tail mid-wag. The hero mark stays in the same primary ink
as its headline. */
.fish {
display: block;
overflow: visible;
transform-origin: 50% 60%;
color: var(--dsw-alias-label-primary);
}
/* Hover swim: a slow continuous sway about the body center — the head
(left) lifts as the whale rises, then settles back with a small
counter-tilt, like treading water. Negative rotation = head up (the head
is the left half; origin 50% 60%). */
@keyframes hero-fish-swim {
0%,
100% {
transform: none;
}
35% {
transform: rotate(-4deg) translate(-0.4px, -0.9px);
}
70% {
transform: rotate(1.6deg) translate(0.3px, 0.2px);
}
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.fishHitbox:hover .fish {
animation: hero-fish-swim 1.6s ease-in-out infinite;
}
}
/* Workspace row sits 12px above the input card (figma y80 → y112). */
.body {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
overflow: visible;
}
.body > * {
position: relative;
z-index: 1;
}
/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its
right-hand submenu) paints above the InputBar card. */
.body > .workspaceRow {
z-index: 10;
display: flex;
align-items: center;
min-width: 0;
/* figma 75:8208 workspace row: px 8 above the card. */
padding-left: 8px;
}
/* Folder + label + chevron — transparent at rest; fill only on hover / open.
Overflow-driven degradation, no breakpoint: the label shrinks with an
ellipsis down to nothing, so folder and chevron always sit snug against
whatever text remains — full text, ellipsized text, or none. */
.workspace {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: min(100%, 360px);
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
}
.workspace[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
@media (hover: hover) {
.workspace:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* Locked form (bound guidance state): a static echo — no hover feedback, no
pointer affordance; label keeps full contrast. */
.workspace:disabled {
cursor: default;
}
.folder {
flex: none;
color: var(--dsw-alias-label-primary);
}
.workspaceLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: none;
color: var(--dsw-alias-label-caption);
}
/* Dialog field: 44 tall on the modal's 332 content column, hairline
border, pad 14/7, 14/22 wt400 primary text, caption placeholder. Focus
keeps the resting border (design shows no focus ring). */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-lg);
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.modalAction {
min-width: 72px;
}
.modalError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
@@ -0,0 +1,458 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
the sides narrow with the shared width axis); the bottom gradient mask
is owned by the chat scroller. No top pad: the composer stack's gap owns
the space above; the status strip still carries its own margin. */
padding: 0 var(--dsh-composer-side-clearance) 4px;
}
.dock {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
max-width: 100%;
padding-top: 4px;
}
.hero .dock:empty {
display: none;
}
.hero {
/* No bottom pad in the centered hero, but the side clearance must survive:
the hero wrapper is full-width on narrow viewports, so this padding is
the only thing keeping the card off the edges there. */
padding: 0 var(--dsh-composer-side-clearance);
}
.notice {
width: 100%;
max-width: var(--dsh-composer-card-max-width);
margin-bottom: 6px;
padding: 4px 8px;
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
}
.card {
box-sizing: border-box;
position: relative; /* overlay anchor positioning context */
display: flex;
flex-direction: column;
/* figma Input 75:8208: 12px between the text area and the button row; the
card's top pad before .InputText tightened from the drawn 10 to 8. */
gap: 12px;
width: 100%;
max-width: var(--dsh-composer-card-max-width);
padding-top: 8px;
/* Input stroke: the light neutral (l2, one step above the menus' l1),
drawn as the elevation hairline so it costs no layout; the shadow is the
soft elevation tier. Fill: the input surface token (elevated in dark). */
border: 0;
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
border-radius: var(--dsw-radius-panel);
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-elevation-soft);
/* The draft text follows the Settings font-size preference (the textarea,
mirror, and backdrop all inherit from here, so the three layers stay in
step); the line height keeps the default 10px leading via the shared px
delta. */
font-size: var(--dsh-content-font-size, 14px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
/* Elevated surface in dark, same as the menus: the draft scrollport inside
scrolls once the composer hits its height cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the box that actually scrolls
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
@media (pointer: coarse) {
.card {
font-size: max(16px, var(--dsh-content-font-size, 14px));
}
}
/* No-workspace trigger state: an inset 1px dashed outline marks the card as a
workspace picker rather than a live composer. Keeping the complete stroke
inside the rounded box prevents clipped corner pixels from reading heavier. */
.cardWorkspaceTrigger {
--dsw-elevation-stroke-color: transparent;
cursor: pointer;
}
.cardWorkspaceTrigger::after {
content: '';
position: absolute;
inset: 0;
box-sizing: border-box;
border: 1px dashed currentColor;
border-radius: var(--dsw-radius-panel);
color: var(--dsw-alias-border-l4);
transition: color 100ms ease;
pointer-events: none;
}
/* Disabled toolbar controls neither receive nor swallow clicks in the trigger
state: pointer events fall through to the card's own click handler, making
the whole card one pick target. */
.cardWorkspaceTrigger :disabled {
pointer-events: none;
}
@media (hover: hover) {
.cardWorkspaceTrigger:hover::after {
color: var(--dsw-alias-state-business-primary);
}
}
.accessory {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 0;
}
/* Floating overlay anchor (menu / popupSelect shell): entries position
themselves against the card (bottom: 100% + gap); closed entries render null. */
.overlayAnchor {
position: absolute;
inset: 0 0 auto;
height: 0;
}
/* The draft's scrollport, and the ONLY scrolling box in the composer. The
contenteditable grows with its content inside; the 14-line cap lives here
because this is the box the cap describes. */
.scroll {
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
/* Keeps the bar off the card's right border; .input's right pad gives the
4px back so the text column is unchanged. */
margin-right: 4px;
}
/* The card's corner arc still overlaps the scrollport's top (8px top
pad reaches only y=8): start the thumb's travel below the arc so it never
pokes outside the capsule. WebKit-path only; Firefox's thin bar has no
track margin, an accepted remainder. */
.scroll::-webkit-scrollbar-track {
margin-top: 8px;
}
/* Auto-grow anchor: the contenteditable is in normal flow and sets the
draft's height; the placeholder rides it absolutely. */
.grow {
position: relative;
}
/* Machine pending dot (adjudicating / submitting). */
.pending {
width: 8px;
height: 8px;
border-radius: 50%;
corner-shape: round;
background: var(--dsw-alias-state-business-primary);
animation: input-pending 1s ease-in-out infinite alternate;
}
@keyframes input-pending {
from { opacity: 0.35; }
to { opacity: 1; }
}
/* The contenteditable draft surface (grows with its content; .scroll caps
and scrolls it). figma .InputText 34:10434 drew pl 16 / pr 12 / pt 4; the
left pad tightened to 14, and the right pad is 8 here plus .scroll's 4px
bar inset. */
.input {
box-sizing: border-box;
/* Docked floor: one 24px line + 4px top pad = 28 content height; 36 leaves
an 8px breath below the line (the hero keeps its own 52px floor below). */
min-height: 36px;
padding: 4px 8px 0 14px;
font-family: var(--dsw-font-family);
font-size: inherit;
line-height: inherit;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
outline: none;
color: var(--dsw-alias-label-primary);
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
caret-color: var(--dsw-alias-state-business-primary);
}
/* Lexical paragraphs are <p> blocks: strip the UA margins so the surface
keeps the textarea's line rhythm. */
.input p {
margin: 0;
}
/* Claim ghost hint as generated content after the last paragraph: the bar
sets --dsh-composer-hint (a quoted string) while the claim's args are
blank; without the variable the declaration is invalid and nothing shows. */
.input p:last-child::after {
content: var(--dsh-composer-hint);
color: var(--dsw-alias-label-caption);
}
.input[data-composer-composing] p:last-child::after {
content: none;
}
/* A selected command chip stands in for the caret until the next key. */
.input[data-composer-chip-selected] {
caret-color: transparent;
}
.input[data-composer-composing] + .placeholder {
visibility: hidden;
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly.
Insets mirror .input's pads (the other right 4px sits on .scroll). One
ellipsized line: a wrapped second line would clip against the docked
36px floor. */
.placeholder {
position: absolute;
inset: 4px 8px auto 14px;
color: var(--dsw-alias-label-caption);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
user-select: none;
}
.inputDisabled {
color: var(--dsw-alias-label-tertiary);
cursor: not-allowed;
}
.input[aria-haspopup='menu'] {
cursor: pointer;
}
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
line + 4pt); the docked composer collapses to the content height. */
.hero .input {
min-height: 52px;
}
/* The hero's 52px floor fits two lines, so its placeholder clamps at two
instead of the docked single line. */
.hero .placeholder {
white-space: normal;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
/* 2px moved from the bottom pad to the top: the whole control row sits 2px
lower in the card (it read too high against the textarea) while the
controls' own centering stays untouched. */
padding: 2px 8px 6px;
min-width: 0;
/* Size container so the chips inside can collapse to icon-only when the
card runs out of row width (PermissionSelect @container rule). Anonymous
on purpose: CSS modules hash container-name per module, so a name declared
here can never match a query in another module's sheet. */
container-type: inline-size;
}
.row[data-model-compact] {
--dsh-composer-model-text-display: none;
--dsh-composer-model-icon-display: block;
}
.tools,
.modes,
.trailing {
display: flex;
align-items: center;
min-width: 0;
}
/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan /
Read-only; both gaps settled on 12 so the pill chips read as separate
controls without drifting from the + control. */
.tools {
gap: 12px;
}
.modes {
gap: 12px;
}
.trailing {
flex: none;
/* Wrap keeps the left mode chips and the right controls apart when the card
runs out of row width: the trailing group (model + send) moves to its own
line instead of the left group shrinking until its chip overlaps the
model trigger (external:107). The auto margin re-anchors it right on the
wrapped line; on a single line space-between already pins it right. */
margin-left: auto;
gap: 12px;
}
/* Compact spacing preserves room for the fixed-width controls in narrow cards. */
@container (max-width: 560px) {
.tools,
.modes,
.trailing {
gap: 8px;
}
}
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
.add {
display: grid;
place-items: center;
flex: none;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
corner-shape: round;
background: var(--dsw-specific-selector);
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
@media (hover: hover) {
.add:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
}
.add:disabled {
opacity: 0.5;
cursor: default;
}
/* Plan / Read-only / model — native <select>, chip-like closed chrome
(figma ToggleButton: 13/20 medium secondary, 12px chevron). */
.select {
max-width: 220px;
height: 28px;
padding: 0 20px 0 8px;
border: none;
border-radius: var(--dsw-radius-sm);
outline: none;
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 4px center;
background-size: 12px 12px;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
appearance: none;
}
@media (hover: hover) {
.select:hover:not(:disabled) {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.select:disabled {
opacity: 0.5;
cursor: default;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
#679EFE dark — the info-fill pair (500→400), NOT button-primary (ink);
white glyph; empty text = 0.4 opacity. */
.primary {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
border: none;
border-radius: 999px;
corner-shape: round;
background: var(--dsw-alias-button-info-fill);
/* Static white, not the foreground token: the arrow stays white on the blue
fill in both themes (design 34:10465). */
color: #fff;
cursor: pointer;
transition: background-color 100ms ease;
/* Opts out of the row's 2px downward shift (.row top pad): the send circle
keeps its original seat while the smaller chips sit lower. Transform, not
margin, so flex centering math is untouched. */
transform: translateY(-2px);
}
@media (hover: hover) {
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
}
.primary:disabled {
opacity: 0.4;
cursor: default;
}
/* Sending: the message waits for the Host to accept it. The circle keeps its
full fill rather than the disabled fade, and an arc turns where the arrow was. */
.primary[data-sending]:disabled {
opacity: 1;
cursor: progress;
}
.sendingSpinner {
animation: input-sending 800ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.sendingSpinner {
animation-duration: 2400ms;
}
}
@keyframes input-sending {
to { transform: rotate(360deg); }
}
.retry {
margin-left: 8px;
padding: 1px 8px;
border: 1px solid currentColor;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
}
.standardControls { display: flex; align-items: center; gap: 12px; min-width: 0; }
.tools[hidden], .standardControls[hidden] { display: none; }
.trailingActive { flex: 1; margin-left: 0; }
.activity { display: flex; align-items: center; flex: none; }
.activity:empty { display: none; }
.activityExpanded { flex: 1; min-width: 0; }
@@ -0,0 +1,123 @@
.root {
box-sizing: border-box;
flex: none;
overflow: hidden;
margin: 0 auto;
width: calc(
100% -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
max-width: calc(
var(--dsh-composer-card-max-width) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
border: 0;
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
border-radius: var(--dsw-radius-lg);
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
box-shadow: var(--dsw-elevation-panel);
/* The menu surface and the scrolling `.list` use the l2 scrollbar colors.
Declared here because the custom properties inherit into `.list` (see
ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 6px 12px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
}
.lead {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 13px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.progress {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: 13px;
line-height: 20px;
font-weight: 400;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
max-height: 180px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.glyph {
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
}
/* Figma strip is single-line; long items ellipsize with no inline expand. */
.content {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+9
View File
@@ -0,0 +1,9 @@
/* Vendored DeepSeek Harness design tokens + surfaces (Phase 1).
* NOTE: do NOT import corner-shape.css — `*,*::before,*::after{corner-shape}`
* freezes WebKitGTK (Tauri) with continuous layout thrash. */
@import "./styles/base.css";
@import "./styles/design-platform.css";
@import "./styles/scrollbar.css";
@import "./styles/focus.css";
@import "./styles/gradient-shadow-text.css";
@import "./shell-vars.css";
@@ -0,0 +1,388 @@
.frame {
position: relative; /* anchors the drag handles, which straddle column borders */
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
height: 100%;
box-sizing: border-box;
overflow: hidden;
background: var(--dsw-alias-bg-base);
}
/* Only discrete collapse/expand and fold changes ease the tracks (the TSX holds
data-animating across the toggle on the deepsuite sider curve). Steady-state
tracks move only with the viewport, and those updates must land instantly:
an eased track chases the live window edge and the centre column visibly
rubber-bands while the user resizes the window. */
.frame[data-animating] {
transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Dragging writes widths at pointer cadence; easing them would detach the
column from the handle. */
.frame[data-dragging] {
transition: none;
}
/* The Windows caption row remains outside every conversation and file panel. */
:global([data-windows-titlebar]) .frame {
--dsh-windows-content-radius: 16px;
box-sizing: border-box;
padding-top: var(--dsh-windows-titlebar-height);
background: var(--dsw-specific-sidebar-fill);
}
:global([data-windows-titlebar]) .centerCol {
background: var(--dsw-alias-bg-base);
border-radius: var(--dsh-windows-content-radius) 0 0 0;
corner-shape: round;
}
:global([data-windows-titlebar]) .frame::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: var(--dsh-windows-titlebar-height);
background: var(--dsw-specific-sidebar-fill);
-webkit-app-region: drag;
}
:global([data-windows-titlebar]) .sidebarCol {
border-right: none;
}
@media (prefers-reduced-motion: reduce) {
.frame[data-animating] {
transition: none;
}
}
.sidebarCol {
grid-column: 1;
grid-row: 1;
min-width: 0;
overflow: hidden;
background: var(--dsw-specific-sidebar-fill);
border-right: 0.5px solid var(--dsw-alias-border-l3);
}
.centerCol {
grid-column: 2;
grid-row: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mainPanel {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
}
/* macOS desktop: the frame goes transparent so window vibrancy reaches the
sidebar column, which keeps only a translucent tint; the centre paints the
base background itself to stay opaque. The sidebar/centre separator moves
to the opaque centre: on the translucent sidebar the alpha border mixes
with the wallpaper and reads as a dark seam. */
:global([data-platform='darwin']) .frame {
background: transparent;
}
/* The constant step below the window's top strip (traffic lights, sidebar
toggle, shell.leading controls), the same in both sidebar states. Entry
pages in the main panel (plugin manager, settings-like pages — not the
conversation, which owns its header) pad their top by it so their leading
content clears the strip, and overlay primitives add their own safe inset.
Declared on the root element rather than the frame so
overlays portalled to document.body inherit it too. */
:global(html[data-platform='darwin']) {
--dsh-frame-top-clearance: 48px;
}
/* In-frame Windows content already clears the caption through .frame padding. */
:global(html[data-windows-titlebar]) {
--dsh-frame-top-clearance: var(--dsh-windows-titlebar-height);
--dsh-frame-chrome-top: var(--dsh-windows-titlebar-height);
}
/* Portalled overlays share a viewport inset without adding space to entry pages. */
:global(html[data-platform='darwin']),
:global(html[data-windows-titlebar]) {
--dsh-frame-overlay-top: calc(var(--dsh-frame-top-clearance) + 20px);
}
:global(html[data-platform='darwin'][data-fullscreen]),
:global(html[data-windows-titlebar][data-fullscreen]) {
--dsh-frame-overlay-top: 20px;
--dsh-frame-chrome-top: 0px;
}
:global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol {
/* A blue wash over the top of the translucent tint, gone by a third of the
column; a faint blue-grey-violet rises through the bottom third; and a
faint blue cast is mixed into the tint itself. Every layer stays
transparent so vibrancy shows. */
background:
linear-gradient(
to bottom,
rgb(122 155 240 / 0.1),
rgb(122 155 240 / 0) 35%,
rgb(143 137 184 / 0) 68%,
rgb(143 137 184 / 0.09)
),
color-mix(in srgb, color-mix(in srgb, var(--dsw-specific-sidebar-fill) 97%, rgb(122 155 240)) 40%, transparent);
border-right: none;
}
/* Dark drops the blue cast (the light-mode wash reads purple over dark fills)
and thins the fill so more wallpaper shows; the washes stay. */
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .frame:not([data-sidebar-drawer]) .sidebarCol {
background:
linear-gradient(
to bottom,
rgb(122 155 240 / 0.08),
rgb(122 155 240 / 0) 35%,
rgb(143 137 184 / 0) 68%,
rgb(143 137 184 / 0.07)
),
color-mix(in srgb, var(--dsw-specific-sidebar-fill) 50%, transparent);
}
/* macOS Reduce Transparency: the OS flattens the window's vibrancy layer to a
solid grey, so the thin tint and washes read washed-out over it. Replace
them with a 90%-opaque fill of the same token in both themes (the dark
selector repeats so this outranks its thinner fill). */
@media (prefers-reduced-transparency: reduce) {
:global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol,
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .frame:not([data-sidebar-drawer]) .sidebarCol {
background: color-mix(in srgb, var(--dsw-specific-sidebar-fill) 90%, transparent);
}
}
:global([data-platform='darwin']) .centerCol {
background: var(--dsw-alias-bg-base);
border-left: 0.5px solid var(--dsw-alias-border-l3);
}
/* The right track paints the base ground on the transparent frame: while the
track and the occupant's translateX slide ride the same curve, the grid
eases in layout and the transform on the compositor, so frames where they
diverge would otherwise show vibrancy glass between the centre's right edge
and the incoming panel. The occupant's opaque panel covers this at rest. */
:global([data-platform='darwin']) .rightbarCol {
background: var(--dsw-alias-bg-base);
}
/* The darwin closed sidebar has no rail: the centre reaches the window edge,
where a separator would draw a seam against nothing. */
:global([data-platform='darwin']) [data-sidebar-collapsed] .centerCol {
border-left: none;
}
/* Published while the shell.leading seat is mounted (same condition as the
TSX mount: darwin with the sidebar collapsed — the collapse that hides the
column entirely): the inline band the window chrome occupies, measured from
the frame's left edge — traffic lights to x 68, controls at x 88
(28 + 8 + 28), then 8px of air. Main panels whose content reaches the
top-left corner pad by this. */
:global([data-platform='darwin']) .frame[data-sidebar-collapsed] {
--dsh-frame-leading-clearance: 160px;
}
/* Fullscreen hides the traffic lights (preload mirrors the window state onto
html[data-fullscreen]): the seat takes their band and the clearance keeps
only the controls (12 + 28 + 8 + 28 + 8). The 12px start matches the
expanded sidebar strip's inline padding (ui-sidebar .topStrip), so
collapsing never shifts the toggle. */
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-collapsed] {
--dsh-frame-leading-clearance: 84px;
}
:global([data-platform='darwin'][data-fullscreen]) .leadingSeat {
left: 12px;
}
/* The frame declares no window drag of its own: every chrome row owns the drag
over its own box (ui-web base.css holds the one rule; the ui-theme app-region
gate lists the rows). The ordering rule that replaces the old "band first"
invariant: a surface that covers the window and must stay clickable portals
beside #root, which puts it after every drag row in document order. The Windows
caption row above is the other platform's chrome and stays. */
/* The seat itself: the two 28px controls centered on the conversation title
row (10px header inset + 30px row → centre y 25) and level with the traffic
lights (centre y ≈ 24). Sits above column content, below frame overlays
(20) and the fullscreen right panel (40). The box subtracts itself from any
window drag row beneath (Electron app-regions are geometry, not stacking). */
.leadingSeat {
position: absolute;
top: 11px;
left: 88px;
z-index: 15;
display: flex;
align-items: center;
-webkit-app-region: no-drag;
}
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. No
handle draws a visible pill: the details column was the only one that did,
and the right column never had one. The right handle sits above the right
panel (which states z-index 10) so it stays grabbable when the panel hangs
over the centre. */
.handle {
position: absolute;
grid-area: 1 / 1 / 2 / -1;
top: 0;
bottom: 0;
width: 8px;
margin-left: -4px;
cursor: col-resize;
z-index: 11;
touch-action: none;
}
/* Rides the same curve as the tracks so the pill stays on the moving border
during collapse/expand; instant at steady state (viewport-driven moves)
and while dragging. */
.frame[data-animating] .handle {
transition: left var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.frame[data-dragging] .handle {
transition: none;
}
/* Fullscreen covers the frame while its underlying columns enter or exit. */
.frame[data-rightbar-fullscreen],
.frame[data-rightbar-fullscreen] .handle,
.frame[data-rightbar-instant],
.frame[data-rightbar-instant] .handle {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.frame[data-animating] .handle {
transition: none;
}
}
/*
* The right column never clips: its occupant anchors a fixed-width panel to the
* column's right edge (the frame's edge, which never moves) and lets the track
* decide whether the centre makes room. With no track the panel hangs over the
* centre from a zero-width column, and while the track animates the panel stays
* where it is. The occupant draws its own left border.
*/
.rightbarCol {
grid-column: 3;
grid-row: 1;
position: relative;
min-width: 0;
overflow: visible;
}
.frame[data-sidebar-drawer] {
--dsh-frame-leading-clearance: 84px;
overflow: clip;
}
:global(html:not([data-platform])) .frame[data-sidebar-drawer] {
/* 12px seat offset + 28px opener + 8px gap; Desktop keeps room for two controls. */
--dsh-frame-leading-clearance: 48px;
}
.frame[data-sidebar-drawer] .mainPanel[data-global-panel] {
padding-top: max(0px, calc(48px - var(--dsh-frame-top-clearance, 0px)));
}
.frame[data-sidebar-drawer] .sidebarCol {
position: absolute;
grid-area: auto;
inset: 0 auto 0 0;
z-index: 71;
box-sizing: border-box;
border-right: none;
transform: translateX(0);
visibility: visible;
transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol {
transform: translateX(-100%);
visibility: hidden;
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
visibility 0s linear var(--ds-transition-duration-slow);
}
.frame[data-sidebar-drawer][data-sidebar-instant] .sidebarCol {
transition: none;
}
.drawerMask {
position: absolute;
inset: var(--dsh-frame-chrome-top, 0px) 0 0;
z-index: 70;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
opacity: 0;
pointer-events: none;
transition: opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.frame[data-sidebar-drawer]:not([data-sidebar-collapsed]) .drawerMask {
opacity: 1;
pointer-events: auto;
}
.frame[data-sidebar-drawer] .leadingSeat {
top: 11px;
left: 12px;
}
:global([data-platform='darwin']) .frame[data-sidebar-drawer] {
--dsh-frame-leading-clearance: 160px;
}
:global([data-platform='darwin']) .frame[data-sidebar-drawer] .leadingSeat {
left: 88px;
}
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] {
--dsh-frame-leading-clearance: 84px;
}
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] .leadingSeat {
left: 12px;
}
@media (prefers-reduced-motion: reduce) {
.frame[data-sidebar-drawer] .sidebarCol,
.frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol,
.drawerMask {
transition: none;
}
}
.bottomRow {
grid-column: 1 / -1;
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--dsw-alias-bg-base);
}
.overlayLayer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
.overlayLayer > * {
pointer-events: auto;
}
@@ -0,0 +1,121 @@
/* Layout vars published by DSH shells that we don't mount wholesale. */
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
}
body {
font-family: var(--dsw-font-family);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-base);
-webkit-font-smoothing: antialiased;
}
html[data-ds-dark-theme],
body[data-ds-dark-theme] {
color-scheme: dark;
}
/* WebKitGTK: component sheets still set corner-shape on pills; neutralize. */
*,
*::before,
*::after {
corner-shape: initial !important;
}
/* Ensure dark aliases win even if a sheet only keyed body without attribute specificity. */
html[data-ds-dark-theme] body,
body[data-ds-dark-theme] {
background-color: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
}
/* Sidebar row metrics (normally on SidebarRoot). */
.hermit-sidebar {
--dsh-sidebar-inline-padding: 8px;
--dsh-sidebar-row-padding: 12px;
--dsh-sidebar-label-gap: 6px;
--dsh-sidebar-paint-inset: 3px;
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
padding: 6px var(--dsh-sidebar-inline-padding);
background: var(--dsw-specific-sidebar-fill);
}
/* Conversation width axis fallback when column width isn't measured. */
.hermit-conversation-body {
--dsh-conversation-column-width: 100%;
--dsh-chat-content-width: clamp(680px, 64vw, 920px);
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
--dsh-composer-side-clearance: 16px;
--dsh-composer-dock-inset: 8px;
--dsh-composer-text-max-height: 336px;
}
@media (max-width: 900px) {
.hermit-conversation-body {
--dsh-chat-content-width: min(calc(100% - 32px), 920px);
--dsh-composer-card-max-width: min(calc(100% - 16px), 952px);
--dsh-composer-side-clearance: 8px;
}
}
/* Engine / host status chip in conversation header */
.hermit-engine-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
font-family: var(--ds-font-family-code);
font-size: 11px;
line-height: 16px;
letter-spacing: 0.02em;
border: 0.5px solid var(--dsw-alias-border-l3);
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
text-transform: uppercase;
}
.hermit-engine-chip[data-mode="engine"] {
color: var(--dsw-alias-state-success-primary, #3dd68c);
border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #3dd68c) 40%, transparent);
}
.hermit-engine-chip[data-mode="host"],
.hermit-engine-chip[data-mode="browser"] {
color: var(--dsw-alias-state-warn-primary);
border-color: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent);
}
.hermit-engine-banner {
width: 100%;
max-width: var(--dsh-composer-card-max-width);
margin: 0 auto 8px;
padding: 8px 12px;
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 12px;
line-height: 18px;
}
.hermit-brand {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px 14px;
color: var(--dsw-alias-label-primary);
font-weight: 600;
font-size: 15px;
line-height: 20px;
letter-spacing: -0.01em;
user-select: none;
}
@@ -0,0 +1,39 @@
/* Base variables referenced by the token sheets and component CSS but defined
* upstream (deepsuite theme/global.css) — supplied here so the composite
* --dsw-font-* variables resolve and motion rides the upstream curve. Code
* font stack deliberately omits a bare `monospace` tail (Windows CJK falls
* back to SimSun otherwise). */
:root {
--dsh-font-family-text-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--dsw-font-family: var(--dsh-font-family-text-default);
--dsw-font-family-brand: 'Montserrat', var(--dsh-font-family-text-default);
--dsh-font-family-code-default: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
--ds-font-family-code: var(--dsh-font-family-code-default);
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ds-transition-duration: 0.2s;
--ds-transition-duration-fast: 0.1s;
--ds-transition-duration-slow: 0.3s;
--dsw-radius-xs: 4px;
--dsw-radius-sm: 8px;
--dsw-radius-md: 12px;
--dsw-radius-lg: 16px;
--dsw-radius-xl: 20px;
--dsw-radius-panel: 28px;
}
/* User font lists (--dsh-font-family-text/-code, written on body by the theme
* boot script and ThemePresenter) lead the built-in stacks. An unset list
* falls back to the built-in stack itself; the repeated families are inert. */
body {
--dsw-font-family: var(--dsh-font-family-text, var(--dsh-font-family-text-default)), var(--dsh-font-family-text-default);
--ds-font-family-code: var(--dsh-font-family-code, var(--dsh-font-family-code-default)), var(--dsh-font-family-code-default);
--dsw-alias-settings-card-fill: var(--dsw-alias-bg-layer-2);
--dsw-alias-settings-card-stroke: var(--dsw-alias-border-l4);
}
[data-dsh-automatic-focus]:focus,
[data-dsh-automatic-focus]:focus:focus-visible {
outline: none;
}
@@ -0,0 +1,26 @@
/* Smooth corner curvature for every rounded surface. Inside the support
* guard, all corners curve along superellipse(1.5) — between a circular arc
* (round = superellipse(1)) and a squircle (superellipse(2)) — instead of the
* default arc; engines without corner-shape keep circular corners because the
* token and the rule exist only inside the guard.
*
* corner-shape does not inherit, so the universal selector applies the token
* to elements and their ::before/::after; other pseudo-elements (for example
* the ::-webkit-scrollbar parts) stay circular.
*
* Full-round shapes opt out where they are declared: a superellipse deforms a
* circle into a squircle and squares off capsule ends, so every effectively
* uncapped radius (50%, 100%, or a pill radius far above the box size) pairs
* `corner-shape: round` with its `border-radius` in the owning component
* sheet. The corner-shape stylesheet spec enforces that pairing. */
@supports (corner-shape: superellipse(1.5)) {
:root {
--dsw-corner-shape: superellipse(1.5);
}
*,
*::before,
*::after {
corner-shape: var(--dsw-corner-shape);
}
}
@@ -0,0 +1,430 @@
/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as
font-weight: 500 in this UI — non-variable webfonts snap intermediate
weights unpredictably across platforms. */
body {
--dsw-static-amber-100: rgb(254, 245, 231);
--dsw-static-amber-400: rgb(247, 173, 49);
--dsw-static-amber-500: rgb(245, 158, 11);
--dsw-static-amber-600: rgb(221, 134, 41);
--dsw-static-amber-900: rgb(39, 36, 31);
--dsw-static-blue-100: rgb(219, 234, 254);
--dsw-static-blue-300: rgb(147, 197, 253);
--dsw-static-blue-400: rgb(96, 165, 250);
--dsw-static-blue-450: rgb(77, 147, 248);
--dsw-static-blue-500: rgb(59, 130, 246);
--dsw-static-blue-50: rgb(239, 246, 255);
--dsw-static-blue-50p: rgb(234, 243, 255);
--dsw-static-blue-600: rgb(37, 99, 235);
--dsw-static-blue-75: rgb(229, 240, 255);
--dsw-static-blue-800: rgb(30, 64, 175);
--dsw-static-blue-900: rgb(14, 48, 116);
--dsw-static-blue-950: rgb(23, 37, 84);
--dsw-static-deepseek-100: rgb(228, 237, 253);
--dsw-static-deepseek-200: rgb(211, 226, 255);
--dsw-static-deepseek-300: rgb(183, 200, 254);
/* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */
--dsw-static-deepseek-400: rgb(122, 170, 255);
--dsw-static-deepseek-450: rgb(86, 134, 254);
--dsw-static-deepseek-500: rgb(65, 118, 230);
--dsw-static-deepseek-50: rgb(237, 243, 254);
--dsw-static-deepseek-600: rgb(72, 104, 178);
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
--dsw-static-deepseek-800: rgb(52, 65, 91);
--dsw-static-deepseek-900: rgb(40, 49, 66);
--dsw-static-green-100: rgb(230, 250, 237);
--dsw-static-green-400: rgb(78, 209, 126);
--dsw-static-green-500: rgb(34, 197, 94);
--dsw-static-green-500-a08: rgb(34 197 94 / 8%);
--dsw-static-green-500-a12: rgb(34 197 94 / 12%);
--dsw-static-green-900: rgb(35, 60, 44);
--dsw-static-neutral-00: rgb(255, 255, 255);
--dsw-static-neutral-1000: rgb(0, 0, 0);
--dsw-static-neutral-100: rgb(245, 245, 245);
--dsw-static-neutral-150: rgb(237, 237, 237);
--dsw-static-neutral-200: rgb(229, 229, 229);
--dsw-static-neutral-250: rgb(220, 220, 220);
--dsw-static-neutral-300: rgb(212, 212, 212);
--dsw-static-neutral-400: rgb(162, 164, 166);
--dsw-static-neutral-500: rgb(127, 130, 135);
--dsw-static-neutral-50: rgb(250, 250, 250);
--dsw-static-neutral-550: rgb(101, 103, 107);
--dsw-static-neutral-600: rgb(84, 85, 87);
--dsw-static-neutral-700: rgb(60, 60, 61);
--dsw-static-neutral-800: rgb(41, 41, 41);
--dsw-static-neutral-850: rgb(33, 33, 35);
--dsw-static-neutral-900: rgb(15, 15, 15);
--dsw-static-neutral-bluish-00: rgb(255, 255, 255);
--dsw-static-neutral-bluish-1000: rgb(15, 17, 21);
--dsw-static-neutral-bluish-100: rgb(235, 238, 242);
--dsw-static-neutral-bluish-150: rgb(233, 236, 242);
--dsw-static-neutral-bluish-200: rgb(225, 229, 238);
--dsw-static-neutral-bluish-300: rgb(207, 211, 214);
--dsw-static-neutral-bluish-310: rgb(202, 210, 219);
--dsw-static-neutral-bluish-400: rgb(173, 178, 184);
--dsw-static-neutral-bluish-500: rgb(151, 157, 166);
--dsw-static-neutral-bluish-50: rgb(249, 250, 251);
--dsw-static-neutral-bluish-55: rgb(245, 247, 250);
--dsw-static-neutral-bluish-600: rgb(129, 133, 140);
--dsw-static-neutral-bluish-60: rgb(245, 246, 247);
--dsw-static-neutral-bluish-700: rgb(97, 102, 107);
--dsw-static-neutral-bluish-750: rgb(67, 69, 74);
--dsw-static-neutral-bluish-75: rgb(241, 243, 245);
--dsw-static-neutral-bluish-800: rgb(53, 54, 56);
--dsw-static-neutral-bluish-850: rgb(44, 44, 46);
--dsw-static-neutral-bluish-875: rgb(35, 35, 36);
--dsw-static-neutral-bluish-900: rgb(27, 27, 28);
--dsw-static-neutral-bluish-90: rgb(237, 240, 244);
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
--dsw-static-red-100: rgb(254, 226, 226);
--dsw-static-red-400: rgb(242, 90, 90);
--dsw-static-red-400-a12: rgb(242 90 90 / 12%);
--dsw-static-red-500: rgb(239, 68, 68);
--dsw-static-red-50: rgb(254, 242, 242);
--dsw-static-red-600: rgb(236, 19, 19);
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
--dsw-static-red-900: rgb(87, 12, 12);
--dsw-static-violet-400: rgb(167, 139, 250);
--dsw-static-violet-600: rgb(124, 58, 237);
}
body[data-ds-dark-theme] {
--dsw-static-amber-100: rgb(254, 245, 231);
--dsw-static-amber-400: rgb(247, 173, 49);
--dsw-static-amber-500: rgb(245, 158, 11);
--dsw-static-amber-600: rgb(221, 134, 41);
--dsw-static-amber-900: rgb(39, 36, 31);
--dsw-static-blue-100: rgb(219, 234, 254);
--dsw-static-blue-300: rgb(147, 197, 253);
--dsw-static-blue-400: rgb(96, 165, 250);
--dsw-static-blue-450: rgb(77, 147, 248);
--dsw-static-blue-500: rgb(59, 130, 246);
--dsw-static-blue-50: rgb(239, 246, 255);
--dsw-static-blue-50p: rgb(234, 243, 255);
--dsw-static-blue-600: rgb(37, 99, 235);
--dsw-static-blue-75: rgb(229, 240, 255);
--dsw-static-blue-800: rgb(30, 64, 175);
--dsw-static-blue-900: rgb(14, 48, 116);
--dsw-static-blue-950: rgb(23, 37, 84);
--dsw-static-deepseek-100: rgb(228, 237, 253);
--dsw-static-deepseek-200: rgb(211, 226, 255);
--dsw-static-deepseek-300: rgb(183, 200, 254);
/* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */
--dsw-static-deepseek-400: rgb(122, 170, 255);
--dsw-static-deepseek-450: rgb(86, 134, 254);
--dsw-static-deepseek-500: rgb(65, 118, 230);
--dsw-static-deepseek-50: rgb(237, 243, 254);
--dsw-static-deepseek-600: rgb(72, 104, 178);
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
--dsw-static-deepseek-800: rgb(52, 65, 91);
--dsw-static-deepseek-900: rgb(40, 49, 66);
--dsw-static-green-100: rgb(230, 250, 237);
--dsw-static-green-400: rgb(78, 209, 126);
--dsw-static-green-500: rgb(34, 197, 94);
--dsw-static-green-500-a08: rgb(34 197 94 / 8%);
--dsw-static-green-500-a12: rgb(34 197 94 / 12%);
--dsw-static-green-900: rgb(35, 60, 44);
--dsw-static-neutral-00: rgb(255, 255, 255);
--dsw-static-neutral-1000: rgb(0, 0, 0);
--dsw-static-neutral-100: rgb(245, 245, 245);
--dsw-static-neutral-150: rgb(237, 237, 237);
--dsw-static-neutral-200: rgb(229, 229, 229);
--dsw-static-neutral-250: rgb(220, 220, 220);
--dsw-static-neutral-300: rgb(212, 212, 212);
--dsw-static-neutral-400: rgb(162, 164, 166);
--dsw-static-neutral-500: rgb(127, 130, 135);
--dsw-static-neutral-50: rgb(250, 250, 250);
--dsw-static-neutral-550: rgb(101, 103, 107);
--dsw-static-neutral-600: rgb(84, 85, 87);
--dsw-static-neutral-700: rgb(60, 60, 61);
--dsw-static-neutral-800: rgb(41, 41, 41);
--dsw-static-neutral-850: rgb(33, 33, 35);
--dsw-static-neutral-900: rgb(15, 15, 15);
--dsw-static-neutral-bluish-00: rgb(255, 255, 255);
--dsw-static-neutral-bluish-1000: rgb(15, 17, 21);
--dsw-static-neutral-bluish-100: rgb(235, 238, 242);
--dsw-static-neutral-bluish-150: rgb(233, 236, 242);
--dsw-static-neutral-bluish-200: rgb(225, 229, 238);
--dsw-static-neutral-bluish-300: rgb(207, 211, 214);
--dsw-static-neutral-bluish-310: rgb(202, 210, 219);
--dsw-static-neutral-bluish-400: rgb(173, 178, 184);
--dsw-static-neutral-bluish-500: rgb(151, 157, 166);
--dsw-static-neutral-bluish-50: rgb(249, 250, 251);
--dsw-static-neutral-bluish-55: rgb(245, 247, 250);
--dsw-static-neutral-bluish-600: rgb(129, 133, 140);
--dsw-static-neutral-bluish-60: rgb(249, 250, 251);
--dsw-static-neutral-bluish-700: rgb(97, 102, 107);
--dsw-static-neutral-bluish-750: rgb(67, 69, 74);
--dsw-static-neutral-bluish-75: rgb(241, 243, 245);
--dsw-static-neutral-bluish-800: rgb(53, 54, 56);
--dsw-static-neutral-bluish-850: rgb(44, 44, 46);
--dsw-static-neutral-bluish-875: rgb(35, 35, 36);
--dsw-static-neutral-bluish-900: rgb(27, 27, 28);
--dsw-static-neutral-bluish-90: rgb(237, 240, 244);
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
--dsw-static-red-100: rgb(254, 226, 226);
--dsw-static-red-400: rgb(242, 90, 90);
--dsw-static-red-400-a12: rgb(242 90 90 / 12%);
--dsw-static-red-500: rgb(239, 68, 68);
--dsw-static-red-50: rgb(254, 242, 242);
--dsw-static-red-600: rgb(236, 19, 19);
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
--dsw-static-red-900: rgb(87, 12, 12);
--dsw-static-violet-400: rgb(167, 139, 250);
--dsw-static-violet-600: rgb(124, 58, 237);
}
body {
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-00);
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-100);
--dsw-alias-bg-document-selection: color-mix(in srgb, var(--dsw-static-blue-500) 40%, transparent);
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-700);
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-00);
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-00);
--dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-00);
--dsw-alias-bg-mask-1: rgba(0, 0, 0, 0.24);
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12);
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
--dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7);
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60);
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60);
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150);
--dsw-alias-bg-skeleton: rgba(0, 0, 0, 0.04);
--dsw-alias-border-inverted2: rgba(0, 0, 0, 0);
--dsw-alias-border-inverted: rgba(0, 0, 0, 0);
--dsw-alias-border-l1: rgba(0, 0, 0, 0.04);
--dsw-alias-border-l2-darkmode-thin: rgba(0, 0, 0, 0.1);
--dsw-alias-border-l2: rgba(0, 0, 0, 0.1);
--dsw-alias-border-l3: rgba(0, 0, 0, 0.12);
--dsw-alias-border-l4: rgba(0, 0, 0, 0.16);
--dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230);
--dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700);
--dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-00);
--dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-00);
--dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-75);
--dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-500);
--dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-100);
--dsw-alias-button-ghost-active-hover: var(--dsw-static-neutral-bluish-150);
--dsw-alias-button-info-fill: var(--dsw-static-deepseek-500);
--dsw-alias-button-info-hover: var(--dsw-static-deepseek-400);
--dsw-alias-button-primary-dimmed: var(--dsw-static-neutral-bluish-100);
--dsw-alias-button-primary-fill: var(--dsw-alias-brand-primary);
--dsw-alias-button-primary-hover: var(--dsw-static-neutral-bluish-750);
--dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36);
--dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5);
--dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6);
--dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-55);
--dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-310);
--dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-310);
--dsw-alias-illustration-inset: var(--dsw-alias-illustration-surface);
--dsw-alias-illustration-shadow: var(--dsw-alias-illustration-surface);
--dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-90);
--dsw-alias-interactive-bg-active: rgba(38, 49, 72, 0.1);
--dsw-alias-interactive-bg-hover-accent: rgba(38, 49, 72, 0.14);
--dsw-alias-interactive-bg-hover-danger: rgba(236, 19, 19, 0.05);
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75);
--dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06);
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400);
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-500) 70%, var(--dsw-static-blue-950));
--dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-deepseek-500) 30%, var(--dsw-static-blue-950));
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200);
--dsw-alias-label-primary-bluish: var(--dsw-static-blue-900);
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950);
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-00);
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00);
--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700);
--dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-1000) 30%, transparent);
--dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600);
--dsw-alias-menu-icon: var(--dsw-static-neutral-bluish-800);
--dsw-alias-link: var(--dsw-static-deepseek-500);
--dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-100);
--dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-50);
--dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-50);
--dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-00);
--dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-75);
--dsw-alias-markdown-inline-code: var(--dsw-static-neutral-50);
--dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-60);
--dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-75);
--dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-200);
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-200);
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-300);
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-300);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100);
--dsw-alias-code-diff-added: var(--dsw-static-green-500-a08);
--dsw-alias-code-diff-deleted: var(--dsw-static-red-600-a08);
--dsw-alias-file-diff-added-bg: rgb(230, 244, 231);
--dsw-alias-file-diff-added-gutter: rgb(237, 247, 237);
--dsw-alias-file-diff-added-marker: rgb(1, 162, 65);
--dsw-alias-file-diff-deleted-bg: rgb(252, 230, 226);
--dsw-alias-file-diff-deleted-gutter: rgb(253, 236, 233);
--dsw-alias-file-diff-deleted-marker: rgb(186, 39, 35);
--dsw-alias-state-done-primary: var(--dsw-static-violet-600);
--dsw-alias-state-error-primary: var(--dsw-static-red-600);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-300);
--dsw-alias-state-success-primary: var(--dsw-static-green-500);
--dsw-alias-state-success-secondary: var(--dsw-static-green-400);
--dsw-alias-state-success-tertiary: var(--dsw-static-green-100);
--dsw-alias-state-warn-label: var(--dsw-static-amber-600);
--dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
--dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
--dsw-alias-state-warn-tertiary: var(--dsw-static-amber-100);
--dsw-alias-switch-thumb: var(--dsw-static-neutral-bluish-00);
--dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-800);
--dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00);
--dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-850);
--dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%);
--dsw-alias-turn-trigger-bg: var(--dsw-alias-markdown-code-block);
--dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-hover);
--dsw-specific-bubble-highlight: var(--dsw-static-deepseek-200);
--dsw-specific-bubble: var(--dsw-static-deepseek-50);
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-00);
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-50);
--dsw-menu-surface-fill: rgba(252, 253, 254, 0.7);
--dsw-alias-menu-group-header-fill: rgba(248, 249, 250, 0.94);
--dsw-specific-menu: var(--dsw-menu-surface-fill);
--dsw-specific-selector: var(--dsw-static-neutral-bluish-60);
--dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-50);
--dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-deepseek-100);
--dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-100);
--dsw-specific-tip: var(--dsw-static-neutral-bluish-60);
background-color: var(--dsw-alias-bg-base);
}
body[data-ds-dark-theme] {
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-950);
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-950);
--dsw-alias-bg-document-selection: color-mix(in srgb, var(--dsw-static-blue-500) 40%, transparent);
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-300);
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-875);
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-850);
--dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-800);
--dsw-alias-bg-mask-1: rgba(0, 0, 0, 0.5);
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2);
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
--dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7);
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800);
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-850);
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700);
--dsw-alias-bg-skeleton: rgba(255, 255, 255, 0.08);
--dsw-alias-border-inverted2: rgba(255, 255, 255, 0.08);
--dsw-alias-border-inverted: rgba(255, 255, 255, 0.06);
--dsw-alias-border-l1: rgba(255, 255, 255, 0.06);
--dsw-alias-border-l2-darkmode-thin: rgba(255, 255, 255, 0.06);
--dsw-alias-border-l2: rgba(255, 255, 255, 0.12);
--dsw-alias-border-l3: rgba(255, 255, 255, 0.16);
--dsw-alias-border-l4: rgba(255, 255, 255, 0.2);
--dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50);
--dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450);
--dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50);
--dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50);
--dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50);
--dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750);
--dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850);
--dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800);
--dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600);
--dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750);
--dsw-alias-button-ghost-active-hover: var(--dsw-static-neutral-bluish-700);
--dsw-alias-button-info-fill: var(--dsw-static-deepseek-400);
--dsw-alias-button-info-hover: var(--dsw-static-deepseek-500);
--dsw-alias-button-primary-dimmed: var(--dsw-static-neutral-bluish-750);
--dsw-alias-button-primary-fill: var(--dsw-alias-brand-primary);
--dsw-alias-button-primary-hover: var(--dsw-static-neutral-bluish-100);
--dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36);
--dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5);
--dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6);
--dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-850);
--dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-750);
--dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-600);
--dsw-alias-illustration-inset: var(--dsw-alias-illustration-backing);
--dsw-alias-illustration-shadow: var(--dsw-alias-illustration-backing);
--dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-700);
--dsw-alias-interactive-bg-active: rgba(255, 255, 255, 0.14);
--dsw-alias-interactive-bg-hover-accent: rgba(255, 255, 255, 0.24);
--dsw-alias-interactive-bg-hover-danger: rgba(242, 90, 90, 0.15);
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800);
--dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08);
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600);
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-450) 55%, var(--dsw-static-neutral-bluish-400));
--dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-blue-300) 65%, var(--dsw-static-deepseek-400));
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750);
--dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50);
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100);
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800);
--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50);
--dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300);
--dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-00) 45%, transparent);
--dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400);
--dsw-alias-menu-icon: var(--dsw-alias-label-primary-dimmed);
--dsw-alias-link: var(--dsw-static-deepseek-400);
--dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-800);
--dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-850);
--dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-900);
--dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-800);
--dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-900);
--dsw-alias-markdown-inline-code: var(--dsw-static-neutral-800);
--dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-850);
--dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-850);
--dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-700);
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600);
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600);
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
--dsw-alias-code-diff-added: var(--dsw-static-green-500-a12);
--dsw-alias-code-diff-deleted: var(--dsw-static-red-400-a12);
--dsw-alias-file-diff-added-bg: rgb(31, 49, 36);
--dsw-alias-file-diff-added-gutter: rgb(19, 32, 22);
--dsw-alias-file-diff-added-marker: rgb(65, 201, 119);
--dsw-alias-file-diff-deleted-bg: rgb(60, 31, 27);
--dsw-alias-file-diff-deleted-gutter: rgb(40, 19, 14);
--dsw-alias-file-diff-deleted-marker: rgb(250, 66, 62);
--dsw-alias-state-done-primary: var(--dsw-static-violet-400);
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-600);
--dsw-alias-state-success-primary: var(--dsw-static-green-500);
--dsw-alias-state-success-secondary: var(--dsw-static-green-400);
--dsw-alias-state-success-tertiary: var(--dsw-static-green-900);
--dsw-alias-state-warn-label: var(--dsw-static-amber-600);
--dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
--dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
--dsw-alias-state-warn-tertiary: var(--dsw-static-amber-900);
--dsw-alias-switch-thumb: var(--dsw-static-neutral-bluish-400);
--dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-750);
--dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00);
--dsw-alias-turn-trigger-bg: var(--dsw-alias-interactive-bg-hover);
--dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-active);
--dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-750);
--dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%);
--dsw-specific-bubble-highlight: var(--dsw-static-neutral-bluish-750);
--dsw-specific-bubble: var(--dsw-static-neutral-bluish-850);
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-850);
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-900);
--dsw-menu-surface-fill: rgba(48, 49, 54, 0.64);
--dsw-alias-menu-group-header-fill: rgba(48, 49, 54, 0.94);
--dsw-specific-menu: var(--dsw-menu-surface-fill);
--dsw-specific-selector: var(--dsw-static-neutral-bluish-800);
--dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-900);
--dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-neutral-bluish-800);
--dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-750);
--dsw-specific-tip: var(--dsw-static-neutral-bluish-800);
}
/* Overlays without MenuSurface's opaque backing cannot blur page content over
* native vibrancy reliably, so their fill stays nearly opaque on macOS. */
html[data-platform='darwin'] body {
--dsw-specific-menu: rgba(248, 249, 250, 0.94);
}
html[data-platform='darwin'] body[data-ds-dark-theme] {
--dsw-specific-menu: rgba(48, 49, 54, 0.94);
}
@@ -0,0 +1,21 @@
/* Components own offsets; dense tables and toolbars may keep a 1px ring. */
:root {
--dsw-focus-ring-width: 2px;
}
/* The fallback names the colour and the standard width, never the style: a control that
* disables its outline keeps `none` and paints nothing. Without the width, Chromium's
* unnamed ring stayed at `auto 1px` while every declared ring used the standard geometry.
* Declared rings also read this color, including rings on descendants and pseudo-elements. */
:focus-visible {
outline-color: var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-width: var(--dsw-focus-ring-width);
}
/* ui-primitives/input-modality publishes the attribute. Editable controls keep
* their focus feedback on click; other controls reveal rings on keyboard
* navigation. Only ring paint is suppressed, not elevation or selected borders. */
html[data-input-modality='pointer'] body :focus-visible:not(:read-write) {
--dsw-focus-ring-color: transparent;
outline-color: transparent;
}
@@ -0,0 +1,275 @@
body {
--dsw-linear-gradient-think: linear-gradient(180deg, #fff 20.19%, rgba(255, 255, 255, 0) 100%);
--dsw-linear-think-select: linear-gradient(180deg, #f5f6f7 20.19%, rgba(245, 246, 247, 0) 100%);
/* 阴影 */
--dsw-shadow-lv1: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
--dsw-shadow-lv1-blur: 0 4px 12px 0 rgba(0, 0, 0, 0.02);
--dsw-shadow-lv2: 0 4px 12px 0 rgba(0, 0, 0, 0.02), 0 2px 8px 0 rgba(0, 0, 0, 0.04);
--dsw-shadow-lv3:
0 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 4px 0 rgba(0, 0, 0, 0.02), 0 12px 32px 0 rgba(0, 0, 0, 0.08);
/* Elevation:浮层/面板/面型按钮的描边+投影三件套。0.5px 发丝描边画在
box-shadow 里(组件配 border: 0,不占布局),叠两层极淡柔光;深色主题下
柔光几乎不可见,由描边承担分离。描边颜色经 --dsw-elevation-stroke-color
间接,组件可重绑(菜单面重绑最浅的 l1、输入框重绑 l2,输入框的
workspace-trigger 态置 transparent 只保留柔光)。soft 供输入框:更大
模糊、更低透明度的柔光。默认色只声明在 body 上,让表面的重绑沿继承
传给真正消费投影的后代。 */
--dsw-elevation-stroke-color: var(--dsw-alias-border-l4);
/* Blocking masks dim the page; menu backgrounds retain their blur. */
--dsw-mask-blur: none;
--dsw-menu-backdrop-filter: blur(40px) saturate(150%);
}
body[data-ds-dark-theme] [data-menu-material] {
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
}
/* 派生 elevation 值逐元素声明而非从 body 继承:继承传下来的是在 body 上就
已替换完 var() 的值,组件重绑 --dsw-elevation-stroke-color 将无法进入
var(--dsw-elevation-*);逐元素声明让每个元素按自己看到的描边色重新替换
(同 scrollbar.css 对 --dsh-scrollbar-thumb 的处理)。 */
body,
body * {
--dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color);
--dsw-elevation-panel:
var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.03), 0 0 16px 0 rgba(0, 0, 0, 0.02);
--dsw-elevation-prominent:
var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.04), 0 0 20px 0 rgba(0, 0, 0, 0.05);
--dsw-elevation-soft:
var(--dsw-elevation-stroke), 0 4px 16px 0 rgba(0, 0, 0, 0.03), 0 0 24px 0 rgba(0, 0, 0, 0.03);
}
body[data-ds-dark-theme] {
--dsw-linear-gradient-think: linear-gradient(180deg, #151517 20.19%, rgba(21, 21, 23, 0) 100%);
--dsw-linear-think-select: linear-gradient(180deg, #232325 20.19%, rgba(35, 35, 37, 0) 100%);
}
/* ---------- 字体 ---------- */
/* 由 @deepseek-figma-plugin/custom-variable-name 插件导出 */
/* Markdown 阶梯整体按 0.875(正文 16 → 14)等比缩放,四舍五入到整数像素;
重新从 Figma 导出时需要保留这一缩放。 */
/* Bootstrap and ThemePresenter set the integer content size on body (10–22px,
default 14px). Headings and body variants share its pixel delta. Secondary
text uses size − 1 through 14px, then size − 2: 10→9, 14→13, 15→13, 22→20.
max(13px, ...) selects the delta, not a font-size floor. Small typography keeps a
fixed size; code typography follows the separate code size setting. */
body {
--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px);
--dsh-content-font-size-secondary: min(calc(var(--dsh-content-font-size, 14px) - 1px), max(13px, calc(var(--dsh-content-font-size, 14px) - 2px)));
--dsh-content-font-delta-secondary: calc(var(--dsh-content-font-size-secondary) - 13px);
/* Code size setting (--dsh-code-font-size, integer px 10–16, default 11)
is the code-block size; inline code and the small tool variant move by
the same increment. */
--dsh-code-font-delta: calc(var(--dsh-code-font-size, 11px) - 11px);
--dsw-font-markdown-h1: 700 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-h1-font-family: var(--dsw-font-family);
--dsw-font-markdown-h1-font-weight: 700;
--dsw-font-markdown-h1-line-height: calc(30px + var(--dsh-content-font-delta));
--dsw-font-markdown-h1-font-size: calc(21px + var(--dsh-content-font-delta));
--dsw-font-markdown-h1-font-style: normal;
--dsw-font-markdown-h2: 700 calc(19px + var(--dsh-content-font-delta)) / calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-h2-font-family: var(--dsw-font-family);
--dsw-font-markdown-h2-font-weight: 700;
--dsw-font-markdown-h2-line-height: calc(28px + var(--dsh-content-font-delta));
--dsw-font-markdown-h2-font-size: calc(19px + var(--dsh-content-font-delta));
--dsw-font-markdown-h2-font-style: normal;
--dsw-font-markdown-h3: 700 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-h3-font-family: var(--dsw-font-family);
--dsw-font-markdown-h3-font-weight: 700;
--dsw-font-markdown-h3-line-height: calc(26px + var(--dsh-content-font-delta));
--dsw-font-markdown-h3-font-size: calc(18px + var(--dsh-content-font-delta));
--dsw-font-markdown-h3-font-style: normal;
--dsw-font-markdown-h4: 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-h4-font-family: var(--dsw-font-family);
--dsw-font-markdown-h4-font-weight: 600;
--dsw-font-markdown-h4-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-h4-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-h4-font-style: normal;
--dsw-font-markdown-base: var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-base-font-family: var(--dsw-font-family);
--dsw-font-markdown-base-font-weight: 400;
--dsw-font-markdown-base-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-base-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-base-font-style: normal;
--dsw-font-markdown-base-strong: 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-base-strong-font-family: var(--dsw-font-family);
--dsw-font-markdown-base-strong-font-weight: 600;
--dsw-font-markdown-base-strong-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-base-strong-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-base-strong-font-style: normal;
--dsw-font-markdown-base-italic: italic var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-base-italic-font-family: var(--dsw-font-family);
--dsw-font-markdown-base-italic-font-weight: 400;
--dsw-font-markdown-base-italic-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-base-italic-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-base-italic-font-style: italic;
--dsw-font-markdown-base-strong-italic: italic 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-base-strong-italic-font-family: var(--dsw-font-family);
--dsw-font-markdown-base-strong-italic-font-weight: 600;
--dsw-font-markdown-base-strong-italic-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-base-strong-italic-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-base-strong-italic-font-style: italic;
--dsw-font-markdown-table: var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family);
--dsw-font-markdown-table-font-family: var(--dsw-font-family);
--dsw-font-markdown-table-font-weight: 400;
--dsw-font-markdown-table-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px));
--dsw-font-markdown-table-font-size: var(--dsh-content-font-size-secondary, 13px);
--dsw-font-markdown-table-font-style: normal;
--dsw-font-markdown-table-head: 500 var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family);
--dsw-font-markdown-table-head-font-family: var(--dsw-font-family);
--dsw-font-markdown-table-head-font-weight: 500;
--dsw-font-markdown-table-head-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px));
--dsw-font-markdown-table-head-font-size: var(--dsh-content-font-size-secondary, 13px);
--dsw-font-markdown-table-head-font-style: normal;
--dsw-font-markdown-small: 12px/20px var(--dsw-font-family);
--dsw-font-markdown-small-font-family: var(--dsw-font-family);
--dsw-font-markdown-small-font-weight: 400;
--dsw-font-markdown-small-line-height: 20px;
--dsw-font-markdown-small-font-size: 12px;
--dsw-font-markdown-small-font-style: normal;
--dsw-font-markdown-small-strong: 600 12px/20px var(--dsw-font-family);
--dsw-font-markdown-small-strong-font-family: var(--dsw-font-family);
--dsw-font-markdown-small-strong-font-weight: 600;
--dsw-font-markdown-small-strong-line-height: 20px;
--dsw-font-markdown-small-strong-font-size: 12px;
--dsw-font-markdown-small-strong-font-style: normal;
--dsw-font-markdown-small-italic: italic 12px/20px var(--dsw-font-family);
--dsw-font-markdown-small-italic-font-family: var(--dsw-font-family);
--dsw-font-markdown-small-italic-font-weight: 400;
--dsw-font-markdown-small-italic-line-height: 20px;
--dsw-font-markdown-small-italic-font-size: 12px;
--dsw-font-markdown-small-italic-font-style: italic;
--dsw-font-markdown-small-strong-italic: italic 600 12px/20px var(--dsw-font-family);
--dsw-font-markdown-small-strong-italic-font-family: var(--dsw-font-family);
--dsw-font-markdown-small-strong-italic-font-weight: 600;
--dsw-font-markdown-small-strong-italic-line-height: 20px;
--dsw-font-markdown-small-strong-italic-font-size: 12px;
--dsw-font-markdown-small-strong-italic-font-style: italic;
--dsw-font-markdown-code: calc(12px + var(--dsh-code-font-delta)) / calc(19px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
--dsw-font-markdown-code-font-family: var(--ds-font-family-code);
--dsw-font-markdown-code-font-weight: 400;
--dsw-font-markdown-code-line-height: calc(19px + var(--dsh-code-font-delta));
--dsw-font-markdown-code-font-size: calc(12px + var(--dsh-code-font-delta));
--dsw-font-markdown-code-font-style: normal;
--dsw-font-markdown-code-block: var(--dsh-code-font-size, 11px) / calc(19px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
--dsw-font-markdown-code-block-font-family: var(--ds-font-family-code);
--dsw-font-markdown-code-block-font-weight: 400;
--dsw-font-markdown-code-block-line-height: calc(19px + var(--dsh-code-font-delta));
--dsw-font-markdown-code-block-font-size: var(--dsh-code-font-size, 11px);
--dsw-font-markdown-code-block-font-style: normal;
/* 手工补充(非插件导出):tool row 展开卡片内的小号 code 字体。 */
--dsw-font-markdown-code-block-small: var(--dsh-code-font-size, 11px) / calc(16px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
--dsw-font-markdown-code-block-small-font-family: var(--ds-font-family-code);
--dsw-font-markdown-code-block-small-font-weight: 400;
--dsw-font-markdown-code-block-small-line-height: calc(16px + var(--dsh-code-font-delta));
--dsw-font-markdown-code-block-small-font-size: var(--dsh-code-font-size, 11px);
--dsw-font-markdown-code-block-small-font-style: normal;
--dsw-font-xl-24: 600 24px/32px var(--dsw-font-family);
--dsw-font-xl-24-font-family: var(--dsw-font-family);
--dsw-font-xl-24-font-weight: 600;
--dsw-font-xl-24-line-height: 32px;
--dsw-font-xl-24-font-size: 24px;
--dsw-font-xl-24-font-style: normal;
--dsw-font-l-20: 500 20px/28px var(--dsw-font-family);
--dsw-font-l-20-font-family: var(--dsw-font-family);
--dsw-font-l-20-font-weight: 500;
--dsw-font-l-20-line-height: 28px;
--dsw-font-l-20-font-size: 20px;
--dsw-font-l-20-font-style: normal;
--dsw-font-m-18: 500 16px/28px var(--dsw-font-family);
--dsw-font-m-18-font-family: var(--dsw-font-family);
--dsw-font-m-18-font-weight: 500;
--dsw-font-m-18-line-height: 28px;
--dsw-font-m-18-font-size: 16px;
--dsw-font-m-18-font-style: normal;
--dsw-font-base-16: 16px/24px var(--dsw-font-family);
--dsw-font-base-16-font-family: var(--dsw-font-family);
--dsw-font-base-16-font-weight: 400;
--dsw-font-base-16-line-height: 24px;
--dsw-font-base-16-font-size: 16px;
--dsw-font-base-16-font-style: normal;
--dsw-font-base-strong-16: 500 16px/24px var(--dsw-font-family);
--dsw-font-base-strong-16-font-family: var(--dsw-font-family);
--dsw-font-base-strong-16-font-weight: 500;
--dsw-font-base-strong-16-line-height: 24px;
--dsw-font-base-strong-16-font-size: 16px;
--dsw-font-base-strong-16-font-style: normal;
--dsw-font-s-14: 14px/22px var(--dsw-font-family);
--dsw-font-s-14-font-family: var(--dsw-font-family);
--dsw-font-s-14-font-weight: 400;
--dsw-font-s-14-line-height: 22px;
--dsw-font-s-14-font-size: 14px;
--dsw-font-s-14-font-style: normal;
--dsw-font-s-strong-14: 500 14px/22px var(--dsw-font-family);
--dsw-font-s-strong-14-font-family: var(--dsw-font-family);
--dsw-font-s-strong-14-font-weight: 500;
--dsw-font-s-strong-14-line-height: 22px;
--dsw-font-s-strong-14-font-size: 14px;
--dsw-font-s-strong-14-font-style: normal;
--dsw-font-xs-13: 13px/20px var(--dsw-font-family);
--dsw-font-xs-13-font-family: var(--dsw-font-family);
--dsw-font-xs-13-font-weight: 400;
--dsw-font-xs-13-line-height: 20px;
--dsw-font-xs-13-font-size: 13px;
--dsw-font-xs-13-font-style: normal;
--dsw-font-xs-strong-13: 500 13px/20px var(--dsw-font-family);
--dsw-font-xs-strong-13-font-family: var(--dsw-font-family);
--dsw-font-xs-strong-13-font-weight: 500;
--dsw-font-xs-strong-13-line-height: 20px;
--dsw-font-xs-strong-13-font-size: 13px;
--dsw-font-xs-strong-13-font-style: normal;
--dsw-font-xxs-12: 12px/18px var(--dsw-font-family);
--dsw-font-xxs-12-font-family: var(--dsw-font-family);
--dsw-font-xxs-12-font-weight: 400;
--dsw-font-xxs-12-line-height: 18px;
--dsw-font-xxs-12-font-size: 12px;
--dsw-font-xxs-12-font-style: normal;
--dsw-font-xxs-strong-12: 500 12px/18px var(--dsw-font-family);
--dsw-font-xxs-strong-12-font-family: var(--dsw-font-family);
--dsw-font-xxs-strong-12-font-weight: 500;
--dsw-font-xxs-strong-12-line-height: 18px;
--dsw-font-xxs-strong-12-font-size: 12px;
--dsw-font-xxs-strong-12-font-style: normal;
--dsw-font-xxxs-11: 11px/14px var(--dsw-font-family);
--dsw-font-xxxs-11-font-family: var(--dsw-font-family);
--dsw-font-xxxs-11-font-weight: 400;
--dsw-font-xxxs-11-line-height: 14px;
--dsw-font-xxxs-11-font-size: 11px;
--dsw-font-xxxs-11-font-style: normal;
--dsw-font-xxxs-strong-11: 500 11px/14px var(--dsw-font-family);
--dsw-font-xxxs-strong-11-font-family: var(--dsw-font-family);
--dsw-font-xxxs-strong-11-font-weight: 500;
--dsw-font-xxxs-strong-11-line-height: 14px;
--dsw-font-xxxs-strong-11-font-size: 11px;
--dsw-font-xxxs-strong-11-font-style: normal;
}
@@ -0,0 +1,97 @@
/* Scrollbar skin: the sole consumer of the four --dsw-alias-scrollbar-*
* tokens. Without it every scrolling region renders the UA scrollbar, which
* ignores the theme — a light native bar over the dark palette.
*
* The rules sit on `body`, not `html`: design-platform.css declares the
* --dsw-alias-* tokens on `body` (and the dark overrides on
* `body[data-ds-dark-theme]`), and custom properties only inherit downward,
* so an `html` rule resolves them to the guaranteed-invalid value and
* `scrollbar-color` falls back to `auto`.
*
* Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}:
* the l1 pair here is the base-surface default, and an elevated surface
* (menu, popover, dialog) rebinds to the l2 pair on its own container. A
* scroll container may also rebind the three geometry variables below. */
body {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
--dsh-scrollbar-thumb-border: 0px;
--dsh-scrollbar-track-margin: 0px;
/* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule
below. A surface that must align itself beside a space-consuming bar
(ConversationRoot's overlay composer seat) reads this instead of
hardcoding the number. */
--dsh-scrollbar-width: 5px;
}
/* The two paths are mutually exclusive, and the gate is load-bearing rather
than defensive. A non-`auto` `scrollbar-width` or `scrollbar-color` makes
Chromium and Safari drop every `::-webkit-scrollbar*` rule for that
element, including `::-webkit-scrollbar-thumb:hover` — measured in chromium
as a `::-webkit-scrollbar` width taking effect on its own and being
ignored as soon as `scrollbar-width: thin` is added. Declaring both
unconditionally therefore leaves the hover tokens with no rendering at all,
because the engines that implement the hover pseudo-element are exactly the
ones the standard properties silence, and Firefox has no hover
pseudo-element to fall back on.
`not selector(::-webkit-scrollbar)` is true only where the pseudo-element
is unimplemented, so Firefox takes the standard path and WebKit-based
engines take the pseudo-element path. An engine too old for the
`selector()` function makes the condition invalid, which evaluates false
and selects the pseudo-element path — the correct side for the pre-16.4
Safari that is the realistic case. */
@supports not selector(::-webkit-scrollbar) {
/* Declared on every element rather than inherited from `body`. Inheriting
would pass down the COLOUR already substituted at `body`, so a descendant
rebinding --dsh-scrollbar-thumb could not change it; re-declaring makes
each element substitute the variable as it sees it, which is what gives
an elevated surface a working rebind. `scrollbar-width` is not an
inherited property at all, so it needs the per-element declaration
regardless.
No hover counterpart exists on this path: `scrollbar-color` states one
thumb colour and the engine derives its own hover treatment. */
body,
body * {
scrollbar-width: thin;
scrollbar-color: var(--dsh-scrollbar-thumb) transparent;
}
}
/* Not gated in turn: an engine that does not implement these pseudo-elements
drops the rules as unknown selectors, so the gate would only restate what
selector matching already does. Not inherited either, hence the unscoped
selectors. */
::-webkit-scrollbar {
width: var(--dsh-scrollbar-width);
height: var(--dsh-scrollbar-width);
}
/* Track stays transparent so the thumb reads against whatever surface scrolls
under it; only the thumb carries a token colour. */
::-webkit-scrollbar-track {
margin-block: var(--dsh-scrollbar-track-margin);
background: transparent;
}
::-webkit-scrollbar-thumb {
border: var(--dsh-scrollbar-thumb-border) solid transparent;
border-radius: 999px;
corner-shape: round;
background: var(--dsh-scrollbar-thumb);
background-clip: content-box;
}
@media (hover: hover) {
::-webkit-scrollbar-thumb:hover {
background-color: var(--dsh-scrollbar-thumb-hover);
}
}
/* Both scrollbars meeting in a corner: no separate token, so the corner
matches the transparent track rather than the UA's opaque default. */
::-webkit-scrollbar-corner {
background: transparent;
}
@@ -0,0 +1,491 @@
.projectRow,
.sessionRow {
display: flex;
align-items: center;
gap: var(--dsh-sidebar-label-gap);
border-radius: var(--dsw-radius-md);
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
background-clip: padding-box;
box-sizing: border-box;
padding: 0 8px;
padding-inline-start: calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset) + var(--dsh-workspace-indent, 0px));
cursor: pointer;
-webkit-user-select: none;
user-select: none;
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.projectRow:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.sessionRow.selected {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.dragPreview {
background: var(--dsw-specific-sidebar-fill);
}
.searchResultRow {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
min-height: 48px;
box-sizing: border-box;
border: none;
border-radius: var(--dsw-radius-lg);
padding: 4px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
background: transparent;
background-clip: padding-box;
cursor: pointer;
text-align: left;
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.searchResultRow:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.searchResultRow.selected {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.searchResultHeading {
display: flex;
align-items: center;
min-width: 0;
}
.searchResultTitle {
flex: 0 1 auto;
min-width: 0;
margin: 0 var(--dsh-sidebar-label-gap);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 20px;
}
.searchResultMeta {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
margin-left: calc(16px + var(--dsh-sidebar-label-gap));
}
.searchResultWorkspace,
.searchResultSnippet {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 17px;
}
.searchResultWorkspace {
flex: none;
max-width: 40%;
color: var(--dsw-alias-label-tertiary);
}
/* The 40% share only protects a snippet; a snippetless match keeps the row. */
.searchResultWorkspace:only-child {
max-width: 100%;
}
.searchResultSnippet {
flex: 1;
min-width: 0;
color: var(--dsw-alias-label-secondary);
}
/* Compact one-line Workspace row after removing the session-count subtitle. */
.projectRow {
height: 34px;
align-items: center;
box-sizing: border-box;
}
.projectRow .rowActions {
height: 20px;
}
/* Session titles share the workspace icon slot and compact label gap. */
.sessionRow {
height: 32px;
gap: 0;
}
.sessionRow .title {
margin: 0 6px 0 var(--dsh-sidebar-label-gap);
}
.slot {
flex: none;
width: 16px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.folderActive {
color: var(--dsw-alias-state-business-primary);
}
/* Project leading slot: folder by default, expand arrow on row hover. */
.projectRow .chevron { display: none; }
@media (hover: hover) {
.projectRow:hover .chevron { display: inline-flex; }
.projectRow:hover .folder { display: none; }
}
/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
.arrow {
transition: transform 150ms var(--ds-ease-in-out);
}
.arrowOpen {
transform: rotate(90deg);
}
.projectText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 20px;
}
.renameInput {
min-width: 0;
font-size: 14px;
line-height: 20px;
padding: 0 2px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-sm);
background: var(--dsw-alias-button-elevated-fill);
color: inherit;
outline: none;
}
/* Hovering a session row marquees a title wider than its cell: Rows.tsx crawls
it at a constant speed to its far edge while the pointer rests on the row,
holds it there, and returns it to the start in one step when the pointer
leaves. The motion is scripted frame by frame (reduced motion jumps), so no
scroll-behavior applies here. */
.sessionRow .title {
flex: 1;
}
/* While the marquee travels, both clipping edges fade in over 12px instead of
hard-cutting a character: the left once the title has moved off its start
(data-scrolled), the right while text still remains beyond the cell
(data-clipped) — at the far edge the right fade lifts so the final character
reads at full strength. The mask rides the title span itself, so the status
slot and every other leading cell keep their full color. */
.sessionRow .title[data-scrolled] {
mask-image: linear-gradient(to right, transparent, #000 12px);
}
.sessionRow .title[data-clipped] {
mask-image: linear-gradient(to left, transparent, #000 12px);
}
.sessionRow .title[data-scrolled][data-clipped] {
mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
}
.meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
.time {
flex: none;
font-size: 10px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
margin-inline-end: -1px;
}
.pinIndicator {
display: inline-flex;
flex: none;
width: 16px;
height: 20px;
align-items: center;
justify-content: center;
margin-left: 6px;
color: var(--dsw-alias-label-caption);
}
/* Archived rows stay in place but read as inactive: titles drop to
the caption label step (one step above dimmed, which reads as too faint
over the sidebar fill). Archived search rows drop their meta line to the
same step so both lines read as one unit. */
.sessionRow.archived .title,
.searchResultRow.archived .searchResultTitle,
.searchResultRow.archived .searchResultWorkspace,
.searchResultRow.archived .searchResultSnippet {
color: var(--dsw-alias-label-caption);
}
.dot {
flex: none;
}
/* The trailing strip keeps a shared gap between its fixed-size control boxes. */
.rowActions {
flex: none;
display: none;
align-items: center;
gap: 13px;
}
@media (hover: hover) {
.projectRow:hover .rowActions,
.searchResultRow:hover .rowActions {
display: inline-flex;
}
}
.projectRow.menuOpen .rowActions {
display: inline-flex;
}
.rowQuickActions {
display: contents;
}
/* The search heading has no trailing time cell, so the unarchive action
right-aligns itself. */
.searchResultHeading .rowActions {
margin-left: auto;
}
.rowResting {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--dsw-alias-label-tertiary);
}
.rowResting .pinIndicator {
margin-left: 0;
}
/* The unclipped hover state drops the ellipsis, which would otherwise cover
the characters the marquee reached. Guarded because a touch tap
latches `:hover` on the row after the title has already returned: an
unguarded rule would strand that row without its ellipsis. */
@media (hover: hover) {
.sessionRow:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.sessionRow:hover .rowActions,
.sessionRow.menuOpen .rowActions,
:global(html[data-input-modality='keyboard']) .sessionRow:focus-within .rowActions {
display: inline-flex;
}
.sessionRow:hover .rowResting,
.sessionRow.menuOpen .rowResting,
:global(html[data-input-modality='keyboard']) .sessionRow:focus-within .rowResting {
display: none;
}
.sessionRow:hover .title,
.sessionRow.menuOpen .title {
text-overflow: clip;
}
}
@media (hover: none) {
.projectRow .rowActions,
.searchResultRow .rowActions,
.sessionRow .rowActions {
display: inline-flex;
}
/* Only update timestamps disappear; pending-interaction labels remain visible. */
.sessionRow .timestamp,
.sessionRow:has(.pinnedAction) .pinIndicator,
.sessionRow .rowQuickActions > :global([data-slot]) > :not(.pinnedAction),
.projectRow[aria-expanded='false'] .rowActions {
display: none;
}
.sessionRow .pinnedAction {
/* Keep More aligned across rows. Tab order remains More, then Unpin. */
order: -1;
}
}
/* An open row menu pins the hover affordances (figma: the row keeps its
hover fill while its dropdown is up). */
.projectRow.menuOpen,
.sessionRow.menuOpen {
background-color: var(--dsw-alias-interactive-bg-hover);
}
/* Session drag insert marker: a leading chevron and 2px rule between rows,
absolutely positioned so it neither resembles a row border nor changes layout. */
.sessionRow.dropBefore,
.sessionRow.dropAfter {
position: relative;
}
.sessionRow.dropBefore::before,
.sessionRow.dropAfter::after {
content: '';
position: absolute;
z-index: 1;
left: calc(-1 * var(--dsh-sidebar-paint-inset));
right: calc(4px - var(--dsh-sidebar-paint-inset));
height: 12px;
background:
linear-gradient(
55deg,
transparent calc(50% - 1px),
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
transparent calc(50% + 1px)
) 0 0 / 5px 7px no-repeat,
linear-gradient(
125deg,
transparent calc(50% - 1px),
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
transparent calc(50% + 1px)
) 0 5px / 5px 7px no-repeat,
linear-gradient(
var(--dsw-alias-state-business-primary) 0 0
) 4px 5px / calc(100% - 4px) 2px no-repeat;
pointer-events: none;
}
.sessionRow.dropBefore::before {
top: -7px;
}
.sessionRow.dropAfter::after {
bottom: -7px;
}
/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
.hoverContent {
display: flex;
flex-direction: column;
gap: 8px;
}
.hoverTitle {
font-size: 14px;
line-height: 20px;
color: #FFFFFF;
overflow-wrap: break-word;
}
.hoverPath {
font-size: 12px;
line-height: 16px;
color: #CFD3D6;
word-break: break-all;
}
.hoverTime {
font-size: 12px;
line-height: 16px;
color: #CFD3D6;
}
.hoverStatus {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 20px;
color: #ADB2B8;
}
/* Archive glyph rides the status row's text color; slight inset aligns it with the 6px status dots. */
.hoverArchived svg {
flex-shrink: 0;
margin: 0 -4px;
}
/* Compensated margins keep the 24px target on the compact icon's center. */
.iconButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: -4px;
border: none;
border-radius: var(--dsw-radius-xs);
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-tertiary);
}
/* Keep the focus ring on its original compact outline. */
.iconButton:focus-visible {
outline-offset: -2px;
}
@media (hover: hover) {
.iconButton:hover {
color: var(--dsw-alias-label-primary);
}
}
/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
darker (tertiary, #81858C) per the cell spec. */
.chevron {
color: var(--dsw-alias-label-caption);
}
@media (prefers-reduced-motion: reduce) {
.arrow {
animation: none;
transition: none;
}
}
@@ -0,0 +1,686 @@
/* Sidebar column: vertical stack, padding 8/6, sidebar
fill and desktop divider painted by the layout column. Collapse is a
slide + crossfade, not a morph: the content holds its frozen expanded
layout (inline width set by the component) and fades in place (.fading)
while the sliding column (AppFrame grid tracks) clips it; the rail layout
(.collapsed) only applies after the fade settles, so nothing reflows
mid-slide. */
.root {
--dsh-sidebar-inline-padding: 8px;
--dsh-sidebar-row-padding: 12px;
--dsh-sidebar-label-gap: 6px;
--dsh-sidebar-paint-inset: 3px;
display: flex;
flex-direction: column;
height: 100%;
padding: 6px var(--dsh-sidebar-inline-padding);
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
/* The sidebar is elevated above the conversation surface, so a revealed
scrollbar uses the l2 pair. .quietBars hides it without changing layout. */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
:global([data-animating]:not([data-dragging])) .root {
transition: width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* macOS desktop: the AppFrame column owns the translucent sidebar tint; an
opaque fill here would sit on top of it and block the window vibrancy. */
:global([data-platform='darwin']) .root {
background: transparent;
}
/* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px
rail (10px side padding), 12px vertical rhythm, 18px from the rail top to
the whale's box (24px to the 24-wide whale glyph itself). */
.root.collapsed {
padding: 18px 10px 6px;
}
/* The brand stays in the sidebar; fixed caption controls survive zero-width collapse. */
:global([data-windows-titlebar]) .root .logoRow {
height: 40px;
margin: 0;
padding: 0;
}
:global([data-windows-titlebar]) .toggle {
position: fixed;
top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
left: 12px;
z-index: 30;
-webkit-app-region: no-drag;
}
:global([data-windows-titlebar]) .root.collapsed .logoRow {
height: 0;
}
:global([data-windows-titlebar]) .root:not(.collapsed) .brand {
padding-left: 8px;
}
:global([data-windows-titlebar]) .brandIdentity {
transform: translateY(1px);
}
:global([data-windows-titlebar]) .brandMark {
transform: translateX(1px);
}
:global([data-windows-titlebar]) .brandName {
font-weight: 400;
}
:global([data-windows-titlebar]) .root.collapsed {
padding: 0;
}
:global([data-windows-titlebar]) .root:not(.collapsed) .newSession {
margin-top: 6px;
}
:global([data-windows-titlebar]) .collapsed .panelList,
:global([data-windows-titlebar]) .collapsed .regionArea,
:global([data-windows-titlebar]) .collapsed .footArea {
display: none;
}
:global(html[data-windows-titlebar]:has([data-sidebar-collapsed='true'])) {
--dsh-windows-menu-start: 84px;
}
:global([data-windows-titlebar]) .collapsed .newSession {
position: fixed;
top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
left: 48px;
margin: 0;
padding: 0;
border: none;
z-index: 30;
-webkit-app-region: no-drag;
}
:global([data-windows-titlebar]) .railIn .iconButton,
:global([data-windows-titlebar]) .railIn .newSession {
animation: none;
}
:global([data-windows-titlebar]) .collapsed .toggle,
:global([data-windows-titlebar]) .collapsed .newSession {
width: 28px;
height: 28px;
border-radius: var(--dsw-radius-sm);
color: var(--dsw-alias-label-secondary);
}
:global([data-windows-titlebar]) .collapsed .toggle .panelIcon {
display: inline;
}
/* Scrollbars in the column are a pointer affordance: the shell adds this
class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
and rebinding ui-theme's indirection pair to `transparent` takes the thumb
out of every scroll region nested under it — the workspace browser's
session list. `transparent` rather than `display: none` on the bar:
the reservation (`scrollbar-gutter: stable` on the list) stays in force, so
revealing the thumb never reflows a row. Rebinding contract and the two
rendering paths it reaches: ui-theme's README. */
.root.quietBars {
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
/* Collapse phase 1: the whole frozen-width content fades out in place over
150ms; at settle the children unmount/snap to the rail layout. */
.fading > * {
opacity: 0;
transition: opacity 150ms var(--ds-ease-in-out);
}
/* Wide-only content fades back in on expand remount. */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
}
@keyframes wide-in {
from { opacity: 0; }
}
/* At the 150ms rail settle, the upper controls enter from the former
rail right edge over the remaining 150ms of the AppFrame track transition.
The bottom-pinned settings seat shares their opacity timeline but stays
horizontally fixed. Only a live collapse gets .railIn; a cold collapsed
render stays static. */
.railIn .iconButton,
.railIn .newSession,
.railIn .panelList,
.railIn .regionArea {
animation: rail-in 150ms var(--ds-ease-in-out) backwards;
}
.railIn .footArea {
animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards;
}
@keyframes rail-in {
from {
opacity: 0;
transform: translateX(49px);
}
}
@keyframes rail-fade-in {
from { opacity: 0; }
}
/* macOS hiddenInset titlebar: the strip spans the column's top edge (negative
margins cancel the root padding) so it clears the traffic lights at
(16, 18). The strip marks itself `data-window-drag` in markup (SidebarRoot), so
its own run is the column's draggable geometry and no frame band has to match
the strip's height; the toggle inside subtracts via the base.css rule. */
.topStrip {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
height: 52px;
box-sizing: border-box;
margin: -6px calc(-1 * var(--dsh-sidebar-inline-padding)) 0;
/* The 2px foot centres the 28px toggle at y 25, level with the collapsed
seat (ui-layout .leadingSeat, top 11) and the conversation title row, so
collapse/expand never shifts it vertically. */
padding: 0 12px 2px;
}
:global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed) > .topStrip > .toggle {
transform: translateX(4px);
}
/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop
preload): the toggle takes their spot at the strip's left edge. */
:global([data-fullscreen]) .topStrip {
justify-content: flex-start;
}
/* The strip's full 52px foot leaves the wordmark too low; the content column
tucks 12px back under it, still clear of the toggle (28px control centered
in the strip ends at y 40). */
.topStrip + .logoRow {
margin-top: -12px;
}
/* Logo row: 8px vertical and leading padding, with no trailing padding or
bottom margin. Expanded keeps the panel toggle at the
right edge; the rail gives every control the same base left anchor before
the shared entry translation is applied. macOS hiddenInset titlebar: the row
is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's
single darwin rule carries the drag over its own box — below the top strip
(40–100px: the strip's 52px foot with the row's -12px tuck). The brand rides
that box (SidebarRoot renders the wordmark as a plain span there, so the
global interactive no-drag rule never subtracts it; only the local-build
fallbacks opt out through .copyVersion); the dedicated New
Session button below keeps the pointer and keyboard paths. Safe as a drag
source: it precedes the sidebar's overlay slots in document order, so their
no-drag still wins. */
.logoRow {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 60px;
padding: 8px 0 8px 8px;
margin-bottom: 0;
box-sizing: border-box;
overflow: hidden;
}
.collapsed .logoRow {
height: 36px;
padding: 0;
margin-bottom: 12px;
justify-content: flex-start;
/* The settled rail's 36px toggle is wider than this 35px row; the base row's clip would cut
the control's right edge, so the rail stops clipping once the slide has settled. */
overflow: visible;
}
/* Brand group (figma I133:7632): the full wordmark rides the text ink
(figma flows: the main-screen instance is black; blue is brand
emphasis only). A button only in behavior (New Session shortcut): the
pointer cursor is the sole affordance — no hover chrome on the mark. */
.brand {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
overflow: hidden;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
/* On macOS the brand is a window-drag surface, not a New Session button;
a pointer cursor would promise a click that drags the window instead. */
:global([data-platform='darwin']) .brand {
cursor: default;
}
/* Mark and name are independent slots; the shell owns their shared baseline,
spacing, and height so either occupant can vary in width. */
.brandIdentity {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
min-width: 0;
}
.brandMark {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brandName {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
height: 24px;
font-size: 18px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.04em;
}
.fallbackBrandName {
font-size: 17px;
letter-spacing: 0px;
white-space: nowrap;
}
.localBuildBrand {
flex: none;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1px;
height: 24px;
white-space: nowrap;
}
/* Local-build fallbacks copy the version, also on macOS, where the
surrounding brand is a window-drag surface. */
.copyVersion {
display: inline-flex;
cursor: pointer;
-webkit-app-region: no-drag;
}
.localBuildTitle {
font-size: 12px;
line-height: 13px;
letter-spacing: 0;
}
.iconButton {
position: relative;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
@media (hover: hover) {
.iconButton:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
/* The toggle is flush with .logoRow, which clips its own overflow, so an outward ring loses
the pixels that leave the row. Draw this control's ring inside its own box instead. */
.iconButton.toggle:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: -2px;
}
.collapsed .iconButton {
width: 36px;
height: 36px;
/* Rail hovers share the panel rows' and New Session's 12px rounding. */
border-radius: var(--dsw-radius-md);
}
/* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink,
no hover circle) and hovering reveals the panel icon — the expand
affordance (figma sidebar-hover flow). Expanded it is a plain panel icon. */
.collapsed .toggle .panelIcon {
display: none;
}
@media (hover: hover) {
.collapsed .toggle:hover .panelIcon {
display: inline;
}
.collapsed .toggle:hover .railMark {
display: none;
}
}
.railMark {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Rail icons ride the primary ink (figma rail spec); expanded keeps the
secondary icon-button ink. */
.collapsed .iconButton {
color: var(--dsw-alias-label-primary);
}
.buildVersion {
flex: none;
display: inline-flex;
align-items: center;
height: 10px;
padding: 0 3px;
border-radius: 2px;
color: var(--dsw-alias-label-primary-inverted);
background: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 6px;
font-weight: 500;
line-height: 10px;
white-space: nowrap;
}
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
corners); collapsed it renders as the rail's plain icon control. */
.newSession {
container-type: inline-size;
position: relative;
flex: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0;
height: 38px;
padding: 8px 12px;
margin: 0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px;
box-sizing: border-box;
border: 0.5px solid var(--dsw-alias-border-l3);
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-button-elevated-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
font-weight: 500;
line-height: 22px;
cursor: pointer;
overflow: hidden;
}
@media (hover: hover) {
.newSession:hover {
background: var(--dsw-alias-button-floating-hover);
}
}
/* macOS: the themed elevated fill reads as a foreign slab on the vibrancy
sidebar; a white wash sits in the glass like native macOS sidebar
controls. Dark keeps the same white ink at lower alpha. */
:global([data-platform='darwin']) :where(.root:not([role='dialog'])) .newSession {
background: rgb(255 255 255 / 0.55);
}
@media (hover: hover) {
:global([data-platform='darwin']) :where(.root:not([role='dialog'])) .newSession:hover {
background: rgb(255 255 255 / 0.3);
}
}
:global([data-platform='darwin']) :global([data-ds-dark-theme]) :where(.root:not([role='dialog'])) .newSession {
background: rgb(255 255 255 / 0.15);
}
@media (hover: hover) {
:global([data-platform='darwin']) :global([data-ds-dark-theme]) :where(.root:not([role='dialog'])) .newSession:hover {
background: rgb(255 255 255 / 0.2);
}
}
.collapsed .newSession {
align-self: flex-start;
width: 36px;
height: 36px;
padding: 0;
margin: 0 0 12px;
gap: 0;
border-color: transparent;
background: transparent;
}
@media (hover: hover) {
.collapsed .newSession:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.newSessionLabel {
max-width: 200px;
overflow: hidden;
white-space: nowrap;
}
.collapsed .newSessionLabel {
max-width: 0;
}
.newSessionLabelMask { flex: 1; min-width: 0; }
.newSession:focus-visible .newSessionLabelMask:has(+ .newSessionShortcut) {
overflow: hidden;
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
}
@media (hover: hover) {
.newSession:hover .newSessionLabelMask:has(+ .newSessionShortcut) {
overflow: hidden;
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
}
}
.newSessionContent { display: flex; width: 100cqw; align-items: center; justify-content: center; gap: 6px; }
.newSessionContent > svg { flex: none; }
.newSessionShortcut {
opacity: 0;
display: inline-flex;
flex: none;
pointer-events: none;
font-weight: 400;
}
.newSession:focus-visible .newSessionShortcut {
opacity: 1;
}
@media (hover: hover) {
.newSession:hover .newSessionShortcut {
opacity: 1;
}
}
.panelList {
flex: none;
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 8px;
}
/* Panel entries use a 3px paint inset, the New Session bar's 12px radius,
the session rows' hover/selected fill, and the primary ink. */
.panelRow {
display: flex;
align-items: center;
gap: var(--dsh-sidebar-label-gap);
margin: 0;
min-height: 36px;
padding: 7px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
box-sizing: border-box;
border: none;
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
border-radius: var(--dsw-radius-md);
background: transparent;
background-clip: padding-box;
color: var(--dsw-alias-label-primary);
font: inherit;
line-height: 22px;
text-align: left;
cursor: pointer;
}
@media (hover: hover) {
.panelRow:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.panelRow.panelActive {
background-color: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.panelRow:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: -2px;
}
/* The More launcher keeps the panel rows' geometry inside the menu's anchor wrapper. */
.moreAnchor {
display: flex;
flex-direction: column;
}
.panelRow[aria-expanded='true'] {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.moreMenu {
width: 288px;
max-width: calc(100vw - 24px);
}
.panelGlyph {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.panelTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collapsed .panelList {
gap: 12px;
margin-bottom: 12px;
}
.collapsed .panelRow {
width: 36px;
height: 36px;
justify-content: center;
margin: 0;
border-inline: 0;
background-clip: border-box;
padding: 0;
color: var(--dsw-alias-label-primary);
}
/* Region seat: always mounted so the foot never moves. Its trailing margin
cancels the wide shell inset so the nested scrollbar can sit at the sidebar
edge; the browser restores that inset inside its own rows. */
.regionArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-left: -4px;
margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
padding-left: 4px;
overflow: hidden;
}
.collapsed .regionArea {
margin-left: 0;
margin-right: 0;
padding-left: 0;
}
/* Footer seats: additive actions stack above Settings. Each occupant owns its
button geometry and hover chrome. */
.footArea {
flex: none;
display: flex;
flex-direction: column;
}
.settingsArea,
.footerActions {
flex: none;
min-width: 0;
width: 100%;
}
.footerActions {
display: flex;
}
.collapsed .footArea {
align-items: center;
}
.collapsed .settingsArea,
.collapsed .footerActions {
display: flex;
justify-content: center;
width: auto;
}
@media (prefers-reduced-motion: reduce) {
:global([data-animating]:not([data-dragging])) .root,
.wide,
.fading > *,
.railIn .iconButton,
.railIn .newSession,
.railIn .panelList,
.railIn .footArea,
.railIn .regionArea {
transition: none;
animation: none;
}
}
@@ -0,0 +1,662 @@
.root {
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
--dsh-session-list-scrollbar-width: 5px;
--dsh-session-list-scrollbar-offset: 2px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-right: var(--dsh-session-list-edge-inset);
}
.root.rail {
padding-right: 0;
}
.iconButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
/* The action cluster animates its width and the search pill rounds its corners,
* so both clip an outer ring; these controls keep theirs inside instead. */
.iconButton:focus-visible,
.searchButton:focus-visible,
.clearButton:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: -2px;
}
@media (hover: hover) {
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* View-options dropdown card: labels are short, so a content-sized card reads
cramped and its width shifts with the locale — hold a stable floor. */
.viewOptionsMenu {
min-width: 200px;
}
/* Section header: title, an inline search control, and the two trailing
actions. Expanding search collapses the action cluster and takes its room. */
.sectionHeader {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 36px;
padding-left: var(--dsh-sidebar-row-padding);
margin-bottom: 4px;
box-sizing: border-box;
border-radius: var(--dsw-radius-md);
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
}
.root:not(.rail) .sectionHeader {
margin-top: 2px;
margin-right: -4px;
}
.root:not(.rail) .sectionHeader.searchSpacingMotion {
transition:
margin-right 180ms var(--ds-ease-in-out),
padding-inline 180ms var(--ds-ease-in-out),
gap 180ms var(--ds-ease-in-out);
}
.root:not(.rail) .sectionHeader:not(:has(.searchExpanded)) {
padding-right: 1px;
}
.sectionLabel {
position: relative;
left: -1px;
flex: none;
max-width: 45%;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
opacity: 1;
visibility: visible;
transition:
max-width 180ms var(--ds-ease-in-out),
margin-right 180ms var(--ds-ease-in-out),
opacity 120ms var(--ds-ease-in-out),
transform 180ms var(--ds-ease-in-out),
visibility 0s linear;
}
.sectionLabelHidden {
max-width: 0;
margin-right: -4px;
opacity: 0;
transform: translateX(-4px);
visibility: hidden;
transition-delay: 0s, 0s, 0s, 0s, 180ms;
}
.searchSlot {
flex: 1;
max-width: 28px;
min-width: 0;
display: flex;
align-items: center;
margin-left: auto;
padding-left: 0;
box-sizing: border-box;
transition:
max-width 180ms var(--ds-ease-in-out),
padding-left 180ms var(--ds-ease-in-out);
}
.searchSlotExpanded {
max-width: 100%;
padding-left: 0;
}
.headerActions {
flex: none;
display: flex;
align-items: center;
gap: 4px;
max-width: 60px;
opacity: 1;
overflow: hidden;
visibility: visible;
transition:
max-width 180ms var(--ds-ease-in-out),
opacity 120ms var(--ds-ease-in-out),
transform 180ms var(--ds-ease-in-out),
visibility 0s linear;
}
.headerActionsHidden {
max-width: 0;
opacity: 0;
transform: translateX(4px);
visibility: hidden;
pointer-events: none;
transition-delay: 0s, 0s, 0s, 180ms;
}
/* Inline search always fills the room between the title and trailing actions;
it grows farther right when the action cluster collapses. */
.search {
flex: none;
display: flex;
align-items: center;
gap: 0;
width: 100%;
height: 28px;
margin: 0;
padding: 0;
box-sizing: border-box;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
cursor: text;
color: var(--dsw-alias-label-secondary);
overflow: hidden;
transition:
width 180ms var(--ds-ease-in-out),
padding 180ms var(--ds-ease-in-out),
border-color 180ms var(--ds-ease-in-out),
background-color 180ms var(--ds-ease-in-out);
}
.root:not(.rail) .sectionHeader:has(.searchExpanded) {
margin-right: 0;
padding-inline: calc(var(--dsh-sidebar-paint-inset) + 1px);
gap: 0;
}
.sectionHeader:has(.searchExpanded) .sectionLabelHidden {
margin-right: 0;
}
.searchExpanded {
width: 100%;
height: 32px;
margin-inline: 0;
padding: 0 4px 0 0;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-md);
background: transparent;
color: var(--dsw-alias-label-caption);
}
.searchButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0;
background: transparent;
cursor: pointer;
color: inherit;
}
.searchExpanded .searchButton {
width: 28px;
height: 32px;
}
@media (hover: hover) {
.searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchExpanded .searchButton:hover {
background: transparent;
}
}
.searchInput {
flex: 1;
width: 0;
min-width: 0;
border: none;
outline: none;
background: transparent;
opacity: 0;
pointer-events: none;
font-size: 13px;
line-height: 18px;
color: var(--dsw-alias-label-primary);
transition: opacity 120ms var(--ds-ease-in-out);
}
.searchExpanded .searchInput {
margin-left: -2px;
opacity: 1;
pointer-events: auto;
}
.searchInput::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.clearButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
@media (hover: hover) {
.clearButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* Rail variant (own .rail class from the wide owner prop — the region never
reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. Both use the rail's shared base
left anchor so the outer shell can translate the whole region uniformly. */
.rail .sectionHeader {
gap: 0;
padding-left: 0;
margin-bottom: 12px;
justify-content: flex-start;
}
.rail .headerActions {
max-width: none;
}
.rail .iconButton {
width: 36px;
height: 36px;
/* Rail hovers share the shell rail's 12px rounding. */
border-radius: var(--dsw-radius-md);
color: var(--dsw-alias-label-primary);
}
.rail .search {
width: 36px;
height: 36px;
padding: 0;
margin: 0 0 12px;
gap: 0;
/* The collapsed search frame clips its button's hover, so it must carry the
rail's 12px rounding itself. */
border-radius: var(--dsw-radius-md);
border-color: transparent;
background: transparent;
}
.rail .searchButton {
width: 36px;
height: 36px;
border-radius: var(--dsw-radius-md);
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.rail .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* List seat: always mounted so the shell foot never moves. */
.listArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-left: -4px;
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
padding-left: 4px;
/* The list remains the scroll clip. This seat stays visible so the
absolutely positioned first-boundary marker can occupy the header gap. */
overflow: visible;
}
.rail .listArea {
margin-left: 0;
margin-right: 0;
padding-left: 0;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* Bottom fade: compact overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: var(--dsh-session-list-edge-inset);
bottom: 0;
height: 24px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
/* macOS desktop: the sidebar is translucent over window vibrancy, so the
opaque fill gradient would paint a solid smear instead of a fade. */
:global([data-platform='darwin']) .fade {
display: none;
}
/* Wide-only content fades back in on expand remount (mirrors the shell). */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
}
@keyframes wide-in {
from { opacity: 0; }
}
/* List: the only scrolling region. Block children keep their design heights
under content overflow. The 2px edge offset, stable 5px themed scrollbar,
and remaining padding equal the shell's right inset, with or without
overflow, so moving the bar does not move the rows. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-left: -4px;
margin-right: var(--dsh-session-list-scrollbar-offset);
padding-left: 4px;
padding-right: calc(
var(--dsh-session-list-edge-inset)
- var(--dsh-session-list-scrollbar-width)
- var(--dsh-session-list-scrollbar-offset)
);
/* Clears the compact bottom fade overlay: at scroll end the last row sits
above the gradient instead of under it. */
padding-bottom: 16px;
scrollbar-gutter: stable;
}
.flatList > * + *,
.searchTree > [role='treeitem'] + [role='treeitem'],
.groupSection > * + * {
margin-top: 2px;
}
.searchStatus {
padding: 10px 12px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* Pending content search: skeleton rows on searchResultRow metrics — a round
leading glyph beside a title bar and a wider snippet bar, breathing through
opacity (same pulse as the input-trigger menu skeleton). */
.skeletonRow {
display: flex;
/* Top alignment lines the 16px dot up with the equally tall title bar;
the paddings center the 35px content in the 48px row. */
align-items: flex-start;
gap: var(--dsh-sidebar-label-gap);
box-sizing: border-box;
min-height: 48px;
padding: 6px var(--dsh-sidebar-row-padding) 7px;
}
.skeletonDot {
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
corner-shape: round;
}
.skeletonBars {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.skeletonDot,
.skeletonBar {
background: var(--dsw-alias-bg-skeleton);
animation: search-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
}
.skeletonBar {
width: 65%;
height: 16px;
border-radius: var(--dsw-radius-xs);
}
.skeletonBarWide {
width: 90%;
height: 13px;
}
@keyframes search-skeleton {
0% { opacity: 1; }
40% { opacity: 0.6; }
80%, 100% { opacity: 1; }
}
/* One workspace section: header row + a compact expanded session run. */
.groupSection {
position: relative;
}
.groupSection + .groupSection {
margin-top: 4px;
}
.listTopDropIndicator,
.workspaceDropBefore::before,
.workspaceDropAfter::after {
content: '';
position: absolute;
z-index: 1;
left: 0;
right: 0;
height: 12px;
background:
linear-gradient(
55deg,
transparent calc(50% - 1px),
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
transparent calc(50% + 1px)
) 0 0 / 5px 7px no-repeat,
linear-gradient(
125deg,
transparent calc(50% - 1px),
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
transparent calc(50% + 1px)
) 0 5px / 5px 7px no-repeat,
linear-gradient(
var(--dsw-alias-state-business-primary) 0 0
) 4px 5px / calc(100% - 4px) 2px no-repeat;
pointer-events: none;
}
/* The first insertion boundary keeps the same -8px coordinate as every
Workspace boundary, but lives outside the scrolling clip. */
.listTopDropIndicator {
top: -8px;
left: 0;
right: var(--dsh-session-list-edge-inset);
}
.listTopDropActive > .workspaceDropBefore:first-child::before {
display: none;
}
.workspaceDropBefore::before {
top: -8px;
}
.workspaceDropAfter::after {
bottom: -8px;
}
.sessionOverflowButton {
width: 100%;
height: 28px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0 var(--dsh-sidebar-row-padding) 0 calc(var(--dsh-sidebar-row-padding) + 16px + var(--dsh-sidebar-label-gap) + var(--dsh-workspace-indent, 0px));
background: transparent;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
}
.groupSection > .sessionOverflowButton {
margin-top: 0;
}
@media (hover: hover) {
.sessionOverflowButton:hover {
background: transparent;
color: var(--dsw-alias-label-secondary);
}
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-top: 80px;
padding: 0 12px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.emptyState > svg {
margin-bottom: 4px;
color: var(--dsw-alias-label-caption);
}
.emptyAction {
padding: 0;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-link);
}
/* Rename dialog form (same figma dialog family as the create modals). */
.renameInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-lg);
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.renameInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.renameError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
.deleteAction:not(:disabled) {
color: var(--dsw-alias-state-error-primary);
}
.deleteStatus {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
/* The stop-and-archive dialog's list of work that will be stopped. */
.archiveActivity {
margin: 0 0 8px;
padding-left: 18px;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
}
.archiveActivity li {
overflow-wrap: anywhere;
}
@media (pointer: coarse) {
.searchInput,
.renameInput {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.wide {
animation: none;
}
.skeletonDot,
.skeletonBar {
animation: none;
}
.root:not(.rail) .sectionHeader.searchSpacingMotion,
.search,
.sectionLabel,
.searchSlot,
.searchInput,
.headerActions {
transition: none;
}
}
+588
View File
@@ -0,0 +1,588 @@
/** IPC / mock bridge for Hermit desktop. */
export type SessionInfo = {
id: string;
title: string;
updated_at: number;
pinned?: boolean;
folder?: string | null;
};
export type ConsentRequest = {
id: string;
tool: string;
detail: string;
preview?: string;
};
export type QuestionRequest = {
id: string;
question: string;
options: string[];
};
export type AttachmentInfo = {
id: string;
name: string;
path: string;
};
export type ProviderInfo = {
name: string;
model: string;
has_key: boolean;
key_from?: string;
};
export type UsageInfo = {
prompt: number;
out: number;
cost: number;
priced: boolean;
pct: number;
};
export type BashRun = {
id: string;
command: string;
status: string;
output?: string;
exit_code?: number;
started_at: number;
ended_at?: number;
};
export type ChatItem =
| { kind: "user"; id: string; text: string; ts?: number }
| { kind: "assistant"; id: string; text: string; ts?: number }
| { kind: "thought"; id: string; text: string; ts?: number }
| {
kind: "tool";
id: string;
name: string;
status: "running" | "ok" | "denied" | "error";
detail: string;
output?: string;
exit_code?: number;
ts?: number;
};
export type EngineEvent =
| { type: "assistant_delta"; text: string }
| { type: "thought_delta"; text: string }
| { type: "tool_start"; id: string; name: string; detail: string }
| {
type: "tool_end";
id: string;
status: "ok" | "denied" | "error";
detail: string;
output?: string;
exit_code?: number;
}
| { type: "consent"; req: ConsentRequest }
| { type: "question"; req: QuestionRequest }
| { type: "todo"; text: string }
| { type: "status"; message: string }
| { type: "turn_end"; ok: boolean }
| { type: "error"; message: string };
export type Settings = {
provider: string;
model: string;
approval: "ask" | "auto" | "yolo";
workspace: string;
plan_mode?: boolean;
api_key?: string;
/** "" | low | medium | high | max */
reasoning_effort?: string;
font_size?: "s" | "m" | "l" | "xl";
language?: "zh" | "en";
native_titlebar?: boolean;
};
type Unlisten = () => void;
async function tryInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> {
try {
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<T>(cmd, args);
} catch {
return null;
}
}
const mockSessions: SessionInfo[] = [
{
id: "s1",
title: "Fix home TUI layout",
updated_at: Date.now() - 3600_000,
pinned: true,
folder: "desktop",
},
{
id: "s2",
title: "Ship hermit-update",
updated_at: Date.now() - 86400_000,
folder: "desktop",
},
];
const mockTranscripts = new Map<string, ChatItem[]>([
[
"s1",
[
{ kind: "user", id: "u1", text: "右上角布局不对,帮我看看。", ts: Date.now() - 3600_000 },
{
kind: "thought",
id: "t1",
text: "Sidebar starts at row 0 while topbar only covers the main pane…",
ts: Date.now() - 3590_000,
},
{
kind: "tool",
id: "tool1",
name: "read_file",
status: "ok",
detail: "src/tui.c · draw_side / layout",
output: "…layout code…",
exit_code: 0,
ts: Date.now() - 3580_000,
},
{
kind: "assistant",
id: "a1",
text: "顶栏只盖主区、侧栏从第 0 行铺满,右上角会空出一块。已把 topbar 拉满并把 seat deck 对齐到顶栏下方。",
ts: Date.now() - 3570_000,
},
],
],
]);
let mockBusy = false;
let mockSettings: Settings = {
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: "~/hermit",
plan_mode: false,
api_key: "",
reasoning_effort: "high",
font_size: "m",
language: "zh",
native_titlebar: false,
};
let mockPending: AttachmentInfo[] = [];
let mockTodo = "";
let mockBash: BashRun[] = [];
const mockListeners = new Set<(e: EngineEvent) => void>();
let mockConsentResolve: ((n: number) => void) | null = null;
let mockQuestionResolve: ((s: string) => void) | null = null;
function emitMock(e: EngineEvent) {
mockListeners.forEach((fn) => fn(e));
}
function normalizeSession(s: SessionInfo): SessionInfo {
let updated_at = s.updated_at;
if (updated_at > 0 && updated_at < 1e12) updated_at *= 1000;
return { ...s, updated_at };
}
export async function listSessions(): Promise<SessionInfo[]> {
const real = await tryInvoke<SessionInfo[]>("session_list");
const list = real ?? mockSessions;
return list.map(normalizeSession);
}
export async function newSession(): Promise<SessionInfo> {
const real = await tryInvoke<SessionInfo>("session_new");
if (real) return real;
const s: SessionInfo = {
id: `s${Date.now()}`,
title: "New session",
updated_at: Date.now(),
};
mockSessions.unshift(s);
mockTranscripts.set(s.id, []);
mockBash = [];
return s;
}
export async function openSession(id: string): Promise<ChatItem[]> {
const real = await tryInvoke<ChatItem[]>("session_open", { id });
if (real) return real;
return mockTranscripts.get(id) ?? [];
}
export async function renameSession(id: string, title: string): Promise<boolean> {
const real = await tryInvoke<boolean>("session_rename", { id, title });
if (real !== null) return real;
const s = mockSessions.find((x) => x.id === id);
if (!s) return false;
s.title = title;
s.updated_at = Date.now();
return true;
}
export async function deleteSession(id: string): Promise<boolean> {
const real = await tryInvoke<boolean>("session_delete", { id });
if (real !== null) return real;
const i = mockSessions.findIndex((x) => x.id === id);
if (i < 0) return false;
mockSessions.splice(i, 1);
mockTranscripts.delete(id);
return true;
}
export async function pinSession(id: string): Promise<boolean> {
const real = await tryInvoke<boolean>("session_pin", { id });
if (real !== null) return real;
const s = mockSessions.find((x) => x.id === id);
if (!s) return false;
s.pinned = !s.pinned;
mockSessions.sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned));
return true;
}
export async function setSessionFolder(
id: string,
folder: string | null,
): Promise<boolean> {
const real = await tryInvoke<boolean>("session_set_folder", { id, folder });
if (real !== null) return real;
const s = mockSessions.find((x) => x.id === id);
if (!s) return false;
s.folder = folder;
return true;
}
export async function getSettings(): Promise<Settings> {
const real = await tryInvoke<Settings>("settings_get");
return real ?? { ...mockSettings };
}
export async function setSettings(settings: Settings): Promise<Settings> {
const real = await tryInvoke<Settings>("settings_set", { settings });
if (real) {
mockSettings = real;
return real;
}
mockSettings = { ...settings };
return mockSettings;
}
export async function pickWorkspace(): Promise<string | null> {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
directory: true,
multiple: false,
title: "Choose workspace",
});
if (typeof selected === "string") return selected;
return null;
} catch {
const next = window.prompt("Workspace path", mockSettings.workspace);
return next && next.trim() ? next.trim() : null;
}
}
export async function pickFiles(): Promise<string[]> {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: true,
title: "Attach files",
});
if (!selected) return [];
return Array.isArray(selected) ? selected : [selected];
} catch {
return [];
}
}
export async function listPending(): Promise<AttachmentInfo[]> {
const real = await tryInvoke<AttachmentInfo[]>("attach_list");
return real ?? mockPending;
}
export async function attachFile(path: string): Promise<AttachmentInfo | null> {
const real = await tryInvoke<AttachmentInfo>("attach_file", { path });
if (real) return real;
const name = path.split(/[/\\]/).pop() || path;
const info: AttachmentInfo = { id: `a${Date.now()}`, name, path };
mockPending.push(info);
return info;
}
export async function clearPending(): Promise<void> {
await tryInvoke("attach_clear");
mockPending = [];
}
export async function removePending(id: string): Promise<void> {
await tryInvoke("attach_remove", { id });
mockPending = mockPending.filter((a) => a.id !== id);
}
export async function listProviders(): Promise<ProviderInfo[]> {
const real = await tryInvoke<ProviderInfo[]>("providers_list");
return (
real ?? [
{ name: "deepseek", model: "deepseek-flash", has_key: true, key_from: "env" },
{ name: "openai", model: "gpt-4.1", has_key: false },
{ name: "anthropic", model: "claude-sonnet-4", has_key: false },
]
);
}
export async function useProvider(
provider: string,
model: string,
key?: string,
): Promise<string> {
const real = await tryInvoke<string>("providers_use", { provider, model, key: key ?? "" });
if (real !== null) return real;
mockSettings = { ...mockSettings, provider, model, api_key: key ?? mockSettings.api_key };
return "";
}
export async function getUsage(): Promise<UsageInfo> {
const real = await tryInvoke<UsageInfo>("usage_get");
return real ?? { prompt: 0, out: 0, cost: 0, priced: false, pct: 0 };
}
export async function listWorkspaceFiles(query: string): Promise<string[]> {
const real = await tryInvoke<string[]>("workspace_files", { query });
if (real) return real;
const sample = [
"src/main.c",
"src/tui.c",
"apps/desktop/src/App.tsx",
"README.md",
"include/cst_agent.h",
];
const q = query.toLowerCase();
return sample.filter((p) => !q || p.toLowerCase().includes(q)).slice(0, 20);
}
export async function compactSession(): Promise<string> {
const real = await tryInvoke<string>("session_compact");
return real ?? "Compacted (mock).";
}
export async function exportSession(): Promise<string> {
const real = await tryInvoke<string>("session_export");
return real ?? "# Session export (mock)\n";
}
export async function engineStatus(): Promise<string> {
return (await tryInvoke<string>("engine_status")) ?? "browser";
}
export async function getTodo(): Promise<string> {
const real = await tryInvoke<string>("todo_get");
if (real !== null) {
mockTodo = real;
return real;
}
return mockTodo;
}
export async function listBashRuns(): Promise<BashRun[]> {
const real = await tryInvoke<BashRun[]>("bash_list");
return real ?? mockBash;
}
export function isShellTool(name: string): boolean {
const n = name.toLowerCase();
return n.includes("shell") || n === "bash" || n === "sh" || n === "run_terminal";
}
/** Opt-in browser/host mock turns. Default: refuse so UI never silents as product. */
export function mockTurnsAllowed(): boolean {
return (
import.meta.env.VITE_HERMIT_ALLOW_MOCK === "1" ||
(typeof localStorage !== "undefined" &&
localStorage.getItem("hermit.allowMock") === "1")
);
}
export async function sendTurn(text: string): Promise<void> {
const real = await tryInvoke<void>("send_turn", { text });
if (real !== null) return;
if (!mockTurnsAllowed()) {
emitMock({
type: "error",
message:
"Engine unavailable (host/browser). Start with apps/desktop/run-dev.sh (needs libcstcore + DEEPSEEK_API_KEY or Settings key). Set hermit.allowMock=1 only for UI demos.",
});
return;
}
if (mockBusy) return;
mockBusy = true;
emitMock({ type: "status", message: "mock demo (not engine)" });
emitMock({ type: "thought_delta", text: "Planning a short reply…" });
await delay(300);
const tid = `m${Date.now()}`;
mockBash.push({
id: tid,
command: "hermit --version",
status: "running",
started_at: Date.now(),
});
emitMock({
type: "tool_start",
id: tid,
name: "shell",
detail: "hermit --version",
});
if (mockSettings.approval === "ask") {
const id = `c${Date.now()}`;
const ans = await new Promise<number>((resolve) => {
mockConsentResolve = resolve;
emitMock({
type: "consent",
req: {
id,
tool: "shell",
detail: "hermit --version",
preview: "$ hermit --version\n",
},
});
});
mockConsentResolve = null;
if (ans === 0) {
emitMock({
type: "tool_end",
id: tid,
status: "denied",
detail: "rejected",
});
const b = mockBash.find((x) => x.id === tid);
if (b) {
b.status = "denied";
b.ended_at = Date.now();
}
emitMock({ type: "turn_end", ok: false });
mockBusy = false;
return;
}
}
await delay(400);
const b = mockBash.find((x) => x.id === tid);
if (b) {
b.status = "ok";
b.output = "hermit 1.1.0\n";
b.exit_code = 0;
b.ended_at = Date.now();
}
emitMock({
type: "tool_end",
id: tid,
status: "ok",
detail: "hermit 1.1.0",
output: "hermit 1.1.0\n",
exit_code: 0,
});
mockTodo = "- [x] Inspect layout\n- [ ] Verify send path\n- [ ] Ship desktop polish";
emitMock({ type: "todo", text: mockTodo });
if (text.toLowerCase().includes("ask")) {
const qid = `q${Date.now()}`;
const answer = await new Promise<string>((resolve) => {
mockQuestionResolve = resolve;
emitMock({
type: "question",
req: {
id: qid,
question: "Which approach should we take?",
options: ["Polish first", "Features first", "Skip"],
},
});
});
mockQuestionResolve = null;
emitMock({ type: "status", message: `User answered: ${answer}` });
}
const reply =
"[mock] UI demo only. Use run-dev.sh with engine + API key for real turns.";
for (const ch of reply) {
emitMock({ type: "assistant_delta", text: ch });
await delay(8);
}
mockPending = [];
emitMock({ type: "turn_end", ok: true });
mockBusy = false;
}
export async function cancelTurn(): Promise<void> {
await tryInvoke("cancel_turn");
mockBusy = false;
if (mockConsentResolve) {
mockConsentResolve(0);
mockConsentResolve = null;
}
if (mockQuestionResolve) {
mockQuestionResolve("");
mockQuestionResolve = null;
}
emitMock({ type: "turn_end", ok: false });
}
export async function answerConsent(
id: string,
answer: "deny" | "once" | "always_tool" | "always_prefix",
): Promise<void> {
await tryInvoke("consent_answer", { id, answer });
if (mockConsentResolve) {
mockConsentResolve(answer === "deny" ? 0 : 1);
mockConsentResolve = null;
}
}
export async function answerQuestion(id: string, answer: string): Promise<void> {
await tryInvoke("question_answer", { id, answer });
if (mockQuestionResolve) {
mockQuestionResolve(answer);
mockQuestionResolve = null;
}
}
export function getMockTodo(): string {
return mockTodo;
}
export async function onEngineEvent(fn: (e: EngineEvent) => void): Promise<Unlisten> {
try {
const { listen } = await import("@tauri-apps/api/event");
const un = await listen<EngineEvent>("hermit://event", (ev) => {
if (ev.payload.type === "todo") mockTodo = ev.payload.text;
fn(ev.payload);
});
return () => {
un();
};
} catch {
mockListeners.add(fn);
return () => {
mockListeners.delete(fn);
};
}
}
export async function onMenuEvent(fn: (id: string) => void): Promise<Unlisten> {
try {
const { listen } = await import("@tauri-apps/api/event");
const un = await listen<string>("hermit://menu", (ev) => fn(ev.payload));
return () => {
un();
};
} catch {
return () => undefined;
}
}
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
@@ -1,6 +1,8 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "@/dsh/index.css";
import "@/styles/app.css";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
+400
View File
@@ -0,0 +1,400 @@
/* Minimal Hermit glue — DSH modules own surfaces. Bypass old theme color card. */
.app-frame-fill {
height: 100%;
min-height: 0;
}
.hermit-palette {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(100% + 8px);
z-index: 20;
max-height: 240px;
overflow: auto;
border-radius: var(--dsw-radius-lg);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-elevation-panel);
border: 0;
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
}
.hermit-palette button {
display: flex;
width: 100%;
gap: 10px;
align-items: baseline;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
font: inherit;
}
.hermit-palette button.active,
.hermit-palette button:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.hermit-palette .pal-cmd {
font-family: var(--ds-font-family-code);
font-size: 12px;
color: var(--dsw-alias-state-business-primary);
}
.hermit-palette .pal-desc {
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.hermit-chip-menu {
position: absolute;
left: 0;
bottom: calc(100% + 6px);
z-index: 30;
min-width: 220px;
padding: 6px;
border-radius: var(--dsw-radius-lg);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-elevation-panel);
}
.hermit-chip-menu .menu-section {
padding: 6px 8px 4px;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.hermit-chip-menu button {
display: flex;
flex-direction: column;
width: 100%;
gap: 2px;
padding: 8px;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
font: inherit;
}
.hermit-chip-menu button.active,
.hermit-chip-menu button:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.hermit-chip-menu .menu-desc {
font-size: 12px;
color: var(--dsw-alias-label-secondary);
font-weight: 400;
}
.hermit-effort-row {
display: flex;
gap: 4px;
padding: 4px;
}
.hermit-effort-row button {
flex: 1;
align-items: center;
padding: 6px 4px;
font-size: 12px;
}
.hermit-attach-rail {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 14px;
}
.hermit-attach-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.hermit-attach-chip button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
}
.hermit-toolbar-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.hermit-toolbar-btn:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.hermit-toolbar-btn strong {
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.hermit-send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 999px;
background: var(--dsw-alias-state-business-primary);
color: #fff;
cursor: pointer;
}
.hermit-send:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.hermit-send.stop {
width: auto;
padding: 0 12px;
gap: 6px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.hermit-textarea {
display: block;
width: 100%;
box-sizing: border-box;
resize: none;
border: none;
outline: none;
background: transparent;
color: var(--dsw-alias-label-primary);
font-family: var(--dsw-font-family);
font-size: inherit;
line-height: inherit;
caret-color: var(--dsw-alias-state-business-primary);
padding: 4px 8px 0 14px;
min-height: 52px;
}
.hermit-textarea:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: not-allowed;
}
.hermit-drop-overlay {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--dsw-radius-panel);
background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);
border: 1px dashed var(--dsw-alias-state-business-primary);
color: var(--dsw-alias-state-business-primary);
font-size: 13px;
pointer-events: none;
}
/* Settings overlay kept simple */
.settings-scrim {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: var(--dsw-alias-bg-mask-1);
}
.settings-card {
width: min(520px, calc(100% - 48px));
max-height: calc(100% - 48px);
overflow: auto;
padding: 20px;
border-radius: var(--dsw-radius-xl);
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-elevation-panel);
color: var(--dsw-alias-label-primary);
}
.settings-card h2 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
}
.settings-card label {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.settings-card input,
.settings-card select {
height: 32px;
padding: 0 10px;
border-radius: var(--dsw-radius-sm);
border: 0.5px solid var(--dsw-alias-border-l3);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: inherit;
}
.settings-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.settings-actions button {
height: 32px;
padding: 0 14px;
border: none;
border-radius: var(--dsw-radius-sm);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
cursor: pointer;
font: inherit;
}
.settings-actions .primary {
background: var(--dsw-alias-state-business-primary);
color: #fff;
}
.hermit-user-bubble {
box-sizing: border-box;
padding: 10px 14px;
border-radius: var(--dsw-radius-xl);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font-size: var(--dsh-content-font-size, 14px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
white-space: pre-wrap;
word-break: break-word;
}
.hermit-cursor {
display: inline-block;
width: 6px;
height: 14px;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--dsw-alias-state-business-primary);
animation: hermit-blink 1s step-end infinite;
}
@keyframes hermit-blink {
50% { opacity: 0; }
}
/* Soften leftover Hermit theme tokens if any sheet still references them */
:root {
--hermit-accent: var(--dsw-alias-state-business-primary);
}
/* WebKitGTK paints native light buttons; kill UA chrome inside DSH shells. */
.hermit-conversation-body button,
aside button {
appearance: none;
-webkit-appearance: none;
color: inherit;
font: inherit;
outline: none;
}
aside [class*="projectRow"],
aside [class*="sessionRow"] {
background-color: transparent;
border: none;
outline: none;
box-shadow: none;
}
/* Hero mark: DSH fish slot is currentColor silhouette; keep crab readable. */
.hermit-hero-mark {
color: var(--dsw-alias-label-primary);
flex: none;
}
.hermit-hero-mark svg,
.hermit-mark svg {
display: block;
overflow: visible;
animation: none !important;
}
.hermit-mark svg *,
.hermit-hero-mark svg * {
animation: none !important;
}
/* Kill DSH hover swim / pending pulses that thrash WebKitGTK compositing. */
@media (prefers-reduced-motion: no-preference) {
[class*="fishHitbox"]:hover [class*="fish"],
[class*="pending"],
[class*="sendingSpinner"] {
animation: none !important;
}
}
/* Session row quick actions: hover-only (override DSH coarse-pointer always-on). */
.hermit-row-actions {
display: none !important;
}
@media (hover: hover) {
[class*="sessionRow"]:hover .hermit-row-actions {
display: inline-flex !important;
}
}
.hermit-row-actions button {
width: 20px;
height: 20px;
padding: 0;
}
/* Plan/Bash panels — DSH surface tokens */
.plan-panel,
.bash-panel {
margin: 16px auto;
width: min(100%, var(--dsh-chat-content-width));
padding: 16px;
border-radius: var(--dsw-radius-xl);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
border: 0.5px solid var(--dsw-alias-border-l3);
}
+117
View File
@@ -0,0 +1,117 @@
/* DSH dark surfaces + Hermit brand accents */
:root {
/* Brand from color card + agent-motion eye */
--brand: #009cc6;
--brand-bright: #70cde5;
--brand-dim: #007a9c;
--accent: var(--brand);
--accent-bright: var(--brand-bright);
--crab: #ffa939;
--crab-dim: #e8922a;
--cream: #f9f3ee;
--eye: #9957ad;
--send: var(--crab);
--send-hover: var(--crab-dim);
--ok: #22c55e;
--warn: #f59e0b;
--danger: #f25a5a;
/* DSH dark bluish neutrals (design-platform.css) */
--bg: #151517; /* bluish-950 */
--bg-elev: #1b1b1c; /* bluish-900 sidebar */
--bg-layer: #232325; /* bluish-875 */
--bg-card: #2c2c2e; /* bluish-850 input */
--bg-hover: rgba(255, 255, 255, 0.08);
--bg-active: rgba(255, 255, 255, 0.14);
--border-l1: rgba(255, 255, 255, 0.06);
--border-l2: rgba(255, 255, 255, 0.12);
--border-l3: rgba(255, 255, 255, 0.16);
--border-l4: rgba(255, 255, 255, 0.2);
--border: var(--border-l1);
--border-2: var(--border-l2);
--border-3: var(--border-l3);
--border-4: var(--border-l4);
--text: #f9fafb;
--text-dim: #ebf0f4;
--text-muted: #cfd3d6;
--text-faint: #adb2b8;
--text-caption: #81858c;
--bubble-user: #2c2c2e;
--radius-xs: 4px;
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-panel: 28px;
--rail-w: 280px;
--rail-collapsed: 56px;
--chat-w: 820px;
--composer-w: 852px; /* chat + 32 */
--composer-side: 16px;
--header-h: 52px;
--new-session-h: 38px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Noto Sans SC", "Helvetica Neue", Helvetica, Arial, sans-serif;
--mono: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, Menlo, monospace;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--elevation-stroke: 0 0 0 0.5px var(--border-l2);
/* DSH soft: hairline + very light lift (not heavy drop shadow) */
--elevation-soft: var(--elevation-stroke), 0 4px 16px rgba(0, 0, 0, 0.03),
0 0 24px rgba(0, 0, 0, 0.03);
--sidebar-wash: linear-gradient(
to bottom,
rgb(0 156 198 / 0.1),
rgb(112 205 229 / 0) 38%,
rgb(153 87 173 / 0) 68%,
rgb(153 87 173 / 0.07)
);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
a {
color: var(--accent-bright);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module "*.svg?raw" {
const content: string;
export default content;
}
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ApprovalCard.tsx","./src/components/BashPanel.tsx","./src/components/Composer.tsx","./src/components/Hero.tsx","./src/components/Icons.tsx","./src/components/PlanPanel.tsx","./src/components/QuestionCard.tsx","./src/components/Rail.tsx","./src/components/SessionHeader.tsx","./src/components/SettingsPanel.tsx","./src/components/Transcript.tsx","./src/lib/bridge.ts"],"version":"5.9.3"}
@@ -2,7 +2,8 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "node:path"; import path from "node:path";
const host = process.env.TAURI_DEV_HOST; // 固定 IPv4:WebKitGTK 访问 localhost 常走 ::1,Vite 默认只听 127.0.0.1 → 空白灰窗
const host = process.env.TAURI_DEV_HOST || "127.0.0.1";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
@@ -13,8 +14,8 @@ export default defineConfig({
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
host: host || false, host,
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined, hmr: { protocol: "ws", host, port: 1421 },
watch: { ignored: ["**/src-tauri/**"] }, watch: { ignored: ["**/src-tauri/**"] },
}, },
envPrefix: ["VITE_", "TAURI_"], envPrefix: ["VITE_", "TAURI_"],
+10
View File
@@ -0,0 +1,10 @@
# Deprecated: Qt front end (archived)
This directory is the **legacy Qt 6 / QML** Hermit GUI (`cst-agent-app`),
formerly at `app/`. It is **frozen** under `archive/qt-app`.
Hermit’s product surface is the CLI/TUI + `libcstcore` + `--web` (`cst-webui/1`).
Another archived desktop shell is `archive/desktop-tauri` (Tauri 2 + React).
Do not add features here. Experimental rebuild: `-DCST_BUILD_APP=ON` from the
repo root (see root `archive/README.md`).

Some files were not shown because too many files have changed in this diff Show More