feat: Hermit 1.1.0 — HERMIT_* env, Tauri desktop shell
ci / tcc-check (push) Failing after 40s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped

Wire hermit_getenv across the engine, rebrand WebUI/systemd/docs, and
add apps/desktop (Tauri 2 + React) with a DeepSeek-Harness-like UI.
Qt app/ is frozen; full libcstcore FFI is scaffolded behind --features engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-10-30 13:15:47 +08:00
co-authored by Cursor
parent 15c6c4a02a
commit ea38247161
71 changed files with 15336 additions and 143 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
src-tauri/target/
.DS_Store
*.log
+57
View File
@@ -0,0 +1,57 @@
# Hermit Desktop (Tauri 2)
Cross-platform shell for Hermit. Visual layout follows DeepSeek Harness
(session rail · chat · tool timeline · composer); the agent engine is the
in-process C core (`libcstcore`), not a Cordis/plugin runtime.
## Stack
- **Tauri 2** + Rust host
- **React 19** + Vite + TypeScript UI
- FFI to `libcstcore` (same embed path as the legacy Qt app)
## Dependencies
| Platform | Needs |
|----------|--------|
| All | Rust (stable), Node 20+, `pnpm` or `npm` |
| Linux | `webkit2gtk-4.1`, `libgtk-3`, `librsvg`, `patchelf`, pkg-config, curl |
| Windows | WebView2 runtime |
| macOS | Xcode CLT |
Nix example:
```bash
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
```
## Develop
```bash
cd apps/desktop
npm install
npm run tauri dev
```
Without a full engine link yet, the UI runs against the mock bridge
(`HERMIT_DESKTOP_MOCK=1` or automatic when FFI is unavailable).
## Build
```bash
npm run tauri build
```
Artifacts land in `src-tauri/target/release/bundle/`.
## Layout
```
apps/desktop/
src/ React UI (DSH-like)
src-tauri/ Rust + Tauri + FFI
README.md
```
The legacy Qt front end under `/app` is frozen and will be archived once
desktop feature parity lands.
+12
View File
@@ -0,0 +1,12 @@
<!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>
+3584
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "hermit-desktop",
"private": true,
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.0.3",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}
+4515
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "hermit-desktop"
version = "1.1.0"
description = "Hermit desktop shell (Tauri 2)"
authors = ["WHC"]
edition = "2021"
[lib]
name = "hermit_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
cc = "1.2"
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] }
once_cell = "1"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
# Link libcstcore from the repo (requires curl + built archive).
engine = []
+29
View File
@@ -0,0 +1,29 @@
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()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default Hermit desktop capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default Hermit desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+173
View File
@@ -0,0 +1,173 @@
//! 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
@@ -0,0 +1,31 @@
//! 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
}
+14
View File
@@ -0,0 +1,14 @@
/* Placeholder for future cbindgen / manual FFI against include/cst_agent.h.
* Build with --features engine and HERMIT_CORE_LIB pointing at libcstcore.a.
*/
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
/* Mirrors the subset AgentCore used; real symbols come from libcstcore. */
typedef struct cst_agent cst_agent_t;
#ifdef __cplusplus
}
#endif
+73
View File
@@ -0,0 +1,73 @@
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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hermit_desktop_lib::run();
}
+53
View File
@@ -0,0 +1,53 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermit",
"version": "1.1.0",
"identifier": "xyz.whcself.hermit",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Hermit",
"width": 1280,
"height": 840,
"minWidth": 880,
"minHeight": 560,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"linux": {
"deb": {
"depends": []
}
},
"macOS": {
"minimumSystemVersion": "12.0"
},
"windows": {
"nsis": {
"installMode": "currentUser"
}
}
}
}
+209
View File
@@ -0,0 +1,209 @@
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
@@ -0,0 +1,72 @@
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>
);
}
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,79 @@
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>
);
}
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,191 @@
/** 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));
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+425
View File
@@ -0,0 +1,425 @@
@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
@@ -0,0 +1,72 @@
: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
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"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"}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
});