Prepare Cassady v0.4.0 desktop app
@@ -1,3 +1,6 @@
|
|||||||
/target/
|
/target/
|
||||||
/dist/
|
/dist/
|
||||||
/site/
|
/site/
|
||||||
|
*node_modules/
|
||||||
|
*dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cassady"
|
name = "cassady"
|
||||||
version = "0.3.5"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Cassady/Cass minimal terminal coding agent"
|
description = "Cassady/Cass minimal terminal coding agent"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -47,3 +47,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["cassady-desktop/src-tauri"]
|
||||||
|
resolver = "2"
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
# Cassady (Cass) Roadmap
|
# Cassady (Cass) Roadmap
|
||||||
|
|
||||||
## v0.3.5 — Tool Output Context Reliability
|
## v0.4.0 — Desktop App
|
||||||
|
|
||||||
|
This release focuses on adding a native desktop app for Cassady that runs the real coding agent via the experimental Rust embedding API, themed to match the `cassady-web/` landing page. The desktop app is a peer of the TUI: same config, providers, tools, safety policy, and JSONL storage, with a windowed chat surface for streaming transcripts, tool calls, approvals, and cancellation. See `plans/V0_4_0_DESKTOP_APP_PLAN.md`.
|
||||||
|
|
||||||
|
### Workspace Setup
|
||||||
|
|
||||||
|
- [ ] **Add the `cassady-desktop` workspace member.** Convert the root `Cargo.toml` to a Cargo workspace with `members = ["cassady-desktop"]` while keeping the existing `cassady` package at the repo root.
|
||||||
|
- Depend on `cassady` via a path dependency; embed through the public `cassady::prelude` API only.
|
||||||
|
- Do not modify `src/` (the existing lib, bins, or TUI).
|
||||||
|
|
||||||
|
### Design System Port
|
||||||
|
|
||||||
|
- [ ] **Port the landing-page design system into the desktop frontend.** Copy `cassady-web/src/index.css` (tokens, scanlines, grain, vignette, glow utilities) and IBM Plex `@fontsource` setup into the Tauri frontend.
|
||||||
|
- Reuse `cass-logo-transparent.png` for the app icon and in-app branding.
|
||||||
|
- Copy the shadcn `ui/` primitives (button, card, separator, tooltip, accordion) so chrome matches the landing page.
|
||||||
|
|
||||||
|
### Chat Surface
|
||||||
|
|
||||||
|
- [ ] **Build the streaming chat UI.** Composer, transcript with full markdown rendering for assistant text, tool-call blocks for `read`/`write`/`edit`/`shell`/`grep`/`ls`, reasoning toggle, and a terminal-style status footer.
|
||||||
|
- Stream events over `tauri::ipc::Channel<StreamEvent>` from a per-turn Rust worker that drives `Session`/`Turn`/`Event`.
|
||||||
|
- Approve or deny `ApprovalRequested` events through a native dialog; cancel running turns with `Turn::cancel`.
|
||||||
|
|
||||||
|
### Session Lifecycle
|
||||||
|
|
||||||
|
- [ ] **Make desktop and CLI sessions interoperable.** New chat, resume by id, and list chats for the current cwd all use the same `~/.cass/conversations/*.jsonl` storage.
|
||||||
|
- A chat started in the desktop app is resumable from `cass --resume <id>`, and a CLI-started chat is openable in the desktop app.
|
||||||
|
- Show a "run `cass setup` first" card when `~/.cass` has no valid provider config; a setup-wizard GUI is deferred.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- [ ] **Build, test, and document the desktop app.** `cargo build -p cassady-desktop`, `npm run tauri build`, and a manual smoke test across new chat, streaming, approval, cancel, and resume interop.
|
||||||
|
- Add Rust tests for `StreamEvent` mapping and `SessionBuilder` round-trip against a temp `config_root`.
|
||||||
|
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass across the workspace.
|
||||||
|
|
||||||
|
## v0.3.5 — Tool Output Context Reliability ✅ Completed
|
||||||
|
|
||||||
This release focuses on making large tool outputs easier for the assistant to recover from when model-context compaction or truncation hides important details. Cassady should guide the assistant toward smaller, targeted reads and searches, preserve enough provenance for follow-up inspection, and add regression coverage for broad-output workflows that previously stalled safe edits. See `plans/V0_3_5_TOOL_OUTPUT_CONTEXT_RELIABILITY_PLAN.md`.
|
This release focuses on making large tool outputs easier for the assistant to recover from when model-context compaction or truncation hides important details. Cassady should guide the assistant toward smaller, targeted reads and searches, preserve enough provenance for follow-up inspection, and add regression coverage for broad-output workflows that previously stalled safe edits. See `plans/V0_3_5_TOOL_OUTPUT_CONTEXT_RELIABILITY_PLAN.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0b0d10" />
|
||||||
|
<title>Cassady</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "cassady-desktop-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.4.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
|
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.14",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"motion": "^11.15.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^26.0.1",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "cassady-desktop"
|
||||||
|
version = "0.4.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Cassady desktop app"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "cassady_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
cassady = { path = "../.." }
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 905 B |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 565 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 868 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
mod session;
|
||||||
|
mod state;
|
||||||
|
mod turn;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
use state::DesktopState;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(DesktopState::new())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
session::new_session,
|
||||||
|
session::resume_session,
|
||||||
|
session::get_cwd,
|
||||||
|
session::list_chats_cmd,
|
||||||
|
session::session_info,
|
||||||
|
session::session_records,
|
||||||
|
session::list_models_cmd,
|
||||||
|
session::update_session_settings,
|
||||||
|
turn::start_turn,
|
||||||
|
turn::approve,
|
||||||
|
turn::deny,
|
||||||
|
turn::cancel_turn,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running cassady desktop");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
cassady_desktop_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
use crate::state::DesktopState;
|
||||||
|
use crate::types::{
|
||||||
|
ChatSummaryDto, ConversationInfoDto, ListChatsArgs, ModelOptionDto, NewSessionArgs,
|
||||||
|
ResumeSessionArgs, SessionIdArgs, UpdateSessionSettingsArgs,
|
||||||
|
};
|
||||||
|
use cassady::config::{cass_root, load_or_create_default_model_registry, save_last_used_provider};
|
||||||
|
use cassady::conversation::list_chats as list_chats_fn;
|
||||||
|
use cassady::embedding::SessionBuilder;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn new_session(
|
||||||
|
state: State<'_, DesktopState>,
|
||||||
|
args: NewSessionArgs,
|
||||||
|
) -> Result<ConversationInfoDto, String> {
|
||||||
|
let mut builder = SessionBuilder::new();
|
||||||
|
if let Some(cwd) = args.cwd {
|
||||||
|
builder = builder.cwd(cwd);
|
||||||
|
}
|
||||||
|
if let Some(mode) = args.access_mode {
|
||||||
|
builder = builder.access_mode(mode);
|
||||||
|
}
|
||||||
|
if let Some(model) = args.model {
|
||||||
|
builder = builder.model(model);
|
||||||
|
}
|
||||||
|
if let Some(base_url) = args.base_url {
|
||||||
|
builder = builder.base_url(base_url);
|
||||||
|
}
|
||||||
|
if let Some(api_key_env) = args.api_key_env {
|
||||||
|
builder = builder.api_key_env(api_key_env);
|
||||||
|
}
|
||||||
|
if let Some(effort) = args.reasoning_effort {
|
||||||
|
builder = builder.reasoning_effort(effort);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = builder.new_session().await.map_err(|e| e.to_string())?;
|
||||||
|
let info: ConversationInfoDto = session.info().into();
|
||||||
|
state
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("sessions lock: {e}"))?
|
||||||
|
.insert(info.id.clone(), session);
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn resume_session(
|
||||||
|
state: State<'_, DesktopState>,
|
||||||
|
args: ResumeSessionArgs,
|
||||||
|
) -> Result<ConversationInfoDto, String> {
|
||||||
|
let mut builder = SessionBuilder::new();
|
||||||
|
if let Some(cwd) = args.cwd {
|
||||||
|
builder = builder.cwd(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = builder
|
||||||
|
.resume(args.chat_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let info: ConversationInfoDto = session.info().into();
|
||||||
|
state
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("sessions lock: {e}"))?
|
||||||
|
.insert(info.id.clone(), session);
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_chats_cmd(
|
||||||
|
_state: State<'_, DesktopState>,
|
||||||
|
args: ListChatsArgs,
|
||||||
|
) -> Result<Vec<ChatSummaryDto>, String> {
|
||||||
|
let root = cass_root();
|
||||||
|
let conversations_dir = root.join("conversations");
|
||||||
|
let cwd = match args.cwd {
|
||||||
|
Some(cwd) => PathBuf::from(cwd),
|
||||||
|
None => std::env::current_dir().map_err(|e| e.to_string())?,
|
||||||
|
};
|
||||||
|
let cwd = cwd.canonicalize().map_err(|e| e.to_string())?;
|
||||||
|
let summaries = list_chats_fn(&conversations_dir, &cwd).map_err(|e| e.to_string())?;
|
||||||
|
Ok(summaries.into_iter().map(ChatSummaryDto::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn session_info(
|
||||||
|
state: State<'_, DesktopState>,
|
||||||
|
args: SessionIdArgs,
|
||||||
|
) -> Result<ConversationInfoDto, String> {
|
||||||
|
let info = {
|
||||||
|
let sessions = state
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("sessions lock: {e}"))?;
|
||||||
|
let session = sessions
|
||||||
|
.get(&args.chat_id)
|
||||||
|
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
|
||||||
|
session.info()
|
||||||
|
};
|
||||||
|
Ok(info.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_cwd() -> Result<String, String> {
|
||||||
|
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
|
||||||
|
Ok(cwd.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn session_records(
|
||||||
|
state: State<'_, DesktopState>,
|
||||||
|
args: SessionIdArgs,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let records = {
|
||||||
|
let sessions = state
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("sessions lock: {e}"))?;
|
||||||
|
let session = sessions
|
||||||
|
.get(&args.chat_id)
|
||||||
|
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
|
||||||
|
session.records().to_vec()
|
||||||
|
};
|
||||||
|
serde_json::to_value(records).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_models_cmd() -> Result<Vec<ModelOptionDto>, String> {
|
||||||
|
let root = cass_root();
|
||||||
|
let models = load_or_create_default_model_registry(&root).map_err(|e| e.to_string())?;
|
||||||
|
Ok(models
|
||||||
|
.models
|
||||||
|
.into_iter()
|
||||||
|
.map(ModelOptionDto::from)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_session_settings(
|
||||||
|
state: State<'_, DesktopState>,
|
||||||
|
args: UpdateSessionSettingsArgs,
|
||||||
|
) -> Result<ConversationInfoDto, String> {
|
||||||
|
{
|
||||||
|
let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
|
||||||
|
if turns.values().any(|entry| entry.chat_id == args.chat_id) {
|
||||||
|
return Err("settings can be changed when idle".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = {
|
||||||
|
let mut sessions = state
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("sessions lock: {e}"))?;
|
||||||
|
let session = sessions
|
||||||
|
.get_mut(&args.chat_id)
|
||||||
|
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
|
||||||
|
|
||||||
|
if let Some(mode) = args.access_mode {
|
||||||
|
session.set_access_mode(mode);
|
||||||
|
}
|
||||||
|
if let Some(model) = args.model {
|
||||||
|
session.set_model(model).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if let Some(effort) = args.reasoning_effort {
|
||||||
|
session.set_reasoning_effort(effort);
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = session.info();
|
||||||
|
let _ = save_last_used_provider(
|
||||||
|
&cass_root(),
|
||||||
|
session.provider_id(),
|
||||||
|
session.model(),
|
||||||
|
session.reasoning_effort(),
|
||||||
|
);
|
||||||
|
info
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(info.into())
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use cassady::embedding::Session;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DesktopState {
|
||||||
|
pub sessions: Arc<Mutex<HashMap<String, Session>>>,
|
||||||
|
pub turns: Arc<Mutex<HashMap<String, TurnEntry>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TurnEntry {
|
||||||
|
pub approval_tx: mpsc::UnboundedSender<ApprovalDecision>,
|
||||||
|
pub cancel_tx: tokio::sync::oneshot::Sender<()>,
|
||||||
|
pub chat_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ApprovalDecision {
|
||||||
|
pub request_id: String,
|
||||||
|
pub approved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
turns: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DesktopState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
use crate::state::{ApprovalDecision, DesktopState, TurnEntry};
|
||||||
|
use crate::types::{ApproveArgs, CancelTurnArgs, ConversationInfoDto, StreamEvent, TurnHandle};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
use tauri::State;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
static TURN_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn next_turn_id() -> String {
|
||||||
|
let n = TURN_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||||
|
format!("turn-{n}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_turn(
|
||||||
|
state: State<'_, DesktopState>,
|
||||||
|
chat_id: String,
|
||||||
|
message: String,
|
||||||
|
on_event: Channel<StreamEvent>,
|
||||||
|
) -> Result<TurnHandle, String> {
|
||||||
|
let session = {
|
||||||
|
let mut sessions = state
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("sessions lock: {e}"))?;
|
||||||
|
sessions
|
||||||
|
.remove(&chat_id)
|
||||||
|
.ok_or_else(|| format!("session {} not found", chat_id))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let turn = session
|
||||||
|
.start_turn(message)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let turn_id = next_turn_id();
|
||||||
|
let (approval_tx, approval_rx) = mpsc::unbounded_channel::<ApprovalDecision>();
|
||||||
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
|
||||||
|
state
|
||||||
|
.turns
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("turns lock: {e}"))?
|
||||||
|
.insert(
|
||||||
|
turn_id.clone(),
|
||||||
|
TurnEntry {
|
||||||
|
approval_tx,
|
||||||
|
cancel_tx,
|
||||||
|
chat_id: chat_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let state_clone = state.inner().clone();
|
||||||
|
spawn_turn_worker(
|
||||||
|
state_clone,
|
||||||
|
turn_id.clone(),
|
||||||
|
chat_id.clone(),
|
||||||
|
turn,
|
||||||
|
on_event,
|
||||||
|
approval_rx,
|
||||||
|
cancel_rx,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(TurnHandle { turn_id, chat_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_turn_worker(
|
||||||
|
state: DesktopState,
|
||||||
|
turn_id: String,
|
||||||
|
chat_id: String,
|
||||||
|
turn: cassady::embedding::Turn,
|
||||||
|
on_event: Channel<StreamEvent>,
|
||||||
|
mut approval_rx: mpsc::UnboundedReceiver<ApprovalDecision>,
|
||||||
|
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
||||||
|
) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut turn: Option<cassady::embedding::Turn> = Some(turn);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let event_result = tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = &mut cancel_rx => {
|
||||||
|
// Cancel during streaming.
|
||||||
|
if let Some(t) = turn.take() {
|
||||||
|
match t.cancel().await {
|
||||||
|
Ok(session) => {
|
||||||
|
let info: ConversationInfoDto = session.info().into();
|
||||||
|
if let Ok(mut sessions) = state.sessions.lock() {
|
||||||
|
sessions.insert(chat_id.clone(), session);
|
||||||
|
}
|
||||||
|
let _ = on_event.send(StreamEvent::Status {
|
||||||
|
text: "Turn cancelled.".to_string(),
|
||||||
|
});
|
||||||
|
let _ = info;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = on_event.send(StreamEvent::Error {
|
||||||
|
message: format!("cancel: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_turn(&state, &turn_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event = async {
|
||||||
|
match turn.as_mut() {
|
||||||
|
Some(t) => t.next_event().await,
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
} => event,
|
||||||
|
};
|
||||||
|
|
||||||
|
match event_result {
|
||||||
|
Ok(Some(event)) => {
|
||||||
|
let mapped = StreamEvent::from_embedding(event);
|
||||||
|
let is_finished = matches!(mapped, StreamEvent::Finished);
|
||||||
|
let is_approval = matches!(mapped, StreamEvent::ApprovalRequested { .. });
|
||||||
|
if on_event.send(mapped).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if is_finished {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if is_approval {
|
||||||
|
// The agent is now blocked waiting for a decision.
|
||||||
|
// Wait for the frontend to approve/deny or cancel.
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = &mut cancel_rx => {
|
||||||
|
if let Some(t) = turn.take() {
|
||||||
|
match t.cancel().await {
|
||||||
|
Ok(session) => {
|
||||||
|
if let Ok(mut sessions) = state.sessions.lock() {
|
||||||
|
sessions.insert(chat_id.clone(), session);
|
||||||
|
}
|
||||||
|
let _ = on_event.send(StreamEvent::Status {
|
||||||
|
text: "Turn cancelled.".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = on_event.send(StreamEvent::Error {
|
||||||
|
message: format!("cancel: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup_turn(&state, &turn_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
decision = approval_rx.recv() => {
|
||||||
|
if let Some(decision) = decision {
|
||||||
|
if let Some(t) = turn.as_mut() {
|
||||||
|
let res = if decision.approved {
|
||||||
|
t.approve(&decision.request_id)
|
||||||
|
} else {
|
||||||
|
t.deny(&decision.request_id)
|
||||||
|
};
|
||||||
|
if let Err(e) = res {
|
||||||
|
let _ = on_event.send(StreamEvent::Error {
|
||||||
|
message: format!("approval: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = on_event.send(StreamEvent::Error {
|
||||||
|
message: e.to_string(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal completion: finish the turn and re-insert the session.
|
||||||
|
if let Some(t) = turn {
|
||||||
|
match t.finish().await {
|
||||||
|
Ok(session) => {
|
||||||
|
if let Ok(mut sessions) = state.sessions.lock() {
|
||||||
|
sessions.insert(chat_id.clone(), session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = on_event.send(StreamEvent::Error {
|
||||||
|
message: format!("finish: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_turn(&state, &turn_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_turn(state: &DesktopState, turn_id: &str) {
|
||||||
|
if let Ok(mut turns) = state.turns.lock() {
|
||||||
|
turns.remove(turn_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn approve(state: State<'_, DesktopState>, args: ApproveArgs) -> Result<(), String> {
|
||||||
|
let tx = {
|
||||||
|
let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
|
||||||
|
let entry = turns
|
||||||
|
.get(&args.turn_id)
|
||||||
|
.ok_or_else(|| format!("turn {} not found", args.turn_id))?;
|
||||||
|
entry.approval_tx.clone()
|
||||||
|
};
|
||||||
|
tx.send(ApprovalDecision {
|
||||||
|
request_id: args.request_id,
|
||||||
|
approved: true,
|
||||||
|
})
|
||||||
|
.map_err(|_| format!("turn {} worker gone", args.turn_id))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn deny(state: State<'_, DesktopState>, args: ApproveArgs) -> Result<(), String> {
|
||||||
|
let tx = {
|
||||||
|
let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
|
||||||
|
let entry = turns
|
||||||
|
.get(&args.turn_id)
|
||||||
|
.ok_or_else(|| format!("turn {} not found", args.turn_id))?;
|
||||||
|
entry.approval_tx.clone()
|
||||||
|
};
|
||||||
|
tx.send(ApprovalDecision {
|
||||||
|
request_id: args.request_id,
|
||||||
|
approved: false,
|
||||||
|
})
|
||||||
|
.map_err(|_| format!("turn {} worker gone", args.turn_id))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cancel_turn(
|
||||||
|
state: State<'_, DesktopState>,
|
||||||
|
args: CancelTurnArgs,
|
||||||
|
) -> Result<ConversationInfoDto, String> {
|
||||||
|
let (cancel_tx, chat_id) = {
|
||||||
|
let mut turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
|
||||||
|
let entry = turns
|
||||||
|
.remove(&args.turn_id)
|
||||||
|
.ok_or_else(|| format!("turn {} not found", args.turn_id))?;
|
||||||
|
(entry.cancel_tx, entry.chat_id)
|
||||||
|
};
|
||||||
|
let _ = cancel_tx.send(());
|
||||||
|
|
||||||
|
// Wait for the worker to re-insert the session after cancel completes.
|
||||||
|
let info = loop {
|
||||||
|
{
|
||||||
|
let sessions = state
|
||||||
|
.sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("sessions lock: {e}"))?;
|
||||||
|
if let Some(session) = sessions.get(&chat_id) {
|
||||||
|
break session.info();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(info.into())
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
use cassady::access::AccessMode;
|
||||||
|
use cassady::config::{ModelDefinition, ReasoningEffort};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConversationInfoDto {
|
||||||
|
pub id: String,
|
||||||
|
pub cwd: String,
|
||||||
|
pub model: String,
|
||||||
|
pub access_mode: AccessMode,
|
||||||
|
pub reasoning_effort: ReasoningEffort,
|
||||||
|
pub path: String,
|
||||||
|
pub record_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<cassady::embedding::ConversationInfo> for ConversationInfoDto {
|
||||||
|
fn from(info: cassady::embedding::ConversationInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
id: info.id,
|
||||||
|
cwd: info.cwd.display().to_string(),
|
||||||
|
model: info.model,
|
||||||
|
access_mode: info.access_mode,
|
||||||
|
reasoning_effort: info.reasoning_effort,
|
||||||
|
path: info.path.display().to_string(),
|
||||||
|
record_count: info.record_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChatSummaryDto {
|
||||||
|
pub id: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub model: String,
|
||||||
|
pub cwd: String,
|
||||||
|
pub first_user_preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<cassady::conversation::ChatSummary> for ChatSummaryDto {
|
||||||
|
fn from(s: cassady::conversation::ChatSummary) -> Self {
|
||||||
|
Self {
|
||||||
|
id: s.id,
|
||||||
|
created_at: s.created_at,
|
||||||
|
model: s.model,
|
||||||
|
cwd: s.cwd,
|
||||||
|
first_user_preview: s.first_user_preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelOptionDto {
|
||||||
|
pub id: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub reasoning_supported: bool,
|
||||||
|
pub reasoning_required: bool,
|
||||||
|
pub default_reasoning_effort: ReasoningEffort,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ModelDefinition> for ModelOptionDto {
|
||||||
|
fn from(model: ModelDefinition) -> Self {
|
||||||
|
Self {
|
||||||
|
id: model.id,
|
||||||
|
provider: model.provider,
|
||||||
|
display_name: model.display_name,
|
||||||
|
reasoning_supported: model.reasoning.supported,
|
||||||
|
reasoning_required: model.reasoning.required,
|
||||||
|
default_reasoning_effort: model.reasoning.default_effort,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateSessionSettingsArgs {
|
||||||
|
pub chat_id: String,
|
||||||
|
pub access_mode: Option<AccessMode>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub reasoning_effort: Option<ReasoningEffort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NewSessionArgs {
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
pub access_mode: Option<AccessMode>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
pub api_key_env: Option<String>,
|
||||||
|
pub reasoning_effort: Option<ReasoningEffort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResumeSessionArgs {
|
||||||
|
pub chat_id: String,
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ListChatsArgs {
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApproveArgs {
|
||||||
|
pub turn_id: String,
|
||||||
|
pub request_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionIdArgs {
|
||||||
|
pub chat_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CancelTurnArgs {
|
||||||
|
pub turn_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||||
|
pub enum StreamEvent {
|
||||||
|
AssistantChunk {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
ReasoningChunk {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
ToolCallStarted {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
arguments: serde_json::Value,
|
||||||
|
},
|
||||||
|
ToolOutputChunk {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
stream: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
ok: bool,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
ApprovalRequested {
|
||||||
|
request_id: String,
|
||||||
|
tool_call_id: String,
|
||||||
|
name: String,
|
||||||
|
arguments: serde_json::Value,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
ApprovalResolved {
|
||||||
|
request_id: String,
|
||||||
|
approved: bool,
|
||||||
|
},
|
||||||
|
Status {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Finished,
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamEvent {
|
||||||
|
pub fn from_embedding(event: cassady::embedding::Event) -> Self {
|
||||||
|
match event {
|
||||||
|
cassady::embedding::Event::AssistantChunk(text) => Self::AssistantChunk { text },
|
||||||
|
cassady::embedding::Event::ReasoningChunk(text) => Self::ReasoningChunk { text },
|
||||||
|
cassady::embedding::Event::ToolCallStarted {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments,
|
||||||
|
} => Self::ToolCallStarted {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments,
|
||||||
|
},
|
||||||
|
cassady::embedding::Event::ToolOutputChunk {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
stream,
|
||||||
|
content,
|
||||||
|
} => Self::ToolOutputChunk {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
stream,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
cassady::embedding::Event::ToolResult {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
ok,
|
||||||
|
content,
|
||||||
|
} => Self::ToolResult {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
ok,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
cassady::embedding::Event::ApprovalRequested(req) => Self::ApprovalRequested {
|
||||||
|
request_id: req.request_id,
|
||||||
|
tool_call_id: req.tool_call_id,
|
||||||
|
name: req.name,
|
||||||
|
arguments: req.arguments,
|
||||||
|
reason: req.reason,
|
||||||
|
},
|
||||||
|
cassady::embedding::Event::ApprovalResolved {
|
||||||
|
request_id,
|
||||||
|
approved,
|
||||||
|
} => Self::ApprovalResolved {
|
||||||
|
request_id,
|
||||||
|
approved,
|
||||||
|
},
|
||||||
|
cassady::embedding::Event::Status(text) => Self::Status { text },
|
||||||
|
cassady::embedding::Event::Finished => Self::Finished,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TurnHandle {
|
||||||
|
pub turn_id: String,
|
||||||
|
pub chat_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_event_serializes_camel_case_with_kind_tag() {
|
||||||
|
let ev = StreamEvent::AssistantChunk { text: "hi".into() };
|
||||||
|
let json = serde_json::to_value(&ev).unwrap();
|
||||||
|
assert_eq!(json["kind"], "assistantChunk");
|
||||||
|
assert_eq!(json["text"], "hi");
|
||||||
|
|
||||||
|
let ev = StreamEvent::ToolResult {
|
||||||
|
id: "1".into(),
|
||||||
|
name: "ls".into(),
|
||||||
|
ok: true,
|
||||||
|
content: "x".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&ev).unwrap();
|
||||||
|
assert_eq!(json["kind"], "toolResult");
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_embedding_maps_each_variant() {
|
||||||
|
use cassady::embedding::Event;
|
||||||
|
let cases: Vec<(Event, &str)> = vec![
|
||||||
|
(Event::AssistantChunk("a".into()), "assistantChunk"),
|
||||||
|
(Event::ReasoningChunk("r".into()), "reasoningChunk"),
|
||||||
|
(
|
||||||
|
Event::ToolCallStarted {
|
||||||
|
id: "1".into(),
|
||||||
|
name: "ls".into(),
|
||||||
|
arguments: serde_json::Value::Null,
|
||||||
|
},
|
||||||
|
"toolCallStarted",
|
||||||
|
),
|
||||||
|
(Event::Status("s".into()), "status"),
|
||||||
|
(Event::Finished, "finished"),
|
||||||
|
];
|
||||||
|
for (ev, expected_kind) in cases {
|
||||||
|
let mapped = StreamEvent::from_embedding(ev);
|
||||||
|
let json = serde_json::to_value(&mapped).unwrap();
|
||||||
|
assert_eq!(json["kind"], expected_kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Cassady",
|
||||||
|
"version": "0.4.0",
|
||||||
|
"identifier": "com.cassady.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Cassady",
|
||||||
|
"width": 1000,
|
||||||
|
"height": 720,
|
||||||
|
"minWidth": 720,
|
||||||
|
"minHeight": 480,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"decorations": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
},
|
||||||
|
"withGlobalTauri": false
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"category": "DeveloperTool"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ChatShell } from "@/components/ChatShell";
|
||||||
|
import { getCwd } from "@/lib/tauri";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [cwd, setCwd] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const c = await getCwd();
|
||||||
|
setCwd(c);
|
||||||
|
} catch {
|
||||||
|
setCwd("");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!cwd) {
|
||||||
|
return (
|
||||||
|
<div className="vignette scanlines grain flex h-screen items-center justify-center bg-[var(--color-bg)] font-mono text-xs text-[var(--color-fg-dim)]">
|
||||||
|
loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ChatShell cwd={cwd} />;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
import { type PendingApproval } from "@/hooks/useTurn";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ApprovalDialog({
|
||||||
|
approval,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
approval: PendingApproval;
|
||||||
|
onResolve: (approved: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const args = approval.arguments
|
||||||
|
? JSON.stringify(approval.arguments, null, 2)
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-[var(--color-amber)]">
|
||||||
|
approval required
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<span className="font-mono text-[var(--color-fg)]">
|
||||||
|
{approval.name}
|
||||||
|
</span>{" "}
|
||||||
|
— {approval.reason}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{args && (
|
||||||
|
<pre className="max-h-60 overflow-auto border border-[var(--color-line)] bg-[var(--color-bg)] p-3 font-mono text-xs text-[var(--color-fg-muted)] whitespace-pre-wrap break-words">
|
||||||
|
{args}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="gap-3 justify-end">
|
||||||
|
<Button variant="outline" onClick={() => onResolve(false)}>
|
||||||
|
deny
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onResolve(true)}>approve</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { TopBar, OpenChatList, NoConfigCard, useSessionManager } from "@/components/TopBar";
|
||||||
|
import { Transcript } from "@/components/Transcript";
|
||||||
|
import { Composer } from "@/components/Composer";
|
||||||
|
import { ACCESS_MODES, REASONING_EFFORTS } from "@/lib/sessionSettings";
|
||||||
|
import { StatusFooter } from "@/components/StatusFooter";
|
||||||
|
import { ApprovalDialog } from "@/components/ApprovalDialog";
|
||||||
|
import { useTurn } from "@/hooks/useTurn";
|
||||||
|
import type { AccessMode, ReasoningEffort } from "@/lib/tauri";
|
||||||
|
import { listModels, updateSessionSettings, type ModelOption } from "@/lib/tauri";
|
||||||
|
|
||||||
|
export function ChatShell({ cwd }: { cwd: string }) {
|
||||||
|
const {
|
||||||
|
chat,
|
||||||
|
setChat,
|
||||||
|
chats,
|
||||||
|
showOpen,
|
||||||
|
setShowOpen,
|
||||||
|
configError,
|
||||||
|
handleNew,
|
||||||
|
handleOpen,
|
||||||
|
handlePick,
|
||||||
|
} = useSessionManager(cwd);
|
||||||
|
const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
|
||||||
|
|
||||||
|
const turn = useTurn(chat);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chat) {
|
||||||
|
const shortId = chat.id.slice(0, 8);
|
||||||
|
document.title = `cass — ${shortId}`;
|
||||||
|
} else {
|
||||||
|
document.title = "cass";
|
||||||
|
}
|
||||||
|
}, [chat]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
setModelOptions(await listModels());
|
||||||
|
} catch {
|
||||||
|
setModelOptions([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [chat?.id]);
|
||||||
|
|
||||||
|
const accessMode = chat?.accessMode ?? "read-only";
|
||||||
|
const reasoningEffort = chat?.reasoningEffort ?? "medium";
|
||||||
|
const model = chat?.model ?? "";
|
||||||
|
|
||||||
|
const updateSettings = async (settings: {
|
||||||
|
accessMode?: AccessMode;
|
||||||
|
model?: string;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
}) => {
|
||||||
|
if (!chat) return null;
|
||||||
|
if (turn.state === "running" || turn.state === "approval") {
|
||||||
|
turn.setStatusHint("settings can be changed when idle");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const info = await updateSessionSettings({ chatId: chat.id, ...settings });
|
||||||
|
setChat(info);
|
||||||
|
return info;
|
||||||
|
} catch (e) {
|
||||||
|
turn.setStatusHint(String(e));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleAccessMode = () => {
|
||||||
|
const idx = ACCESS_MODES.indexOf(accessMode);
|
||||||
|
const next = ACCESS_MODES[(idx + 1) % ACCESS_MODES.length];
|
||||||
|
void (async () => {
|
||||||
|
const info = await updateSettings({ accessMode: next });
|
||||||
|
if (info) turn.setStatusHint(`mode: ${info.accessMode}`);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleReasoning = () => {
|
||||||
|
const idx = REASONING_EFFORTS.indexOf(reasoningEffort);
|
||||||
|
const next = REASONING_EFFORTS[(idx + 1) % REASONING_EFFORTS.length];
|
||||||
|
void (async () => {
|
||||||
|
const info = await updateSettings({ reasoningEffort: next });
|
||||||
|
if (info) turn.setStatusHint(`reasoning:${info.reasoningEffort}`);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectModel = (nextModel: string) => {
|
||||||
|
if (!nextModel || nextModel === model) return;
|
||||||
|
void (async () => {
|
||||||
|
const info = await updateSettings({ model: nextModel });
|
||||||
|
if (info) turn.setStatusHint(`model: ${info.model}`);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSend = (message: string) => {
|
||||||
|
void turn.send(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
void turn.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResolveApproval = (approved: boolean) => {
|
||||||
|
void turn.resolveApproval(approved);
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = useMemo(() => {
|
||||||
|
if (configError) {
|
||||||
|
return <NoConfigCard cwd={cwd} />;
|
||||||
|
}
|
||||||
|
if (!chat) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-6">
|
||||||
|
<p className="font-mono text-sm text-[var(--color-fg-muted)]">
|
||||||
|
no chat open.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleNew()}
|
||||||
|
className="font-mono text-sm text-[var(--color-accent)] hover:underline"
|
||||||
|
>
|
||||||
|
▸ start a new chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Transcript blocks={turn.blocks} />
|
||||||
|
{turn.pendingApproval && (
|
||||||
|
<ApprovalDialog
|
||||||
|
approval={turn.pendingApproval}
|
||||||
|
onResolve={onResolveApproval}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [configError, chat, turn.blocks, turn.pendingApproval, cwd, handleNew]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vignette scanlines grain relative flex h-screen flex-col bg-[var(--color-bg)]">
|
||||||
|
<TopBar
|
||||||
|
chat={chat}
|
||||||
|
onNewChat={() => void handleNew()}
|
||||||
|
onOpenChat={() => void handleOpen()}
|
||||||
|
chats={chats}
|
||||||
|
cwd={cwd}
|
||||||
|
/>
|
||||||
|
{showOpen && (
|
||||||
|
<OpenChatList
|
||||||
|
chats={chats}
|
||||||
|
onPick={(id) => void handlePick(id)}
|
||||||
|
onClose={() => setShowOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{body}
|
||||||
|
{chat && (
|
||||||
|
<Composer
|
||||||
|
onSend={onSend}
|
||||||
|
onCancel={onCancel}
|
||||||
|
running={turn.state === "running" || turn.state === "approval"}
|
||||||
|
accessMode={accessMode}
|
||||||
|
reasoningEffort={reasoningEffort}
|
||||||
|
model={model}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
onCycleAccessMode={cycleAccessMode}
|
||||||
|
onCycleReasoning={cycleReasoning}
|
||||||
|
onSelectModel={selectModel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{chat && (
|
||||||
|
<StatusFooter
|
||||||
|
chatId={chat.id}
|
||||||
|
cwd={chat.cwd}
|
||||||
|
model={chat.model}
|
||||||
|
accessMode={accessMode}
|
||||||
|
reasoningEffort={reasoningEffort}
|
||||||
|
state={turn.state}
|
||||||
|
status={turn.status}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ModelSelector } from "@/components/ModelSelector";
|
||||||
|
import { ArrowUp, Square } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AccessMode, ModelOption, ReasoningEffort } from "@/lib/tauri";
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
disabled,
|
||||||
|
running,
|
||||||
|
onSend,
|
||||||
|
onCancel,
|
||||||
|
accessMode,
|
||||||
|
reasoningEffort,
|
||||||
|
model,
|
||||||
|
modelOptions,
|
||||||
|
onCycleAccessMode,
|
||||||
|
onCycleReasoning,
|
||||||
|
onSelectModel,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
running?: boolean;
|
||||||
|
onSend: (message: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
accessMode: AccessMode;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
model: string;
|
||||||
|
modelOptions: ModelOption[];
|
||||||
|
onCycleAccessMode: () => void;
|
||||||
|
onCycleReasoning: () => void;
|
||||||
|
onSelectModel: (model: string) => void;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || running) return;
|
||||||
|
onSend(trimmed);
|
||||||
|
setValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
} else if (e.key === "Escape" && running) {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 px-4 py-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 font-mono text-[11px] text-[var(--color-fg-dim)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCycleAccessMode}
|
||||||
|
disabled={running}
|
||||||
|
className="border border-[var(--color-line)] px-2 py-0.5 uppercase tracking-[0.16em] hover:border-[var(--color-accent)]/50 hover:text-[var(--color-accent)] disabled:opacity-50 disabled:hover:border-[var(--color-line)] disabled:hover:text-[var(--color-fg-dim)] transition-colors"
|
||||||
|
>
|
||||||
|
{accessMode}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCycleReasoning}
|
||||||
|
disabled={running}
|
||||||
|
className="border border-[var(--color-line)] px-2 py-0.5 uppercase tracking-[0.16em] hover:border-[var(--color-accent)]/50 hover:text-[var(--color-accent)] disabled:opacity-50 disabled:hover:border-[var(--color-line)] disabled:hover:text-[var(--color-fg-dim)] transition-colors"
|
||||||
|
>
|
||||||
|
reasoning:{reasoningEffort}
|
||||||
|
</button>
|
||||||
|
<ModelSelector
|
||||||
|
model={model}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
disabled={running}
|
||||||
|
onSelect={onSelectModel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<span className="font-mono text-[var(--color-accent)] pb-2">›</span>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
placeholder="message cass…"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none bg-transparent font-mono text-sm text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:outline-none",
|
||||||
|
"py-2",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{running ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={onCancel}>
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
stop
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={submit} disabled={disabled || !value.trim()}>
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
send
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Check, ChevronDown, Search, Sparkles } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ModelOption } from "@/lib/tauri";
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
model: string;
|
||||||
|
modelOptions: ModelOption[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onSelect: (model: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelLabel(option: ModelOption): string {
|
||||||
|
return option.displayName && option.displayName !== option.id
|
||||||
|
? option.displayName
|
||||||
|
: option.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry =
|
||||||
|
| { kind: "header"; provider: string }
|
||||||
|
| { kind: "item"; option: ModelOption; index: number };
|
||||||
|
|
||||||
|
export function ModelSelector({
|
||||||
|
model,
|
||||||
|
modelOptions,
|
||||||
|
disabled,
|
||||||
|
onSelect,
|
||||||
|
}: ModelSelectorProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const current = modelOptions.find((o) => o.id === model);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const filtered = modelOptions.filter((o) => {
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
o.id.toLowerCase().includes(q) ||
|
||||||
|
o.provider.toLowerCase().includes(q) ||
|
||||||
|
(o.displayName ?? "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const map = new Map<string, ModelOption[]>();
|
||||||
|
for (const o of filtered) {
|
||||||
|
const arr = map.get(o.provider) ?? [];
|
||||||
|
arr.push(o);
|
||||||
|
map.set(o.provider, arr);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).sort((a, b) =>
|
||||||
|
a[0].localeCompare(b[0]),
|
||||||
|
);
|
||||||
|
}, [modelOptions, query]);
|
||||||
|
|
||||||
|
const flat = useMemo(
|
||||||
|
() => grouped.flatMap(([, opts]) => opts),
|
||||||
|
[grouped],
|
||||||
|
);
|
||||||
|
|
||||||
|
const entries = useMemo<Entry[]>(() => {
|
||||||
|
const out: Entry[] = [];
|
||||||
|
let idx = 0;
|
||||||
|
for (const [provider, opts] of grouped) {
|
||||||
|
out.push({ kind: "header", provider });
|
||||||
|
for (const option of opts) {
|
||||||
|
out.push({ kind: "item", option, index: idx++ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [grouped]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
wrapperRef.current &&
|
||||||
|
!wrapperRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const i = flat.findIndex((o) => o.id === model);
|
||||||
|
setActiveIndex(i >= 0 ? i : 0);
|
||||||
|
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
setQuery("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const el = listRef.current?.querySelector(
|
||||||
|
`[data-index="${activeIndex}"]`,
|
||||||
|
);
|
||||||
|
el?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [activeIndex, open]);
|
||||||
|
|
||||||
|
const select = (id: string) => {
|
||||||
|
onSelect(id);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.min(i + 1, flat.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const opt = flat[activeIndex];
|
||||||
|
if (opt) select(opt.id);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerLabel = current ? modelLabel(current) : model || "no model";
|
||||||
|
const isEmpty = modelOptions.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setOpen((o) => !o)}
|
||||||
|
disabled={disabled || isEmpty}
|
||||||
|
title="model"
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-1.5 border px-2 py-0.5 font-mono text-[11px] transition-colors",
|
||||||
|
"border-[var(--color-line)] text-[var(--color-fg-dim)]",
|
||||||
|
"hover:border-[var(--color-accent)]/50 hover:text-[var(--color-fg)]",
|
||||||
|
"focus:outline-none focus:border-[var(--color-accent)]",
|
||||||
|
"disabled:opacity-50 disabled:hover:border-[var(--color-line)] disabled:hover:text-[var(--color-fg-dim)]",
|
||||||
|
open && "border-[var(--color-accent)]/60 text-[var(--color-fg)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{current?.reasoningSupported && (
|
||||||
|
<Sparkles
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-2.5 shrink-0",
|
||||||
|
current.reasoningRequired
|
||||||
|
? "text-[var(--color-amber)]"
|
||||||
|
: "text-[var(--color-accent)]/70",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="max-w-[min(40vw,22rem)] truncate tracking-tight">
|
||||||
|
{triggerLabel}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 shrink-0 transition-transform",
|
||||||
|
open && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute bottom-full left-0 z-40 mb-2 w-[min(90vw,28rem)] border border-[var(--color-line)] bg-[var(--color-bg-elev)] shadow-[0_24px_60px_-20px_rgba(0,0,0,0.7)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-[var(--color-line)] px-3 py-2">
|
||||||
|
<Search className="h-3 w-3 shrink-0 text-[var(--color-fg-dim)]" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="filter models…"
|
||||||
|
className="flex-1 bg-transparent font-mono text-xs text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)]">
|
||||||
|
{flat.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref={listRef} className="max-h-72 overflow-y-auto py-1">
|
||||||
|
{flat.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center font-mono text-xs text-[var(--color-fg-dim)]">
|
||||||
|
no models match.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => {
|
||||||
|
if (entry.kind === "header") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`h:${entry.provider}`}
|
||||||
|
className="sticky top-0 bg-[var(--color-bg-elev)] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]"
|
||||||
|
>
|
||||||
|
{entry.provider}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const opt = entry.option;
|
||||||
|
const isActive = entry.index === activeIndex;
|
||||||
|
const isSelected = opt.id === model;
|
||||||
|
const showId =
|
||||||
|
opt.displayName && opt.displayName !== opt.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${opt.provider}:${opt.id}`}
|
||||||
|
type="button"
|
||||||
|
data-index={entry.index}
|
||||||
|
onClick={() => select(opt.id)}
|
||||||
|
onMouseEnter={() => setActiveIndex(entry.index)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||||
|
isActive && "bg-[var(--color-accent-glow)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex h-3 w-3 shrink-0 items-center justify-center">
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="h-3 w-3 text-[var(--color-accent)]" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
|
||||||
|
{modelLabel(opt)}
|
||||||
|
</span>
|
||||||
|
{showId && (
|
||||||
|
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
|
||||||
|
{opt.id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{opt.reasoningSupported && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 font-mono text-[10px] uppercase tracking-[0.16em]",
|
||||||
|
opt.reasoningRequired
|
||||||
|
? "text-[var(--color-amber)]"
|
||||||
|
: "text-[var(--color-accent)]/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
reason
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AccessMode, ReasoningEffort } from "@/lib/tauri";
|
||||||
|
import type { TurnState } from "@/hooks/useTurn";
|
||||||
|
|
||||||
|
export function StatusFooter({
|
||||||
|
chatId,
|
||||||
|
cwd,
|
||||||
|
model,
|
||||||
|
accessMode,
|
||||||
|
reasoningEffort,
|
||||||
|
state,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
chatId: string;
|
||||||
|
cwd: string;
|
||||||
|
model: string;
|
||||||
|
accessMode: AccessMode;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
state: TurnState;
|
||||||
|
status: string;
|
||||||
|
}) {
|
||||||
|
const shortId = chatId.length > 8 ? `${chatId.slice(0, 8)}…` : chatId;
|
||||||
|
const shortCwd = cwd.replace(/^\/Users\/[^/]+/, "~");
|
||||||
|
|
||||||
|
const stateLabel = state === "approval" ? "approval" : status;
|
||||||
|
const stateColor =
|
||||||
|
state === "running"
|
||||||
|
? "text-[var(--color-accent)]"
|
||||||
|
: state === "approval"
|
||||||
|
? "text-[var(--color-amber)]"
|
||||||
|
: state === "error"
|
||||||
|
? "text-[var(--color-amber)]"
|
||||||
|
: "text-[var(--color-fg-dim)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 px-5 py-2 font-mono text-[11px] text-[var(--color-fg-dim)]">
|
||||||
|
<span className="text-[var(--color-fg-muted)]">cass</span>
|
||||||
|
<span className="mx-1.5">·</span>
|
||||||
|
<span>{accessMode}</span>
|
||||||
|
<span className="mx-1.5">·</span>
|
||||||
|
<span className={cn(stateColor)}>{stateLabel}</span>
|
||||||
|
<span className="mx-1.5">·</span>
|
||||||
|
<span>{model}</span>
|
||||||
|
<span className="mx-1.5">·</span>
|
||||||
|
<span>{shortCwd}</span>
|
||||||
|
<span className="mx-1.5">·</span>
|
||||||
|
<span>{shortId}</span>
|
||||||
|
<span className="mx-1.5">·</span>
|
||||||
|
<span>reasoning:{reasoningEffort}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import logo from "@/assets/cass-logo-transparent.png";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, FolderOpen, TerminalSquare } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ChatSummary, ConversationInfo } from "@/lib/tauri";
|
||||||
|
import { listChats, newSession, resumeSession } from "@/lib/tauri";
|
||||||
|
|
||||||
|
interface TopBarProps {
|
||||||
|
chat: ConversationInfo | null;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onOpenChat: () => void;
|
||||||
|
chats: ChatSummary[];
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ chat, onNewChat, onOpenChat }: TopBarProps) {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-[var(--color-line)] bg-[var(--color-bg)]/85 px-4 backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img src={logo} alt="cass" className="h-6 w-6" />
|
||||||
|
<span className="font-mono text-sm font-medium tracking-tight text-[var(--color-fg)]">
|
||||||
|
cassady
|
||||||
|
</span>
|
||||||
|
{chat && (
|
||||||
|
<span className="ml-3 font-mono text-xs text-[var(--color-fg-dim)]">
|
||||||
|
{chat.id.slice(0, 8)}…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={onNewChat}>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
new
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onOpenChat}>
|
||||||
|
<FolderOpen className="h-3 w-3" />
|
||||||
|
open
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenChatList({
|
||||||
|
chats,
|
||||||
|
onPick,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
chats: ChatSummary[];
|
||||||
|
onPick: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full max-w-md border border-[var(--color-line)] bg-[var(--color-bg-soft)]",
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="border-b border-[var(--color-line)] px-4 py-3 font-mono text-xs uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
|
||||||
|
open chat
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{chats.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 font-mono text-xs text-[var(--color-fg-dim)]">
|
||||||
|
no chats for this directory.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
chats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(c.id)}
|
||||||
|
className="flex w-full flex-col gap-0.5 border-b border-[var(--color-line)] px-4 py-3 text-left hover:bg-[var(--color-bg-elev)]/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-[var(--color-fg)]">
|
||||||
|
{c.id}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-[var(--color-fg-dim)]">
|
||||||
|
{c.model} · {c.firstUserPreview.slice(0, 60) || "(empty)"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoConfigCard({ cwd }: { cwd: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-6">
|
||||||
|
<div className="max-w-md border border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 p-8 text-center backdrop-blur-sm">
|
||||||
|
<TerminalSquare className="mx-auto h-8 w-8 text-[var(--color-accent)]" />
|
||||||
|
<h2 className="mt-4 font-mono text-base font-medium text-[var(--color-fg)]">
|
||||||
|
run <span className="text-[var(--color-accent)]">cass setup</span> first
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 font-sans text-sm text-[var(--color-fg-muted)]">
|
||||||
|
Cassady needs a provider and model configured under{" "}
|
||||||
|
<code className="font-mono text-[var(--color-fg)]">~/.cass</code>.
|
||||||
|
Run the setup wizard in a terminal:
|
||||||
|
</p>
|
||||||
|
<pre className="mt-4 overflow-x-auto border border-[var(--color-line)] bg-[var(--color-bg)] px-3 py-2 text-left font-mono text-xs text-[var(--color-fg)]">
|
||||||
|
<span className="text-[var(--color-accent)]">▸</span> cass setup
|
||||||
|
</pre>
|
||||||
|
<p className="mt-4 font-mono text-xs text-[var(--color-fg-dim)]">
|
||||||
|
cwd: {cwd.replace(/^\/Users\/[^/]+/, "~")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionManager(cwd: string) {
|
||||||
|
const [chat, setChat] = useState<ConversationInfo | null>(null);
|
||||||
|
const [chats, setChats] = useState<ChatSummary[]>([]);
|
||||||
|
const [showOpen, setShowOpen] = useState(false);
|
||||||
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshChats = async () => {
|
||||||
|
try {
|
||||||
|
setChats(await listChats(cwd));
|
||||||
|
} catch {
|
||||||
|
// ignore list errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshChats();
|
||||||
|
}, [cwd]);
|
||||||
|
|
||||||
|
const handleNew = async () => {
|
||||||
|
try {
|
||||||
|
const info = await newSession({ cwd });
|
||||||
|
setChat(info);
|
||||||
|
setConfigError(null);
|
||||||
|
await refreshChats();
|
||||||
|
} catch (e) {
|
||||||
|
setConfigError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
await refreshChats();
|
||||||
|
setShowOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePick = async (id: string) => {
|
||||||
|
setShowOpen(false);
|
||||||
|
try {
|
||||||
|
const info = await resumeSession(id, cwd);
|
||||||
|
setChat(info);
|
||||||
|
setConfigError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setConfigError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
chat,
|
||||||
|
setChat,
|
||||||
|
chats,
|
||||||
|
showOpen,
|
||||||
|
setShowOpen,
|
||||||
|
configError,
|
||||||
|
handleNew,
|
||||||
|
handleOpen,
|
||||||
|
handlePick,
|
||||||
|
refreshChats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { type TranscriptBlock } from "@/hooks/useTurn";
|
||||||
|
import { UserBlock } from "@/components/blocks/UserBlock";
|
||||||
|
import { AssistantBlock } from "@/components/blocks/AssistantBlock";
|
||||||
|
import { ReasoningBlock } from "@/components/blocks/ReasoningBlock";
|
||||||
|
import { ToolBlock } from "@/components/blocks/ToolBlock";
|
||||||
|
import { StatusBlock } from "@/components/blocks/StatusBlock";
|
||||||
|
|
||||||
|
export function Transcript({ blocks }: { blocks: TranscriptBlock[] }) {
|
||||||
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [blocks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative flex-1 overflow-y-auto px-5 py-4 font-mono text-[13px] leading-relaxed"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-0 opacity-[0.04] bg-[repeating-linear-gradient(to_bottom,transparent_0,transparent_2px,rgba(255,255,255,1)_2px,rgba(255,255,255,1)_3px)]" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
{blocks.length === 0 ? (
|
||||||
|
<div className="font-mono text-xs text-[var(--color-fg-dim)]">
|
||||||
|
send a message to start.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
blocks.map((b) => {
|
||||||
|
switch (b.kind) {
|
||||||
|
case "user":
|
||||||
|
return <UserBlock key={b.id} text={b.text} />;
|
||||||
|
case "assistant":
|
||||||
|
return <AssistantBlock key={b.id} text={b.text} />;
|
||||||
|
case "reasoning":
|
||||||
|
return <ReasoningBlock key={b.id} text={b.text} />;
|
||||||
|
case "tool":
|
||||||
|
return <ToolBlock key={b.id} block={b} />;
|
||||||
|
case "status":
|
||||||
|
return <StatusBlock key={b.id} text={b.text} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function AssistantBlock({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("py-1.5", className)}>
|
||||||
|
<span className="text-[var(--color-accent)] text-glow">cass</span>
|
||||||
|
<div className="mt-1 pl-4 text-[var(--color-fg)] prose-cass">
|
||||||
|
{text ? (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({ className: c, children, ...props }) {
|
||||||
|
const isInline = !c;
|
||||||
|
return isInline ? (
|
||||||
|
<code
|
||||||
|
className="font-mono text-[0.85em] text-[var(--color-accent)] bg-[var(--color-bg-soft)] px-1 py-0.5 rounded-[3px]"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<code className={cn("font-mono", c)} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre({ children }) {
|
||||||
|
return (
|
||||||
|
<pre className="my-2 overflow-x-auto border border-[var(--color-line)] bg-[var(--color-bg-soft)] p-3 text-[13px] font-mono text-[var(--color-fg-muted)] rounded-[6px]">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
a({ children, href }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[var(--color-accent)] underline underline-offset-2 hover:opacity-80"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-fg-dim)]">…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function ReasoningBlock({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className={cn("py-1", className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-1 font-mono text-xs text-[var(--color-fg-dim)] hover:text-[var(--color-fg-muted)] transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 transition-transform duration-200",
|
||||||
|
open && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
reasoning
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-1 pl-4 font-mono text-xs text-[var(--color-fg-dim)] whitespace-pre-wrap break-words">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function StatusBlock({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("py-1 font-mono text-xs text-[var(--color-amber)]", className)}>
|
||||||
|
· {text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { TranscriptBlock } from "@/hooks/useTurn";
|
||||||
|
|
||||||
|
export function ToolBlock({
|
||||||
|
block,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
block: TranscriptBlock;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ok = block.toolOk;
|
||||||
|
const done = block.toolDone;
|
||||||
|
const args = block.toolArguments
|
||||||
|
? JSON.stringify(block.toolArguments, null, 2)
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<div className={cn("py-1.5", className)}>
|
||||||
|
<div className="font-mono text-xs text-[var(--color-fg-dim)]">
|
||||||
|
· {block.toolName}
|
||||||
|
{done && (
|
||||||
|
<span>
|
||||||
|
{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
ok
|
||||||
|
? "text-[var(--color-accent)]"
|
||||||
|
: "text-[var(--color-amber)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ok ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{args && (
|
||||||
|
<pre className="mt-1 overflow-x-auto pl-4 font-mono text-xs text-[var(--color-fg-dim)] whitespace-pre-wrap break-words">
|
||||||
|
{args}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{block.toolOutput && (
|
||||||
|
<pre className="mt-1 overflow-x-auto pl-4 font-mono text-xs text-[var(--color-fg-muted)] whitespace-pre-wrap break-words">
|
||||||
|
{block.toolOutput}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{!done && block.text && (
|
||||||
|
<pre className="mt-1 overflow-x-auto pl-4 font-mono text-xs text-[var(--color-fg-dim)] whitespace-pre-wrap break-words">
|
||||||
|
{block.text}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function UserBlock({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("py-1.5", className)}>
|
||||||
|
<span className="text-[var(--color-accent)]">› you</span>
|
||||||
|
<div className="mt-0.5 pl-4 text-[var(--color-fg-muted)] whitespace-pre-wrap break-words">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Accordion = AccordionPrimitive.Root;
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b border-[var(--color-line)] last:border-b-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AccordionItem.displayName = "AccordionItem";
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 items-center justify-between py-5 font-mono text-sm font-medium text-[var(--color-fg)] transition-all hover:text-[var(--color-accent)] [&[data-state=open]>svg]:rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--color-fg-dim)] transition-transform duration-200 group-data-[state=open]:text-[var(--color-accent)]" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
));
|
||||||
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-hidden text-sm data-[state=closed]:animate-[accordion-up_0.2s_ease-out] data-[state=open]:animate-[accordion-down_0.2s_ease-out]"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pb-5 pr-8 font-sans text-[var(--color-fg-muted)] leading-relaxed",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
));
|
||||||
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-mono text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-[var(--color-accent)] text-[#0b0d10] hover:bg-[var(--color-accent)]/90 hover:shadow-[0_0_24px_-4px_var(--color-accent-glow)]",
|
||||||
|
outline:
|
||||||
|
"border border-[var(--color-line)] bg-transparent text-[var(--color-fg)] hover:border-[var(--color-accent)]/60 hover:text-[var(--color-accent)] hover:bg-[var(--color-accent-glow)]",
|
||||||
|
ghost:
|
||||||
|
"text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-white/[0.03]",
|
||||||
|
link: "text-[var(--color-accent)] underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-5 py-2",
|
||||||
|
sm: "h-8 px-3 text-xs",
|
||||||
|
lg: "h-12 px-7 text-base",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative border border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 text-[var(--color-fg)] backdrop-blur-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-base font-medium leading-none tracking-tight text-[var(--color-fg)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"font-sans text-sm text-[var(--color-fg-muted)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-[var(--color-line)]",
|
||||||
|
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden border border-[var(--color-line)] bg-[var(--color-bg-elev)] px-2.5 py-1.5 font-mono text-xs text-[var(--color-fg)] shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
type ConversationInfo,
|
||||||
|
type ConversationRecord,
|
||||||
|
type StreamEvent,
|
||||||
|
type TurnHandle,
|
||||||
|
approve,
|
||||||
|
cancelTurn,
|
||||||
|
deny,
|
||||||
|
sessionRecords,
|
||||||
|
startTurn,
|
||||||
|
} from "@/lib/tauri";
|
||||||
|
|
||||||
|
export type BlockKind =
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "reasoning"
|
||||||
|
| "tool"
|
||||||
|
| "status";
|
||||||
|
|
||||||
|
export interface TranscriptBlock {
|
||||||
|
id: string;
|
||||||
|
kind: BlockKind;
|
||||||
|
text: string;
|
||||||
|
toolId?: string;
|
||||||
|
toolName?: string;
|
||||||
|
toolArguments?: unknown;
|
||||||
|
toolOutput?: string;
|
||||||
|
toolOk?: boolean;
|
||||||
|
toolDone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApproval {
|
||||||
|
requestId: string;
|
||||||
|
toolCallId: string;
|
||||||
|
name: string;
|
||||||
|
arguments: unknown;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TurnState = "idle" | "running" | "approval" | "cancelled" | "error";
|
||||||
|
|
||||||
|
let blockSeq = 0;
|
||||||
|
function nextBlockId(): string {
|
||||||
|
blockSeq += 1;
|
||||||
|
return `b-${blockSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocksFromRecords(records: ConversationRecord[]): TranscriptBlock[] {
|
||||||
|
const blocks: TranscriptBlock[] = [];
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
switch (record.type) {
|
||||||
|
case "user": {
|
||||||
|
blocks.push({
|
||||||
|
id: nextBlockId(),
|
||||||
|
kind: "user",
|
||||||
|
text: record.content,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "assistant": {
|
||||||
|
if (record.reasoning?.trim()) {
|
||||||
|
blocks.push({
|
||||||
|
id: nextBlockId(),
|
||||||
|
kind: "reasoning",
|
||||||
|
text: record.reasoning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (record.content.trim()) {
|
||||||
|
blocks.push({
|
||||||
|
id: nextBlockId(),
|
||||||
|
kind: "assistant",
|
||||||
|
text: record.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const call of record.tool_calls ?? []) {
|
||||||
|
blocks.push({
|
||||||
|
id: nextBlockId(),
|
||||||
|
kind: "tool",
|
||||||
|
text: "",
|
||||||
|
toolId: call.id,
|
||||||
|
toolName: call.name,
|
||||||
|
toolArguments: call.arguments,
|
||||||
|
toolOutput: "",
|
||||||
|
toolDone: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tool": {
|
||||||
|
const idx = blocks.findIndex(
|
||||||
|
(block) => block.kind === "tool" && block.toolId === record.tool_call_id,
|
||||||
|
);
|
||||||
|
const toolBlock: TranscriptBlock = {
|
||||||
|
id: idx >= 0 ? blocks[idx].id : nextBlockId(),
|
||||||
|
kind: "tool",
|
||||||
|
text: record.content,
|
||||||
|
toolId: record.tool_call_id,
|
||||||
|
toolName: record.name,
|
||||||
|
toolArguments: idx >= 0 ? blocks[idx].toolArguments : undefined,
|
||||||
|
toolOutput: idx >= 0 ? blocks[idx].toolOutput : "",
|
||||||
|
toolOk: record.ok,
|
||||||
|
toolDone: true,
|
||||||
|
};
|
||||||
|
if (idx >= 0) {
|
||||||
|
blocks[idx] = toolBlock;
|
||||||
|
} else {
|
||||||
|
blocks.push(toolBlock);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "meta":
|
||||||
|
case "system":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTurn {
|
||||||
|
blocks: TranscriptBlock[];
|
||||||
|
state: TurnState;
|
||||||
|
status: string;
|
||||||
|
pendingApproval: PendingApproval | null;
|
||||||
|
turnId: string | null;
|
||||||
|
send: (message: string) => Promise<void>;
|
||||||
|
cancel: () => Promise<void>;
|
||||||
|
resolveApproval: (approved: boolean) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
setStatusHint: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTurn(chat: ConversationInfo | null): UseTurn {
|
||||||
|
const [blocks, setBlocks] = useState<TranscriptBlock[]>([]);
|
||||||
|
const [state, setState] = useState<TurnState>("idle");
|
||||||
|
const [status, setStatus] = useState<string>("idle");
|
||||||
|
const [pendingApproval, setPendingApproval] = useState<PendingApproval | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [turnId, setTurnId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const turnIdRef = useRef<string | null>(null);
|
||||||
|
const loadSeqRef = useRef(0);
|
||||||
|
const streamSeqRef = useRef(0);
|
||||||
|
const chatRef = useRef<ConversationInfo | null>(chat);
|
||||||
|
chatRef.current = chat;
|
||||||
|
|
||||||
|
const appendBlock = useCallback((block: TranscriptBlock) => {
|
||||||
|
setBlocks((prev) => [...prev, block]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateBlock = useCallback(
|
||||||
|
(id: string, updater: (b: TranscriptBlock) => TranscriptBlock) => {
|
||||||
|
setBlocks((prev) => prev.map((b) => (b.id === id ? updater(b) : b)));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEvent = useCallback(
|
||||||
|
(event: StreamEvent) => {
|
||||||
|
switch (event.kind) {
|
||||||
|
case "assistantChunk": {
|
||||||
|
setBlocks((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.kind === "assistant" && !last.toolDone) {
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...last, text: last.text + event.text },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: nextBlockId(),
|
||||||
|
kind: "assistant",
|
||||||
|
text: event.text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "reasoningChunk": {
|
||||||
|
setBlocks((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.kind === "reasoning") {
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...last, text: last.text + event.text },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: nextBlockId(),
|
||||||
|
kind: "reasoning",
|
||||||
|
text: event.text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "toolCallStarted": {
|
||||||
|
appendBlock({
|
||||||
|
id: nextBlockId(),
|
||||||
|
kind: "tool",
|
||||||
|
text: "",
|
||||||
|
toolId: event.id,
|
||||||
|
toolName: event.name,
|
||||||
|
toolArguments: event.arguments,
|
||||||
|
toolOutput: "",
|
||||||
|
toolDone: false,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "toolOutputChunk": {
|
||||||
|
setBlocks((prev) =>
|
||||||
|
prev.map((b) =>
|
||||||
|
b.toolId === event.id
|
||||||
|
? {
|
||||||
|
...b,
|
||||||
|
toolOutput: (b.toolOutput ?? "") + event.content,
|
||||||
|
}
|
||||||
|
: b,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "toolResult": {
|
||||||
|
setBlocks((prev) =>
|
||||||
|
prev.map((b) =>
|
||||||
|
b.toolId === event.id
|
||||||
|
? {
|
||||||
|
...b,
|
||||||
|
toolOk: event.ok,
|
||||||
|
toolDone: true,
|
||||||
|
text: event.content,
|
||||||
|
}
|
||||||
|
: b,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "approvalRequested": {
|
||||||
|
setPendingApproval({
|
||||||
|
requestId: event.requestId,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
name: event.name,
|
||||||
|
arguments: event.arguments,
|
||||||
|
reason: event.reason,
|
||||||
|
});
|
||||||
|
setState("approval");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "approvalResolved": {
|
||||||
|
setPendingApproval(null);
|
||||||
|
setState("running");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "status": {
|
||||||
|
setStatus(event.text);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "finished": {
|
||||||
|
setState("idle");
|
||||||
|
setStatus("idle");
|
||||||
|
turnIdRef.current = null;
|
||||||
|
setTurnId(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
setState("error");
|
||||||
|
setStatus(event.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendBlock, updateBlock],
|
||||||
|
);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (message: string) => {
|
||||||
|
const activeChat = chatRef.current;
|
||||||
|
if (!activeChat || turnIdRef.current) return;
|
||||||
|
const chatId = activeChat.id;
|
||||||
|
const streamSeq = streamSeqRef.current;
|
||||||
|
loadSeqRef.current += 1;
|
||||||
|
appendBlock({
|
||||||
|
id: nextBlockId(),
|
||||||
|
kind: "user",
|
||||||
|
text: message,
|
||||||
|
});
|
||||||
|
setState("running");
|
||||||
|
setStatus("running");
|
||||||
|
try {
|
||||||
|
const handle: TurnHandle = await startTurn(chatId, message, (event) => {
|
||||||
|
if (
|
||||||
|
streamSeqRef.current === streamSeq &&
|
||||||
|
chatRef.current?.id === chatId
|
||||||
|
) {
|
||||||
|
handleEvent(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (streamSeqRef.current === streamSeq && chatRef.current?.id === chatId) {
|
||||||
|
turnIdRef.current = handle.turnId;
|
||||||
|
setTurnId(handle.turnId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (streamSeqRef.current === streamSeq && chatRef.current?.id === chatId) {
|
||||||
|
setState("error");
|
||||||
|
setStatus(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendBlock, handleEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancel = useCallback(async () => {
|
||||||
|
const id = turnIdRef.current;
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await cancelTurn(id);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e));
|
||||||
|
}
|
||||||
|
turnIdRef.current = null;
|
||||||
|
setTurnId(null);
|
||||||
|
setState("cancelled");
|
||||||
|
setStatus("cancelled");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveApproval = useCallback(
|
||||||
|
async (approved: boolean) => {
|
||||||
|
const id = turnIdRef.current;
|
||||||
|
const req = pendingApproval;
|
||||||
|
if (!id || !req) return;
|
||||||
|
setPendingApproval(null);
|
||||||
|
setState("running");
|
||||||
|
try {
|
||||||
|
if (approved) {
|
||||||
|
await approve(id, req.requestId);
|
||||||
|
} else {
|
||||||
|
await deny(id, req.requestId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState("error");
|
||||||
|
setStatus(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pendingApproval],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setBlocks([]);
|
||||||
|
setState("idle");
|
||||||
|
setStatus("idle");
|
||||||
|
setPendingApproval(null);
|
||||||
|
turnIdRef.current = null;
|
||||||
|
setTurnId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setStatusHint = useCallback((text: string) => {
|
||||||
|
setStatus(text);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chatId = chat?.id;
|
||||||
|
const loadSeq = loadSeqRef.current + 1;
|
||||||
|
loadSeqRef.current = loadSeq;
|
||||||
|
streamSeqRef.current += 1;
|
||||||
|
turnIdRef.current = null;
|
||||||
|
setTurnId(null);
|
||||||
|
setPendingApproval(null);
|
||||||
|
setState("idle");
|
||||||
|
setStatus("idle");
|
||||||
|
setBlocks([]);
|
||||||
|
|
||||||
|
if (!chatId) return;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const records = await sessionRecords(chatId);
|
||||||
|
if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) {
|
||||||
|
setBlocks(blocksFromRecords(records));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) {
|
||||||
|
setState("error");
|
||||||
|
setStatus(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [chat?.id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocks,
|
||||||
|
state,
|
||||||
|
status,
|
||||||
|
pendingApproval,
|
||||||
|
turnId,
|
||||||
|
send,
|
||||||
|
cancel,
|
||||||
|
resolveApproval,
|
||||||
|
reset,
|
||||||
|
setStatusHint,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", "Menlo", monospace;
|
||||||
|
--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
--color-bg: #0b0d10;
|
||||||
|
--color-bg-soft: #111418;
|
||||||
|
--color-bg-elev: #161a1f;
|
||||||
|
--color-line: #232830;
|
||||||
|
--color-line-soft: #1a1f25;
|
||||||
|
|
||||||
|
--color-fg: #c7ccd4;
|
||||||
|
--color-fg-muted: #8b929d;
|
||||||
|
--color-fg-dim: #5b626c;
|
||||||
|
|
||||||
|
--color-accent: #5b9fe0;
|
||||||
|
--color-accent-soft: #3d6fa8;
|
||||||
|
--color-accent-glow: rgba(91, 159, 224, 0.18);
|
||||||
|
|
||||||
|
--color-amber: #d4a55a;
|
||||||
|
|
||||||
|
--radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--color-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-accent-glow);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-line);
|
||||||
|
border-radius: 0;
|
||||||
|
border: 2px solid var(--color-bg);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.font-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.font-sans {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanlines::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(255, 255, 255, 0.016) 2px,
|
||||||
|
rgba(255, 255, 255, 0.016) 3px
|
||||||
|
);
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grain::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.04;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.vignette::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
transparent 40%,
|
||||||
|
rgba(0, 0, 0, 0.4) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-glow {
|
||||||
|
text-shadow: 0 0 12px var(--color-accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-fade {
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 60% 50% at 50% 0%, black 30%, transparent 75%);
|
||||||
|
mask-image: radial-gradient(ellipse 60% 50% at 50% 0%, black 30%, transparent 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-shadow {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--color-line),
|
||||||
|
0 24px 60px -20px rgba(0, 0, 0, 0.7),
|
||||||
|
0 0 80px -30px var(--color-accent-glow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 49% { opacity: 1; }
|
||||||
|
50%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.cursor-blink {
|
||||||
|
animation: blink 1.05s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-drift {
|
||||||
|
0% { transform: translateY(-100%); }
|
||||||
|
100% { transform: translateY(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from { height: 0; opacity: 0; }
|
||||||
|
to { height: var(--radix-accordion-content-height); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from { height: var(--radix-accordion-content-height); opacity: 1; }
|
||||||
|
to { height: 0; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-cass p {
|
||||||
|
margin: 0 0 0.6em 0;
|
||||||
|
}
|
||||||
|
.prose-cass p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.prose-cass h1,
|
||||||
|
.prose-cass h2,
|
||||||
|
.prose-cass h3 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-fg);
|
||||||
|
margin: 0.8em 0 0.3em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.prose-cass h1 { font-size: 1.2em; }
|
||||||
|
.prose-cass h2 { font-size: 1.1em; }
|
||||||
|
.prose-cass h3 { font-size: 1em; }
|
||||||
|
.prose-cass ul,
|
||||||
|
.prose-cass ol {
|
||||||
|
margin: 0 0 0.6em 0;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
.prose-cass li {
|
||||||
|
margin: 0.15em 0;
|
||||||
|
}
|
||||||
|
.prose-cass blockquote {
|
||||||
|
border-left: 2px solid var(--color-line);
|
||||||
|
margin: 0 0 0.6em 0;
|
||||||
|
padding-left: 0.8em;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
}
|
||||||
|
.prose-cass hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
margin: 0.8em 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { AccessMode, ReasoningEffort } from "@/lib/tauri";
|
||||||
|
|
||||||
|
export const ACCESS_MODES: AccessMode[] = [
|
||||||
|
"read-only",
|
||||||
|
"workspace-edit",
|
||||||
|
"full-access",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const REASONING_EFFORTS: ReasoningEffort[] = [
|
||||||
|
"off",
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
];
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export type AccessMode = "read-only" | "workspace-edit" | "full-access";
|
||||||
|
export type ReasoningEffort = "off" | "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export interface ConversationInfo {
|
||||||
|
id: string;
|
||||||
|
cwd: string;
|
||||||
|
model: string;
|
||||||
|
accessMode: AccessMode;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
path: string;
|
||||||
|
recordCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSummary {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
model: string;
|
||||||
|
cwd: string;
|
||||||
|
firstUserPreview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelOption {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
displayName?: string;
|
||||||
|
reasoningSupported: boolean;
|
||||||
|
reasoningRequired: boolean;
|
||||||
|
defaultReasoningEffort: ReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewSessionArgs {
|
||||||
|
cwd?: string;
|
||||||
|
accessMode?: AccessMode;
|
||||||
|
model?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKeyEnv?: string;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSessionSettingsArgs {
|
||||||
|
chatId: string;
|
||||||
|
accessMode?: AccessMode;
|
||||||
|
model?: string;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnHandle {
|
||||||
|
turnId: string;
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationRecord =
|
||||||
|
| {
|
||||||
|
type: "meta";
|
||||||
|
chat_id: string;
|
||||||
|
created_at: string;
|
||||||
|
model: string;
|
||||||
|
cwd: string;
|
||||||
|
parent_chat_id?: string | null;
|
||||||
|
}
|
||||||
|
| { type: "system"; content: string }
|
||||||
|
| { type: "user"; content: string; ts: string }
|
||||||
|
| {
|
||||||
|
type: "assistant";
|
||||||
|
content: string;
|
||||||
|
reasoning?: string;
|
||||||
|
reasoning_field?: string | null;
|
||||||
|
tool_calls?: StoredToolCall[];
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool";
|
||||||
|
tool_call_id: string;
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
content: string;
|
||||||
|
ts: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StreamEvent =
|
||||||
|
| { kind: "assistantChunk"; text: string }
|
||||||
|
| { kind: "reasoningChunk"; text: string }
|
||||||
|
| {
|
||||||
|
kind: "toolCallStarted";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "toolOutputChunk";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stream: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "toolResult";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "approvalRequested";
|
||||||
|
requestId: string;
|
||||||
|
toolCallId: string;
|
||||||
|
name: string;
|
||||||
|
arguments: unknown;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| { kind: "approvalResolved"; requestId: string; approved: boolean }
|
||||||
|
| { kind: "status"; text: string }
|
||||||
|
| { kind: "finished" }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
export async function newSession(
|
||||||
|
args: NewSessionArgs,
|
||||||
|
): Promise<ConversationInfo> {
|
||||||
|
return invoke<ConversationInfo>("new_session", { args });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resumeSession(
|
||||||
|
chatId: string,
|
||||||
|
cwd?: string,
|
||||||
|
): Promise<ConversationInfo> {
|
||||||
|
return invoke<ConversationInfo>("resume_session", { args: { chatId, cwd } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChats(cwd?: string): Promise<ChatSummary[]> {
|
||||||
|
return invoke<ChatSummary[]>("list_chats_cmd", { args: { cwd } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listModels(): Promise<ModelOption[]> {
|
||||||
|
return invoke<ModelOption[]>("list_models_cmd");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSessionSettings(
|
||||||
|
args: UpdateSessionSettingsArgs,
|
||||||
|
): Promise<ConversationInfo> {
|
||||||
|
return invoke<ConversationInfo>("update_session_settings", { args });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCwd(): Promise<string> {
|
||||||
|
return invoke<string>("get_cwd");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sessionInfo(chatId: string): Promise<ConversationInfo> {
|
||||||
|
return invoke<ConversationInfo>("session_info", { args: { chatId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sessionRecords(
|
||||||
|
chatId: string,
|
||||||
|
): Promise<ConversationRecord[]> {
|
||||||
|
return invoke<ConversationRecord[]>("session_records", { args: { chatId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTurn(
|
||||||
|
chatId: string,
|
||||||
|
message: string,
|
||||||
|
onEvent: (event: StreamEvent) => void,
|
||||||
|
): Promise<TurnHandle> {
|
||||||
|
const channel = new Channel<StreamEvent>();
|
||||||
|
channel.onmessage = onEvent;
|
||||||
|
return invoke<TurnHandle>("start_turn", { chatId, message, onEvent: channel });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approve(turnId: string, requestId: string): Promise<void> {
|
||||||
|
return invoke("approve", { args: { turnId, requestId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deny(turnId: string, requestId: string): Promise<void> {
|
||||||
|
return invoke("deny", { args: { turnId, requestId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelTurn(
|
||||||
|
turnId: string,
|
||||||
|
): Promise<ConversationInfo> {
|
||||||
|
return invoke<ConversationInfo>("cancel_turn", { args: { turnId } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import "@fontsource/ibm-plex-mono/400.css";
|
||||||
|
import "@fontsource/ibm-plex-mono/500.css";
|
||||||
|
import "@fontsource/ibm-plex-mono/600.css";
|
||||||
|
import "@fontsource/ibm-plex-sans/400.css";
|
||||||
|
import "@fontsource/ibm-plex-sans/500.css";
|
||||||
|
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.png" {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
declare module "*.jpg" {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
declare module "*.svg" {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"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,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||