Skip to main content

Backend State

use std::sync::Arc;
use tokio::sync::Mutex;
use picrust::llm::{AnthropicProvider, LlmProvider};

pub struct AppState {
    pub runtime: AgentRuntime,
    pub llm: Arc<dyn LlmProvider>,
    pub tools: Arc<ToolRegistry>,
}

impl AppState {
    pub fn new() -> anyhow::Result<Self> {
        let llm: Arc<dyn LlmProvider> = Arc::new(AnthropicProvider::from_env()?);

        let mut tools = ToolRegistry::new();
        tools.register(ReadTool::new()?);
        tools.register(WriteTool::new()?);
        tools.register(BashTool::new()?);

        let runtime = AgentRuntime::with_global_rules(vec![
            PermissionRule::allow_tool("Read"),
        ]);

        Ok(Self { runtime, llm, tools: Arc::new(tools) })
    }
}

Commands

Create Agent

#[tauri::command]
async fn create_agent(
    state: tauri::State<'_, Arc<Mutex<AppState>>>,
    session_id: String,
    name: String,
    system_prompt: String,
) -> Result<(), String> {
    let state = state.lock().await;

    let session = AgentSession::new(&session_id, "assistant", &name, "")
        .map_err(|e| e.to_string())?;

    let config = AgentConfig::new(&system_prompt)
        .with_tools(state.tools.clone())
        .with_streaming(true);

    let agent = StandardAgent::new(config, state.llm.clone());

    state.runtime.spawn(session, |i| agent.run(i)).await;

    Ok(())
}

Send Message

#[tauri::command]
async fn send_message(
    state: tauri::State<'_, Arc<Mutex<AppState>>>,
    window: tauri::Window,
    session_id: String,
    message: String,
) -> Result<(), String> {
    let state = state.lock().await;

    let handle = state.runtime.get(&session_id).await
        .ok_or("Agent not found")?;

    // Subscribe BEFORE sending
    let mut rx = handle.subscribe();

    handle.send_input(&message).await.map_err(|e| e.to_string())?;

    // Forward output to frontend
    tokio::spawn(async move {
        while let Ok(chunk) = rx.recv().await {
            let event = match &chunk {
                OutputChunk::TextDelta(_) => "text-delta",
                OutputChunk::ToolStart { .. } => "tool-start",
                OutputChunk::ToolEnd { .. } => "tool-end",
                OutputChunk::PermissionRequest { .. } => "permission-request",
                OutputChunk::Done => "done",
                _ => continue,
            };

            let _ = window.emit(event, &chunk);

            if matches!(chunk, OutputChunk::Done) {
                break;
            }
        }
    });

    Ok(())
}

Send Permission

#[tauri::command]
async fn send_permission(
    state: tauri::State<'_, Arc<Mutex<AppState>>>,
    session_id: String,
    tool_name: String,
    allowed: bool,
    remember: bool,
) -> Result<(), String> {
    let state = state.lock().await;

    let handle = state.runtime.get(&session_id).await
        .ok_or("Agent not found")?;

    handle.send_permission_response(tool_name, allowed, remember)
        .await
        .map_err(|e| e.to_string())
}

List Sessions

#[tauri::command]
async fn list_sessions(top_level_only: bool) -> Result<Vec<SessionInfo>, String> {
    let sessions = AgentSession::list_with_metadata(top_level_only)
        .map_err(|e| e.to_string())?;

    Ok(sessions.into_iter().map(|(id, meta)| SessionInfo {
        session_id: id,
        name: meta.name,
        created_at: meta.created_at.to_rfc3339(),
    }).collect())
}

Get History

#[tauri::command]
async fn get_history(session_id: String) -> Result<Vec<Message>, String> {
    AgentSession::get_history(&session_id).map_err(|e| e.to_string())
}

Frontend

Create Agent

import { invoke } from '@tauri-apps/api/tauri';

await invoke('create_agent', {
  sessionId: 'chat-1',
  name: 'My Assistant',
  systemPrompt: 'You are helpful.',
});

Listen for Output

import { listen } from '@tauri-apps/api/event';

await listen('text-delta', (event) => {
  appendText(event.payload);
});

await listen('permission-request', (event) => {
  const { tool_name, action } = event.payload;
  showDialog(tool_name, action, async (allowed, remember) => {
    await invoke('send_permission', {
      sessionId: 'chat-1',
      toolName: tool_name,
      allowed,
      remember,
    });
  });
});

await listen('done', () => {
  setLoading(false);
});

Send Message

await invoke('send_message', {
  sessionId: 'chat-1',
  message: 'Hello!',
});

Load Sessions

const sessions = await invoke('list_sessions', { topLevelOnly: true });

Load History

const history = await invoke('get_history', { sessionId: 'chat-1' });

Subscribe Before Send

Critical pattern:
// ✅ Correct
let mut rx = handle.subscribe();
handle.send_input("message").await?;

// ❌ Wrong - will miss initial chunks
handle.send_input("message").await?;
let mut rx = handle.subscribe();

Event Names

Common events to listen for:
  • text-delta - Streaming text chunks
  • thinking-delta - Extended thinking (if enabled)
  • tool-start - Tool execution starting
  • tool-end - Tool execution complete
  • permission-request - Permission needed
  • state-change - Agent state changed
  • done - Turn complete
  • error - Error occurred