Prepare Cassady v0.4.0 desktop app

This commit is contained in:
2026-06-27 11:03:43 -05:00
parent e564777877
commit 99ea3fed50
108 changed files with 15635 additions and 44 deletions
+13
View File
@@ -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>
+4662
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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"
}
}
+20
View File
@@ -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"] }
+3
View File
@@ -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"]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

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>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+28
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
cassady_desktop_lib::run()
}
+181
View File
@@ -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())
}
+37
View File
@@ -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()
}
}
+271
View File
@@ -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())
}
+283
View File
@@ -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);
}
}
}
+43
View File
@@ -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"
}
}
+28
View File
@@ -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} />;
}
Binary file not shown.

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>
);
}
+116
View File
@@ -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>
);
}
+177
View File
@@ -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 };
+408
View File
@@ -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,
};
}
+199
View File
@@ -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",
];
+187
View File
@@ -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 } });
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+17
View File
@@ -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>,
);
+14
View File
@@ -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;
}
+24
View File
@@ -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"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+38
View File
@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
clearScreen: false,
server: {
port: 5173,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: {
target:
process.env.TAURI_ENV_PLATFORM == "windows" ? "chrome105" : "safari13",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
});