Skip to main content

recursive/tui/
events.rs

1//! UI-facing event and action types.
2//!
3//! [`UiEvent`] flows from the agent backend → UI thread; the UI applies
4//! them to [`crate::tui::app::App`] state. [`UserAction`] flows the other way
5//! — the UI thread captures key events and the
6//! [`crate::tui::backend::Backend`] worker dispatches them onto the
7//! `AgentRuntime`.
8//!
9//! Goal-144 widens this surface from goal-143's four variants to
10//! consume seven extra `AgentEvent` flavours: streaming partial
11//! tokens, completed assistant text, token usage, latency, transcript
12//! compaction and id-paired tool call/result events.
13//!
14//! Goal-161 adds a separate `PermissionRequest` side-channel (not
15//! part of `UiEvent`) because it carries a `oneshot::Sender<bool>` which
16//! cannot implement `PartialEq`. The backend exposes a
17//! `perm_rx: mpsc::UnboundedReceiver<PermissionRequest>` alongside
18//! `event_rx`; the main event loop polls both.
19
20/// Events bubbled up from the backend worker into the UI loop.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum UiEvent {
23    /// A streamed partial token chunk to append to the in-flight
24    /// assistant message.
25    AssistantPartial { text: String },
26    /// A completed assistant message (non-streaming providers, or the
27    /// final flush after `PartialToken` chunks).
28    AssistantMessage { content: String },
29    /// Model requested to execute a tool. Carries the call id so the
30    /// matching [`UiEvent::ToolResult`] can pair up.
31    ToolCall {
32        id: String,
33        name: String,
34        arguments: String,
35    },
36    /// Tool finished executing. `id` matches the originating
37    /// [`UiEvent::ToolCall`].
38    ToolResult {
39        id: String,
40        name: String,
41        output: String,
42        success: bool,
43    },
44    /// Token usage for the latest LLM call.
45    Usage {
46        input_tokens: u64,
47        output_tokens: u64,
48    },
49    /// Reasoning / thinking content produced by the model for the
50    /// current step (DeepSeek R1, OpenAI o1, …). Carries the full
51    /// reasoning text; the TUI renders it as a `thinking…` block
52    /// above the corresponding assistant message. Steps that did
53    /// not produce reasoning never emit this event.
54    Reasoning { content: String },
55    /// Latency (ms) of the latest LLM call.
56    Latency { llm_ms: u64 },
57    /// Transcript compaction notification.
58    Compacted { removed: usize, kept: usize },
59    /// Marks the end of a turn so the UI can stop the spinner.
60    TurnFinished,
61    /// A non-fatal error worth surfacing to the user.
62    Error { message: String },
63    /// Goal-147: structured plan proposal from the runtime
64    /// (`AgentEvent::PlanProposed`). The UI opens a `Modal::PlanReview`
65    /// over the chat screen and freezes input until the user
66    /// approves / rejects / edits.
67    ///
68    /// `tool_calls` carries the pending tool calls as JSON values
69    /// — each one has `name`, `id`, and `arguments` fields, mirroring
70    /// the kernel's serialised `StepEvent::PlanProposed` payload.
71    PlanProposed {
72        plan_text: String,
73        tool_calls: Vec<serde_json::Value>,
74    },
75    /// Goal-147: the runtime accepted the plan and resumed execution.
76    /// Closes any open `Modal::PlanReview` and pushes a System block.
77    PlanConfirmed,
78    /// Goal-147: the runtime rejected (or had its plan rejected) with
79    /// a free-form `reason`. Same UI handling as `PlanConfirmed` plus
80    /// the reason in the System block.
81    PlanRejected { reason: String },
82    /// Goal-202: agent called `request_plan_mode`; user should approve or skip.
83    PlanModeRequested { reason: String },
84    /// Goal-202: user approved the plan-mode entry request.
85    PlanModeApproved,
86    /// Goal-202: user rejected the plan-mode entry request.
87    PlanModeRejected { reason: String },
88    /// Goal-167: the agent updated its task list via `todo_write`. Carries
89    /// the complete replacement list so the UI can re-render without a diff.
90    TodoUpdated {
91        todos: Vec<crate::tools::todo::TodoItem>,
92    },
93
94    // ── Goal-168: goal-loop status events ────────────────────────────────────
95    /// Goal loop is running; the judge found the condition not yet met.
96    GoalContinuing { reason: String, turns: u32 },
97    /// Goal loop completed — condition confirmed met.
98    GoalAchieved { condition: String, turns: u32 },
99    /// Active goal was cleared (budget exceeded, `/goal clear`, or API).
100    GoalCleared,
101
102    // ── Goal-170: turn abort ────────────────────────────────────────────────
103    /// The current turn was aborted by the user (Esc/Ctrl+C). The backend
104    /// cancelled the in-flight LLM request via `JoinHandle::abort()` and
105    /// truncated the transcript back to the pre-turn state.
106    Interrupted,
107
108    // ── Goal-171: session resume ────────────────────────────────────────────
109    /// A previous session was successfully loaded into the runtime.
110    /// The UI should clear the in-progress transcript and show a System block.
111    SessionResumed {
112        session_id: String,
113        turn_count: usize,
114    },
115
116    // ── Goal-173: MCP server list ────────────────────────────────────────────
117    /// MCP server list loaded from the workspace config.
118    McpServersLoaded {
119        entries: Vec<crate::tui::ui::modal::McpEntry>,
120    },
121
122    // ── Goal-210: hook progress display ─────────────────────────────────────
123    /// A hook started executing.
124    HookStarted {
125        hook_event: String,
126        hook_name: String,
127        status_message: Option<String>,
128    },
129    /// A hook produced incremental output (last stdout line).
130    HookProgress {
131        hook_event: String,
132        hook_name: String,
133        last_line: String,
134    },
135    /// A hook finished executing.
136    HookFinished {
137        hook_event: String,
138        hook_name: String,
139        outcome: String,
140        duration_ms: u64,
141    },
142    /// The hook produced a system message to surface to the user.
143    HookSystemMessage { text: String },
144
145    // ── WeChat channel ───────────────────────────────────────────────────────
146    /// An incoming WeChat message from the iLink daemon.
147    /// Displayed in the TUI with a 📱 prefix so the user can see
148    /// what WeChat users are asking.
149    #[cfg(feature = "weixin")]
150    WeixinMessage {
151        /// WeChat user ID of the sender.
152        user_id: String,
153        /// The raw message text.
154        text: String,
155    },
156}
157
158// ── Goal-161: permission side-channel ────────────────────────────────────────
159
160/// A pending permission request bubbled up from the `TuiPermissionHook`
161/// running inside the backend worker. Carried on a dedicated side-channel
162/// (separate from `UiEvent`) because `oneshot::Sender` is not `PartialEq`.
163pub struct PermissionRequest {
164    /// The name of the tool that wants to run.
165    pub tool_name: String,
166    /// A short human-readable preview of the tool arguments (≤ 80 chars).
167    pub args_preview: String,
168    /// Resolve the request: `true` → allow, `false` → deny.
169    pub reply: tokio::sync::oneshot::Sender<bool>,
170}
171
172/// Actions originating from key events that the backend worker must
173/// service against the [`recursive::AgentRuntime`].
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum UserAction {
176    /// Send a user message and run one turn.
177    SendMessage(String),
178    /// Run a shell command directly via the runtime's tool registry,
179    /// bypassing the LLM. The command is dispatched to the
180    /// `run_shell` tool and its result surfaces as a
181    /// [`UiEvent::ToolCall`] + [`UiEvent::ToolResult`] pair, but is
182    /// **not** appended to the runtime transcript.
183    ///
184    /// Goal-145: this powers the `!`-prefixed bash mode of the
185    /// PromptInput so users get a quick scratch shell without
186    /// polluting the agent dialogue.
187    RunShell(String),
188    /// Confirm the pending plan and resume execution.
189    ConfirmPlan,
190    /// Reject the pending plan with a free-form reason.
191    RejectPlan(String),
192    /// Goal-202: user approves the plan-mode entry request (`request_plan_mode`).
193    ApprovePlanMode,
194    /// Goal-202: user rejects the plan-mode entry request; agent executes directly.
195    RejectPlanMode(String),
196    /// Goal-146: trigger a transcript compaction pass via
197    /// [`AgentRuntime::compact_now`]. The worker pushes a
198    /// `Compacted` event when summarisation succeeds.
199    Compact,
200    /// Goal-146: flip the runtime's planning mode. `true` enables
201    /// plan-first mode, `false` reverts to immediate execution.
202    /// The worker echoes a `System` block confirming the new state.
203    SetPlanningMode(bool),
204    /// Goal-147: signal the worker to abort the in-flight turn.
205    /// The worker flips its `cancel_flag`, and any `tokio::select!`
206    /// waiting on `wait_for_cancel` returns immediately. The runtime
207    /// is *not* cancelled mid-HTTP-request (reqwest doesn't support
208    /// that); on the next tool-call boundary the next turn will
209    /// surface as a `UiEvent::Error { message: "interrupted" }`.
210    Interrupt,
211    /// Tear down the worker and exit the runtime.
212    Shutdown,
213
214    // ── Goal-168: goal-loop actions ───────────────────────────────────────────
215    /// Start a condition-based autonomous loop. The backend will kick off
216    /// `run_goal_loop` and emit `GoalContinuing`/`GoalAchieved` events.
217    SetGoal {
218        /// The completion condition.
219        condition: String,
220        /// Hard cap on autonomous turns (default 20).
221        max_turns: u32,
222    },
223    /// Clear the active goal immediately.
224    ClearGoal,
225
226    // ── Goal-169: skill command ───────────────────────────────────────────────
227    /// Send an already-expanded skill prompt to the runtime.
228    RunSkillPrompt {
229        /// The expanded prompt text (with `$ARGUMENTS` substituted).
230        prompt: String,
231    },
232
233    // ── Goal-171: session resume ────────────────────────────────────────────
234    /// Load a previously saved session transcript into the runtime.
235    ResumeSession {
236        /// The session directory path (absolute).
237        session_dir: std::path::PathBuf,
238    },
239
240    // ── Goal-173: MCP server list ────────────────────────────────────────────
241    /// List configured MCP servers.
242    ListMcpServers,
243}
244
245// ── WeChat side-channel ───────────────────────────────────────────────────────
246
247/// A WeChat message request from the iLink daemon to the backend worker.
248///
249/// Carried on a dedicated side-channel (separate from [`UserAction`]) because
250/// `oneshot::Sender` is not `Clone` or `PartialEq`.
251#[cfg(feature = "weixin")]
252pub struct WeixinBackendRequest {
253    /// WeChat user ID of the sender.
254    pub user_id: String,
255    /// The message text to pass to the agent runtime.
256    pub text: String,
257    /// Channel for the backend to return the agent's final text response.
258    pub reply_tx: tokio::sync::oneshot::Sender<Option<String>>,
259}