Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion crates/switchyard-gui/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2377,6 +2377,7 @@ function App() {
setWorkspaces(list);
const current = await invoke<Workspace | null>('get_current_workspace');
setCurrentWorkspace(current);
if (current?.core_provider) setNewSessionProvider(current.core_provider);
return current;
} catch (e) {
console.error('Failed to load workspaces:', e);
Expand All @@ -2390,6 +2391,8 @@ function App() {
try {
const next = await invoke<Workspace>('set_current_workspace', { workspaceId });
setCurrentWorkspace(next);
// Restore the core provider this workspace last used (or default).
setNewSessionProvider(next.core_provider ?? 'codex');
// Wipe per-workspace UI state — sessions, turns, events all belong
// to the previous workspace and would be confusing if left visible.
resetWorkspaceScopedUi();
Expand All @@ -2402,6 +2405,20 @@ function App() {
}
};

// Selecting a core provider persists it on the active workspace so reopening
// the workspace restores the same core.
const handleSelectCoreProvider = (provider: string) => {
setNewSessionProvider(provider);
const ws = currentWorkspace;
if (ws && ws.core_provider !== provider) {
setCurrentWorkspace({ ...ws, core_provider: provider });
void invoke('set_workspace_core', {
workspaceId: ws.workspace_id,
provider,
}).catch((e) => console.error('failed to persist workspace core', e));
}
};

const handleCreateWorkspace = async (primaryRoot: string, name: string | null) => {
try {
const created = await invoke<Workspace>('create_workspace', { primaryRoot, name });
Expand Down Expand Up @@ -5731,7 +5748,7 @@ function App() {
selectedSession={selectedSession}
config={config}
newSessionProvider={newSessionProvider}
setNewSessionProvider={setNewSessionProvider}
setNewSessionProvider={handleSelectCoreProvider}
onCreateSession={createNewSession}
onSelectSession={selectSession}
onDeleteSession={handleDeleteSession}
Expand Down
82 changes: 76 additions & 6 deletions crates/switchyard-gui/frontend/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
defaultProviderConfigFor,
inferProviderBackend,
} from '../providerCliCapabilities';
import { listKohakuModels } from '../services/api';
import {
listKohakuModels,
listKohakuCreatures,
kohakuInstallBiome,
type KohakuCreature,
} from '../services/api';

interface SettingsModalProps {
config: SwitchyardConfig;
Expand Down Expand Up @@ -48,17 +53,37 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
// KohakuTerrarium model selectors are listed dynamically from `kt model
// list` (the user's configured llm profiles), like KT's own app.
const [kohakuModels, setKohakuModels] = React.useState<string[]>([]);
const [kohakuCreatures, setKohakuCreatures] = React.useState<KohakuCreature[]>([]);
const [installingBiome, setInstallingBiome] = React.useState(false);
const [biomeMsg, setBiomeMsg] = React.useState<string | null>(null);
React.useEffect(() => {
const prov = config.providers[settingsTab] || defaultProviderConfigFor(settingsTab);
if (inferProviderBackend(settingsTab, prov.backend) === 'kohaku') {
listKohakuModels(prov.command || 'kt')
.then(setKohakuModels)
.catch(() => setKohakuModels([]));
const cmd = prov.command || 'kt';
listKohakuModels(cmd).then(setKohakuModels).catch(() => setKohakuModels([]));
listKohakuCreatures(cmd).then(setKohakuCreatures).catch(() => setKohakuCreatures([]));
} else {
setKohakuModels([]);
setKohakuCreatures([]);
}
setBiomeMsg(null);
}, [settingsTab, config]);

const handleInstallBiome = async (command: string) => {
setInstallingBiome(true);
setBiomeMsg(null);
try {
await kohakuInstallBiome(command);
const list = await listKohakuCreatures(command);
setKohakuCreatures(list);
setBiomeMsg(`Installed. ${list.length} creatures/terrariums available.`);
} catch (e) {
setBiomeMsg(`Install failed: ${String(e)}`);
} finally {
setInstallingBiome(false);
}
};

return (
<div className="settings-overlay">
<div className="settings-modal glass-panel">
Expand Down Expand Up @@ -186,10 +211,55 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
/>
</div>

{cliMapping.backend === 'kohaku' && (
<div className="settings-form-group">
<label
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<span>Creature (KohakuTerrarium agent)</span>
<button
className="btn-add-row"
style={{ padding: '2px 8px' }}
disabled={installingBiome}
onClick={() => handleInstallBiome(prov.command || 'kt')}
>
{installingBiome ? 'Installing…' : 'Install kt-biome'}
</button>
</label>
<input
type="text"
list={`creatures-${pName}`}
className="settings-input settings-input-mono"
placeholder="@kt-biome/creatures/general"
value={prov.args[0] ?? ''}
onChange={(e) => {
const ref = e.target.value.trim();
const rest = prov.args.slice(1);
onProviderFieldChange(pName, 'args', ref ? [ref, ...rest] : rest);
}}
/>
{kohakuCreatures.length > 0 && (
<datalist id={`creatures-${pName}`}>
{kohakuCreatures.map((c) => (
<option key={c.reference} value={c.reference}>
{`${c.kind}: ${c.package}/${c.name}`}
</option>
))}
</datalist>
)}
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginTop: 4 }}>
{biomeMsg ??
(kohakuCreatures.length === 0
? 'No creatures found — install the kt-biome pack, or type a config-folder path.'
: 'The kt agent run each turn. Becomes the first CLI argument.')}
</div>
</div>
)}

<div className="settings-form-group">
<label>CLI Execution Arguments (comma separated)</label>
<input
type="text"
<input
type="text"
className="settings-input settings-input-mono"
value={prov.args.join(', ')}
onChange={(e) => {
Expand Down
23 changes: 23 additions & 0 deletions crates/switchyard-gui/frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ export const listKohakuModels = (command?: string): Promise<string[]> => {
return invoke<string[]>('list_kohaku_models', { command: command ?? null });
};

// Record the core provider last used in a workspace (restored on reopen).
export const setWorkspaceCore = (workspaceId: string, provider: string): Promise<void> => {
return invoke<void>('set_workspace_core', { workspaceId, provider });
};

// A KohakuTerrarium ecosystem agent (creature/terrarium) from installed packages.
export interface KohakuCreature {
reference: string;
package: string;
name: string;
kind: string;
}

// List creatures/terrariums available from kt's installed packages (kt-biome…).
export const listKohakuCreatures = (command?: string): Promise<KohakuCreature[]> => {
return invoke<KohakuCreature[]>('list_kohaku_creatures', { command: command ?? null });
};

// One-click install of the official kt-biome creature pack.
export const kohakuInstallBiome = (command?: string): Promise<string> => {
return invoke<string>('kohaku_install_biome', { command: command ?? null });
};

export const runTurn = (
sessionId: string,
message: string,
Expand Down
2 changes: 2 additions & 0 deletions crates/switchyard-gui/frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export interface Workspace {
name: string;
primary_root: string;
extra_roots: string[];
/// Core provider last used in this workspace (restored on reopen).
core_provider: string | null;
created_at: string;
updated_at: string;
}
Expand Down
117 changes: 117 additions & 0 deletions crates/switchyard-gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,26 @@ fn update_workspace(
Ok(updated)
}

/// Record the core provider last used in a workspace so reopening it restores
/// the same default. Persisted in workspaces.json; does not bump `updated_at`
/// (a provider switch shouldn't reorder the recents list).
#[tauri::command]
fn set_workspace_core(
workspace_state: tauri::State<'_, WorkspaceState>,
workspace_id: String,
provider: String,
) -> Result<(), String> {
let id =
uuid::Uuid::parse_str(&workspace_id).map_err(|e| format!("invalid workspace ID: {}", e))?;
workspace_state.mutate(|idx| {
let ws = idx
.get_mut(id)
.ok_or_else(|| format!("workspace {id} not found"))?;
ws.core_provider = Some(provider.clone()).filter(|p| !p.trim().is_empty());
Ok(())
})
}

#[tauri::command]
fn delete_workspace(
app: tauri::AppHandle,
Expand Down Expand Up @@ -3273,6 +3293,100 @@ async fn list_kohaku_models(command: Option<String>) -> Result<Vec<String>, Stri
Ok(models)
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct KohakuCreature {
/// `@<package>/creatures/<name>` ref to pass as the kt agent_path.
reference: String,
package: String,
name: String,
/// "creature" or "terrarium".
kind: String,
}

/// List the creatures/terrariums available from KohakuTerrarium's installed
/// packages (kt-biome / kohaku-creatures, the ecosystem YAML agents) by
/// parsing `kt list`, so the GUI can offer them as the kt backend's agent
/// instead of hand-typing a `@pkg/creatures/<name>` ref.
#[tauri::command]
async fn list_kohaku_creatures(command: Option<String>) -> Result<Vec<KohakuCreature>, String> {
let cmd = command
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.unwrap_or_else(|| "kt".to_string());
let mut process = tokio::process::Command::new(&cmd);
process.arg("list");
rg_no_window(&mut process);
let Ok(output) = process.output().await else {
return Ok(Vec::new());
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut out = Vec::new();
let mut pkg = String::new();
for line in stdout.lines() {
if line.starts_with(" ") {
// Detail line (4-space indent): Creatures:/Terrariums: lists.
let trimmed = line.trim();
let (rest, kind, segment) = if let Some(r) = trimmed.strip_prefix("Creatures:") {
(Some(r), "creature", "creatures")
} else if let Some(r) = trimmed.strip_prefix("Terrariums:") {
(Some(r), "terrarium", "terrariums")
} else {
(None, "", "")
};
if let Some(rest) = rest {
if pkg.is_empty() {
continue;
}
for name in rest.split(',').map(str::trim).filter(|s| !s.is_empty()) {
out.push(KohakuCreature {
reference: format!("@{pkg}/{segment}/{name}"),
package: pkg.clone(),
name: name.to_string(),
kind: kind.to_string(),
});
}
}
} else if line.starts_with(" ") {
// Package header (2-space indent): "<pkg> vX.Y.Z".
if let Some((name, ver)) = line.trim().split_once(' ') {
if ver.starts_with('v') {
pkg = name.to_string();
}
}
}
}
Ok(out)
}

/// One-click install of the official kt-biome creature pack (`kt install
/// @kt-biome`). Returns the CLI output; errors carry stderr for display.
#[tauri::command]
async fn kohaku_install_biome(command: Option<String>) -> Result<String, String> {
let cmd = command
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.unwrap_or_else(|| "kt".to_string());
let mut process = tokio::process::Command::new(&cmd);
process.arg("install").arg("@kt-biome");
rg_no_window(&mut process);
let output = process
.output()
.await
.map_err(|e| format!("run `{cmd} install @kt-biome`: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err(format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.trim()
.to_string())
}
}

#[tauri::command]
async fn list_sessions(
workspace_state: tauri::State<'_, WorkspaceState>,
Expand Down Expand Up @@ -5049,6 +5163,8 @@ fn main() {
load_config,
save_config,
list_kohaku_models,
list_kohaku_creatures,
kohaku_install_biome,
list_provider_status_quick,
list_provider_status,
list_sessions,
Expand Down Expand Up @@ -5096,6 +5212,7 @@ fn main() {
clear_current_workspace,
create_workspace,
update_workspace,
set_workspace_core,
delete_workspace,
// Filesystem (workspace-scoped)
read_file,
Expand Down
5 changes: 5 additions & 0 deletions crates/switchyard-session/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ pub struct Workspace {
/// not a CLI argument.
#[serde(default)]
pub extra_roots: Vec<PathBuf>,
/// The core provider (session backend) last used in this workspace, so
/// reopening it restores the same default. `None` until first set.
#[serde(default)]
pub core_provider: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
Expand All @@ -54,6 +58,7 @@ impl Workspace {
name,
primary_root,
extra_roots: Vec::new(),
core_provider: None,
created_at: now,
updated_at: now,
}
Expand Down
Loading