Skip to main content

lattice_ai/acp/
conversation_mode.rs

1//! `ai-conversation` major mode (AU‑2).
2//!
3//! The read-only `*ai:opencode*` buffer backing the agent conversation view.
4//! Mirrors `lattice_agent::log::modes::AiLogMode`: `on_activate` reads the
5//! [`ConversationStore`](crate::acp::conversation::ConversationStore) service,
6//! seeds the buffer from the current snapshot, subscribes to
7//! [`ConversationUpdated`](crate::acp::conversation::ConversationUpdated), and
8//! spawns a drain task that re-projects on every change. The returned
9//! `Subscription` guard unsubscribes on drop.
10//!
11//! [`render_conversation`] is pure and unit-testable. Projection is a
12//! **line-granular suffix replace**: unchanged leading lines are never
13//! rewritten, so streaming appends touch only the tail and a tool-call status
14//! change rewrites only from its line down (AU‑2 shows status inline as text; a
15//! decoration-based in-place update is a follow-up).
16
17use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
18use std::sync::{Arc, OnceLock};
19
20use lattice_cells::{
21    Cell, Headerline, HeaderlineProvider, HeaderlineRow, ProviderId, VirtualRowProvider,
22};
23use lattice_grammar::effect::{EchoLevel, Effect};
24use lattice_mode::{
25    ActionContext, ActionHandler, ActionHandlerContribution, BufferStoreHandle, CapabilitySet,
26    EditableTail, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
27    OptionOverrideSet, ReplMode, Subscription, VirtualRowRegistrar, keymap_entry,
28};
29
30use crate::acp::conversation::{
31    Block, Conversation, ConversationProjected, ConversationStore, ConversationUpdated,
32    PermissionStatus, Role,
33};
34use crate::acp::handle::AiClientHandle;
35use crate::acp::tool_fold::{
36    ConversationFold, ConversationFoldKind, ReasoningFoldSource, ToolCallFoldSource,
37    reasoning_fold_identity, tool_fold_identity,
38};
39
40/// AUX‑2: provider id for the conversation headerline. Derived from a fixed
41/// tag so the host can unregister it on buffer teardown.
42const CONV_HEADERLINE_PROVIDER_ID: ProviderId = 0x4155_0002;
43
44/// AUX‑2/4: headerline that reads the usage snapshot, processing status, and
45/// queue length, and formats them as a sticky row above the buffer.
46struct ConversationHeaderline {
47    store: ConversationStore,
48    version: Arc<AtomicU64>,
49    queue_len: Arc<AtomicUsize>,
50}
51
52impl Headerline for ConversationHeaderline {
53    fn version(&self) -> u64 {
54        self.version.load(Ordering::Acquire)
55    }
56
57    fn render(&self) -> Option<HeaderlineRow> {
58        let snap = self.store.snapshot();
59        let mut text = String::new();
60        // AUX‑3: status prefix.
61        text.push_str(&snap.status.to_string());
62        // AUX‑4: queue count when prompts are queued.
63        let ql = self.queue_len.load(Ordering::Relaxed);
64        if ql > 0 {
65            use std::fmt::Write;
66            let _ = write!(text, " \u{231B} {} queued", ql); // ⌛ N queued
67        }
68        // AUX‑2: usage suffix (tokens/cost).
69        if let Some(usage) = &snap.usage {
70            use std::fmt::Write;
71            let _ = write!(
72                text,
73                " \u{2502} CPU: {}",
74                format_tokens(usage.used, usage.size)
75            );
76            if let Some(cost) = &usage.cost {
77                let _ = write!(&mut text, " \u{00B7} ${:.3} {}", cost.amount, cost.currency);
78            }
79        }
80        let cells: Arc<[Cell]> = text
81            .chars()
82            .map(|c| Cell::new(c as u32, 0, 0, 0))
83            .collect::<Vec<_>>()
84            .into();
85        Some(HeaderlineRow { cells, bg: None })
86    }
87}
88
89/// Format token count in human-readable form: `31.4K` or `1.2M`.
90fn format_tokens(used: u64, size: u64) -> String {
91    let used_s = humanize(used);
92    let size_s = humanize(size);
93    format!("{used_s}/{size_s}")
94}
95
96fn humanize(n: u64) -> String {
97    if n >= 1_000_000 {
98        format!("{:.1}M", n as f64 / 1_000_000.0)
99    } else if n >= 1_000 {
100        format!("{:.1}K", n as f64 / 1_000.0)
101    } else {
102        n.to_string()
103    }
104}
105
106/// AU‑3: the prompt marker rendered at the head of the editable tail line.
107/// Two bytes, so [`EditableTail::first_line_min_byte`] is `2`: the marker
108/// itself is not user-editable (backspace at the prompt start is refused by
109/// the read-only gate), only the text after it.
110const PROMPT_MARKER: &str = "> ";
111
112/// The synthetic buffer name for the (single, v1) opencode conversation.
113pub fn conversation_buffer_name() -> String {
114    "*ai:opencode*".to_string()
115}
116
117/// `ai-conversation-mode` -- major mode for the `*ai:opencode*` buffer.
118///
119/// Holds the prompt's editable-region `anchor`: the absolute line where the
120/// prompt begins (== the transcript line count). The drain updates it on each
121/// re-projection; [`editable_tail`](Mode::editable_tail) publishes it so the
122/// read-only gate lets the user edit a multi-line prompt (`<C-j>` inserts a
123/// newline) while the transcript above stays frozen. One instance backs the
124/// single (v1) conversation buffer, so one shared anchor suffices.
125///
126/// AUX‑2: `headerline_version` is bumped by the drain on each re-projection so
127/// the `ConversationHeaderline` widget republishes its row when usage changes.
128#[derive(Clone)]
129pub struct AiConversationMode {
130    anchor: Arc<AtomicU32>,
131    headerline_version: Arc<AtomicU64>,
132}
133
134impl Default for AiConversationMode {
135    fn default() -> Self {
136        Self {
137            anchor: Arc::new(AtomicU32::new(0)),
138            headerline_version: Arc::new(AtomicU64::new(0)),
139        }
140    }
141}
142
143impl AiConversationMode {
144    pub fn new() -> Self {
145        Self::default()
146    }
147    pub fn mode_id() -> ModeId {
148        ModeId::new("ai-conversation-mode")
149    }
150}
151
152/// The absolute line where the prompt begins for a given transcript: the
153/// transcript's line count (each rendered turn ends in `\n`, so the trailing
154/// `PROMPT_MARKER` lands on the next line). Pure — the seed and the drain both
155/// feed this into the mode's anchor.
156fn prompt_anchor_line(transcript: &str) -> u32 {
157    transcript.matches('\n').count() as u32
158}
159
160/// Render the whole conversation to plain text. Pure; the projection diffs this
161/// against the buffer to compute a minimal edit. Thin wrapper over
162/// [`project_conversation`] — the text half.
163pub fn render_conversation(conv: &Conversation) -> String {
164    project_conversation(conv).0
165}
166
167/// TCF: project the conversation to `(transcript_text, fold_spans)` in a single
168/// pass, so a fold's line range can never drift from the rendered layout. The
169/// text half is what the drain diffs into the buffer; the span half is what the
170/// [`ToolCallFoldSource`](crate::acp::tool_fold::ToolCallFoldSource) and
171/// [`ReasoningFoldSource`](crate::acp::tool_fold::ReasoningFoldSource) read.
172///
173/// A tool call with captured `input`/`output` gains indented detail rows under
174/// its `▸ summary [status]` head; the fold spans the head through the last
175/// detail row (head stays visible when closed, detail hides). Each multi-line
176/// reasoning block folds the same way. Detail-less tool calls and single-line
177/// reasoning yield no span — a 1-line fold is a `z*` no-op.
178///
179/// Fold line indices are 0-based on the split-on-`\n` grid and align with the
180/// buffer's line numbers: the transcript occupies buffer lines `0..N` and the
181/// prompt marker is appended after it, so it never shifts a transcript line.
182pub fn project_conversation(conv: &Conversation) -> (String, Vec<ConversationFold>) {
183    let mut out = String::new();
184    let mut folds: Vec<ConversationFold> = Vec::new();
185    // Current line index == number of `\n` pushed so far. Bumped on every line
186    // written, so `line` names the row the NEXT push lands on.
187    let mut line: u32 = 0;
188    // Reasoning blocks carry no wire id; their fold identity is keyed on this
189    // document-order ordinal, incremented for every reasoning block regardless
190    // of whether it is foldable so a given block's ordinal stays stable.
191    let mut reasoning_ordinal: usize = 0;
192    for turn in &conv.turns {
193        let who = match turn.role {
194            Role::User => "you",
195            Role::Assistant => "opencode",
196        };
197        out.push_str(who);
198        out.push_str(":\n");
199        line += 1;
200        for block in &turn.blocks {
201            match block {
202                Block::Text(s) => {
203                    for l in s.split('\n') {
204                        out.push_str(l);
205                        out.push('\n');
206                        line += 1;
207                    }
208                }
209                Block::Reasoning(s) => {
210                    let start = line;
211                    for l in s.split('\n') {
212                        out.push_str("  \u{2502} ");
213                        out.push_str(l);
214                        out.push('\n');
215                        line += 1;
216                    }
217                    let end = line - 1;
218                    if end > start {
219                        folds.push(ConversationFold {
220                            start_line: start,
221                            end_line: end,
222                            identity: reasoning_fold_identity(reasoning_ordinal),
223                            kind: ConversationFoldKind::Reasoning,
224                        });
225                    }
226                    reasoning_ordinal += 1;
227                }
228                Block::ToolCall {
229                    id,
230                    title,
231                    status,
232                    input,
233                    output,
234                    ..
235                } => {
236                    let start = line;
237                    out.push_str("  \u{25b8} ");
238                    out.push_str(title);
239                    out.push_str(" [");
240                    out.push_str(status_tag(status));
241                    out.push_str("]\n");
242                    line += 1;
243                    for (label, body) in [("input", input), ("output", output)] {
244                        let Some(body) = body else { continue };
245                        out.push_str("    ");
246                        out.push_str(label);
247                        out.push_str(":\n");
248                        line += 1;
249                        for l in body.split('\n') {
250                            out.push_str("      ");
251                            out.push_str(l);
252                            out.push('\n');
253                            line += 1;
254                        }
255                    }
256                    let end = line - 1;
257                    if end > start {
258                        folds.push(ConversationFold {
259                            start_line: start,
260                            end_line: end,
261                            identity: tool_fold_identity(id),
262                            kind: ConversationFoldKind::ToolCall,
263                        });
264                    }
265                }
266                Block::Edit { path, status } => {
267                    out.push_str("  \u{270e} ");
268                    out.push_str(path);
269                    out.push_str(" [");
270                    out.push_str(edit_tag(status));
271                    out.push_str("]\n");
272                    line += 1;
273                }
274                Block::Permission {
275                    title,
276                    description,
277                    options,
278                    status,
279                    ..
280                } => {
281                    out.push_str("  ");
282                    out.push_str(permission_prefix(status));
283                    out.push_str(title);
284                    out.push_str(" [");
285                    out.push_str(permission_tag(status));
286                    out.push_str("]\n");
287                    line += 1;
288                    if let Some(desc) = description {
289                        out.push_str("    ");
290                        out.push_str(desc);
291                        out.push('\n');
292                        line += 1;
293                    }
294                    if status == &PermissionStatus::Pending {
295                        // PU-B.2: the inline block is a record, not the
296                        // interaction surface — the popup menu (`ai-permission-mode`)
297                        // owns the numbered selectors. List the offered options
298                        // without the old `(a)/(A)/(r)/(R)` key hints, which named
299                        // chords that were never bound.
300                        for (i, opt) in options.iter().enumerate() {
301                            out.push_str(&format!("    {}: {}\n", i + 1, opt.name));
302                            line += 1;
303                        }
304                    }
305                }
306            }
307        }
308        out.push('\n');
309        line += 1;
310    }
311    (out, folds)
312}
313
314fn status_tag(status: &crate::acp::conversation::ToolStatus) -> &'static str {
315    use crate::acp::conversation::ToolStatus::*;
316    match status {
317        Pending => "pending",
318        Running => "running",
319        Ok => "ok",
320        Err => "error",
321    }
322}
323
324fn edit_tag(status: &crate::acp::conversation::EditStatus) -> &'static str {
325    use crate::acp::conversation::EditStatus::*;
326    match status {
327        Proposed => "proposed",
328        Accepted => "accepted",
329        Rejected => "rejected",
330    }
331}
332
333fn permission_prefix(status: &PermissionStatus) -> &'static str {
334    use PermissionStatus::*;
335    match status {
336        Pending => "\u{25cc} ", // ◌
337        Allowed => "\u{2713} ", // ✓
338        Denied => "\u{2717} ",  // ✗
339    }
340}
341
342fn permission_tag(status: &PermissionStatus) -> &'static str {
343    use PermissionStatus::*;
344    match status {
345        Pending => "pending",
346        Allowed => "allowed",
347        Denied => "denied",
348    }
349}
350
351/// The minimal line-granular replacement turning `old` into `new`: the index of
352/// the first differing line, and the replacement text for everything from that
353/// line onward. `None` when the texts are already equal.
354///
355/// Pure and unit-testable; the mode turns this into an
356/// [`Edit`](lattice_protocol::edit::Edit) against the live buffer positions.
357pub fn suffix_replace(old: &str, new: &str) -> Option<LineSuffixReplace> {
358    if old == new {
359        return None;
360    }
361    let old_lines: Vec<&str> = old.split('\n').collect();
362    let new_lines: Vec<&str> = new.split('\n').collect();
363    let common = old_lines
364        .iter()
365        .zip(new_lines.iter())
366        .take_while(|(a, b)| a == b)
367        .count();
368    Some(LineSuffixReplace {
369        first_diff_line: common,
370        old_line_count: old_lines.len(),
371        replacement: new_lines[common..].join("\n"),
372    })
373}
374
375/// Result of [`suffix_replace`]: replace lines `first_diff_line..old_line_count`
376/// (0-based, the split-on-`\n` line grid) with `replacement`.
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct LineSuffixReplace {
379    pub first_diff_line: usize,
380    pub old_line_count: usize,
381    pub replacement: String,
382}
383
384/// AU‑3+ activation guard: the `ConversationUpdated` subscription. Dropping
385/// it on deactivation unsubscribes, so a stopped mode contributes no further
386/// work.
387pub struct AiConversationGuard {
388    _subscription: Subscription,
389    /// AUX‑2: the headerline provider registration. Removed on deactivate so the
390    /// mode owns its full surface — nothing else in the host knows this provider
391    /// exists, so nothing else can clean it up.
392    headerline: Option<(Arc<dyn VirtualRowRegistrar>, lattice_core::BufferId)>,
393    /// TCF: the tool-call + reasoning fold source registrations. Removed on
394    /// deactivate so the mode owns its full surface — the same Drop-based
395    /// lifecycle `DiffModeGuard` uses for its hunk/unchanged sources. Empty when
396    /// the fold service wasn't registered (some test harnesses) — Drop no-ops.
397    fold_registrations: Vec<(
398        lattice_core::FoldOverlayServiceHandle,
399        lattice_core::ProviderId,
400    )>,
401}
402
403impl Drop for AiConversationGuard {
404    fn drop(&mut self) {
405        if let Some((registrar, buffer_id)) = self.headerline.take() {
406            registrar.unregister(buffer_id, CONV_HEADERLINE_PROVIDER_ID);
407        }
408        for (svc, id) in self.fold_registrations.drain(..) {
409            svc.remove_source(id);
410        }
411    }
412}
413
414impl Mode for AiConversationMode {
415    type Guard = Option<AiConversationGuard>;
416    fn id(&self) -> ModeId {
417        Self::mode_id()
418    }
419    fn kind(&self) -> ModeKind {
420        ModeKind::Major
421    }
422    fn options(&self) -> OptionOverrideSet {
423        lattice_config::overrides! {
424            lattice_config::ReadOnly = true,
425            lattice_config::NoFile = true,
426        }
427    }
428    fn required_capabilities(&self) -> CapabilitySet {
429        CapabilitySet::empty()
430    }
431
432    /// AU‑3 gap fix: claim the invocation runner so vim operators respect the
433    /// editable tail. The host registers `Editor::run_editable_tail_invocation`
434    /// under this mode id (`editor_boot.rs`); it gates mutating operators
435    /// (`x` / `dd` / `dw`) started in the frozen transcript and lets them
436    /// through in the prompt. Without a runner, operators fell through to the
437    /// ungated document path and mutated the read-only transcript.
438    fn invocation_runner(&self) -> Option<ModeId> {
439        Some(Self::mode_id())
440    }
441
442    /// Pull in `repl-mode` on the conversation buffer: it owns the generic
443    /// insert-entry surface (`i`/`a`/`o`/… → jump-to-prompt-and-Insert). The
444    /// activation cascade turns it on wherever this major is active and off
445    /// when the buffer's major changes, so the affordance is scoped to the
446    /// REPL buffer without a bespoke keymap on this mode.
447    fn implies(&self) -> &[ModeId] {
448        ai_conversation_implies()
449    }
450
451    /// AU‑3: the prompt is the single trailing line, editable only after the
452    /// `"> "` marker (2 bytes). Consulted by the host's read-only edit gate so
453    /// Insert/operator keystrokes land only in the prompt; the transcript above
454    /// stays owner-written.
455    fn editable_tail(&self) -> Option<EditableTail> {
456        Some(EditableTail {
457            trailing_lines: 1,
458            first_line_min_byte: PROMPT_MARKER.len() as u32,
459            // Absolute anchor = the transcript-end line, kept current by the
460            // drain. Lets the read-only gate cover a MULTI-line prompt (`<C-j>`):
461            // every line at or below the anchor is editable, the transcript
462            // above is frozen. The `trailing_lines: 1` above is the inert
463            // fallback for the first frame before the anchor is seeded.
464            first_editable_line: Some(self.anchor.load(Ordering::Relaxed)),
465        })
466    }
467
468    /// AU‑3: the modal-input surface. Insert-entering chords relocate the
469    /// cursor into the prompt first (so Insert only ever edits the prompt, and
470    /// history is unreachable from Insert); `<CR>` sends; `<C-c>` interrupts.
471    /// The host's K.2.4 translate pass pushes these under
472    /// `MajorMode(ai-conversation-mode)`, gated by K.1.c to this buffer.
473    fn keymap(&self) -> Keymap {
474        Keymap::from_entries(ai_conversation_keymap_entries())
475    }
476
477    /// AU‑3: the handler bodies for the chords above, mode-owned (no host
478    /// `Editor::` method, no `Action` variant). Bound at boot by the host's
479    /// `register_mode_action_handlers` walk. Each reads the buffer / services
480    /// from the [`ActionContext`] and returns an [`Effect`] the host applies.
481    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
482        vec![
483            ActionHandlerContribution {
484                action_name: "action:ai-conv-send",
485                handler: send_handler(self.anchor.clone()),
486            },
487            ActionHandlerContribution {
488                action_name: "action:ai-conv-newline",
489                handler: newline_handler(),
490            },
491            ActionHandlerContribution {
492                action_name: "action:ai-conv-interrupt",
493                handler: interrupt_handler(),
494            },
495            ActionHandlerContribution {
496                action_name: "action:ai-conv-toggle-trust",
497                handler: toggle_trust_handler(),
498            },
499        ]
500    }
501
502    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
503        Box::pin(async move {
504            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
505            let Some(store) = ctx.service::<lattice_mode::BufferStoreHandle>() else {
506                return Ok(None);
507            };
508            let Some(handle) = store.handle_for(buffer_id) else {
509                return Ok(None);
510            };
511            let Some(conv_store) = ctx.service::<ConversationStore>() else {
512                return Ok(None);
513            };
514            let Ok(runtime) = tokio::runtime::Handle::try_current() else {
515                return Ok(None);
516            };
517
518            // Sync the buffer to the current snapshot with a single full replace
519            // (handles a fresh OR reopened buffer uniformly). `last` then tracks
520            // the buffer content so subsequent updates diff self-consistently --
521            // no fragile buffer-text round-trip.
522            // AU‑3: `last` tracks the *conversation zone* only; the buffer is
523            // `{conversation}{PROMPT_MARKER}` with the editable prompt as the
524            // trailing line. Seeding appends the marker once; the update path
525            // below is unchanged because `suffix_edit`'s replace range ends at
526            // `text_end(last)` — the start of the prompt line — so re-projecting
527            // the transcript never rewrites the user's in-progress prompt.
528            let seed = conv_store.snapshot();
529            let mut last = render_conversation(&seed);
530            let mut last_user_turns = user_turn_count(&seed);
531            full_replace(&handle, &format!("{last}{PROMPT_MARKER}")).await;
532
533            // AU‑3+ (`<C-j>` multi-line prompt): the editable-region anchor, the
534            // absolute line where the prompt begins. `editable_tail` publishes it
535            // to the read-only gate; the drain keeps it current as the transcript
536            // streams. Seed it from the initial transcript.
537            let anchor = self.anchor.clone();
538            anchor.store(prompt_anchor_line(&last), Ordering::Relaxed);
539
540            // AUX‑2: register the conversation headerline (token/cost display).
541            // `conv_store` is `Arc<ConversationStore>` from the service lookup;
542            // cloning the Arc gives another reference to the same store.
543            let hl_version = self.headerline_version.clone();
544            let headerline_registration =
545                ctx.service::<Arc<dyn VirtualRowRegistrar>>()
546                    .map(|registrar| {
547                        let queue_len = ctx
548                            .service::<AiClientHandle>()
549                            .map(|h| h.queue_len.clone())
550                            .unwrap_or_default();
551                        let headerline = ConversationHeaderline {
552                            store: (*conv_store).clone(),
553                            version: hl_version.clone(),
554                            queue_len,
555                        };
556                        let provider = Arc::new(HeaderlineProvider::new(
557                            CONV_HEADERLINE_PROVIDER_ID,
558                            Arc::new(headerline),
559                        ));
560                        let registrar: Arc<dyn VirtualRowRegistrar> = (*registrar).clone();
561                        // The provider id is a fixed tag, and `register` refuses to
562                        // replace a live id. Clear any registration a previous
563                        // activation left behind so a re-opened `:opencode` binds its
564                        // own headerline rather than silently keeping the stale one.
565                        registrar.unregister(buffer_id, CONV_HEADERLINE_PROVIDER_ID);
566                        registrar.register(buffer_id, provider as Arc<dyn VirtualRowProvider>);
567                        (registrar, buffer_id)
568                    });
569
570            // TCF: register the tool-call + reasoning fold sources. Each holds a
571            // `ConversationStore` clone and reads the published snapshot on every
572            // `recompute_folds` (driven by the drain's owner-write edits bumping
573            // the buffer version → `maybe_reparse_syntax`). Mirrors
574            // `DiffMode::on_activate`; the guard's Drop removes them. Distinct
575            // provider ids so each deregisters independently. `ctx.service`
576            // yields `Arc<FoldOverlayServiceHandle>` (an `Arc<Arc<dyn …>>`);
577            // unwrap one layer to the inner handle.
578            let fold_registrations = ctx
579                .service::<lattice_core::FoldOverlayServiceHandle>()
580                .map(|outer| {
581                    let svc = (*outer).clone();
582                    let tool: Arc<dyn lattice_core::FoldSource> =
583                        Arc::new(ToolCallFoldSource::new((*conv_store).clone(), buffer_id));
584                    let tool_id = svc.add_source(tool, buffer_id);
585                    let reasoning: Arc<dyn lattice_core::FoldSource> =
586                        Arc::new(ReasoningFoldSource::new((*conv_store).clone(), buffer_id));
587                    let reasoning_id = svc.add_source(reasoning, buffer_id);
588                    vec![(svc.clone(), tool_id), (svc, reasoning_id)]
589                })
590                .unwrap_or_default();
591
592            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ConversationUpdated>();
593            let sub_id = ctx.events().subscribe_typed::<ConversationUpdated>(tx);
594            let bus_handle = ctx.events_handle();
595            // A separate handle for the drain to *publish* `ConversationProjected`
596            // once its edit lands (see below).
597            let projected_bus = ctx.events_handle();
598
599            let drain_anchor = anchor;
600            let drain_hl_version = hl_version;
601            runtime.spawn(async move {
602                // Coalesce a burst of updates into one re-projection.
603                while rx.recv().await.is_some() {
604                    while rx.try_recv().is_ok() {}
605                    let snap = conv_store.snapshot();
606                    let new = render_conversation(&snap);
607                    let user_turns = user_turn_count(&snap);
608                    // Keep the editable-region anchor on the transcript-end line:
609                    // a re-projection may grow the transcript above the prompt
610                    // (streaming) or reset it (send), moving where the prompt
611                    // begins. Store before the edit so the gate is consistent
612                    // with the new content on the next keystroke.
613                    drain_anchor.store(prompt_anchor_line(&new), Ordering::Relaxed);
614                    // A new User turn means the user just sent a prompt. Rewrite
615                    // the tail through EOF and re-append an empty prompt marker,
616                    // clearing the prompt *atomically* with the transcript update.
617                    // The send handler deliberately emits no clear edit: a
618                    // separate clear would race this re-projection (which shifts
619                    // the prompt line down) and land on the wrong, now-read-only
620                    // line. On agent-only updates we leave the user's in-progress
621                    // prompt untouched.
622                    let _did_clear = user_turns > last_user_turns;
623                    let _changed = reproject(&handle, &last, &new, _did_clear).await;
624                    // AUX‑2: bump the headerline version on every re-projection so
625                    // the ConversationHeaderline widget republishes its row (usage
626                    // may have changed).
627                    drain_hl_version.fetch_add(1, Ordering::Release);
628
629                    // Wake the editor actor now that the edit has LANDED, so the
630                    // streamed response repaints (and the focus tick callback
631                    // runs) WITHOUT a keystroke. Published after the await — not
632                    // via `ConversationUpdated`, which fires before the buffer is
633                    // re-projected — so the wake never repaints stale content.
634                    //
635                    // AUX‑2: published unconditionally, NOT gated on the
636                    // transcript text changing. The headerline reads status,
637                    // usage and queue length — none of which appear in the
638                    // transcript — so a `usage_update` re-projects to identical
639                    // text yet must still repaint. Gating this on text change
640                    // froze the headerline at "Ready" forever.
641                    projected_bus.publish_typed(ConversationProjected);
642                    last = new;
643                    last_user_turns = user_turns;
644                }
645            });
646
647            Ok(Some(AiConversationGuard {
648                _subscription: Subscription::new(bus_handle, sub_id),
649                headerline: headerline_registration,
650                fold_registrations,
651            }))
652        })
653    }
654}
655
656/// (line, col) of the end of `s` on the split-on-`\n` line grid.
657fn text_end(s: &str) -> (u32, u32) {
658    let last_line = s.split('\n').count().saturating_sub(1);
659    let last_len = s.rsplit('\n').next().map(str::len).unwrap_or(0);
660    (last_line as u32, last_len as u32)
661}
662
663/// Replace the entire buffer with `text`.
664async fn full_replace(handle: &std::sync::Arc<dyn lattice_runtime::Document>, text: &str) {
665    let snap = handle.snapshot();
666    let last_line = snap.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — whole-buffer extent
667    let last_len = snap.buffer.line(last_line).unwrap_or_default().len() as u32;
668    let range = lattice_protocol::Range::new(
669        lattice_protocol::position::Position::new(0, 0),
670        lattice_protocol::position::Position::new(last_line, last_len),
671    );
672    let edit = lattice_protocol::edit::Edit::replace(range, text.to_string());
673    let _ = handle.apply_edit_batch(vec![edit]).await;
674}
675
676/// Number of `User`-role turns in the conversation. A send is the only thing
677/// that adds one (`ConversationStore::push_user_text`), so an increase between
678/// re-projections is the drain's reliable "the prompt was just sent" signal.
679fn user_turn_count(conv: &Conversation) -> usize {
680    conv.turns.iter().filter(|t| t.role == Role::User).count()
681}
682
683/// Re-project the transcript zone `last` → `new` into the buffer with one edit.
684///
685/// When `clear_prompt` (a send just added a User turn) the edit extends through
686/// the *current* prompt line to EOF and re-appends an empty `PROMPT_MARKER`, so
687/// the transcript grows and the prompt resets in a single atomic edit — the
688/// drain owns the prompt's lifecycle, so there is no separate clear edit to race
689/// this re-projection. Otherwise the edit ends at the prompt-line start, leaving
690/// the user's in-progress prompt intact while the agent streams.
691/// Returns `true` when an edit was applied (the transcript changed), `false`
692/// when `last == new` and the buffer was left untouched. The drain uses this to
693/// wake the render loop only when something actually changed.
694async fn reproject(
695    handle: &std::sync::Arc<dyn lattice_runtime::Document>,
696    last: &str,
697    new: &str,
698    clear_prompt: bool,
699) -> bool {
700    let Some(rep) = suffix_replace(last, new) else {
701        return false; // transcript text unchanged
702    };
703    let (end, replacement) = if clear_prompt {
704        let snap = handle.snapshot();
705        let last_line = snap.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — whole-buffer extent
706        let last_len = snap.buffer.line(last_line).unwrap_or_default().len() as u32;
707        (
708            (last_line, last_len),
709            format!("{}{}", rep.replacement, PROMPT_MARKER),
710        )
711    } else {
712        (text_end(last), rep.replacement)
713    };
714    let range = lattice_protocol::Range::new(
715        lattice_protocol::position::Position::new(rep.first_diff_line as u32, 0),
716        lattice_protocol::position::Position::new(end.0, end.1),
717    );
718    let edit = lattice_protocol::edit::Edit::replace(range, replacement);
719    let _ = handle.apply_edit_batch(vec![edit]).await;
720    true
721}
722
723// ──────────────────────────────────────────────────────────────
724// AU‑3: modal-input surface — keymap entries + action handlers
725// ──────────────────────────────────────────────────────────────
726
727/// The Insert- and Normal-mode chords the `ai-conversation` mode contributes.
728/// The Normal-mode insert-entry chords (`i`/`a`/`o`/`A`/`I`/`O` →
729/// jump-to-prompt-and-Insert) are NOT here: that is the generic REPL input
730/// surface, owned by `repl-mode` (a minor mode this major pulls in via
731/// [`implies`](Mode::implies)). This keeps vim's universal insert keys off
732/// every ordinary buffer. `<CR>` sends; `<C-j>` inserts a newline; `<C-c>`
733/// interrupts; `<C-t>` toggles trust — all ACP-specific, so they stay here.
734/// The minor modes `ai-conversation-mode` implies: `repl-mode` (the generic
735/// REPL input surface). A process-wide static so [`Mode::implies`] can hand back
736/// a `&'static [ModeId]`.
737fn ai_conversation_implies() -> &'static [ModeId] {
738    static IMPLIES: OnceLock<Vec<ModeId>> = OnceLock::new();
739    IMPLIES.get_or_init(|| vec![ReplMode::mode_id()])
740}
741
742fn ai_conversation_keymap_entries() -> &'static [KeymapEntry] {
743    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
744    ENTRIES.get_or_init(|| {
745        vec![
746            keymap_entry! {
747                mode: Insert, chord: "<CR>",
748                doc: "ai-conversation: send the prompt to the agent",
749                cmd: "action:ai-conv-send"
750            },
751            // `<C-j>` inserts a literal newline in the prompt (multi-line
752            // input), matching the `:terminal` / `:claude` convention — `<CR>`
753            // is taken by send, so newline needs its own chord.
754            keymap_entry! {
755                mode: Insert, chord: "<C-j>",
756                doc: "ai-conversation: insert a newline in the prompt",
757                cmd: "action:ai-conv-newline"
758            },
759            keymap_entry! {
760                mode: Insert, chord: "<C-c>",
761                doc: "ai-conversation: interrupt the active turn",
762                cmd: "action:ai-conv-interrupt"
763            },
764            // AU‑5: trust-mode toggle. `<C-t>` (Normal) mirrors the
765            // agent-TUI convention of a single chord cycling the permission
766            // mode (Claude Code's Shift-Tab / opencode's Tab); a Ctrl chord
767            // is chosen for portable representation and is mode-scoped.
768            keymap_entry! {
769                mode: Normal, chord: "<C-t>",
770                doc: "ai-conversation: toggle trust mode (auto-accept vs review)",
771                cmd: "action:ai-conv-toggle-trust"
772            },
773            // PU-B: permission requests are decided in the `*ai-permission*`
774            // popup menu (`ai-permission-mode`), opened by `:ai-permission` or
775            // (PU-B.3) auto-opened — not by chords on the conversation buffer.
776        ]
777    })
778}
779
780/// Join the prompt lines (`anchor..=last`) into the text to send: strip the
781/// `PROMPT_MARKER` from the first (anchor) line, take continuation lines (added
782/// via `<C-j>`) verbatim, and join with `\n`. Pure so the multi-line read is
783/// unit-testable without a live buffer.
784fn assemble_prompt<I: IntoIterator<Item = String>>(lines: I) -> String {
785    lines
786        .into_iter()
787        .enumerate()
788        .map(|(i, line)| {
789            let line = line.trim_end_matches('\n');
790            if i == 0 {
791                line.strip_prefix(PROMPT_MARKER).unwrap_or(line).to_string()
792            } else {
793                line.to_string()
794            }
795        })
796        .collect::<Vec<_>>()
797        .join("\n")
798}
799
800/// `action:ai-conv-send` — read the full (possibly multi-line) prompt and hand
801/// it to the agent via the `AiClientHandle` service, staying in Insert at the
802/// prompt (only <Esc> returns to Normal). The prompt spans `anchor..EOF`: the
803/// anchor line carries the `PROMPT_MARKER` (stripped), continuation lines from
804/// `<C-j>` do not. An empty prompt is a no-op (Enter does nothing); with no
805/// running session it echoes an error and keeps the prompt. The prompt clear +
806/// caret re-park are owned by the drain's re-projection and the per-tick focus
807/// callback (see `on_activate`), not by this handler — so there is no clear edit
808/// here to race the re-projection.
809fn send_handler(anchor: Arc<AtomicU32>) -> ActionHandler {
810    Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
811        let store = ctx.services.get::<BufferStoreHandle>()?;
812        let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
813        let handle = store.handle_for(buffer_id)?;
814        let snap = handle.snapshot();
815        // CV.3: content space — the prompt region is user-typed lines;
816        // a phantom empty line after the terminating newline is not one.
817        let last_line = snap.buffer.content_line_count().saturating_sub(1);
818        // The prompt is `anchor..=last_line` (marker on the anchor line,
819        // `<C-j>` continuation lines below).
820        let anchor_line = anchor.load(Ordering::Relaxed).min(last_line);
821        let prompt = assemble_prompt(
822            (anchor_line..=last_line).map(|l| snap.buffer.line(l).unwrap_or_default()),
823        );
824        if prompt.trim().is_empty() {
825            return None;
826        }
827        let ai = ctx.services.get::<AiClientHandle>()?;
828        // Surface the "no running agent" case instead of silently dropping the
829        // prompt: without a live session the supervisor discards `prompt(..)`, so
830        // Enter would appear to do nothing. Stay in Insert with the prompt intact
831        // so the user can retry after fixing the agent.
832        if !ai.snapshot().running {
833            return Some(Effect::Echo {
834                level: EchoLevel::Error,
835                text: "opencode: no running session — run :opencode; if it fails to \
836                       start, see :ai-log"
837                    .to_string(),
838            });
839        }
840        ai.prompt(prompt.to_string());
841        // AU‑3+: stay in Insert at the prompt (vim / `:terminal` / `:claude`
842        // parity — only <Esc> returns to Normal). `<CR>` resolved to a mode
843        // handler, so returning `None` consumes the key WITHOUT inserting a
844        // newline, and leaves the modal state untouched (Insert). The prompt is
845        // cleared and the caret re-parked at the fresh prompt by the drain's
846        // re-projection + the per-tick focus callback (see `on_activate`), which
847        // fire once the resulting User turn lands — NOT by a clear edit here (a
848        // separate clear would race the re-projection that shifts the prompt
849        // line down, and land on the wrong, now-read-only line).
850        None
851    })
852}
853
854/// `action:ai-conv-newline` — insert a literal newline at the cursor, growing
855/// the multi-line prompt. Bound to `<C-j>` (Insert) because `<CR>` is taken by
856/// send, matching the `:terminal` / `:claude` convention. Uses the generic
857/// `Effect::ApplyEdit` edit primitive; the caret parks at column 0 of the new
858/// line. The cursor is always within the prompt (Insert only edits the tail),
859/// so the insert stays inside the editable region.
860fn newline_handler() -> ActionHandler {
861    Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
862        let target = lattice_core::BufferId(ctx.buffer_id.0 as u32);
863        let edit = lattice_protocol::edit::Edit::insert(ctx.cursor, "\n".to_string());
864        Some(Effect::ApplyEdit {
865            target,
866            edit,
867            cursor: Some(lattice_protocol::position::Position::new(
868                ctx.cursor.line + 1,
869                0,
870            )),
871        })
872    })
873}
874
875/// `action:ai-conv-interrupt` — forward an interrupt to the agent (ACP
876/// `session/cancel`) via the `AiClientHandle` service, without leaving Insert.
877fn interrupt_handler() -> ActionHandler {
878    Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
879        if let Some(ai) = ctx.services.get::<AiClientHandle>() {
880            ai.interrupt();
881        }
882        None
883    })
884}
885
886/// `action:ai-conv-toggle-trust` (AU‑5) — flip trust mode via the
887/// `AiClientHandle` service and echo the new state. No buffer edit; the
888/// per-request permission tasks read the flag live.
889fn toggle_trust_handler() -> ActionHandler {
890    Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
891        let ai = ctx.services.get::<AiClientHandle>()?;
892        let on = ai.toggle_auto_accept();
893        Some(Effect::Echo {
894            level: EchoLevel::Info,
895            text: if on {
896                "ai: trust mode on — edits auto-accepted".to_string()
897            } else {
898                "ai: review mode — edits gated on diff review".to_string()
899            },
900        })
901    })
902}
903
904/// AU‑3: register the `ai-conversation` action commands so the mode's keymap
905/// `cmd` names resolve (the diff subsystem's `register_diff_actions` pattern).
906/// The specs are pure shells returning `Effect::None`: the real bodies live in
907/// [`AiConversationMode::action_handlers`], consulted before the CommandSpec.
908pub fn register_ai_conversation_actions(registry: &mut lattice_grammar::CommandRegistry) {
909    use lattice_grammar::registry::ActionSpec;
910    for (name, doc) in [
911        (
912            "action:ai-conv-send",
913            "ai-conversation: send the prompt to the agent.",
914        ),
915        (
916            "action:ai-conv-newline",
917            "ai-conversation: insert a newline in the prompt.",
918        ),
919        (
920            "action:ai-conv-interrupt",
921            "ai-conversation: interrupt the active turn.",
922        ),
923        (
924            "action:ai-conv-toggle-trust",
925            "ai-conversation: toggle trust mode (auto-accept vs diff review).",
926        ),
927    ] {
928        registry.register_action(
929            name,
930            doc,
931            ActionSpec {
932                apply: Arc::new(|_| Ok(Effect::None)),
933                args_schema: vec![],
934            },
935        );
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    #![allow(clippy::unwrap_used, clippy::panic)]
942    use super::*;
943    use crate::acp::conversation::{Conversation, SessionStatus, ToolStatus, Turn};
944    use lattice_agent::SessionKey;
945
946    fn text_turn(role: Role, text: &str) -> Turn {
947        Turn {
948            role,
949            blocks: vec![Block::Text(text.to_string())],
950        }
951    }
952
953    /// The drain clears the prompt exactly when the User-turn count rises (a
954    /// send), so this count is the load-bearing signal for the clear-vs-preserve
955    /// branch — agent turns must not trip it.
956    #[test]
957    fn user_turn_count_counts_only_user_turns() {
958        let conv = Conversation {
959            turns: vec![
960                text_turn(Role::User, "refactor"),
961                text_turn(Role::Assistant, "on it"),
962                text_turn(Role::User, "also add tests"),
963            ],
964            ..Default::default()
965        };
966        assert_eq!(user_turn_count(&conv), 2);
967        assert_eq!(user_turn_count(&Conversation::default()), 0);
968    }
969
970    #[test]
971    fn mode_id_is_ai_conversation_mode() {
972        assert_eq!(
973            AiConversationMode::mode_id(),
974            ModeId::new("ai-conversation-mode")
975        );
976    }
977
978    /// AU‑3+ (`<C-j>`): the anchor is the transcript-end line — the number of
979    /// newlines in the rendered transcript (each turn ends in `\n`, so the
980    /// trailing prompt marker lands on the next line).
981    #[test]
982    fn prompt_anchor_line_is_transcript_line_count() {
983        assert_eq!(prompt_anchor_line(""), 0); // empty ⇒ prompt on line 0
984        assert_eq!(prompt_anchor_line("you:\nhi\n"), 2); // prompt on line 2
985        assert_eq!(prompt_anchor_line("a\nb\nc\n"), 3);
986    }
987
988    /// AU‑3+ (`<C-j>`): the send read strips the marker from the first prompt
989    /// line, keeps continuation lines verbatim, and joins with newlines.
990    #[test]
991    fn assemble_prompt_reads_multiline_prompt() {
992        assert_eq!(assemble_prompt(["> hello".to_string()]), "hello");
993        assert_eq!(
994            assemble_prompt([
995                "> line1".to_string(),
996                "line2".to_string(),
997                "line3".to_string()
998            ]),
999            "line1\nline2\nline3",
1000        );
1001        // Per-line trailing newlines are trimmed; a bare (unmarked) first line
1002        // is taken as-is.
1003        assert_eq!(
1004            assemble_prompt(["> a\n".to_string(), "b\n".to_string()]),
1005            "a\nb"
1006        );
1007        assert_eq!(assemble_prompt(["plain".to_string()]), "plain");
1008    }
1009
1010    /// AU‑3: the mode declares a prompt tail editable after the 2-byte `"> "`
1011    /// marker — the input the host's read-only gate consults. The absolute
1012    /// anchor (seeded by the drain to the transcript-end line) starts at 0 for a
1013    /// fresh, empty conversation.
1014    #[test]
1015    fn editable_tail_is_anchored_prompt() {
1016        let mode = AiConversationMode::new();
1017        assert_eq!(
1018            <AiConversationMode as Mode>::editable_tail(&mode),
1019            Some(EditableTail {
1020                trailing_lines: 1,
1021                first_line_min_byte: 2,
1022                first_editable_line: Some(0),
1023            }),
1024        );
1025    }
1026
1027    /// AU‑3 gap fix: the mode claims its own invocation runner so the host
1028    /// wires `Editor::run_editable_tail_invocation` (which gates vim operators
1029    /// like `x`/`dd` to the editable prompt) instead of falling through to the
1030    /// ungated document path. `editor_boot.rs` registers the runner fn under
1031    /// exactly this mode id.
1032    #[test]
1033    fn claims_invocation_runner_for_the_editable_tail_gate() {
1034        let mode = AiConversationMode::new();
1035        assert_eq!(
1036            <AiConversationMode as Mode>::invocation_runner(&mode),
1037            Some(AiConversationMode::mode_id()),
1038        );
1039    }
1040
1041    /// The ai-conversation mode contributes only its ACP-specific chords:
1042    /// `<CR>` sends, `<C-j>` newline, `<C-c>` interrupts, `<C-t>` trust. The
1043    /// insert-entry keys (`i`/`a`/`o`/…) are NOT here — `repl-mode` (implied)
1044    /// owns that generic surface now. Catches a dropped chord or a name swap,
1045    /// and a regression that re-adds the insert-entry keys to this major.
1046    #[test]
1047    fn keymap_binds_only_acp_chords_not_insert_entry() {
1048        let pairs: Vec<(&str, Option<&str>)> = ai_conversation_keymap_entries()
1049            .iter()
1050            .map(|e| (e.chord, e.command))
1051            .collect();
1052        assert_eq!(
1053            pairs,
1054            vec![
1055                ("<CR>", Some("action:ai-conv-send")),
1056                ("<C-j>", Some("action:ai-conv-newline")),
1057                ("<C-c>", Some("action:ai-conv-interrupt")),
1058                ("<C-t>", Some("action:ai-conv-toggle-trust")),
1059            ],
1060        );
1061        assert!(
1062            !pairs
1063                .iter()
1064                .any(|(chord, _)| matches!(*chord, "i" | "a" | "o" | "A" | "I" | "O")),
1065            "insert-entry keys must live in repl-mode, not the ai-conversation major",
1066        );
1067    }
1068
1069    /// The major implies `repl-mode` — that is what brings the generic
1070    /// insert-entry surface onto the conversation buffer.
1071    #[test]
1072    fn implies_repl_mode() {
1073        let mode = AiConversationMode::new();
1074        assert_eq!(
1075            <AiConversationMode as Mode>::implies(&mode),
1076            &[ReplMode::mode_id()],
1077        );
1078    }
1079
1080    /// AU‑3: the mode contributes exactly the handler bodies, keyed to the SAME
1081    /// names the keymap binds, so the host's boot walk resolves each.
1082    #[test]
1083    fn action_handlers_contribute_focus_send_interrupt() {
1084        let names: Vec<&str> = AiConversationMode::new()
1085            .action_handlers()
1086            .iter()
1087            .map(|c| c.action_name)
1088            .collect();
1089        assert_eq!(
1090            names,
1091            vec![
1092                "action:ai-conv-send",
1093                "action:ai-conv-newline",
1094                "action:ai-conv-interrupt",
1095                "action:ai-conv-toggle-trust",
1096            ],
1097        );
1098    }
1099
1100    /// AU‑3/AU‑5: `register_ai_conversation_actions` registers every action
1101    /// command so the keymap `cmd` names resolve at boot.
1102    #[test]
1103    fn registers_the_action_commands() {
1104        let mut registry = lattice_grammar::CommandRegistry::new();
1105        register_ai_conversation_actions(&mut registry);
1106        for name in [
1107            "action:ai-conv-send",
1108            "action:ai-conv-interrupt",
1109            "action:ai-conv-toggle-trust",
1110        ] {
1111            assert!(registry.id_by_name(name).is_some(), "{name} registered");
1112        }
1113    }
1114
1115    #[test]
1116    fn renders_turn_headers_and_blocks() {
1117        let conv = Conversation {
1118            turns: vec![
1119                text_turn(Role::User, "refactor parse_args"),
1120                Turn {
1121                    role: Role::Assistant,
1122                    blocks: vec![
1123                        Block::Text("I'll extract a helper.".to_string()),
1124                        Block::ToolCall {
1125                            id: "t1".to_string(),
1126                            title: "edit parse.rs".to_string(),
1127                            status: ToolStatus::Running,
1128                            kind: Default::default(),
1129                            input: None,
1130                            output: None,
1131                        },
1132                    ],
1133                },
1134            ],
1135            ..Default::default()
1136        };
1137        let text = render_conversation(&conv);
1138        assert!(text.contains("you:\nrefactor parse_args\n"));
1139        assert!(text.contains("opencode:\nI'll extract a helper.\n"));
1140        assert!(text.contains("\u{25b8} edit parse.rs [running]"));
1141    }
1142
1143    // ── TCF: tool-call / reasoning fold projection ──
1144
1145    fn detailed_tool_call() -> Block {
1146        Block::ToolCall {
1147            id: "tc-1".to_string(),
1148            title: "bash".to_string(),
1149            status: ToolStatus::Ok,
1150            kind: Default::default(),
1151            input: Some("{\n  \"cmd\": \"echo hi\"\n}".to_string()),
1152            output: Some("\"hi\"".to_string()),
1153        }
1154    }
1155
1156    /// A detailed tool call renders indented `input:` / `output:` rows beneath
1157    /// its summary, and yields one fold spanning the summary head through the
1158    /// last detail row — keyed on the tool-call id. The fold's `start_line`
1159    /// lands on the `▸` summary line in the rendered text (head stays visible
1160    /// when closed; the detail rows below hide).
1161    #[test]
1162    fn tool_call_detail_rows_render_and_fold_from_the_summary_head() {
1163        let conv = Conversation {
1164            turns: vec![Turn {
1165                role: Role::Assistant,
1166                blocks: vec![detailed_tool_call()],
1167            }],
1168            ..Default::default()
1169        };
1170        let (text, folds) = project_conversation(&conv);
1171        assert!(text.contains("\u{25b8} bash [ok]"), "summary head: {text}");
1172        assert!(text.contains("    input:\n"), "input label: {text}");
1173        assert!(
1174            text.contains("      \"cmd\": \"echo hi\""),
1175            "indented input body: {text}"
1176        );
1177        assert!(text.contains("    output:\n"), "output label: {text}");
1178
1179        assert_eq!(folds.len(), 1);
1180        let f = &folds[0];
1181        assert_eq!(f.kind, ConversationFoldKind::ToolCall);
1182        assert_eq!(f.identity, tool_fold_identity("tc-1"));
1183        // The fold head is the summary line; the detail rows are its interior.
1184        let lines: Vec<&str> = text.split('\n').collect();
1185        assert!(
1186            lines[f.start_line as usize].contains("\u{25b8} bash [ok]"),
1187            "start_line is the summary head: {:?}",
1188            lines[f.start_line as usize],
1189        );
1190        assert!(f.end_line > f.start_line, "detail rows form the interior");
1191        // The last folded row is a detail row (the output body), not blank.
1192        assert!(
1193            lines[f.end_line as usize].contains("\"hi\""),
1194            "end is the last detail row"
1195        );
1196    }
1197
1198    /// A tool call with no captured detail renders only its summary and yields
1199    /// no fold — a 1-line region is a `z*` no-op.
1200    #[test]
1201    fn detail_less_tool_call_yields_no_fold() {
1202        let conv = Conversation {
1203            turns: vec![Turn {
1204                role: Role::Assistant,
1205                blocks: vec![Block::ToolCall {
1206                    id: "t1".to_string(),
1207                    title: "think".to_string(),
1208                    status: ToolStatus::Running,
1209                    kind: Default::default(),
1210                    input: None,
1211                    output: None,
1212                }],
1213            }],
1214            ..Default::default()
1215        };
1216        let (_text, folds) = project_conversation(&conv);
1217        assert!(folds.is_empty(), "no detail rows → nothing to fold");
1218    }
1219
1220    /// A multi-line reasoning block folds (identity keyed on its document-order
1221    /// ordinal); a single-line one does not.
1222    #[test]
1223    fn reasoning_folds_only_when_multiline() {
1224        let multi = Conversation {
1225            turns: vec![Turn {
1226                role: Role::Assistant,
1227                blocks: vec![Block::Reasoning(
1228                    "first thought\nsecond thought".to_string(),
1229                )],
1230            }],
1231            ..Default::default()
1232        };
1233        let (text, folds) = project_conversation(&multi);
1234        assert!(
1235            text.contains("  \u{2502} first thought"),
1236            "reasoning prefix: {text}"
1237        );
1238        assert_eq!(folds.len(), 1);
1239        assert_eq!(folds[0].kind, ConversationFoldKind::Reasoning);
1240        assert_eq!(folds[0].identity, reasoning_fold_identity(0));
1241
1242        let single = Conversation {
1243            turns: vec![Turn {
1244                role: Role::Assistant,
1245                blocks: vec![Block::Reasoning("just one line".to_string())],
1246            }],
1247            ..Default::default()
1248        };
1249        assert!(
1250            project_conversation(&single).1.is_empty(),
1251            "a single reasoning line is not foldable",
1252        );
1253    }
1254
1255    /// Fold line numbers are on the same split-on-`\n` grid the drain diffs and
1256    /// the buffer uses: `render_conversation` and `project_conversation` share
1257    /// one pass, so the text and the spans can never disagree.
1258    #[test]
1259    fn render_and_project_share_the_same_text() {
1260        let conv = Conversation {
1261            turns: vec![
1262                text_turn(Role::User, "run it"),
1263                Turn {
1264                    role: Role::Assistant,
1265                    blocks: vec![
1266                        Block::Reasoning("planning\nthe\napproach".to_string()),
1267                        detailed_tool_call(),
1268                    ],
1269                },
1270            ],
1271            ..Default::default()
1272        };
1273        assert_eq!(render_conversation(&conv), project_conversation(&conv).0);
1274    }
1275
1276    #[test]
1277    fn streaming_append_only_touches_the_tail() {
1278        let old = "opencode:\nhello\n\n";
1279        let new = "opencode:\nhello world\n\n";
1280        let rep = suffix_replace(old, new).expect("texts differ");
1281        // Line 0 ("opencode:") is unchanged; the first differing line is line 1.
1282        assert_eq!(rep.first_diff_line, 1);
1283        assert!(rep.replacement.starts_with("hello world"));
1284    }
1285
1286    #[test]
1287    fn tool_status_change_rewrites_only_from_that_line() {
1288        let mut conv = Conversation {
1289            turns: vec![Turn {
1290                role: Role::Assistant,
1291                blocks: vec![
1292                    Block::Text("working".to_string()),
1293                    Block::ToolCall {
1294                        id: "t1".to_string(),
1295                        title: "run".to_string(),
1296                        status: ToolStatus::Running,
1297                        kind: Default::default(),
1298                        input: None,
1299                        output: None,
1300                    },
1301                ],
1302            }],
1303            ..Default::default()
1304        };
1305        let before = render_conversation(&conv);
1306        if let Block::ToolCall { status, .. } = &mut conv.turns[0].blocks[1] {
1307            *status = ToolStatus::Ok;
1308        }
1309        let after = render_conversation(&conv);
1310        let rep = suffix_replace(&before, &after).expect("status changed");
1311        // "opencode:" (0), "working" (1) unchanged; the tool line (2) is first diff.
1312        assert_eq!(rep.first_diff_line, 2);
1313        assert!(rep.replacement.contains("[ok]"));
1314    }
1315
1316    #[test]
1317    fn identical_render_yields_no_edit() {
1318        let t = "opencode:\nhi\n\n";
1319        assert_eq!(suffix_replace(t, t), None);
1320    }
1321
1322    // ── AUX‑1: permission block rendering tests ──
1323
1324    use crate::acp::conversation::PermissionStatus as PS;
1325    use agent_client_protocol::schema::v1::{PermissionOption, PermissionOptionKind as POK};
1326
1327    fn test_permission_option(id: &'static str, name: &'static str, kind: POK) -> PermissionOption {
1328        PermissionOption::new(id, name, kind)
1329    }
1330
1331    #[test]
1332    fn render_permission_block_pending_shows_circle_and_options() {
1333        let conv = Conversation {
1334            turns: vec![Turn {
1335                role: Role::Assistant,
1336                blocks: vec![Block::Permission {
1337                    id: "perm-1".to_string(),
1338                    title: "Allow cargo test?".to_string(),
1339                    description: None,
1340                    options: vec![
1341                        test_permission_option("a1", "Allow once", POK::AllowOnce),
1342                        test_permission_option("r1", "Reject", POK::RejectOnce),
1343                    ],
1344                    status: PS::Pending,
1345                }],
1346            }],
1347            ..Default::default()
1348        };
1349        let text = render_conversation(&conv);
1350        assert!(text.contains("\u{25cc} Allow cargo test? [pending]"));
1351        // PU-B.2: the inline record lists options without the old key hints —
1352        // the popup menu owns the numbered selectors.
1353        assert!(text.contains("1: Allow once"));
1354        assert!(text.contains("2: Reject"));
1355        assert!(!text.contains("(a)"), "no more misleading key hints");
1356    }
1357
1358    #[test]
1359    fn render_permission_block_allowed_shows_checkmark() {
1360        let conv = Conversation {
1361            turns: vec![Turn {
1362                role: Role::Assistant,
1363                blocks: vec![Block::Permission {
1364                    id: "perm-1".to_string(),
1365                    title: "Allow cargo test?".to_string(),
1366                    description: None,
1367                    options: vec![],
1368                    status: PS::Allowed,
1369                }],
1370            }],
1371            ..Default::default()
1372        };
1373        let text = render_conversation(&conv);
1374        assert!(text.contains("\u{2713} Allow cargo test? [allowed]"));
1375        assert!(!text.contains("1:"), "no options after resolution");
1376    }
1377
1378    #[test]
1379    fn render_permission_block_denied_shows_x() {
1380        let conv = Conversation {
1381            turns: vec![Turn {
1382                role: Role::Assistant,
1383                blocks: vec![Block::Permission {
1384                    id: "perm-1".to_string(),
1385                    title: "Allow cargo test?".to_string(),
1386                    description: None,
1387                    options: vec![],
1388                    status: PS::Denied,
1389                }],
1390            }],
1391            ..Default::default()
1392        };
1393        let text = render_conversation(&conv);
1394        assert!(text.contains("\u{2717} Allow cargo test? [denied]"));
1395    }
1396
1397    // ── AUX‑2: headerline / humanize tests ──
1398
1399    #[test]
1400    fn humanize_thousands() {
1401        assert_eq!(humanize(0), "0");
1402        assert_eq!(humanize(500), "500");
1403        assert_eq!(humanize(1000), "1.0K");
1404        assert_eq!(humanize(31400), "31.4K");
1405        assert_eq!(humanize(200000), "200.0K");
1406    }
1407
1408    #[test]
1409    fn humanize_millions() {
1410        assert_eq!(humanize(1_000_000), "1.0M");
1411        assert_eq!(humanize(2_500_000), "2.5M");
1412    }
1413
1414    #[test]
1415    fn format_tokens_joins_used_and_size() {
1416        assert_eq!(format_tokens(31400, 200000), "31.4K/200.0K");
1417        assert_eq!(format_tokens(0, 1000), "0/1.0K");
1418    }
1419
1420    #[test]
1421    fn headerline_always_shows_status_without_usage() {
1422        let hl = ConversationHeaderline {
1423            store: ConversationStore::new(Arc::new(|_| {})),
1424            version: Arc::new(AtomicU64::new(0)),
1425            queue_len: Arc::new(AtomicUsize::new(0)),
1426        };
1427        let row = hl.render().expect("status always present → headerline row");
1428        let text: String = row
1429            .cells
1430            .iter()
1431            .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1432            .collect();
1433        assert!(text.contains("Ready"), "status shows Idle→Ready: {text}");
1434        assert!(!text.contains("CPU:"), "no usage → no CPU segment: {text}");
1435    }
1436
1437    #[test]
1438    fn headerline_shows_status_and_usage() {
1439        let store = {
1440            let published = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1441            let p = published.clone();
1442            let store = ConversationStore::new(Arc::new(move |_| {
1443                p.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1444            }));
1445            let u = agent_client_protocol::schema::v1::UsageUpdate::new(31400, 200000)
1446                .cost(agent_client_protocol::schema::v1::Cost::new(0.045, "USD"));
1447            store.apply(
1448                &SessionKey::new("test", 0),
1449                &agent_client_protocol::schema::v1::SessionUpdate::UsageUpdate(u),
1450            );
1451            store
1452        };
1453        let hl = ConversationHeaderline {
1454            store,
1455            version: Arc::new(AtomicU64::new(1)),
1456            queue_len: Arc::new(AtomicUsize::new(0)),
1457        };
1458        let row = hl.render().expect("headerline row");
1459        let text: String = row
1460            .cells
1461            .iter()
1462            .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1463            .collect();
1464        assert!(text.contains("Ready"), "status prefix: {text}");
1465        assert!(
1466            text.contains("31.4K"),
1467            "headerline shows used tokens: {text}"
1468        );
1469        assert!(
1470            text.contains("200.0K"),
1471            "headerline shows context size: {text}"
1472        );
1473        assert!(text.contains("$0.045"), "headerline shows cost: {text}");
1474        assert!(text.contains("USD"), "headerline shows currency: {text}");
1475    }
1476
1477    /// Why the drain must not gate its repaint wake on transcript-text change:
1478    /// a `usage_update` moves the headerline but leaves `render_conversation`
1479    /// byte-identical. Gating the wake on text change (the AUX‑2 bug) freezes
1480    /// the headerline at its last text-driven repaint — "Ready", never a cost.
1481    #[test]
1482    fn usage_only_update_moves_headerline_but_not_transcript() {
1483        fn row_text(hl: &ConversationHeaderline) -> String {
1484            hl.render()
1485                .expect("row")
1486                .cells
1487                .iter()
1488                .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1489                .collect()
1490        }
1491
1492        let store = ConversationStore::new(Arc::new(|_| {}));
1493        store.push_user_text(&SessionKey::new("test", 0), "hello");
1494        let hl = ConversationHeaderline {
1495            store: store.clone(),
1496            version: Arc::new(AtomicU64::new(0)),
1497            queue_len: Arc::new(AtomicUsize::new(0)),
1498        };
1499
1500        let transcript_before = render_conversation(&store.snapshot());
1501        let headerline_before = row_text(&hl);
1502
1503        let u = agent_client_protocol::schema::v1::UsageUpdate::new(31400, 200000)
1504            .cost(agent_client_protocol::schema::v1::Cost::new(0.045, "USD"));
1505        store.apply(
1506            &SessionKey::new("test", 0),
1507            &agent_client_protocol::schema::v1::SessionUpdate::UsageUpdate(u),
1508        );
1509
1510        assert_eq!(
1511            transcript_before,
1512            render_conversation(&store.snapshot()),
1513            "usage never appears in the transcript text",
1514        );
1515        assert_ne!(
1516            headerline_before,
1517            row_text(&hl),
1518            "usage DOES change the headerline — so a text-unchanged \
1519             re-projection still has to wake the renderer",
1520        );
1521        assert!(row_text(&hl).contains("31.4K"), "{}", row_text(&hl));
1522    }
1523
1524    #[test]
1525    fn headerline_omits_cost_when_missing() {
1526        let store = {
1527            let store = ConversationStore::new(Arc::new(|_| {}));
1528            let u = agent_client_protocol::schema::v1::UsageUpdate::new(5000, 16000);
1529            store.apply(
1530                &SessionKey::new("test", 0),
1531                &agent_client_protocol::schema::v1::SessionUpdate::UsageUpdate(u),
1532            );
1533            store
1534        };
1535        let hl = ConversationHeaderline {
1536            store,
1537            version: Arc::new(AtomicU64::new(1)),
1538            queue_len: Arc::new(AtomicUsize::new(0)),
1539        };
1540        let row = hl.render().expect("usage present");
1541        let text: String = row
1542            .cells
1543            .iter()
1544            .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1545            .collect();
1546        assert!(text.contains("Ready"), "status prefix: {text}");
1547        assert!(text.contains("CPU:"), "headerline has CPU prefix: {text}");
1548        assert!(
1549            text.contains("5.0K/16.0K"),
1550            "headerline shows tokens: {text}"
1551        );
1552        assert!(!text.contains("$"), "no cost segment: {text}");
1553    }
1554
1555    // ── AUX‑3: status-in-headerline tests ──
1556
1557    #[test]
1558    fn headerline_shows_thinking_status() {
1559        let store = {
1560            let store = ConversationStore::new(Arc::new(|_| {}));
1561            store.set_status(&SessionKey::new("test", 0), SessionStatus::Thinking);
1562            store
1563        };
1564        let hl = ConversationHeaderline {
1565            store,
1566            version: Arc::new(AtomicU64::new(1)),
1567            queue_len: Arc::new(AtomicUsize::new(0)),
1568        };
1569        let row = hl.render().expect("headerline row");
1570        let text: String = row
1571            .cells
1572            .iter()
1573            .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1574            .collect();
1575        assert!(
1576            text.contains("Thinking"),
1577            "headerline shows Thinking: {text}"
1578        );
1579    }
1580
1581    #[test]
1582    fn headerline_shows_executing_status() {
1583        let store = {
1584            let store = ConversationStore::new(Arc::new(|_| {}));
1585            store.set_status(
1586                &SessionKey::new("test", 0),
1587                SessionStatus::Executing {
1588                    tool: "edit parse.rs".into(),
1589                },
1590            );
1591            store
1592        };
1593        let hl = ConversationHeaderline {
1594            store,
1595            version: Arc::new(AtomicU64::new(1)),
1596            queue_len: Arc::new(AtomicUsize::new(0)),
1597        };
1598        let row = hl.render().expect("headerline row");
1599        let text: String = row
1600            .cells
1601            .iter()
1602            .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1603            .collect();
1604        assert!(
1605            text.contains("Working: edit parse.rs"),
1606            "headerline shows tool: {text}"
1607        );
1608    }
1609
1610    #[test]
1611    fn headerline_shows_awaiting_permission() {
1612        let store = {
1613            let store = ConversationStore::new(Arc::new(|_| {}));
1614            store.set_status(
1615                &SessionKey::new("test", 0),
1616                SessionStatus::AwaitingPermission,
1617            );
1618            store
1619        };
1620        let hl = ConversationHeaderline {
1621            store,
1622            version: Arc::new(AtomicU64::new(1)),
1623            queue_len: Arc::new(AtomicUsize::new(0)),
1624        };
1625        let row = hl.render().expect("headerline row");
1626        let text: String = row
1627            .cells
1628            .iter()
1629            .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1630            .collect();
1631        assert!(
1632            text.contains("Awaiting your approval"),
1633            "headerline shows awaiting: {text}"
1634        );
1635    }
1636
1637    // ── AUX‑4: queue-in-headerline tests ──
1638
1639    #[test]
1640    fn headerline_shows_queue_count() {
1641        let hl = ConversationHeaderline {
1642            store: ConversationStore::new(Arc::new(|_| {})),
1643            version: Arc::new(AtomicU64::new(0)),
1644            queue_len: Arc::new(AtomicUsize::new(2)),
1645        };
1646        let row = hl.render().expect("headerline row");
1647        let text: String = row
1648            .cells
1649            .iter()
1650            .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1651            .collect();
1652        assert!(text.contains("⌛"), "queue icon present: {text}");
1653        assert!(text.contains("2 queued"), "queue count shown: {text}");
1654    }
1655
1656    #[test]
1657    fn headerline_hides_queue_when_empty() {
1658        let hl = ConversationHeaderline {
1659            store: ConversationStore::new(Arc::new(|_| {})),
1660            version: Arc::new(AtomicU64::new(0)),
1661            queue_len: Arc::new(AtomicUsize::new(0)),
1662        };
1663        let row = hl.render().expect("headerline row");
1664        let text: String = row
1665            .cells
1666            .iter()
1667            .map(|c| char::from_u32(c.codepoint).unwrap_or('�'))
1668            .collect();
1669        assert!(
1670            !text.contains("queued"),
1671            "no queued text when empty: {text}"
1672        );
1673        assert!(text.contains("Ready"), "still shows status: {text}");
1674    }
1675}