Preparing for v0.4.2
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cassady-desktop-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady-desktop"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
description = "Cassady desktop app"
|
||||
license = "MIT"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user