Add tool-backed terminal chat and markdown replies

This commit is contained in:
2026-04-06 22:16:52 -04:00
parent a7cb435206
commit faa5b2198a
16 changed files with 2433 additions and 64 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,9 @@
"lucide-react": "^1.7.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.2.2"
},
"devDependencies": {

View File

@@ -1,21 +1,39 @@
use std::pin::Pin;
use std::sync::Arc;
use futures::{future::BoxFuture, Stream, StreamExt};
use rig::{
agent::MultiTurnStreamItem,
client::completion::CompletionClient,
completion::{CompletionModel, Message},
completion::Message,
message::ToolChoice,
providers::openai,
streaming::StreamedAssistantContent,
streaming::{StreamedAssistantContent, StreamingPrompt},
};
use tauri::{AppHandle, Wry};
use tokio::sync::mpsc;
use crate::agent::tools::terminal_command::{AgentCommandExecutor, RunTerminalCommandTool};
use crate::agent::AgentRuntimeConfig;
use crate::error::AppError;
use crate::state::PendingAgentToolApprovals;
const SYSTEM_PROMPT: &str = "You are MosaicIQ's terminal chat assistant. Answer concisely in plain text. Do not claim to run tools, commands, or file operations. If the request is unclear, ask a short clarifying question.";
const SYSTEM_PROMPT: &str = "You are MosaicIQ's terminal chat assistant. Answer concisely in plain text. Use the available terminal command tool whenever current workspace data or live MosaicIQ terminal actions would improve the answer. Never claim to have run a command unless the tool actually ran it. If the request is unclear, ask a short clarifying question.";
const MAX_TOOL_TURNS: usize = 4;
/// Streaming text output from the upstream chat provider.
pub type ChatGatewayStream = Pin<Box<dyn Stream<Item = Result<String, AppError>> + Send>>;
#[derive(Clone)]
pub struct AgentToolRuntimeContext {
pub app_handle: AppHandle<Wry>,
pub command_executor: Arc<dyn AgentCommandExecutor>,
pub pending_approvals: Arc<PendingAgentToolApprovals>,
pub workspace_id: String,
pub request_id: String,
pub session_id: String,
}
/// Trait used by the agent service so tests can inject a deterministic gateway.
pub trait ChatGateway: Clone + Send + Sync + 'static {
/// Start a streaming chat turn for the given config, prompt, and prior history.
@@ -25,6 +43,7 @@ pub trait ChatGateway: Clone + Send + Sync + 'static {
prompt: String,
context_messages: Vec<Message>,
history: Vec<Message>,
tool_runtime: AgentToolRuntimeContext,
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>>;
}
@@ -39,9 +58,9 @@ impl ChatGateway for RigChatGateway {
prompt: String,
context_messages: Vec<Message>,
history: Vec<Message>,
tool_runtime: AgentToolRuntimeContext,
) -> BoxFuture<'static, Result<ChatGatewayStream, AppError>> {
Box::pin(async move {
let _task_profile = runtime.task_profile;
let api_key = runtime.api_key.unwrap_or_default();
let client = openai::CompletionsClient::builder()
.api_key(api_key)
@@ -49,25 +68,62 @@ impl ChatGateway for RigChatGateway {
.build()
.map_err(|error| AppError::ProviderInit(error.to_string()))?;
let model = client.completion_model(runtime.model);
let history = compose_request_messages(context_messages, history);
let tool = RunTerminalCommandTool {
app_handle: tool_runtime.app_handle,
command_executor: tool_runtime.command_executor,
pending_approvals: tool_runtime.pending_approvals,
workspace_id: tool_runtime.workspace_id,
request_id: tool_runtime.request_id,
session_id: tool_runtime.session_id,
};
let upstream = model
.completion_request(Message::user(prompt))
.messages(compose_request_messages(context_messages, history))
.preamble(SYSTEM_PROMPT.to_string())
let mut rig_stream = client
.agent(runtime.model)
.preamble(SYSTEM_PROMPT)
.temperature(0.2)
.stream()
.await
.map_err(|error| AppError::ProviderRequest(error.to_string()))?;
.tool(tool)
.tool_choice(ToolChoice::Auto)
.default_max_turns(MAX_TOOL_TURNS)
.build()
.stream_prompt(prompt)
.with_history(history)
.multi_turn(MAX_TOOL_TURNS)
.await;
let stream = upstream.filter_map(|item| async move {
match item {
Ok(StreamedAssistantContent::Text(text)) => Some(Ok(text.text)),
Ok(_) => None,
Err(error) => Some(Err(AppError::ProviderRequest(error.to_string()))),
let (sender, receiver) = mpsc::unbounded_channel::<Result<String, AppError>>();
tauri::async_runtime::spawn(async move {
let mut saw_text = false;
while let Some(item) = rig_stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::Text(text),
)) => {
saw_text = true;
if sender.send(Ok(text.text)).is_err() {
return;
}
}
Ok(MultiTurnStreamItem::FinalResponse(final_response)) => {
if !saw_text && !final_response.response().is_empty() {
let _ = sender.send(Ok(final_response.response().to_string()));
}
return;
}
Ok(_) => {}
Err(error) => {
let _ = sender.send(Err(map_streaming_error(error)));
return;
}
}
}
});
let stream = futures::stream::unfold(receiver, |mut receiver| async move {
receiver.recv().await.map(|item| (item, receiver))
});
let stream: ChatGatewayStream = Box::pin(stream);
Ok(stream)
})
@@ -81,6 +137,10 @@ fn compose_request_messages(
context_messages.into_iter().chain(history).collect()
}
fn map_streaming_error(error: rig::agent::StreamingError) -> AppError {
AppError::ProviderRequest(error.to_string())
}
#[cfg(test)]
mod tests {
use rig::completion::Message;

View File

@@ -5,15 +5,18 @@ mod panel_context;
mod routing;
mod service;
mod settings;
mod tools;
mod types;
pub use gateway::{ChatGateway, RigChatGateway};
pub use gateway::{AgentToolRuntimeContext, ChatGateway, RigChatGateway};
pub use service::AgentService;
pub(crate) use settings::AgentSettingsService;
pub use types::{
default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent,
AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPanelContext,
ChatPromptRequest, ChatStreamStart, PreparedChatTurn, RemoteProviderSettings,
SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest,
AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL,
AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, AgentToolApprovalRequiredEvent,
AgentToolCommandEvent, AgentToolResultEvent, ChatPanelContext, ChatPromptRequest,
ChatStreamStart, PreparedChatTurn, RemoteProviderSettings,
ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, TaskProfile,
UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL,
DEFAULT_REMOTE_MODEL,
};

View File

@@ -50,7 +50,7 @@ pub(crate) fn compact_panel_payload(panel: &PanelPayload) -> Value {
}
}
fn panel_type(panel: &PanelPayload) -> &'static str {
pub(crate) fn panel_type(panel: &PanelPayload) -> &'static str {
match panel {
PanelPayload::Company { .. } => "company",
PanelPayload::Error { .. } => "error",

View File

@@ -0,0 +1 @@
pub(crate) mod terminal_command;

View File

@@ -0,0 +1,451 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::Deserialize;
use serde_json::json;
use tauri::{AppHandle, Emitter, Runtime};
use tokio::time::timeout;
use crate::agent::panel_context::{compact_panel_payload, panel_type};
use crate::agent::{
AgentToolApprovalRequiredEvent, AgentToolCommandEvent, AgentToolResultEvent,
};
use crate::error::AppError;
use crate::state::PendingAgentToolApprovals;
use crate::terminal::{
ExecuteTerminalCommandRequest, TerminalCommandResponse, TerminalCommandService,
};
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(60);
pub trait AgentCommandExecutor: Send + Sync {
fn execute<'a>(
&'a self,
request: ExecuteTerminalCommandRequest,
) -> Pin<Box<dyn Future<Output = TerminalCommandResponse> + Send + 'a>>;
}
impl AgentCommandExecutor for TerminalCommandService {
fn execute<'a>(
&'a self,
request: ExecuteTerminalCommandRequest,
) -> Pin<Box<dyn Future<Output = TerminalCommandResponse> + Send + 'a>> {
Box::pin(async move { self.execute(request).await })
}
}
#[derive(Clone)]
pub struct RunTerminalCommandTool<R: Runtime> {
pub app_handle: AppHandle<R>,
pub command_executor: Arc<dyn AgentCommandExecutor>,
pub pending_approvals: Arc<PendingAgentToolApprovals>,
pub workspace_id: String,
pub request_id: String,
pub session_id: String,
}
#[derive(Debug, Deserialize)]
pub struct RunTerminalCommandArgs {
pub command: String,
}
#[derive(Debug, thiserror::Error)]
pub enum RunTerminalCommandToolError {
#[error("{0}")]
App(#[from] AppError),
#[error("failed to emit terminal event: {0}")]
Emit(String),
#[error("failed to serialize tool result: {0}")]
Serialize(String),
}
impl<R: Runtime> Tool for RunTerminalCommandTool<R> {
const NAME: &'static str = "run_terminal_command";
type Error = RunTerminalCommandToolError;
type Args = RunTerminalCommandArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: Self::NAME.to_string(),
description: "Run a MosaicIQ terminal slash command. Supported read-only commands: /search, /news, /analyze, /fa, /cf, /dvd, /em, /portfolio. Supported write commands requiring user approval: /buy, /sell, /cash. /clear and /help are forbidden.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "A full MosaicIQ slash command such as /search AAPL or /fa AAPL annual. Must start with /."
}
},
"required": ["command"],
"additionalProperties": false
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let command = normalize_command(&args.command);
let Some(command_name) = command_name(&command) else {
return Ok(serialize_status_only_tool_result(
&command,
"rejected",
"Commands must start with '/'.",
)?);
};
if !is_allowed_agent_command(command_name) {
return Ok(serialize_status_only_tool_result(
&command,
"rejected",
"This command is not available to the agent.",
)?);
}
self.emit_command(&command)?;
if is_write_command(command_name) {
let approved = self.await_approval(&command).await?;
if !approved {
return Ok(serialize_tool_result(&json!({
"command": command,
"status": "denied",
"summary": "The user denied approval for this portfolio-changing command."
}))?);
}
}
let response = self
.command_executor
.execute(ExecuteTerminalCommandRequest {
workspace_id: self.workspace_id.clone(),
input: command.clone(),
})
.await;
self.emit_result(response.clone())?;
serialize_response_tool_result(command, response)
}
}
impl<R: Runtime> RunTerminalCommandTool<R> {
fn emit_command(&self, command: &str) -> Result<(), RunTerminalCommandToolError> {
self.app_handle
.emit(
"agent_tool_command",
AgentToolCommandEvent {
workspace_id: self.workspace_id.clone(),
request_id: self.request_id.clone(),
session_id: self.session_id.clone(),
command: command.to_string(),
},
)
.map_err(|error| RunTerminalCommandToolError::Emit(error.to_string()))
}
fn emit_result(
&self,
response: TerminalCommandResponse,
) -> Result<(), RunTerminalCommandToolError> {
self.app_handle
.emit(
"agent_tool_result",
AgentToolResultEvent {
workspace_id: self.workspace_id.clone(),
request_id: self.request_id.clone(),
session_id: self.session_id.clone(),
response,
},
)
.map_err(|error| RunTerminalCommandToolError::Emit(error.to_string()))
}
async fn await_approval(&self, command: &str) -> Result<bool, RunTerminalCommandToolError> {
let (approval_id, receiver) = self.pending_approvals.register()?;
self.app_handle
.emit(
"agent_tool_approval_required",
AgentToolApprovalRequiredEvent {
workspace_id: self.workspace_id.clone(),
request_id: self.request_id.clone(),
session_id: self.session_id.clone(),
approval_id: approval_id.clone(),
command: command.to_string(),
title: "Approve portfolio command".to_string(),
message: format!(
"The agent wants to run a portfolio-changing command:\n\n{}\n\nApprove this action to continue.",
command
),
},
)
.map_err(|error| RunTerminalCommandToolError::Emit(error.to_string()))?;
let result = timeout(APPROVAL_TIMEOUT, receiver).await;
match result {
Ok(Ok(approved)) => Ok(approved),
Ok(Err(_)) => Ok(false),
Err(_) => {
self.pending_approvals.cancel(&approval_id);
Ok(false)
}
}
}
}
pub(crate) fn normalize_command(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn command_name(command: &str) -> Option<&str> {
let command = command.trim();
if !command.starts_with('/') {
return None;
}
command.split_whitespace().next()
}
pub(crate) fn is_write_command(command_name: &str) -> bool {
matches!(
command_name.to_ascii_lowercase().as_str(),
"/buy" | "/sell" | "/cash"
)
}
pub(crate) fn is_allowed_agent_command(command_name: &str) -> bool {
matches!(
command_name.to_ascii_lowercase().as_str(),
"/search"
| "/news"
| "/analyze"
| "/fa"
| "/cf"
| "/dvd"
| "/em"
| "/portfolio"
| "/buy"
| "/sell"
| "/cash"
)
}
fn serialize_response_tool_result(
command: String,
response: TerminalCommandResponse,
) -> Result<String, RunTerminalCommandToolError> {
match response {
TerminalCommandResponse::Text { content, .. } => serialize_tool_result(&json!({
"command": command,
"status": "executed",
"responseKind": "text",
"summary": content,
})),
TerminalCommandResponse::Panel { panel } => serialize_tool_result(&json!({
"command": command,
"status": "executed",
"responseKind": "panel",
"panelType": panel_type(&panel),
"summary": compact_panel_payload(&panel),
})),
}
}
fn serialize_tool_result(value: &serde_json::Value) -> Result<String, RunTerminalCommandToolError> {
serde_json::to_string(value)
.map_err(|error| RunTerminalCommandToolError::Serialize(error.to_string()))
}
fn serialize_status_only_tool_result(
command: &str,
status: &str,
summary: &str,
) -> Result<String, RunTerminalCommandToolError> {
serialize_tool_result(&json!({
"command": command,
"status": status,
"summary": summary,
}))
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use rig::tool::Tool;
use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime};
use super::{
command_name, is_allowed_agent_command, is_write_command, normalize_command,
AgentCommandExecutor, RunTerminalCommandArgs, RunTerminalCommandTool,
};
use crate::state::PendingAgentToolApprovals;
use crate::terminal::{
Company, ExecuteTerminalCommandRequest, PanelPayload, TerminalCommandResponse,
};
struct FakeExecutor {
response: TerminalCommandResponse,
calls: AtomicUsize,
}
impl FakeExecutor {
fn new(response: TerminalCommandResponse) -> Self {
Self {
response,
calls: AtomicUsize::new(0),
}
}
}
impl AgentCommandExecutor for FakeExecutor {
fn execute<'a>(
&'a self,
_request: ExecuteTerminalCommandRequest,
) -> Pin<Box<dyn Future<Output = TerminalCommandResponse> + Send + 'a>> {
self.calls.fetch_add(1, Ordering::Relaxed);
let response = self.response.clone();
Box::pin(async move { response })
}
}
#[test]
fn classifies_commands_correctly() {
assert!(is_allowed_agent_command("/search"));
assert!(is_write_command("/buy"));
assert!(!is_allowed_agent_command("/clear"));
assert_eq!(command_name("/search AAPL"), Some("/search"));
assert_eq!(normalize_command(" /search AAPL "), "/search AAPL");
}
#[tokio::test]
async fn read_only_command_executes_and_returns_panel_summary() {
let app = build_test_app();
let executor = Arc::new(FakeExecutor::new(TerminalCommandResponse::Panel {
panel: PanelPayload::Company {
data: Company {
symbol: "AAPL".to_string(),
name: "Apple Inc.".to_string(),
price: 200.0,
change: 1.0,
change_percent: 0.5,
market_cap: 1.0,
volume: None,
volume_label: None,
pe: None,
eps: None,
high52_week: None,
low52_week: None,
profile: None,
price_chart: None,
price_chart_ranges: None,
},
},
}));
let tool = RunTerminalCommandTool {
app_handle: app.handle().clone(),
command_executor: executor.clone(),
pending_approvals: Arc::new(PendingAgentToolApprovals::new()),
workspace_id: "workspace-1".to_string(),
request_id: "request-1".to_string(),
session_id: "session-1".to_string(),
};
let result = tool
.call(RunTerminalCommandArgs {
command: "/search AAPL".to_string(),
})
.await
.expect("tool result");
assert!(result.contains("\"status\":\"executed\""));
assert!(result.contains("\"panelType\":\"company\""));
assert_eq!(executor.calls.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn denied_write_command_does_not_execute() {
let app = build_test_app();
let approvals = Arc::new(PendingAgentToolApprovals::new());
let executor = Arc::new(FakeExecutor::new(TerminalCommandResponse::Text {
content: "ok".to_string(),
portfolio: None,
}));
let tool = RunTerminalCommandTool {
app_handle: app.handle().clone(),
command_executor: executor.clone(),
pending_approvals: approvals.clone(),
workspace_id: "workspace-1".to_string(),
request_id: "request-1".to_string(),
session_id: "session-1".to_string(),
};
let approvals_for_task = approvals.clone();
let handle = tokio::spawn(async move {
tool.call(RunTerminalCommandArgs {
command: "/buy AAPL 1".to_string(),
})
.await
.expect("tool result")
});
tokio::task::yield_now().await;
approvals_for_task.resolve("approval-1", false).unwrap();
let result = handle.await.unwrap();
assert!(result.contains("\"status\":\"denied\""));
assert_eq!(executor.calls.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn approved_write_command_executes_once() {
let app = build_test_app();
let approvals = Arc::new(PendingAgentToolApprovals::new());
let executor = Arc::new(FakeExecutor::new(TerminalCommandResponse::Text {
content: "Bought 1 share".to_string(),
portfolio: None,
}));
let tool = RunTerminalCommandTool {
app_handle: app.handle().clone(),
command_executor: executor.clone(),
pending_approvals: approvals.clone(),
workspace_id: "workspace-1".to_string(),
request_id: "request-1".to_string(),
session_id: "session-1".to_string(),
};
let approvals_for_task = approvals.clone();
let handle = tokio::spawn(async move {
tool.call(RunTerminalCommandArgs {
command: "/buy AAPL 1".to_string(),
})
.await
.expect("tool result")
});
tokio::task::yield_now().await;
approvals_for_task.resolve("approval-1", true).unwrap();
let result = handle.await.unwrap();
assert!(result.contains("\"status\":\"executed\""));
assert_eq!(executor.calls.load(Ordering::Relaxed), 1);
}
fn build_test_app() -> tauri::App<MockRuntime> {
mock_builder()
.plugin(tauri_plugin_store::Builder::new().build())
.build(mock_context(noop_assets()))
.unwrap()
}
}

View File

@@ -2,7 +2,7 @@ use rig::completion::Message;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::terminal::PanelPayload;
use crate::terminal::{PanelPayload, TerminalCommandResponse};
/// Default Z.AI coding plan endpoint used by the app.
pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
@@ -89,6 +89,39 @@ pub struct AgentResultEvent {
pub reply: String,
}
/// Event emitted when the agent decides to run a terminal command tool.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentToolCommandEvent {
pub workspace_id: String,
pub request_id: String,
pub session_id: String,
pub command: String,
}
/// Event emitted after an agent-triggered command completes.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentToolResultEvent {
pub workspace_id: String,
pub request_id: String,
pub session_id: String,
pub response: TerminalCommandResponse,
}
/// Event emitted when the agent requests approval for a mutating command.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentToolApprovalRequiredEvent {
pub workspace_id: String,
pub request_id: String,
pub session_id: String,
pub approval_id: String,
pub command: String,
pub title: String,
pub message: String,
}
/// Error event emitted when the backend cannot complete a stream.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -99,6 +132,14 @@ pub struct AgentErrorEvent {
pub message: String,
}
/// Frontend request payload for approving or denying an agent-triggered write command.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveAgentToolApprovalRequest {
pub approval_id: String,
pub approved: bool,
}
/// Persisted settings for the remote chat provider.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]

