1use 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
40const CONV_HEADERLINE_PROVIDER_ID: ProviderId = 0x4155_0002;
43
44struct 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 text.push_str(&snap.status.to_string());
62 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); }
68 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
89fn 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
106const PROMPT_MARKER: &str = "> ";
111
112pub fn conversation_buffer_name() -> String {
114 "*ai:opencode*".to_string()
115}
116
117#[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
152fn prompt_anchor_line(transcript: &str) -> u32 {
157 transcript.matches('\n').count() as u32
158}
159
160pub fn render_conversation(conv: &Conversation) -> String {
164 project_conversation(conv).0
165}
166
167pub fn project_conversation(conv: &Conversation) -> (String, Vec<ConversationFold>) {
183 let mut out = String::new();
184 let mut folds: Vec<ConversationFold> = Vec::new();
185 let mut line: u32 = 0;
188 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 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} ", Allowed => "\u{2713} ", Denied => "\u{2717} ", }
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
351pub 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#[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
384pub struct AiConversationGuard {
388 _subscription: Subscription,
389 headerline: Option<(Arc<dyn VirtualRowRegistrar>, lattice_core::BufferId)>,
393 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 fn invocation_runner(&self) -> Option<ModeId> {
439 Some(Self::mode_id())
440 }
441
442 fn implies(&self) -> &[ModeId] {
448 ai_conversation_implies()
449 }
450
451 fn editable_tail(&self) -> Option<EditableTail> {
456 Some(EditableTail {
457 trailing_lines: 1,
458 first_line_min_byte: PROMPT_MARKER.len() as u32,
459 first_editable_line: Some(self.anchor.load(Ordering::Relaxed)),
465 })
466 }
467
468 fn keymap(&self) -> Keymap {
474 Keymap::from_entries(ai_conversation_keymap_entries())
475 }
476
477 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 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 let anchor = self.anchor.clone();
538 anchor.store(prompt_anchor_line(&last), Ordering::Relaxed);
539
540 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 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 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 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 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 drain_anchor.store(prompt_anchor_line(&new), Ordering::Relaxed);
614 let _did_clear = user_turns > last_user_turns;
623 let _changed = reproject(&handle, &last, &new, _did_clear).await;
624 drain_hl_version.fetch_add(1, Ordering::Release);
628
629 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
656fn 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
663async 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); 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
676fn user_turn_count(conv: &Conversation) -> usize {
680 conv.turns.iter().filter(|t| t.role == Role::User).count()
681}
682
683async 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; };
703 let (end, replacement) = if clear_prompt {
704 let snap = handle.snapshot();
705 let last_line = snap.buffer.rope_line_count().saturating_sub(1); 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
723fn 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 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 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 ]
777 })
778}
779
780fn 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
800fn 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 let last_line = snap.buffer.content_line_count().saturating_sub(1);
818 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 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 None
851 })
852}
853
854fn 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
875fn 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
886fn 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
904pub 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 #[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 #[test]
982 fn prompt_anchor_line_is_transcript_line_count() {
983 assert_eq!(prompt_anchor_line(""), 0); assert_eq!(prompt_anchor_line("you:\nhi\n"), 2); assert_eq!(prompt_anchor_line("a\nb\nc\n"), 3);
986 }
987
988 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 assert!(
1193 lines[f.end_line as usize].contains("\"hi\""),
1194 "end is the last detail row"
1195 );
1196 }
1197
1198 #[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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[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}