1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
use compact_str::CompactString;
/// Structured classification of tool output for richer downstream
/// rendering. Most tools return plain text and use `Text`; tools
/// that surface file references (`read`, `find_files`,
/// `list_dir`) can opt into `File` so consumers (ACP, future UI
/// features) can render file refs as resource links rather than
/// blobs of text.
///
/// The classification is currently coarse — assigned by the
/// runner based on tool NAME rather than via per-tool plumbing —
/// since that's enough to drive opencode/ACP-style file-link
/// surfaces without touching every tool's `type Output = String`
/// contract. A future refactor could thread the variant through
/// the rig `Tool` trait for finer control.
#[derive(Debug, Clone, Default)]
pub enum ToolContent {
/// Plain text output — the default for every tool that
/// returns prose, JSON, command output, diffs, etc.
#[default]
Text,
/// Tool surfaced one or more file paths (read returned the
/// content of a specific file; find_files returned a listing).
/// Consumers can render as a clickable resource link instead
/// of a text blob.
File,
}
/// What a compaction pass actually did, so consumers (UI / telemetry)
/// can distinguish a cheap pruning-only pass from a real summary — and,
/// crucially, surface when the LLM summarizer is *failing*
/// (IMPROVEMENTS_PLAN #5). A spike in `PruneAndFailedSummary` is an
/// early warning that the summarizer is broken.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::enum_variant_names)]
pub enum CompactionKind {
/// Pruning only — no LLM summarizer ran (none wired, circuit breaker
/// open, or the middle was empty).
PruneOnly,
/// Pruning + a successful LLM/plugin summary.
PruneAndSummary,
/// Pruning + a failed summary (error or invalid) — fell back to the
/// pruned context.
PruneAndFailedSummary,
/// Pruning only because the summarizer circuit breaker is OPEN (it
/// failed too many times this run). Distinct from `PruneOnly` so the
/// ongoing-failure signal stays visible after the breaker latches
/// rather than masquerading as a healthy no-summarizer deployment.
PruneSummarizerDisabled,
}
#[derive(Debug, Clone)]
pub enum AgentEvent {
Token(CompactString),
Reasoning(CompactString),
ToolCall {
/// Provider call id (rig's `ToolCall.id`). Empty for older
/// rig versions or providers that don't emit one; the UI
/// uses it to pair this call with the corresponding
/// `ToolResult` event for structured persistence (Phase 3).
id: CompactString,
name: CompactString,
args: serde_json::Value,
},
/// Fired immediately AFTER `ToolCall` — marks the transition
/// from "LLM has emitted this call" to "dispatch is imminent".
/// Semantically: between this event and the matching
/// `ToolResult`, the tool is *running*. Consumers use it to:
/// - Show per-tool spinners / status badges
/// - Emit ACP `ToolCallStatus::InProgress` updates (the
/// ACP protocol distinguishes pending / in_progress /
/// completed; without this dirge skipped the in_progress
/// transition)
/// - Plugin observability hooks that need a "started" tick
/// distinct from "LLM decided to call"
///
/// The id matches the corresponding `ToolCall.id` so consumers
/// can pair them. UI consumers that already track in-flight
/// state via "saw ToolCall, no matching ToolResult" can ignore
/// this event safely — it's purely additive.
///
/// `name` is intentionally omitted — consumers correlate by
/// `id` against the immediately-prior `ToolCall` which already
/// carries the name. Keeping the variant lean (one field)
/// keeps the per-event allocation cheap; the runner emits
/// many of these per turn.
ToolStarted {
#[cfg_attr(not(feature = "acp"), allow(dead_code))]
id: CompactString,
},
ToolResult {
/// Matching call id from the `ToolCall` event. Empty if the
/// provider didn't emit one — the UI falls back to
/// positional pairing (this result belongs to the most-
/// recent unanswered ToolCall in the same turn).
id: CompactString,
output: CompactString,
#[cfg_attr(not(feature = "acp"), allow(dead_code))]
kind: ToolContent,
},
Error(CompactString),
/// The streaming run failed with a context-length error. Audit
/// H17: the UI used to render this as a hard `Error` and stop;
/// users had to manually `/compress` then re-issue. Now the
/// runner emits `ContextOverflow` carrying the prompt it was
/// trying to send so the UI can auto-compact the session and
/// respawn the run with the same prompt against the compacted
/// history.
ContextOverflow {
prompt: CompactString,
error: CompactString,
},
/// Context was compacted mid-run — old tool results pruned,
/// session rotated. The UI persists the split via session DB,
/// mutates `Session::id` in-place, and calls
/// `Session::compress_reporting(summary, first_kept_index, …)`
/// to push a `Compaction` entry. `summary` is empty when only
/// the cheap tool-output pruner ran (no LLM summary was
/// generated).
ContextCompacted {
new_session_id: CompactString,
tokens_before: u64,
tokens_after: u64,
summary: CompactString,
first_kept_index: usize,
/// Whether this pass was pruning-only, prune+summary, or
/// prune+failed-summary (IMPROVEMENTS_PLAN #5).
compaction_kind: CompactionKind,
/// Model that produced the summary, if known. `None` for
/// pruning-only passes (and currently for summary passes — the
/// summarizer closure is opaque; threading the model name is a
/// follow-up).
summary_model: Option<CompactString>,
},
Done {
response: CompactString,
tokens: u64,
cost: f64,
},
/// Marks the start of one turn within an agent run. A "turn" is one
/// LLM call + any tool calls it dispatched + the tool results
/// returning. A pure-text response has exactly one turn (TurnStart 0
/// → TurnEnd 0 → Done). A run with tool calls has multiple turns,
/// with turn boundaries straddling tool-result/next-assistant
/// content. Plugin hook authors (P3) consume these to bracket
/// per-turn observability.
TurnStart {
index: u32,
},
/// Marks the end of one turn. Fires immediately before the next
/// turn's TurnStart, or just before `Done` for the final turn.
/// Empty runs (stream ended without any assistant content) emit
/// neither TurnStart nor TurnEnd.
TurnEnd {
index: u32,
},
/// Plugin-emitted custom message reaching the UI mid-stream.
/// Carries the raw JSON payload the plugin queued via
/// `harness/add-custom-message`. The UI looks up a registered
/// renderer (see `PluginManager::list_message_renderers`) by
/// the payload's `type` field; without one it falls back to a
/// default formatter. Port of pi's `LoopMessage::Custom` →
/// `registerMessageRenderer` lookup (extensions/types.ts:1171).
CustomMessage {
payload: serde_json::Value,
},
/// The runner observed an interjection request at a tool-result boundary
/// and stopped the stream cleanly. Whatever assistant text had streamed
/// so far is captured in `partial_response`. The UI commits it as an
/// assistant message and then drains its interjection queue as the next
/// user turn.
///
/// Constructed by the bridge when the LLM stream is cancelled via the
/// interject signal (rig_stream error message "stream aborted by
/// cancellation signal" is recognized as a graceful stop, not a hard
/// error).
Interjected {
partial_response: CompactString,
tokens: u64,
},
/// User message injected mid-run via the steering queue.
/// The UI renders this as a user chat message so the user sees
/// their interjected guidance appear in the log when the agent
/// processes it at a turn boundary.
UserMessage {
content: CompactString,
},
/// The retry layer is about to re-attempt a stream request after
/// a transient error. `attempt` is 1-indexed (the Nth retry).
/// PROV-2: consumers should surface a temporary banner so the
/// user isn't staring at silence during backoff.
RetryNotice {
attempt: u32,
delay_ms: u64,
error: CompactString,
},
/// A dirge-originated log/notice line for the user — e.g. the
/// max-agent-turns cap message. The UI renders it as a `<system>`
/// log line in the warning color so it reads as a tool/runtime
/// notice rather than a message the user typed (which would carry
/// the `<you>` prefix). Not persisted to the session.
SystemNotice {
content: CompactString,
},
/// Per-run input-repair telemetry, emitted just before
/// `AgentEvent::Done`. The UI prints a one-line summary
/// ("repaired 3 inputs: 1 md-link, 2 null-strip; 0 invalid")
/// when at least one repair fired. Empty snapshots aren't
/// emitted at all. Phase-1 of docs/AGENTIC_LOOP_PLAN.md.
RepairStats {
snapshot: crate::agent::agent_loop::tool_input_repair::RepairStatsSnapshot,
},
/// Phase 4 part 1 — dual-client tiering: the NEXT LLM call has
/// been swapped to the configured escalation provider after a
/// repair-exhaustion or tree-sitter syntactic failure. One-shot;
/// subsequent calls revert to the default model. The UI surfaces
/// this so the user knows about the unexpected provider change.
EscalationActivated {
provider: CompactString,
reason: crate::agent::agent_loop::message::EscalationReason,
},
}
#[derive(Debug, Clone)]
pub enum UserEvent {
Key(crossterm::event::KeyEvent),
Paste(String),
/// Terminal was resized. Carries no payload — the renderer queries
/// `crossterm::terminal::size()` directly; the variant is just a kick
/// to repaint at the new dimensions.
Resize,
/// Mouse wheel scrolled up — scroll the output pane up by one line.
/// Mouse capture is on (see `TerminalGuard::new`) so the wheel reaches
/// the app instead of being absorbed by the terminal, which under the
/// alt screen would push the TUI off-view.
ScrollUp {
row: u16,
col: u16,
},
/// Mouse wheel scrolled down — scroll the output pane down by one line.
/// See `ScrollUp` for the `(row, col)` semantics.
ScrollDown {
row: u16,
col: u16,
},
/// Left mouse button pressed at terminal cell `(row, col)` — starts
/// an app-level drag selection. Consumed by `ui::selection::handle`
/// before any UI-state-specific consumer sees it.
MouseDown {
row: u16,
col: u16,
},
/// Left mouse button dragged to terminal cell `(row, col)` — extends
/// the active selection.
MouseDrag {
row: u16,
col: u16,
},
/// Left mouse button released at terminal cell `(row, col)` —
/// finalizes the selection, copies it to the clipboard, and clears
/// the highlight.
MouseUp {
row: u16,
col: u16,
},
}