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}