Prepare Cassady v0.4.0 desktop app
@@ -1,3 +1,6 @@
|
||||
/target/
|
||||
/dist/
|
||||
/site/
|
||||
*node_modules/
|
||||
*dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady"
|
||||
version = "0.3.5"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
description = "Cassady/Cass minimal terminal coding agent"
|
||||
license = "MIT"
|
||||
@@ -47,3 +47,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
|
||||
[workspace]
|
||||
members = ["cassady-desktop/src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
# 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`.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||