Preparing for v0.4.2

This commit is contained in:
2026-06-27 16:40:10 -05:00
parent 96b9c9cdba
commit 20e11ccd39
25 changed files with 3365 additions and 1213 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cassady-desktop-frontend",
"private": true,
"version": "0.4.1",
"version": "0.4.2",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cassady-desktop"
version = "0.4.1"
version = "0.4.2"
edition = "2021"
description = "Cassady desktop app"
license = "MIT"
+9
View File
@@ -18,6 +18,15 @@ pub fn run() {
session::session_records,
session::list_models_cmd,
session::update_session_settings,
session::reload_session_config,
session::list_slash_commands,
session::slash_autofill,
session::run_slash_command,
session::create_branch_from_checkpoint,
session::apply_provider_login,
session::discover_models,
session::remove_providers,
session::list_provider_catalog,
turn::start_turn,
turn::approve,
turn::deny,
+255 -2
View File
@@ -1,11 +1,16 @@
use crate::state::DesktopState;
use crate::types::{
ChatSummaryDto, ConversationInfoDto, ListChatsArgs, ModelOptionDto, NewSessionArgs,
ResumeSessionArgs, SessionIdArgs, UpdateSessionSettingsArgs,
ApplyProviderLoginArgs, AutoFillMenuDto, BranchResultDto, ChatSummaryDto, CommandOutcomeDto,
CommandSpecDto, ConversationInfoDto, CreateBranchArgs, DiscoverModelsArgs, ListChatsArgs,
LogoutResultDto, ModelOptionDto, NewSessionArgs, ProviderApplyResultDto,
ProviderLogoutCandidateDto, RemoveProvidersArgs, RestoreReportDto, ResumeSessionArgs,
RunSlashCommandArgs, SessionIdArgs, SlashAutofillArgs, UpdateSessionSettingsArgs,
};
use cassady::commands::{self, CommandContext, CommandOutcome};
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 cassady::setup::SetupSelection;
use std::path::PathBuf;
use tauri::State;
@@ -179,3 +184,251 @@ pub fn update_session_settings(
Ok(info.into())
}
/// Reload the session's resolved config from disk, used after `/login` or
/// `/logout` change the active provider/model outside the session. Returns
/// the updated conversation info so the host can refresh its display.
#[tauri::command]
pub fn reload_session_config(
state: State<'_, DesktopState>,
args: SessionIdArgs,
) -> Result<ConversationInfoDto, 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))?;
session.reload_config().map_err(|e| e.to_string())?;
session.info()
};
Ok(info.into())
}
// --- Slash commands ---------------------------------------------------------
#[tauri::command]
pub fn list_slash_commands() -> Result<Vec<CommandSpecDto>, String> {
Ok(commands::COMMANDS
.iter()
.map(|spec| (*spec).into())
.collect())
}
#[tauri::command]
pub fn list_provider_catalog() -> Result<Vec<crate::types::ProviderCatalogEntryDto>, String> {
Ok(commands::login_catalog()
.into_iter()
.map(crate::types::ProviderCatalogEntryDto::from)
.collect())
}
#[tauri::command]
pub fn slash_autofill(args: SlashAutofillArgs) -> Result<Option<AutoFillMenuDto>, String> {
let root = cass_root();
let config = commands::reload_config(&root).map_err(|e| e.to_string())?;
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 menu =
commands::build_autofill(&args.input, 0, &config, &cwd).map_err(|e| e.to_string())?;
Ok(menu.map(AutoFillMenuDto::from))
}
#[tauri::command]
pub fn run_slash_command(
state: State<'_, DesktopState>,
args: RunSlashCommandArgs,
) -> Result<CommandOutcomeDto, String> {
let busy = {
let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
turns.values().any(|entry| entry.chat_id == args.chat_id)
};
// Clone the session state out so `execute` can mutate a owned config copy
// without holding simultaneous &mut config / &conversation borrows of the
// session. Mutations are written back after the call.
let (mut config, conversation, mut reasoning_effort, cwd, access_mode) = {
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.config().clone(),
session.conversation().clone(),
session.reasoning_effort(),
session.cwd().to_path_buf(),
session.access_mode(),
)
};
let mut ctx = CommandContext {
config: &mut config,
reasoning_effort: &mut reasoning_effort,
conversation: &conversation,
cwd: &cwd,
access_mode,
busy,
status: "",
};
let outcome = commands::execute(&args.input, &mut ctx);
// Write back config + reasoning effort mutations (e.g. /fast, /model).
{
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))?;
*session.config_mut() = config;
session.set_reasoning_effort(reasoning_effort);
}
match outcome {
CommandOutcome::Status { title, content } => Ok(CommandOutcomeDto::Status {
title: title.into(),
content,
}),
CommandOutcome::NewChat {
conversation: new_conv,
status,
} => {
let info = rekey_session(&state, &args.chat_id, new_conv)?;
Ok(CommandOutcomeDto::NewChat {
info: info.into(),
status,
})
}
CommandOutcome::ResumedChat {
conversation: new_conv,
warning,
status,
} => {
let info = rekey_session(&state, &args.chat_id, new_conv)?;
Ok(CommandOutcomeDto::ResumedChat {
info: info.into(),
warning,
status,
})
}
CommandOutcome::OpenBranchPicker { family } => Ok(CommandOutcomeDto::OpenBranchPicker {
family: family.into(),
}),
CommandOutcome::OpenLoginWizard => Ok(CommandOutcomeDto::OpenLoginWizard),
CommandOutcome::OpenLogoutPicker { candidates } => {
Ok(CommandOutcomeDto::OpenLogoutPicker {
candidates: candidates
.into_iter()
.map(ProviderLogoutCandidateDto::from)
.collect(),
})
}
CommandOutcome::Busy(msg) => Ok(CommandOutcomeDto::Busy { message: msg }),
CommandOutcome::ParseError(msg) => Ok(CommandOutcomeDto::ParseError { message: msg }),
CommandOutcome::Error { title, message } => Ok(CommandOutcomeDto::Error {
title: title.into(),
message,
}),
}
}
/// Replace a session's conversation and re-key it in the sessions map under
/// the new conversation id. Used by `/new` and `/resume`.
fn rekey_session(
state: &State<'_, DesktopState>,
old_chat_id: &str,
new_conversation: cassady::conversation::Conversation,
) -> Result<cassady::embedding::ConversationInfo, String> {
let mut sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let mut session = sessions
.remove(old_chat_id)
.ok_or_else(|| format!("session {} not found", old_chat_id))?;
session.set_conversation(new_conversation);
let info = session.info();
sessions.insert(info.id.clone(), session);
Ok(info)
}
#[tauri::command]
pub fn create_branch_from_checkpoint(
state: State<'_, DesktopState>,
args: CreateBranchArgs,
) -> Result<BranchResultDto, String> {
let checkpoint: cassady::branch::Checkpoint = args.checkpoint.into();
let (info, source_chat_id, status, restore) = {
let mut sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let mut session = sessions
.remove(&args.chat_id)
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
let config = session.config().clone();
let outcome =
commands::create_branch_from_checkpoint(&config, &checkpoint, args.restore_files)
.map_err(|e| e.to_string())?;
let source_chat_id = outcome.source_chat_id;
let status = outcome.status;
let restore = outcome.restore.map(|r| RestoreReportDto {
summary: r.summary,
applied: r.applied,
skipped: r.skipped,
conflicts: r.conflicts,
});
session.set_conversation(outcome.conversation);
let info = session.info();
sessions.insert(info.id.clone(), session);
(info, source_chat_id, status, restore)
};
Ok(BranchResultDto {
info: info.into(),
source_chat_id,
status,
restore,
})
}
#[tauri::command]
pub async fn apply_provider_login(
args: ApplyProviderLoginArgs,
) -> Result<ProviderApplyResultDto, String> {
let root = cass_root();
let selections: Vec<SetupSelection> = args
.selections
.into_iter()
.map(SetupSelection::from)
.collect();
commands::apply_login(&root, &selections, args.active_index).map_err(|e| e.to_string())?;
let config = commands::reload_config(&root).map_err(|e| e.to_string())?;
Ok(ProviderApplyResultDto {
active_provider: config.provider_id,
active_model: config.model,
})
}
#[tauri::command]
pub async fn discover_models(args: DiscoverModelsArgs) -> Result<Vec<String>, String> {
commands::discover_models(&args.base_url, &args.api_key)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_providers(args: RemoveProvidersArgs) -> Result<LogoutResultDto, String> {
let root = cass_root();
let result =
commands::remove_providers(&root, &args.provider_ids).map_err(|e| e.to_string())?;
Ok(result.into())
}
+337
View File
@@ -1,5 +1,8 @@
use cassady::access::AccessMode;
use cassady::branch::{BranchFamily, BranchSummary, Checkpoint, CheckpointKind};
use cassady::commands::CommandSpec;
use cassady::config::{ModelDefinition, ReasoningEffort};
use cassady::setup::{LogoutResult, ProviderCatalogEntry, ProviderLogoutCandidate, SetupSelection};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -235,6 +238,340 @@ pub struct TurnHandle {
pub chat_id: String,
}
// --- Slash command DTOs -----------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandSpecDto {
pub name: String,
pub usage: String,
pub description: String,
pub takes_value: bool,
}
impl From<CommandSpec> for CommandSpecDto {
fn from(spec: CommandSpec) -> Self {
Self {
name: spec.name.to_string(),
usage: spec.usage.to_string(),
description: spec.description.to_string(),
takes_value: spec.takes_value,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoFillItemDto {
pub label: String,
pub insert: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoFillMenuDto {
pub title: String,
pub replacement_start: usize,
pub replacement_end: usize,
pub items: Vec<AutoFillItemDto>,
pub selected: usize,
}
impl From<cassady::commands::AutoFillMenu> for AutoFillMenuDto {
fn from(menu: cassady::commands::AutoFillMenu) -> Self {
Self {
title: menu.title,
replacement_start: menu.replacement_start,
replacement_end: menu.replacement_end,
items: menu
.items
.into_iter()
.map(|item| AutoFillItemDto {
label: item.label,
insert: item.insert,
detail: item.detail,
})
.collect(),
selected: menu.selected,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchSummaryDto {
pub id: String,
pub created_at: String,
pub parent_chat_id: Option<String>,
pub branch_label: Option<String>,
pub record_count: usize,
pub current: bool,
}
impl From<BranchSummary> for BranchSummaryDto {
fn from(b: BranchSummary) -> Self {
Self {
id: b.id,
created_at: b.created_at,
parent_chat_id: b.parent_chat_id,
branch_label: b.branch_label,
record_count: b.record_count,
current: b.current,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckpointDto {
pub id: String,
pub chat_id: String,
pub record_index: usize,
pub tool_call_id: Option<String>,
pub kind: CheckpointKind,
pub label: String,
pub detail: String,
pub ts: Option<String>,
}
impl From<Checkpoint> for CheckpointDto {
fn from(c: Checkpoint) -> Self {
Self {
id: c.id,
chat_id: c.chat_id,
record_index: c.record_index,
tool_call_id: c.tool_call_id,
kind: c.kind,
label: c.label,
detail: c.detail,
ts: c.ts,
}
}
}
impl From<CheckpointDto> for Checkpoint {
fn from(c: CheckpointDto) -> Self {
Self {
id: c.id,
chat_id: c.chat_id,
record_index: c.record_index,
tool_call_id: c.tool_call_id,
kind: c.kind,
label: c.label,
detail: c.detail,
ts: c.ts,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchFamilyDto {
pub branches: Vec<BranchSummaryDto>,
pub checkpoints: Vec<CheckpointDto>,
}
impl From<BranchFamily> for BranchFamilyDto {
fn from(f: BranchFamily) -> Self {
Self {
branches: f.branches.into_iter().map(BranchSummaryDto::from).collect(),
checkpoints: f.checkpoints.into_iter().map(CheckpointDto::from).collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderLogoutCandidateDto {
pub id: String,
pub name: Option<String>,
pub default_model: Option<String>,
pub model_count: usize,
}
impl From<ProviderLogoutCandidate> for ProviderLogoutCandidateDto {
fn from(c: ProviderLogoutCandidate) -> Self {
Self {
id: c.id,
name: c.name,
default_model: c.default_model,
model_count: c.model_count,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogoutResultDto {
pub removed_provider_ids: Vec<String>,
pub removed_model_count: usize,
pub remaining_provider_count: usize,
pub active_provider: Option<String>,
pub active_model: Option<String>,
}
impl From<LogoutResult> for LogoutResultDto {
fn from(r: LogoutResult) -> Self {
Self {
removed_provider_ids: r.removed_provider_ids,
removed_model_count: r.removed_model_count,
remaining_provider_count: r.remaining_provider_count,
active_provider: r.active_provider,
active_model: r.active_model,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderCatalogEntryDto {
pub name: String,
pub id: String,
pub base_url: String,
pub api_key_env: String,
}
impl From<ProviderCatalogEntry> for ProviderCatalogEntryDto {
fn from(e: ProviderCatalogEntry) -> Self {
Self {
name: e.name.to_string(),
id: e.id.to_string(),
base_url: e.base_url.to_string(),
api_key_env: e.api_key_env.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupSelectionDto {
pub provider_id: String,
pub provider_name: String,
pub base_url: String,
pub api_key_env: String,
pub model_id: String,
pub supports_tools: bool,
pub supports_reasoning: bool,
}
impl From<SetupSelectionDto> for SetupSelection {
fn from(s: SetupSelectionDto) -> Self {
Self {
provider_id: s.provider_id,
provider_name: s.provider_name,
base_url: s.base_url,
api_key_env: s.api_key_env,
model_id: s.model_id,
supports_tools: s.supports_tools,
supports_reasoning: s.supports_reasoning,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RestoreReportDto {
pub summary: String,
pub applied: usize,
pub skipped: usize,
pub conflicts: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchResultDto {
pub info: ConversationInfoDto,
pub source_chat_id: String,
pub status: String,
pub restore: Option<RestoreReportDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderApplyResultDto {
pub active_provider: String,
pub active_model: String,
}
/// Tagged result of `run_slash_command`, mirroring `cassady::commands::CommandOutcome`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum CommandOutcomeDto {
Status {
title: String,
content: String,
},
NewChat {
info: ConversationInfoDto,
status: String,
},
ResumedChat {
info: ConversationInfoDto,
warning: Option<String>,
status: String,
},
OpenBranchPicker {
family: BranchFamilyDto,
},
OpenLoginWizard,
OpenLogoutPicker {
candidates: Vec<ProviderLogoutCandidateDto>,
},
Busy {
message: String,
},
ParseError {
message: String,
},
Error {
title: String,
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunSlashCommandArgs {
pub chat_id: String,
pub input: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SlashAutofillArgs {
pub input: String,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateBranchArgs {
pub chat_id: String,
pub checkpoint: CheckpointDto,
pub restore_files: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyProviderLoginArgs {
pub selections: Vec<SetupSelectionDto>,
pub active_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoverModelsArgs {
pub base_url: String,
pub api_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveProvidersArgs {
pub provider_ids: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,193 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BranchFamily, Checkpoint } from "@/lib/tauri";
export function BranchModal({
family,
onSwitch,
onBranch,
onClose,
}: {
family: BranchFamily;
onSwitch: (branchId: string) => void;
onBranch: (checkpoint: Checkpoint, restoreFiles: boolean) => Promise<void>;
onClose: () => void;
}) {
const [selectedCheckpoint, setSelectedCheckpoint] = useState<Checkpoint | null>(
null,
);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !applying) {
if (selectedCheckpoint) setSelectedCheckpoint(null);
else onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [applying, selectedCheckpoint, onClose]);
const runBranch = async (restoreFiles: boolean) => {
if (!selectedCheckpoint) return;
setApplying(true);
setError(null);
try {
await onBranch(selectedCheckpoint, restoreFiles);
} catch (e) {
setError(String(e));
setApplying(false);
}
};
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)]">
{selectedCheckpoint ? "branch from checkpoint" : "branch / restore"}
</CardTitle>
<CardDescription>
{selectedCheckpoint
? `${selectedCheckpoint.chatId} · ${selectedCheckpoint.label}`
: "switch to a branch or branch from a checkpoint"}
</CardDescription>
</CardHeader>
<CardContent>
{!selectedCheckpoint ? (
<div className="flex flex-col gap-3">
{family.branches.length > 0 && (
<div>
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
branches
</p>
<div className="flex flex-col gap-1">
{family.branches.map((b) => (
<button
key={b.id}
type="button"
onClick={() => onSwitch(b.id)}
disabled={applying || b.current}
className={cn(
"flex items-center justify-between border px-3 py-2 text-left transition-colors",
b.current
? "border-[var(--color-line)] text-[var(--color-fg-dim)]"
: "border-[var(--color-line)] hover:border-[var(--color-accent)]/50 hover:text-[var(--color-fg)]",
)}
>
<span className="flex flex-col gap-0.5">
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{b.current ? `current · ${b.id}` : b.id}
</span>
{b.branchLabel && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{b.branchLabel}
</span>
)}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--color-fg-dim)]">
{b.recordCount} records
</span>
</button>
))}
</div>
</div>
)}
{family.checkpoints.length > 0 && (
<div>
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
checkpoints
</p>
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{family.checkpoints.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setSelectedCheckpoint(c)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{c.chatId} · {c.label}
</span>
{c.detail && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.detail}
</span>
)}
</button>
))}
</div>
</div>
)}
{family.branches.length === 0 &&
family.checkpoints.length === 0 && (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
no branches or checkpoints found.
</p>
)}
</div>
) : (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setSelectedCheckpoint(null)}
disabled={applying}
className="self-start font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
‹ back
</button>
<button
type="button"
onClick={() => void runBranch(false)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
branch conversation only
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
safe default; leaves files unchanged
</span>
</button>
<button
type="button"
onClick={() => void runBranch(true)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
branch and restore tracked files
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
applies safe write/edit snapshots; conflicts are skipped
</span>
</button>
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button variant="outline" onClick={onClose} disabled={applying}>
cancel
</Button>
</CardFooter>
</Card>
</div>
);
}
+218 -2
View File
@@ -2,12 +2,31 @@ 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 { BranchModal } from "@/components/BranchModal";
import { LogoutModal } from "@/components/LogoutModal";
import { LoginModal } from "@/components/LoginModal";
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";
import type {
AccessMode,
BranchFamily,
Checkpoint,
CommandOutcome,
LogoutResult,
ProviderApplyResult,
ProviderLogoutCandidate,
ReasoningEffort,
} from "@/lib/tauri";
import {
createBranchFromCheckpoint,
listModels,
reloadSessionConfig,
runSlashCommand,
updateSessionSettings,
type ModelOption,
} from "@/lib/tauri";
export function ChatShell({ cwd }: { cwd: string }) {
const {
@@ -22,6 +41,12 @@ export function ChatShell({ cwd }: { cwd: string }) {
handlePick,
} = useSessionManager(cwd);
const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
const [branchFamily, setBranchFamily] = useState<BranchFamily | null>(null);
const [logoutCandidates, setLogoutCandidates] = useState<
ProviderLogoutCandidate[]
>([]);
const [logoutOpen, setLogoutOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const turn = useTurn(chat);
@@ -94,6 +119,165 @@ export function ChatShell({ cwd }: { cwd: string }) {
})();
};
const refreshModelOptions = async () => {
try {
setModelOptions(await listModels());
} catch {
setModelOptions([]);
}
};
const routeSlashOutcome = async (outcome: CommandOutcome) => {
switch (outcome.kind) {
case "status": {
turn.pushNotice(outcome.content, "status");
// The CLI sets the status line to the content for /fast and /model,
// and to "status shown" for /status.
turn.setStatusHint(
outcome.title === "status" ? "status shown" : outcome.content,
);
break;
}
case "newChat": {
turn.queueAfterLoad({ status: outcome.status });
setChat(outcome.info);
break;
}
case "resumedChat": {
turn.queueAfterLoad({
status: outcome.status,
notice: outcome.warning
? { text: outcome.warning, kind: "error" }
: undefined,
});
setChat(outcome.info);
break;
}
case "openBranchPicker": {
setBranchFamily(outcome.family);
turn.setStatusHint("branch/restore menu");
break;
}
case "openLoginWizard": {
setLoginOpen(true);
turn.setStatusHint("login");
break;
}
case "openLogoutPicker": {
setLogoutCandidates(outcome.candidates);
setLogoutOpen(true);
turn.setStatusHint("logout");
break;
}
case "busy":
turn.setStatusHint(outcome.message);
break;
case "parseError":
turn.setStatusHint(outcome.message);
break;
case "error":
turn.pushNotice(outcome.message, "error");
turn.setStatusHint(outcome.message);
break;
}
};
const onSlashCommand = (input: string) => {
if (!chat) return;
void (async () => {
try {
const outcome = await runSlashCommand(chat.id, input);
await routeSlashOutcome(outcome);
} catch (e) {
turn.setStatusHint(String(e));
}
})();
};
const onBranchSwitch = (branchId: string) => {
setBranchFamily(null);
onSlashCommand(`/resume ${branchId}`);
};
const onBranchFromCheckpoint = async (
checkpoint: Checkpoint,
restoreFiles: boolean,
) => {
if (!chat) return;
try {
const result = await createBranchFromCheckpoint(
chat.id,
checkpoint,
restoreFiles,
);
const notice = result.restore
? {
text: `${result.restore.summary}\n\nApplied: ${result.restore.applied}\nSkipped: ${result.restore.skipped}\nConflicts: ${result.restore.conflicts}`,
kind: (result.restore.conflicts > 0 ? "error" : "status") as
| "status"
| "error",
}
: undefined;
turn.queueAfterLoad({ status: result.status, notice });
setChat(result.info);
setBranchFamily(null);
} catch (e) {
turn.setStatusHint(String(e));
}
};
const onLogoutResolved = async (result: LogoutResult) => {
setLogoutOpen(false);
setLogoutCandidates([]);
await refreshModelOptions();
if (result.removedProviderIds.length === 0) {
turn.pushNotice("logout cancelled", "status");
turn.setStatusHint("logout cancelled");
} else if (result.remainingProviderCount === 0) {
turn.pushNotice(
`removed providers: ${result.removedProviderIds.join(", ")}\nremoved model entries: ${result.removedModelCount}\nno providers remain; run /login before sending another message`,
"status",
);
turn.setStatusHint("no provider configured");
} else {
// Reload the open session's config so the active provider/model reflect
// the post-logout defaults (repair_active_defaults updated config.json).
if (chat) {
try {
const info = await reloadSessionConfig(chat.id);
setChat(info);
} catch (e) {
turn.setStatusHint(String(e));
}
}
turn.pushNotice(
`removed providers: ${result.removedProviderIds.join(", ")}\nremoved model entries: ${result.removedModelCount}\nactive provider: ${result.activeProvider ?? "?"}\nactive model: ${result.activeModel ?? "?"}`,
"status",
);
turn.setStatusHint("logout updated");
}
};
const onLoginResolved = async (result: ProviderApplyResult) => {
setLoginOpen(false);
await refreshModelOptions();
// Reload the open session's config so it picks up the new active
// provider/model written by apply_provider_login.
if (chat) {
try {
const info = await reloadSessionConfig(chat.id);
setChat(info);
} catch (e) {
turn.setStatusHint(String(e));
}
}
turn.pushNotice(
`active provider: ${result.activeProvider}\nactive model: ${result.activeModel}`,
"status",
);
turn.setStatusHint("login updated");
};
const onSend = (message: string) => {
void turn.send(message);
};
@@ -159,6 +343,8 @@ export function ChatShell({ cwd }: { cwd: string }) {
<Composer
onSend={onSend}
onCancel={onCancel}
onSlashCommand={onSlashCommand}
cwd={chat.cwd}
running={turn.state === "running" || turn.state === "approval"}
accessMode={accessMode}
reasoningEffort={reasoningEffort}
@@ -180,6 +366,36 @@ export function ChatShell({ cwd }: { cwd: string }) {
status={turn.status}
/>
)}
{chat && branchFamily && (
<BranchModal
family={branchFamily}
onSwitch={onBranchSwitch}
onBranch={onBranchFromCheckpoint}
onClose={() => {
setBranchFamily(null);
turn.setStatusHint("branch menu cancelled");
}}
/>
)}
{chat && logoutOpen && (
<LogoutModal
candidates={logoutCandidates}
onResolved={onLogoutResolved}
onClose={() => {
setLogoutOpen(false);
turn.setStatusHint("logout cancelled");
}}
/>
)}
{chat && loginOpen && (
<LoginModal
onResolved={onLoginResolved}
onClose={() => {
setLoginOpen(false);
turn.setStatusHint("login cancelled");
}}
/>
)}
</div>
);
}
+118 -9
View File
@@ -1,15 +1,24 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ModelSelector } from "@/components/ModelSelector";
import { SlashMenu } from "@/components/SlashMenu";
import { ArrowUp, Square } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AccessMode, ModelOption, ReasoningEffort } from "@/lib/tauri";
import {
slashAutofill,
type AccessMode,
type AutoFillMenu,
type ModelOption,
type ReasoningEffort,
} from "@/lib/tauri";
export function Composer({
disabled,
running,
cwd,
onSend,
onCancel,
onSlashCommand,
accessMode,
reasoningEffort,
model,
@@ -20,8 +29,10 @@ export function Composer({
}: {
disabled?: boolean;
running?: boolean;
cwd: string;
onSend: (message: string) => void;
onCancel: () => void;
onSlashCommand: (input: string) => void;
accessMode: AccessMode;
reasoningEffort: ReasoningEffort;
model: string;
@@ -31,7 +42,10 @@ export function Composer({
onSelectModel: (model: string) => void;
}) {
const [value, setValue] = useState("");
const [slashMenu, setSlashMenu] = useState<AutoFillMenu | null>(null);
const [slashSelected, setSlashSelected] = useState(0);
const ref = useRef<HTMLTextAreaElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
@@ -40,6 +54,39 @@ export function Composer({
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}, [value]);
useEffect(() => {
if (!value.startsWith("/") || value.includes("\n")) {
setSlashMenu(null);
return;
}
let active = true;
void (async () => {
try {
const menu = await slashAutofill(value, cwd);
if (active) {
setSlashMenu(menu);
setSlashSelected(menu?.selected ?? 0);
}
} catch {
if (active) setSlashMenu(null);
}
})();
return () => {
active = false;
};
}, [value, cwd]);
useEffect(() => {
if (!slashMenu) return;
const handler = (e: MouseEvent) => {
if (rowRef.current && !rowRef.current.contains(e.target as Node)) {
setSlashMenu(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [slashMenu]);
const submit = () => {
const trimmed = value.trim();
if (!trimmed || running) return;
@@ -47,13 +94,64 @@ export function Composer({
setValue("");
};
const applySlashSelection = () => {
if (!slashMenu) return;
const item = slashMenu.items[slashSelected];
if (!item) return;
const newInput =
value.slice(0, slashMenu.replacementStart) +
item.insert +
value.slice(slashMenu.replacementEnd);
if (item.insert.endsWith(" ")) {
// Value command name selected (e.g. "/model "); keep the menu open for
// argument selection.
setValue(newInput);
} else {
// Complete command; auto-run and clear the input.
setSlashMenu(null);
setValue("");
onSlashCommand(newInput.trim());
}
};
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();
if (slashMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashSelected((s) => Math.min(s + 1, slashMenu.items.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashSelected((s) => Math.max(s - 1, 0));
return;
}
if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
applySlashSelection();
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenu(null);
return;
}
} else {
if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
const trimmed = value.trim();
if (trimmed.startsWith("/")) {
setValue("");
onSlashCommand(trimmed);
} else {
submit();
}
return;
}
if (e.key === "Escape" && running) {
e.preventDefault();
onCancel();
}
}
};
@@ -83,7 +181,18 @@ export function Composer({
onSelect={onSelectModel}
/>
</div>
<div className="flex items-end gap-2">
<div ref={rowRef} className="relative flex items-end gap-2">
{slashMenu && (
<SlashMenu
menu={slashMenu}
selected={slashSelected}
onSelect={(idx) => {
setSlashSelected(idx);
applySlashSelection();
}}
onHover={setSlashSelected}
/>
)}
<span className="font-mono text-[var(--color-accent)] pb-2">›</span>
<textarea
ref={ref}
@@ -92,7 +201,7 @@ export function Composer({
onKeyDown={onKeyDown}
disabled={disabled}
rows={1}
placeholder="message cass…"
placeholder="message cass… (type / for commands)"
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",
@@ -0,0 +1,198 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
applyProviderLogin,
listProviderCatalog,
type ProviderApplyResult,
type ProviderCatalogEntry,
type SetupSelection,
} from "@/lib/tauri";
const CHATGPT_CODEX_ID = "chatgpt-codex";
export function LoginModal({
onResolved,
onClose,
}: {
onResolved: (result: ProviderApplyResult) => void;
onClose: () => void;
}) {
const [catalog, setCatalog] = useState<ProviderCatalogEntry[]>([]);
const [selection, setSelection] = useState<SetupSelection | null>(null);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
setCatalog(await listProviderCatalog());
} catch {
setCatalog([]);
}
})();
}, []);
const isCodex = selection?.providerId === CHATGPT_CODEX_ID;
const apply = async () => {
if (!selection) return;
if (!selection.providerId.trim() || !selection.modelId.trim()) {
setError("provider id and model id are required");
return;
}
if (!isCodex && !selection.apiKeyEnv.trim()) {
setError("API key environment variable is required for this provider");
return;
}
setApplying(true);
setError(null);
try {
const result = await applyProviderLogin([selection], 0);
onResolved(result);
} catch (e) {
setError(String(e));
setApplying(false);
}
};
const pick = (entry: ProviderCatalogEntry) => {
setError(null);
setSelection({
providerId: entry.id,
providerName: entry.name,
baseUrl: entry.baseUrl,
apiKeyEnv: entry.apiKeyEnv,
modelId: "",
supportsTools: true,
supportsReasoning: false,
});
};
const update = (patch: Partial<SetupSelection>) => {
setSelection((prev) => (prev ? { ...prev, ...patch } : prev));
};
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)]">login</CardTitle>
<CardDescription>
{selection ? "configure provider and model" : "choose a provider"}
</CardDescription>
</CardHeader>
<CardContent>
{!selection ? (
<div className="grid max-h-80 grid-cols-1 gap-1 overflow-y-auto">
{catalog.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => pick(entry)}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
{entry.name}
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
{entry.id} · {entry.baseUrl}
</span>
</button>
))}
{catalog.length === 0 && (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
provider catalog unavailable.
</p>
)}
</div>
) : (
<div className="flex flex-col gap-3">
<Field label="provider id">
<input
value={selection.providerId}
onChange={(e) => update({ providerId: e.target.value })}
className={inputClass}
/>
</Field>
<Field label="base url">
<input
value={selection.baseUrl}
onChange={(e) => update({ baseUrl: e.target.value })}
className={inputClass}
/>
</Field>
<Field label="api key env">
<input
value={selection.apiKeyEnv}
onChange={(e) => update({ apiKeyEnv: e.target.value })}
placeholder={isCodex ? "uses local codex auth" : "$MY_API_KEY"}
disabled={isCodex}
className={cn(inputClass, isCodex && "opacity-50")}
/>
</Field>
<Field label="model id">
<input
value={selection.modelId}
onChange={(e) => update({ modelId: e.target.value })}
placeholder="e.g. gpt-5.5"
className={inputClass}
/>
</Field>
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button
variant="outline"
onClick={() => {
if (selection) setSelection(null);
else onClose();
}}
disabled={applying}
>
{selection ? "back" : "cancel"}
</Button>
{selection && (
<Button onClick={apply} disabled={applying}>
apply
</Button>
)}
</CardFooter>
</Card>
</div>
);
}
const inputClass =
"w-full border border-[var(--color-line)] bg-[var(--color-bg)] px-2 py-1.5 font-mono text-xs text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:border-[var(--color-accent)] focus:outline-none";
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)]">
{label}
</span>
{children}
</label>
);
}
@@ -0,0 +1,127 @@
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { removeProviders, type LogoutResult, type ProviderLogoutCandidate } from "@/lib/tauri";
export function LogoutModal({
candidates,
onResolved,
onClose,
}: {
candidates: ProviderLogoutCandidate[];
onResolved: (result: LogoutResult) => void;
onClose: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const apply = async () => {
if (selected.size === 0) return;
setApplying(true);
setError(null);
try {
const result = await removeProviders([...selected]);
onResolved(result);
} catch (e) {
setError(String(e));
setApplying(false);
}
};
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)]">logout</CardTitle>
<CardDescription>
remove saved providers and their models
</CardDescription>
</CardHeader>
<CardContent>
{candidates.length === 0 ? (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
no providers configured.
</p>
) : (
<div className="flex flex-col gap-1">
{candidates.map((c) => {
const checked = selected.has(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => toggle(c.id)}
className={cn(
"flex items-center gap-2 border px-3 py-2 text-left transition-colors",
checked
? "border-[var(--color-accent)]/60 bg-[var(--color-accent-glow)]"
: "border-[var(--color-line)] hover:border-[var(--color-accent)]/40",
)}
>
<span
className={cn(
"flex h-3 w-3 shrink-0 items-center justify-center border text-[10px]",
checked
? "border-[var(--color-accent)] text-[var(--color-accent)]"
: "border-[var(--color-fg-dim)]",
)}
>
{checked ? "✓" : ""}
</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)]">
{c.id}
</span>
{c.name && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.name}
</span>
)}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.modelCount} models
</span>
</button>
);
})}
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button variant="outline" onClick={onClose} disabled={applying}>
cancel
</Button>
<Button
onClick={apply}
disabled={applying || selected.size === 0}
>
remove {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import type { AutoFillMenu } from "@/lib/tauri";
export function SlashMenu({
menu,
selected,
onSelect,
onHover,
}: {
menu: AutoFillMenu;
selected: number;
onSelect: (index: number) => void;
onHover: (index: number) => void;
}) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${selected}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [selected]);
return (
<div className="absolute bottom-full left-0 z-40 mb-2 w-[min(90vw,30rem)] 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 justify-between border-b border-[var(--color-line)] px-3 py-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
{menu.title}
</span>
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)]">
{menu.items.length}
</span>
</div>
<div ref={listRef} className="max-h-60 overflow-y-auto py-1">
{menu.items.map((item, idx) => {
const active = idx === selected;
return (
<button
key={idx}
type="button"
data-index={idx}
onClick={() => onSelect(idx)}
onMouseEnter={() => onHover(idx)}
className={cn(
"flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors",
active && "bg-[var(--color-accent-glow)]",
)}
>
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{item.label}
</span>
{item.detail && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{item.detail}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
@@ -41,6 +41,8 @@ export function Transcript({ blocks }: { blocks: TranscriptBlock[] }) {
return <ToolBlock key={b.id} block={b} />;
case "status":
return <StatusBlock key={b.id} text={b.text} />;
case "error":
return <StatusBlock key={b.id} text={b.text} marker="✗" />;
default:
return null;
}
@@ -2,14 +2,16 @@ import { cn } from "@/lib/utils";
export function StatusBlock({
text,
marker = "·",
className,
}: {
text: string;
marker?: string;
className?: string;
}) {
return (
<div className={cn("py-1 font-mono text-xs text-[var(--color-amber)]", className)}>
· {text}
<div className={cn("whitespace-pre-wrap py-1 font-mono text-xs text-[var(--color-amber)]", className)}>
{marker} {text}
</div>
);
}
+31 -1
View File
@@ -16,7 +16,8 @@ export type BlockKind =
| "assistant"
| "reasoning"
| "tool"
| "status";
| "status"
| "error";
export interface TranscriptBlock {
id: string;
@@ -130,6 +131,8 @@ export interface UseTurn {
resolveApproval: (approved: boolean) => Promise<void>;
reset: () => void;
setStatusHint: (text: string) => void;
pushNotice: (text: string, kind: "status" | "error") => void;
queueAfterLoad: (pending: { status?: string; notice?: { text: string; kind: "status" | "error" } }) => void;
}
export function useTurn(chat: ConversationInfo | null): UseTurn {
@@ -145,6 +148,10 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
const loadSeqRef = useRef(0);
const streamSeqRef = useRef(0);
const chatRef = useRef<ConversationInfo | null>(chat);
const pendingAfterLoadRef = useRef<{
status?: string;
notice?: { text: string; kind: "status" | "error" };
} | null>(null);
chatRef.current = chat;
const appendBlock = useCallback((block: TranscriptBlock) => {
@@ -364,6 +371,17 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
setStatus(text);
}, []);
const pushNotice = useCallback((text: string, kind: "status" | "error") => {
appendBlock({ id: nextBlockId(), kind, text });
}, [appendBlock]);
const queueAfterLoad = useCallback(
(pending: { status?: string; notice?: { text: string; kind: "status" | "error" } }) => {
pendingAfterLoadRef.current = pending;
},
[],
);
useEffect(() => {
const chatId = chat?.id;
const loadSeq = loadSeqRef.current + 1;
@@ -383,6 +401,16 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
const records = await sessionRecords(chatId);
if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) {
setBlocks(blocksFromRecords(records));
const pending = pendingAfterLoadRef.current;
if (pending) {
pendingAfterLoadRef.current = null;
if (pending.notice) {
appendBlock({ id: nextBlockId(), kind: pending.notice.kind, text: pending.notice.text });
}
if (pending.status) {
setStatus(pending.status);
}
}
}
} catch (e) {
if (loadSeqRef.current === loadSeq && chatRef.current?.id === chatId) {
@@ -404,5 +432,7 @@ export function useTurn(chat: ConversationInfo | null): UseTurn {
resolveApproval,
reset,
setStatusHint,
pushNotice,
queueAfterLoad,
};
}
+177
View File
@@ -148,6 +148,12 @@ export async function updateSessionSettings(
return invoke<ConversationInfo>("update_session_settings", { args });
}
export async function reloadSessionConfig(
chatId: string,
): Promise<ConversationInfo> {
return invoke<ConversationInfo>("reload_session_config", { args: { chatId } });
}
export async function getCwd(): Promise<string> {
return invoke<string>("get_cwd");
}
@@ -185,3 +191,174 @@ export async function cancelTurn(
): Promise<ConversationInfo> {
return invoke<ConversationInfo>("cancel_turn", { args: { turnId } });
}
// --- Slash commands ---------------------------------------------------------
export interface CommandSpec {
name: string;
usage: string;
description: string;
takesValue: boolean;
}
export interface ProviderCatalogEntry {
name: string;
id: string;
baseUrl: string;
apiKeyEnv: string;
}
export interface AutoFillItem {
label: string;
insert: string;
detail?: string;
}
export interface AutoFillMenu {
title: string;
replacementStart: number;
replacementEnd: number;
items: AutoFillItem[];
selected: number;
}
export type CheckpointKind = "user" | "assistant" | "tool_call" | "tool_result";
export interface BranchSummary {
id: string;
createdAt: string;
parentChatId?: string | null;
branchLabel?: string | null;
recordCount: number;
current: boolean;
}
export interface Checkpoint {
id: string;
chatId: string;
recordIndex: number;
toolCallId?: string | null;
kind: CheckpointKind;
label: string;
detail: string;
ts?: string | null;
}
export interface BranchFamily {
branches: BranchSummary[];
checkpoints: Checkpoint[];
}
export interface ProviderLogoutCandidate {
id: string;
name?: string | null;
defaultModel?: string | null;
modelCount: number;
}
export interface LogoutResult {
removedProviderIds: string[];
removedModelCount: number;
remainingProviderCount: number;
activeProvider?: string | null;
activeModel?: string | null;
}
export interface RestoreReport {
summary: string;
applied: number;
skipped: number;
conflicts: number;
}
export interface BranchResult {
info: ConversationInfo;
sourceChatId: string;
status: string;
restore?: RestoreReport | null;
}
export interface SetupSelection {
providerId: string;
providerName: string;
baseUrl: string;
apiKeyEnv: string;
modelId: string;
supportsTools: boolean;
supportsReasoning: boolean;
}
export interface ProviderApplyResult {
activeProvider: string;
activeModel: string;
}
export type CommandOutcome =
| { kind: "status"; title: string; content: string }
| { kind: "newChat"; info: ConversationInfo; status: string }
| {
kind: "resumedChat";
info: ConversationInfo;
warning?: string | null;
status: string;
}
| { kind: "openBranchPicker"; family: BranchFamily }
| { kind: "openLoginWizard" }
| { kind: "openLogoutPicker"; candidates: ProviderLogoutCandidate[] }
| { kind: "busy"; message: string }
| { kind: "parseError"; message: string }
| { kind: "error"; title: string; message: string };
export async function listSlashCommands(): Promise<CommandSpec[]> {
return invoke<CommandSpec[]>("list_slash_commands");
}
export async function listProviderCatalog(): Promise<ProviderCatalogEntry[]> {
return invoke<ProviderCatalogEntry[]>("list_provider_catalog");
}
export async function slashAutofill(
input: string,
cwd?: string,
): Promise<AutoFillMenu | null> {
return invoke<AutoFillMenu | null>("slash_autofill", { args: { input, cwd } });
}
export async function runSlashCommand(
chatId: string,
input: string,
): Promise<CommandOutcome> {
return invoke<CommandOutcome>("run_slash_command", { args: { chatId, input } });
}
export async function createBranchFromCheckpoint(
chatId: string,
checkpoint: Checkpoint,
restoreFiles: boolean,
): Promise<BranchResult> {
return invoke<BranchResult>("create_branch_from_checkpoint", {
args: { chatId, checkpoint, restoreFiles },
});
}
export async function applyProviderLogin(
selections: SetupSelection[],
activeIndex: number,
): Promise<ProviderApplyResult> {
return invoke<ProviderApplyResult>("apply_provider_login", {
args: { selections, activeIndex },
});
}
export async function discoverModels(
baseUrl: string,
apiKey: string,
): Promise<string[]> {
return invoke<string[]>("discover_models", { args: { baseUrl, apiKey } });
}
export async function removeProviders(
providerIds: string[],
): Promise<LogoutResult> {
return invoke<LogoutResult>("remove_providers", { args: { providerIds } });
}