View File

@@ -4,8 +4,8 @@ use futures::StreamExt;
use tauri::{Emitter, Manager};
use crate::agent::{
AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, ChatGateway, ChatPromptRequest,
ChatStreamStart,
AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, AgentToolRuntimeContext, ChatGateway,
ChatPromptRequest, ChatStreamStart, ResolveAgentToolApprovalRequest,
};
use crate::state::AppState;
use crate::terminal::{
@@ -57,6 +57,8 @@ pub async fn start_chat_stream(
};
let app_handle = app.clone();
let command_executor = state.command_service.clone();
let pending_approvals = state.pending_agent_tool_approvals.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
@@ -68,6 +70,14 @@ pub async fn start_chat_stream(
prepared_turn.prompt.clone(),
prepared_turn.context_messages.clone(),
prepared_turn.history.clone(),
AgentToolRuntimeContext {
app_handle: app_handle.clone(),
command_executor,
pending_approvals,
workspace_id: prepared_turn.workspace_id.clone(),
request_id: request_id.clone(),
session_id: prepared_turn.session_id.clone(),
},
)
.await
{
@@ -136,3 +146,15 @@ pub async fn start_chat_stream(
Ok(start)
}
/// Resolves a pending agent-triggered command approval.
#[tauri::command]
pub async fn resolve_agent_tool_approval(
state: tauri::State<'_, AppState>,
request: ResolveAgentToolApprovalRequest,
) -> Result<(), String> {
state
.pending_agent_tool_approvals
.resolve(&request.approval_id, request.approved)
.map_err(|error| error.to_string())
}

View File

@@ -11,6 +11,7 @@ pub enum AppError {
RemoteApiKeyMissing,
InvalidSettings(String),
PanelContext(String),
AgentToolApprovalNotFound(String),
UnknownSession(String),
SettingsStore(String),
ProviderInit(String),
@@ -32,6 +33,9 @@ impl Display for AppError {
Self::PanelContext(message) => {
write!(formatter, "panel context could not be prepared: {message}")
}
Self::AgentToolApprovalNotFound(approval_id) => {
write!(formatter, "unknown agent tool approval: {approval_id}")
}
Self::UnknownSession(session_id) => {
write!(formatter, "unknown session: {session_id}")
}

View File

@@ -32,6 +32,7 @@ pub fn run() {
commands::terminal::execute_terminal_command,
commands::terminal::lookup_company,
commands::terminal::start_chat_stream,
commands::terminal::resolve_agent_tool_approval,
commands::settings::get_agent_config_status,
commands::settings::save_agent_runtime_config,
commands::settings::update_remote_api_key,

View File

@@ -1,9 +1,11 @@
//! Shared application state managed by Tauri.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Wry};
use tokio::sync::oneshot;
use crate::agent::{AgentService, AgentSettingsService};
use crate::error::AppError;
@@ -15,6 +17,59 @@ use crate::terminal::sec_edgar::{
use crate::terminal::security_lookup::SecurityLookup;
use crate::terminal::TerminalCommandService;
pub struct PendingAgentToolApprovals {
next_approval_id: AtomicU64,
senders: Mutex<HashMap<String, oneshot::Sender<bool>>>,
}
impl PendingAgentToolApprovals {
pub fn new() -> Self {
Self {
next_approval_id: AtomicU64::new(1),
senders: Mutex::new(HashMap::new()),
}
}
pub fn register(&self) -> Result<(String, oneshot::Receiver<bool>), AppError> {
let approval_id = format!(
"approval-{}",
self.next_approval_id.fetch_add(1, Ordering::Relaxed)
);
let (sender, receiver) = oneshot::channel();
let mut senders = self
.senders
.lock()
.map_err(|_| AppError::InvalidSettings("approval state is unavailable".to_string()))?;
senders.insert(approval_id.clone(), sender);
Ok((approval_id, receiver))
}
pub fn resolve(&self, approval_id: &str, approved: bool) -> Result<(), AppError> {
let sender = self
.senders
.lock()
.map_err(|_| AppError::InvalidSettings("approval state is unavailable".to_string()))?
.remove(approval_id)
.ok_or_else(|| AppError::AgentToolApprovalNotFound(approval_id.to_string()))?;
sender
.send(approved)
.map_err(|_| AppError::AgentToolApprovalNotFound(approval_id.to_string()))
}
pub fn cancel(&self, approval_id: &str) {
if let Ok(mut senders) = self.senders.lock() {
senders.remove(approval_id);
}
}
}
impl Default for PendingAgentToolApprovals {
fn default() -> Self {
Self::new()
}
}
struct SettingsBackedSecUserAgentProvider {
settings: AgentSettingsService<Wry>,
}
@@ -41,7 +96,9 @@ pub struct AppState {
/// Stateful chat service used for per-session conversation history and agent config.
pub agent: Mutex<AgentService<Wry>>,
/// Slash-command executor backed by shared mock data.
pub command_service: TerminalCommandService,
pub command_service: Arc<TerminalCommandService>,
/// Pending approvals for agent-triggered mutating commands.
pub pending_agent_tool_approvals: Arc<PendingAgentToolApprovals>,
next_request_id: AtomicU64,
}
@@ -60,11 +117,12 @@ impl AppState {
Ok(Self {
agent: Mutex::new(AgentService::new(app_handle)?),
command_service: TerminalCommandService::new(
command_service: Arc::new(TerminalCommandService::new(
security_lookup,
sec_edgar_lookup,
portfolio_service,
),
)),
pending_agent_tool_approvals: Arc::new(PendingAgentToolApprovals::new()),
next_request_id: AtomicU64::new(1),
})
}

View File

@@ -4,6 +4,7 @@ import { CommandInputHandle } from './components/Terminal/CommandInput';
import { Sidebar } from './components/Sidebar/Sidebar';
import { TabBar } from './components/TabBar/TabBar';
import { SettingsPage } from './components/Settings/SettingsPage';
import { ConfirmDialog } from './components/Settings/ConfirmDialog';
import {
isPortfolioCommand,
usePortfolioWorkflow,
@@ -21,6 +22,7 @@ import { terminalBridge } from './lib/terminalBridge';
import { AgentConfigStatus } from './types/agentSettings';
import { Portfolio } from './types/financial';
import {
ResolvedTerminalCommandResponse,
PortfolioAction,
PortfolioActionDraft,
PortfolioActionSeed,
@@ -29,6 +31,15 @@ import './App.css';
type AppView = 'terminal' | 'settings';
interface PendingAgentApproval {
approvalId: string;
command: string;
requestId: string;
workspaceId: string;
title: string;
message: string;
}
const isEditableTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) {
return false;
@@ -50,6 +61,8 @@ function App() {
const [isProcessing, setIsProcessing] = React.useState(false);
const [agentStatus, setAgentStatus] = React.useState<AgentConfigStatus | null>(null);
const [activeView, setActiveView] = React.useState<AppView>('terminal');
const [pendingAgentApproval, setPendingAgentApproval] =
React.useState<PendingAgentApproval | null>(null);
const portfolioWorkflow = usePortfolioWorkflow();
const commandHistoryRefs = useRef<Record<string, string[]>>({});
const commandIndexRefs = useRef<Record<string, number>>({});
@@ -116,6 +129,33 @@ function App() {
portfolioWorkflow.clearPortfolioAction(tabs.activeWorkspaceId);
}, [portfolioWorkflow, tabs.activeWorkspaceId]);
const appendResolvedCommandResponse = useCallback(
(
workspaceId: string,
command: string | undefined,
response: ResolvedTerminalCommandResponse,
) => {
tabs.appendWorkspaceEntry(
workspaceId,
createEntry(
response.kind === 'text'
? { type: 'response', content: response.content }
: { type: 'panel', content: response.panel },
),
);
if (command) {
portfolioWorkflow.noteCommandResponse(workspaceId, command, response);
}
const tickerSymbol = extractTickerSymbolFromResponse(response);
if (tickerSymbol) {
void tickerHistory.recordTicker(tickerSymbol);
}
},
[portfolioWorkflow, tabs, tickerHistory],
);
const handleCommand = useCallback(async (command: string) => {
const trimmedCommand = command.trim();
const latestTicker = tickerHistory.history[0]?.company.symbol;
@@ -155,21 +195,7 @@ function App() {
input: resolvedCommand,
});
tabs.appendWorkspaceEntry(
workspaceId,
createEntry(
response.kind === 'text'
? { type: 'response', content: response.content }
: { type: 'panel', content: response.panel },
),
);
portfolioWorkflow.noteCommandResponse(workspaceId, resolvedCommand, response);
const tickerSymbol = extractTickerSymbolFromResponse(response);
if (tickerSymbol) {
void tickerHistory.recordTicker(tickerSymbol);
}
appendResolvedCommandResponse(workspaceId, resolvedCommand, response);
} catch (error) {
tabs.appendWorkspaceEntry(
workspaceId,
@@ -189,7 +215,12 @@ function App() {
// Plain text keeps the current workspace conversation alive and streams into a placeholder response entry.
const panelContext = extractChatPanelContext(currentWorkspace?.history ?? []);
const commandEntry = createEntry({ type: 'command', content: resolvedCommand });
const responseEntry = createEntry({ type: 'response', content: '' });
const responseEntry = createEntry({
type: 'response',
content: '',
renderMode: 'markdown',
});
const toolCommandQueue: string[] = [];
tabs.appendWorkspaceEntry(workspaceId, commandEntry);
tabs.appendWorkspaceEntry(workspaceId, responseEntry);
@@ -200,7 +231,7 @@ function App() {
workspaceId,
sessionId: currentWorkspace?.chatSessionId,
prompt: resolvedCommand,
agentProfile: 'interactiveChat',
agentProfile: 'toolUse',
panelContext,
},
{
@@ -209,6 +240,7 @@ function App() {
tabs.updateWorkspaceEntry(workspaceId, responseEntry.id, (entry) => ({
...entry,
content: typeof entry.content === 'string' ? `${entry.content}${event.delta}` : event.delta,
renderMode: 'markdown',
timestamp: new Date(),
}));
},
@@ -218,6 +250,7 @@ function App() {
...entry,
type: 'response',
content: event.reply,
renderMode: 'markdown',
timestamp: new Date(),
}));
setIsProcessing(false);
@@ -227,10 +260,32 @@ function App() {
...entry,
type: 'error',
content: event.message,
renderMode: 'plain',
timestamp: new Date(),
}));
setIsProcessing(false);
},
onToolCommand: (event) => {
toolCommandQueue.push(event.command);
tabs.appendWorkspaceEntry(
workspaceId,
createEntry({ type: 'command', content: event.command }),
);
},
onToolResult: (event) => {
const command = toolCommandQueue.shift();
appendResolvedCommandResponse(workspaceId, command, event.response);
},
onToolApprovalRequired: (event) => {
setPendingAgentApproval({
approvalId: event.approvalId,
command: event.command,
requestId: event.requestId,
workspaceId: event.workspaceId,
title: event.title,
message: event.message,
});
},
}
);
@@ -240,6 +295,7 @@ function App() {
...entry,
type: 'error',
content: error instanceof Error ? error.message : 'Chat stream failed.',
renderMode: 'plain',
timestamp: new Date(),
}));
setIsProcessing(false);
@@ -250,6 +306,7 @@ function App() {
pushCommandHistory,
tickerHistory,
portfolioWorkflow,
appendResolvedCommandResponse,
]);
const runCommand = useCallback(
@@ -443,6 +500,39 @@ function App() {
/>
)}
</div>
<ConfirmDialog
isOpen={pendingAgentApproval !== null}
title={pendingAgentApproval?.title ?? 'Approve command'}
message={pendingAgentApproval?.message ?? ''}
confirmLabel="Approve"
cancelLabel="Deny"
variant="warning"
onConfirm={() => {
if (!pendingAgentApproval) {
return;
}
void terminalBridge
.resolveAgentToolApproval({
approvalId: pendingAgentApproval.approvalId,
approved: true,
})
.finally(() => setPendingAgentApproval(null));
}}
onCancel={() => {
if (!pendingAgentApproval) {
return;
}
void terminalBridge
.resolveAgentToolApproval({
approvalId: pendingAgentApproval.approvalId,
approved: false,
})
.finally(() => setPendingAgentApproval(null));
}}
/>
</div>
);
}

