feat: Hermit 1.1.0 — HERMIT_* env, Tauri desktop shell
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:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+3584
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+4515
File diff suppressed because it is too large
Load Diff
@@ -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 = []
|
||||
@@ -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 |
@@ -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.
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermit_desktop_lib::run();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user