View File

@@ -1,4 +1,6 @@
import React, { useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
PanelPayload,
PortfolioAction,
@@ -38,19 +40,101 @@ export const TerminalOutput: React.FC<TerminalOutputProps> = ({
}
}, [history, outputRef]);
const renderPlainText = (content: string) => {
const lines = content.split('\n');
return (
<div className="whitespace-pre-wrap font-mono text-[14px] leading-relaxed">
{lines.map((line, i) => (
<div key={i}>{line || '\u00A0'}</div>
))}
</div>
);
};
const renderMarkdown = (content: string) => (
<div className="text-[14px] leading-7 text-[#d6e9ff] [&_strong]:font-semibold [&_strong]:text-white">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="mb-3 mt-1 text-[1.35rem] font-semibold tracking-[-0.02em] text-white">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-4 text-[1.15rem] font-semibold text-white">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-3 text-[1rem] font-semibold text-[#f3f8ff]">
{children}
</h3>
),
p: ({ children }) => <p className="my-2">{children}</p>,
ul: ({ children }) => <ul className="my-3 list-disc space-y-1 pl-5">{children}</ul>,
ol: ({ children }) => <ol className="my-3 list-decimal space-y-1 pl-5">{children}</ol>,
li: ({ children }) => <li className="pl-1">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="my-3 border-l-2 border-[#58a6ff]/60 pl-4 italic text-[#a8cfff]">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-[#7fc0ff] underline decoration-[#58a6ff]/50 underline-offset-4 transition-colors hover:text-[#b7ddff]"
>
{children}
</a>
),
hr: () => <hr className="my-4 border-[#223044]" />,
table: ({ children }) => (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse text-left text-[13px]">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-[#223044] text-[#9cc9f6]">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-[#192330] last:border-b-0">{children}</tr>,
th: ({ children }) => <th className="px-3 py-2 font-semibold">{children}</th>,
td: ({ children }) => <td className="px-3 py-2 align-top text-[#d6e9ff]">{children}</td>,
code: ({ className, children }) => {
const value = String(children).replace(/\n$/, '');
const isBlock = Boolean(className?.includes('language-')) || value.includes('\n');
return isBlock ? (
<code className="block overflow-x-auto rounded-xl border border-[#1d2a3a] bg-[#09111c] px-4 py-3 font-mono text-[13px] leading-6 text-[#d8ecff]">
{value}
</code>
) : (
<code className="rounded bg-[#111827] px-1.5 py-0.5 font-mono text-[0.92em] text-[#ffd580]">
{value}
</code>
);
},
pre: ({ children }) => <pre className="my-4 overflow-x-auto">{children}</pre>,
}}
>
{content}
</ReactMarkdown>
</div>
);
const renderContent = (entry: TerminalEntry) => {
if (typeof entry.content === 'string') {
const lines = entry.content.split('\n');
return (
<div className="whitespace-pre-wrap font-mono text-[14px] leading-relaxed">
{lines.map((line, i) => (
<div key={i}>{line || '\u00A0'}</div>
))}
</div>
);
if (typeof entry.content !== 'string') {
return null;
}
return null;
if (entry.renderMode === 'markdown') {
return renderMarkdown(entry.content);
}
return renderPlainText(entry.content);
};
const getEntryColor = (type: TerminalEntry['type']) => {

View File

@@ -5,13 +5,18 @@ import {
AgentDeltaEvent,
AgentErrorEvent,
AgentResultEvent,
AgentToolApprovalRequiredEvent,
AgentToolCommandEvent,
AgentToolResultEvent,
ChatStreamStart,
LookupCompanyRequest,
ExecuteTerminalCommandRequest,
PanelPayload,
ResolveAgentToolApprovalRequest,
ResolvedTerminalCommandResponse,
StartChatStreamRequest,
TerminalCommandResponse,
TransportAgentToolResultEvent,
TransportPanelPayload,
} from '../types/terminal';
import { Company } from '../types/financial';
@@ -21,6 +26,9 @@ interface StreamCallbacks {
onDelta: (event: AgentDeltaEvent) => void;
onResult: (event: AgentResultEvent) => void;
onError: (event: AgentErrorEvent) => void;
onToolCommand: (event: AgentToolCommandEvent) => void;
onToolResult: (event: AgentToolResultEvent) => void;
onToolApprovalRequired: (event: AgentToolApprovalRequiredEvent) => void;
}
const deserializePanelPayload = (payload: TransportPanelPayload): PanelPayload => {
@@ -40,6 +48,19 @@ const deserializePanelPayload = (payload: TransportPanelPayload): PanelPayload =
};
};
const deserializeTerminalCommandResponse = (
response: TerminalCommandResponse,
): ResolvedTerminalCommandResponse => {
if (response.kind === 'text') {
return response;
}
return {
kind: 'panel',
panel: deserializePanelPayload(response.panel),
};
};
class TerminalBridge {
private listenersReady: Promise<void> | null = null;
private unlistenFns: UnlistenFn[] = [];
@@ -75,6 +96,30 @@ class TerminalBridge {
callbacks.onError(event.payload);
this.streamCallbacks.delete(event.payload.requestId);
}),
listen<AgentToolCommandEvent>('agent_tool_command', (event) => {
const callbacks = this.streamCallbacks.get(event.payload.requestId);
if (!callbacks || callbacks.workspaceId !== event.payload.workspaceId) {
return;
}
callbacks.onToolCommand(event.payload);
}),
listen<TransportAgentToolResultEvent>('agent_tool_result', (event) => {
const callbacks = this.streamCallbacks.get(event.payload.requestId);
if (!callbacks || callbacks.workspaceId !== event.payload.workspaceId) {
return;
}
callbacks.onToolResult({
...event.payload,
response: deserializeTerminalCommandResponse(event.payload.response),
});
}),
listen<AgentToolApprovalRequiredEvent>('agent_tool_approval_required', (event) => {
const callbacks = this.streamCallbacks.get(event.payload.requestId);
if (!callbacks || callbacks.workspaceId !== event.payload.workspaceId) {
return;
}
callbacks.onToolApprovalRequired(event.payload);
}),
]).then((unlistenFns) => {
this.unlistenFns = unlistenFns;
});
@@ -93,10 +138,7 @@ class TerminalBridge {
return response;
}
return {
kind: 'panel',
panel: deserializePanelPayload(response.panel),
};
return deserializeTerminalCommandResponse(response);
}
async lookupCompany(request: LookupCompanyRequest): Promise<Company> {
@@ -124,6 +166,14 @@ class TerminalBridge {
return start;
}
async resolveAgentToolApproval(
request: ResolveAgentToolApprovalRequest,
): Promise<void> {
await invoke('resolve_agent_tool_approval', {
request,
});
}
async dispose() {
for (const unlisten of this.unlistenFns) {
await unlisten();

View File

@@ -70,6 +70,11 @@ export interface ChatStreamStart {
sessionId: string;
}
export interface ResolveAgentToolApprovalRequest {
approvalId: string;
approved: boolean;
}
export interface AgentDeltaEvent {
workspaceId: string;
requestId: string;
@@ -91,10 +96,42 @@ export interface AgentErrorEvent {
message: string;
}
export interface AgentToolCommandEvent {
workspaceId: string;
requestId: string;
sessionId: string;
command: string;
}
export interface TransportAgentToolResultEvent {
workspaceId: string;
requestId: string;
sessionId: string;
response: TerminalCommandResponse;
}
export interface AgentToolResultEvent {
workspaceId: string;
requestId: string;
sessionId: string;
response: ResolvedTerminalCommandResponse;
}
export interface AgentToolApprovalRequiredEvent {
workspaceId: string;
requestId: string;
sessionId: string;
approvalId: string;
command: string;
title: string;
message: string;
}
export interface TerminalEntry {
id: string;
type: 'command' | 'response' | 'system' | 'error' | 'panel';
content: string | PanelPayload;
renderMode?: 'plain' | 'markdown';
timestamp?: Date;
}