1use std::{
2 cell::RefCell,
3 fmt::Write as _,
4 rc::Rc,
5 time::{Duration, Instant},
6};
7
8use omp_core::{Str, StrMut, fmts};
9use omp_tui::{
10 Border, Charset, Color, Command, Component, EditOutcome, Editor, EditorOptions, EventCtx, Flow,
11 Frame, Hit, HitTag, Icon, Key, Mouse, MouseReport, PaintCtx, Prop, Props, Rect, Size,
12 SlashCommands, Slot, Style, SuggestionDisplay, Theme, Ui, UiContext,
13 anim::{Easing, Shimmer, Tween},
14 components::{
15 Attachment, Attachments, EditorPane, Segment, Status, attachment_color, chip_label,
16 },
17 next_slot,
18 syntax::{SyntaxRun, highlight_xml},
19};
20use smallvec::SmallVec;
21
22const PANEL: Color = Color::Rgb(12, 15, 18);
25const TEXT: Color = Color::Rgb(194, 198, 204);
26const MUTED: Color = Color::Rgb(110, 116, 124);
27const FAINT: Color = Color::Rgb(72, 78, 86);
28const GREEN: Color = Color::Rgb(81, 196, 112);
29const CYAN: Color = Color::Rgb(62, 190, 203);
30const PURPLE: Color = Color::Rgb(171, 119, 230);
31const GOLD: Color = Color::Rgb(210, 167, 86);
32
33const EDIT_BOX_HEIGHT: u16 = 4;
34const MESSAGE_INTERVAL: Duration = Duration::from_millis(430);
35const EMIT_INTERVAL: Duration = Duration::from_millis(700);
36const LIVE_SHARD_ROWS: u16 = 12;
37const SHIMMER_PERIOD: Duration = Duration::from_millis(1900);
40const WORKING_MESSAGE: &str = "Implementing immutable seam commits";
41const SESSION_TITLE: &str = "Immutable Seam Commits & Status Bar Rework";
44#[cfg(test)]
46const CANCEL_HINT: &str = Charset::NerdFont.icon(Icon::Cancellable);
47#[cfg(test)]
49const INPUT_PROMPT: &str = "╰─";
50const BRAND_FADE: Duration = Duration::from_millis(450);
52const FADE_FRAME: Duration = Duration::from_millis(40);
54const STATUS_ID: &str = "status";
55
56#[derive(Clone, Copy)]
57struct Span<'a> {
58 text: &'a str,
59 style: Style,
60}
61
62impl<'a> Span<'a> {
63 const fn new(text: &'a str, style: Style) -> Self {
64 Self { text, style }
65 }
66}
67
68struct LiveRowCache {
70 label: StrMut,
71 label_x: u16,
72 label_shard: u16,
73 label_progress: u64,
74 label_valid: bool,
75 prefix_shard: u16,
76 prefix_phase: u8,
77 prefix_valid: bool,
78}
79impl LiveRowCache {
80 fn new() -> Self {
81 Self {
82 label: StrMut::with_capacity(40),
83 label_x: 0,
84 label_shard: 0,
85 label_progress: 0,
86 label_valid: false,
87 prefix_shard: 0,
88 prefix_phase: 0,
89 prefix_valid: false,
90 }
91 }
92}
93
94#[allow(clippy::too_many_arguments, reason = "immediate-mode painter threading frame state")]
97fn draw_shimmer(
98 frame: &mut Frame,
99 column: &mut u16,
100 start: u16,
101 y: u16,
102 right: u16,
103 text: &str,
104 shimmer: Shimmer,
105 high: Style,
106) {
107 for grapheme in xutf::graphemes_str(text) {
108 if *column >= right {
109 return;
110 }
111 let style = shimmer.pick(*column - start, ink(FAINT), ink(MUTED), high);
112 let next = frame.put(*column, y, grapheme, style);
113 if next == *column {
114 return;
115 }
116 *column = next;
117 }
118}
119
120fn elapsed_label(elapsed: Duration) -> Str {
121 let seconds = elapsed.as_secs();
122 if seconds < 60 {
123 fmts!("{seconds}s")
124 } else if seconds < 3_600 {
125 fmts!("{}m", seconds / 60)
126 } else {
127 fmts!("{}h", (seconds / 3_600).min(99))
128 }
129}
130
131pub fn demo_commands() -> Vec<Command> {
135 vec![
136 Command::new(
137 "security",
138 "Plan, run, inspect, import, and compare OMP-native security scans",
139 &[],
140 )
141 .with_args(&[
142 ("plan", "Draft a scan plan for this workspace", "[focus]"),
143 ("run", "Execute the current scan plan", ""),
144 ("inspect", "Browse findings from the last scan", "[finding-id]"),
145 ("import", "Import an external scan report", "<path>"),
146 ("compare", "Diff two scan runs", "<run-a> <run-b>"),
147 ])
148 .with_hint("plan|run|inspect|import|compare"),
149 Command::new("attach", "Stage an image attachment on the composer", &[]).with_hint("<path>"),
150 Command::new("settings", "Open settings menu", &[]),
151 Command::new("setup", "Open provider setup", &["providers"]).with_hint("[provider]"),
152 Command::new("plan", "Toggle plan mode (agent plans before executing)", &[]),
153 Command::new("plan-review", "Re-open the plan review for the latest plan", &[]),
154 Command::new("vibe", "Toggle persistent fast worker sessions", &[]),
155 Command::new("goal", "Toggle an autonomous objective for this session", &[])
156 .with_hint("<objective>"),
157 Command::new("guided-goal", "Interview you in chat, then set up goal mode", &[]),
158 Command::new("queue", "Queue a message for after the agent yields", &[]),
159 Command::new("switch", "Switch model for this session (same as alt+p)", &[]),
160 Command::new("fast", "Toggle priority service tier", &[]),
161 Command::new("computer", "Toggle the native computer-use tool", &[]),
162 Command::new("vision", "Control inspect_image vision delegation", &[]),
163 Command::new("prewalk", "Switch to a fast model at the next action", &[]),
164 Command::new("advisor", "Toggle the second-model advisor", &[]),
165 Command::new("export", "Export session to an HTML file", &[]),
166 Command::new("dump", "Copy the session transcript to clipboard", &[]),
167 Command::new("share", "Share session via an encrypted link", &[]),
168 Command::new("collab", "Share this session live via a relay", &[]),
169 Command::new("join", "Join a shared collab session", &[]),
170 Command::new("leave", "Leave the collab session", &[]),
171 Command::new("browser", "Toggle browser headless vs visible mode", &[]),
172 Command::new("copy", "Pick conversation text or code to copy", &[]),
173 Command::new("todo", "View or modify the agent's todo list", &[]),
174 Command::new("session", "Session management commands", &[]),
175 Command::new("jobs", "Show async background jobs status", &[]),
176 Command::new("usage", "Show provider usage and limits", &[]),
177 Command::new("stats", "Launch the local stats dashboard", &[]),
178 Command::new("changelog", "Show changelog entries", &[]),
179 Command::new("hotkeys", "Show all keyboard shortcuts", &[]),
180 Command::new("tools", "Show tools currently visible to the agent", &[]),
181 Command::new("context", "Show estimated context usage breakdown", &[]),
182 Command::new("agents", "Open Agent Control Center dashboard", &[]),
183 Command::new("branch", "Create a new branch from a previous message", &[]),
184 Command::new("fork", "Create a new fork from a previous message", &[]),
185 Command::new("tree", "Navigate the session tree", &[]),
186 Command::new("login", "Login with an OAuth provider", &[]),
187 Command::new("logout", "Logout from an OAuth provider", &[]),
188 Command::new("mcp", "Manage MCP servers", &[]),
189 Command::new("ssh", "Manage SSH hosts", &[]),
190 Command::new("new", "Start a new session", &[]),
191 Command::new("fresh", "Reset provider state without changing the transcript", &[]),
192 Command::new("clear", "Clear conversation context, keeping the session", &[]),
193 Command::new("drop", "Delete the current session and start a new one", &[]),
194 Command::new("compact", "Manually compact the session context", &[]),
195 Command::new("shake", "Drop heavy content from context", &[]),
196 Command::new("handoff", "Hand off context to a new session", &[]),
197 Command::new("resume", "Resume a different session", &[]),
198 Command::new("btw", "Ask an ephemeral side question", &[]),
199 Command::new("tan", "Run a background agent on tangential work", &[]),
200 Command::new("omfg", "Forge a rule from a recurring complaint", &[]),
201 Command::new("retry", "Retry the last failed agent turn", &[]),
202 Command::new("debug", "Open the debug tools selector", &[]),
203 Command::new("memory", "Inspect and operate memory maintenance", &[]),
204 Command::new("rename", "Rename the current session", &[]),
205 Command::new("move", "Move the session to a different directory", &[]),
206 Command::new("add-dir", "Add a workspace directory", &[]),
207 Command::new("remove-dir", "Remove a workspace directory", &[]),
208 Command::new("dirs", "List this session's workspace directories", &[]),
209 Command::new("marketplace", "Manage marketplace plugins", &[]),
210 Command::new("plugins", "View and manage installed plugins", &[]),
211 Command::new("reload-plugins", "Reload skills, commands, hooks, tools, and agents", &[]),
212 Command::new("force", "Force the next turn to use a specific tool", &["force:"]),
213 Command::new("live", "Start Codex-backed realtime voice mode", &[]),
214 Command::new("pause", "Freeze all agents until resumed", &[]),
215 Command::new("quit", "Quit the application", &["q"]),
216 ]
217}
218
219struct Submission {
222 text: String,
223 width: u16,
224 view: Option<Ui>,
227}
228
229impl Submission {
230 fn new(text: String, width: u16, ctx: &UiContext) -> Self {
231 let view = Self::view(&text, width, ctx);
232 Self { text, width, view }
233 }
234
235 fn view(text: &str, width: u16, ctx: &UiContext) -> Option<Ui> {
236 (!text.contains("</md>") && next_ref_tag(text).is_none())
237 .then(|| Ui::from_markup(format!("<md>{text}</md>"), width, ctx.clone()).ok())
238 .flatten()
239 }
240
241 fn resize(&mut self, width: u16, ctx: &UiContext) {
242 if self.width != width {
243 self.width = width;
244 self.view = Self::view(&self.text, width, ctx);
245 }
246 }
247
248 fn height(&self) -> u16 {
250 self
251 .view
252 .as_ref()
253 .map_or_else(|| explicit_line_count(&self.text), Ui::height)
254 }
255}
256
257enum Entry {
261 Command,
263 Message(usize),
265 ShardDone(u16),
267 Submitted(Box<Submission>),
269}
270
271struct DemoInput {
273 props: Props,
274 slot: Slot,
275 editor: Rc<RefCell<Editor>>,
276 outcome: Rc<RefCell<Option<EditOutcome>>>,
277}
278
279impl DemoInput {
280 fn new(editor: Rc<RefCell<Editor>>, outcome: Rc<RefCell<Option<EditOutcome>>>) -> Self {
281 Self { props: Props::new(), slot: next_slot(), editor, outcome }
282 }
283
284 const fn input_offset() -> u16 {
287 3
288 }
289
290 fn input_prompt(charset: Charset) -> Str {
292 let (_, _, bl, _, horizontal, _) = charset.border(Border::Round);
293 fmts!("{bl}{horizontal}")
294 }
295
296 const fn input_width(width: u16) -> u16 {
297 width.saturating_sub(Self::input_offset()).saturating_sub(1)
298 }
299
300 fn paint_picker(pc: &mut PaintCtx<'_>, rect: Rect, y: u16, editor: &Editor) {
301 let Some(picker) = editor.picker() else {
302 return;
303 };
304 let (start, suggestions) = picker.visible_suggestions();
305 let overflow = picker.len() > suggestions.len();
306 let row_right = rect
307 .x
308 .saturating_add(rect.width.saturating_sub(u16::from(overflow)));
309 let primary_width = suggestions
310 .iter()
311 .filter_map(|suggestion| match suggestion.display() {
312 SuggestionDisplay::Text(name) => Some(visible_width(name).saturating_add(2)),
313 SuggestionDisplay::Emoji { .. } => None,
314 })
315 .max()
316 .unwrap_or(12)
317 .clamp(12, 32);
318
319 for (offset, suggestion) in suggestions.iter().enumerate() {
320 let Ok(offset) = u16::try_from(offset) else {
321 break;
322 };
323 let row = y.saturating_add(offset);
324 if row >= pc.clip {
325 break;
326 }
327 let selected = start + usize::from(offset) == picker.selected();
328 let label = if selected { ink(GREEN) } else { ink(TEXT) };
329 let description = if selected { ink(GREEN) } else { ink(MUTED) };
330 pc.frame.put(
331 rect.x,
332 row,
333 if selected {
334 pc.ctx.charset.cursor()
335 } else {
336 " "
337 },
338 label,
339 );
340 match suggestion.display() {
341 SuggestionDisplay::Text(name) => {
342 draw_line(
343 pc.frame,
344 rect.x.saturating_add(2),
345 row,
346 row_right.saturating_sub(rect.x.saturating_add(2)),
347 &[Span::new(name, label)],
348 );
349 if let Some(text) = suggestion.description()
350 && rect.width > 40
351 {
352 let description_x = rect
353 .x
354 .saturating_add(2)
355 .saturating_add(primary_width)
356 .min(row_right);
357 draw_line(
358 pc.frame,
359 description_x,
360 row,
361 row_right.saturating_sub(description_x),
362 &[Span::new(text, description)],
363 );
364 }
365 },
366 SuggestionDisplay::Emoji { emoji, shortcode } => {
367 let mut column = pc.frame.put(rect.x.saturating_add(2), row, emoji, label);
368 column = pc.frame.put(column, row, " ", label);
369 if shortcode.starts_with(':') {
370 pc.frame.put(column, row, shortcode, label);
371 } else {
372 column = pc.frame.put(column, row, ":", label);
373 column = pc.frame.put(column, row, shortcode, label);
374 pc.frame.put(column, row, ":", label);
375 }
376 },
377 }
378 }
379
380 if overflow && !suggestions.is_empty() {
381 let (track, thumb_glyph) = pc.ctx.charset.scrollbar();
382 let track_x = rect.x.saturating_add(rect.width.saturating_sub(1));
383 for offset in 0..suggestions.len() {
384 let Ok(offset) = u16::try_from(offset) else {
385 break;
386 };
387 pc.frame
388 .put(track_x, y.saturating_add(offset), track, ink(FAINT));
389 }
390 let thumb = picker
391 .selected()
392 .saturating_mul(suggestions.len().saturating_sub(1))
393 / picker.len().saturating_sub(1);
394 pc.frame.put(
395 track_x,
396 y.saturating_add(u16::try_from(thumb).unwrap_or(u16::MAX)),
397 thumb_glyph,
398 ink(GREEN),
399 );
400 }
401 }
402}
403
404impl Component for DemoInput {
405 fn props(&self) -> &Props {
406 &self.props
407 }
408
409 fn props_mut(&mut self) -> &mut Props {
410 &mut self.props
411 }
412
413 fn slot(&self) -> Slot {
414 self.slot
415 }
416
417 fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
418 (6, 40)
419 }
420
421 fn height(&mut self, _ctx: &UiContext, width: u16) -> u16 {
422 let editor = self.editor.borrow();
423 editor
424 .input_height_for(Self::input_width(width))
425 .saturating_add(editor.picker_height())
426 }
427
428 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
429 pc.hits
430 .push(Hit { rect, slot: self.slot, tag: HitTag::Press });
431 let editor = self.editor.borrow();
432 let input_x = rect.x.saturating_add(Self::input_offset());
433 let input_width = Self::input_width(rect.width);
434 let input_height = editor.input_height_for(input_width);
435 let theme = Theme::default();
436 let mut in_comment = false;
437 for (offset, row) in editor.view(input_width).iter().enumerate() {
438 let row_y = rect
439 .y
440 .saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
441 if row_y >= pc.clip {
442 break;
443 }
444 if offset == 0 {
445 pc.frame
446 .put(rect.x, row_y, &Self::input_prompt(pc.ctx.charset), ink(FAINT));
447 }
448 let mut spans: SmallVec<Span<'_>, 16> = SmallVec::new();
449 if editor.options().xml {
450 let (runs, next) = highlight_xml(row.text, &theme, in_comment);
451 in_comment = next;
452 push_row_spans(&editor, row.text, &runs, &mut spans);
453 } else {
454 push_row_spans(&editor, row.text, &[], &mut spans);
455 }
456 draw_line(pc.frame, input_x, row_y, input_width, &spans);
457 if let Some(cursor_column) = row.cursor_column {
458 if cursor_column >= visible_width(row.text)
459 && let Some(hint) = editor.inline_hint()
460 {
461 let hint_x = input_x.saturating_add(cursor_column).saturating_add(1);
462 let width = input_width.saturating_sub(cursor_column.saturating_add(1));
463 draw_line(pc.frame, hint_x, row_y, width, &[Span::new(
464 hint.as_str(),
465 ink(MUTED).dim(),
466 )]);
467 }
468 pc.frame.set_cursor(
469 input_x
470 .saturating_add(cursor_column)
471 .min(rect.x.saturating_add(rect.width.saturating_sub(2))),
472 row_y,
473 );
474 }
475 }
476 Self::paint_picker(pc, rect, rect.y.saturating_add(input_height), &editor);
477 }
478
479 fn focusable(&self) -> bool {
480 true
481 }
482
483 fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
484 let outcome = self.editor.borrow_mut().handle(key);
485 *self.outcome.borrow_mut() = Some(outcome);
486 Flow::Consumed
490 }
491
492 fn mouse(
493 &mut self,
494 _ec: &mut EventCtx<'_>,
495 _tag: HitTag,
496 at: (u16, u16),
497 rect: Rect,
498 mouse: Mouse,
499 ) -> Flow {
500 let width = Self::input_width(rect.width);
501 match mouse {
502 Mouse::Click => {
503 self.editor.borrow_mut().set_cursor_visual_row(
504 usize::from(at.1.saturating_sub(rect.y)),
505 at.0
506 .saturating_sub(rect.x.saturating_add(Self::input_offset())),
507 width,
508 );
509 Flow::Consumed
510 },
511 Mouse::WheelUp | Mouse::WheelDown => {
512 let delta = if mouse == Mouse::WheelUp { -1 } else { 1 };
513 if self
514 .editor
515 .borrow()
516 .scroll_rows(delta, width, usize::from(rect.height))
517 {
518 Flow::Consumed
519 } else {
520 Flow::Skip
521 }
522 },
523 _ => Flow::Skip,
524 }
525 }
526
527 fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
528 if matches!(self.editor.borrow_mut().insert_text(text), EditOutcome::Changed) {
529 Flow::Consumed
530 } else {
531 Flow::Skip
532 }
533 }
534}
535struct WorkState {
538 working: bool,
539 since: Duration,
541 fade: Tween<Color>,
543}
544
545struct DemoStatus {
551 props: Props,
552 slot: Slot,
553 work: Rc<RefCell<WorkState>>,
554 model: Rc<RefCell<Str>>,
555 charset: Charset,
556 right: Status,
557}
558
559impl DemoStatus {
560 fn new(work: Rc<RefCell<WorkState>>, model: Rc<RefCell<Str>>, charset: Charset) -> Self {
561 let mut props = Props::new();
562 props.set(Prop::Id, STATUS_ID);
563 let right = Self::right_group(charset);
564 Self { props, slot: next_slot(), work, model, charset, right }
565 }
566
567 fn group() -> Status {
569 Status::new()
570 .with(Prop::Bg, Color::Rgb(18, 18, 18))
571 .with(Prop::Fg, TEXT)
572 }
573
574 fn brand_segment(&self, now: Duration) -> Segment {
577 let work = self.work.borrow();
578 let brand = if work.working {
579 fmts!(
580 "{} {}",
581 self.charset.spinner().at(now),
582 elapsed_label(now.saturating_sub(work.since))
583 )
584 } else {
585 fmts!("{} omp", self.charset.icon(Icon::Omp))
586 };
587 Segment::new()
588 .label(brand)
589 .with(Prop::Fg, work.fade.sample(now))
590 }
591
592 fn model_segment(&self) -> Segment {
593 Segment::new()
594 .label(fmts!("{} {}", self.charset.icon(Icon::Model), self.model.borrow()))
595 .with(Prop::Fg, GREEN)
596 }
597
598 fn git_segment(charset: Charset) -> Segment {
599 Segment::new()
600 .label(fmts!("{} main *5 +9", charset.icon(Icon::Branch)))
601 .with(Prop::Fg, CYAN)
602 }
603
604 fn context_segment(charset: Charset) -> Segment {
605 Segment::new()
606 .label(fmts!("{} 39.1%/1M", charset.icon(Icon::Context)))
607 .with(Prop::Fg, GOLD)
608 }
609
610 fn cost_segment() -> Segment {
611 Segment::new()
612 .label("$60.07 (sub) + $8.65 (adv)")
613 .with(Prop::Fg, PURPLE)
614 }
615
616 fn left_group(&self, now: Duration) -> Status {
618 Self::group()
619 .segment(self.brand_segment(now))
620 .segment(self.model_segment())
621 }
622
623 fn right_group(charset: Charset) -> Status {
625 Self::group()
626 .with_str(Prop::Align, "right")
627 .segment(Self::git_segment(charset))
628 .segment(Self::context_segment(charset))
629 .segment(Self::cost_segment())
630 }
631
632 fn combined(&self, now: Duration) -> Status {
634 Self::group()
635 .segment(self.brand_segment(now))
636 .segment(self.model_segment())
637 .segment(Self::git_segment(self.charset))
638 .segment(Self::context_segment(self.charset))
639 .segment(Self::cost_segment())
640 }
641}
642
643impl Component for DemoStatus {
644 fn props(&self) -> &Props {
645 &self.props
646 }
647
648 fn props_mut(&mut self) -> &mut Props {
649 &mut self.props
650 }
651
652 fn slot(&self) -> Slot {
653 self.slot
654 }
655
656 fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
657 self.combined(Duration::ZERO).measure(ctx)
658 }
659
660 fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
661 1
662 }
663
664 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
665 let mut left = self.left_group(pc.now);
666 let (_, left_width) = left.measure(pc.ctx);
667 let (_, right_width) = self.right.measure(pc.ctx);
668 if left_width.saturating_add(2).saturating_add(right_width) <= rect.width {
669 left.paint(pc, Rect::new(rect.x, rect.y, left_width, 1));
670 let dock = rect
671 .x
672 .saturating_add(rect.width)
673 .saturating_sub(right_width);
674 self
675 .right
676 .paint(pc, Rect::new(dock, rect.y, right_width, 1));
677 } else {
678 let mut combined = self.combined(pc.now);
679 combined.paint(pc, rect);
680 }
681 let work = self.work.borrow();
682 let fade_frame = work
683 .fade
684 .settles_at()
685 .min(pc.now.saturating_add(FADE_FRAME));
686 let deadline = match (work.working, work.fade.is_settled(pc.now)) {
687 (true, true) => Some(pc.ctx.charset.spinner().next_change(pc.now)),
688 (true, false) => Some(pc.ctx.charset.spinner().next_change(pc.now).min(fade_frame)),
689 (false, false) => Some(fade_frame),
690 (false, true) => None,
691 };
692 if let Some(at) = deadline {
693 pc.wake(self.slot, at);
694 }
695 }
696
697 fn paints_background(&self) -> bool {
698 false
699 }
700}
701
702pub struct RenderedFrame<'a> {
704 pub(crate) frame: &'a Frame,
705 pub(crate) stable_rows: u16,
706 pub(crate) damage: SmallVec<(u16, u16), 4>,
707}
708
709pub struct Demo {
712 started_at: Instant,
713 ctx: UiContext,
715 cancel_hint: &'static str,
717 editor_ui: Ui,
718 editor: Rc<RefCell<Editor>>,
719 edit_outcome: Rc<RefCell<Option<EditOutcome>>>,
720 work: Rc<RefCell<WorkState>>,
721 last_working: bool,
722 model: Rc<RefCell<Str>>,
723 attachments: Attachments,
725 transcript: Vec<Entry>,
727 drawn_entries: usize,
729 transcript_rows: u16,
731 appended_messages: usize,
732 emitted_shards: u16,
733 last_viewport: Size,
734 height_floor: u16,
735 frame: Frame,
736 live_panel: Option<Rect>,
738 live_rows: [LiveRowCache; LIVE_SHARD_ROWS as usize],
740 live_label_scratch: StrMut,
742 right_inset: u16,
745 switch_requested: bool,
747}
748
749impl Demo {
750 pub fn new(ctx: &UiContext) -> Self {
753 let editor = Rc::new(RefCell::new({
754 let mut editor = Editor::new(EditorOptions::default());
755 editor.set_completion(Box::new(SlashCommands::new(demo_commands())));
756 editor
757 }));
758 let edit_outcome = Rc::new(RefCell::new(None));
759 let work = Rc::new(RefCell::new(WorkState {
760 working: true,
761 since: Duration::ZERO,
762 fade: Tween::settled(GREEN),
763 }));
764 let model = Rc::new(RefCell::new(Str::new_static("Fable 5++")));
765 let pane = EditorPane::new()
766 .input(DemoInput::new(Rc::clone(&editor), Rc::clone(&edit_outcome)))
767 .status(DemoStatus::new(Rc::clone(&work), Rc::clone(&model), ctx.charset));
768 let attachments = pane.attachments();
769 let editor_ui = Ui::from_root(pane, 0, ctx.clone());
770 Self {
771 started_at: Instant::now(),
772 ctx: ctx.clone(),
773 cancel_hint: ctx.charset.icon(Icon::Cancellable),
774 editor_ui,
775 editor,
776 edit_outcome,
777 work,
778 last_working: true,
779 model,
780 attachments,
781 transcript: vec![Entry::Command],
782 drawn_entries: 0,
783 transcript_rows: 0,
784 appended_messages: 0,
785 emitted_shards: 0,
786 last_viewport: Size::new(0, 0),
787 height_floor: 0,
788 frame: Frame::new(Size::new(0, 0)),
789 live_panel: None,
790 live_rows: std::array::from_fn(|_| LiveRowCache::new()),
791 live_label_scratch: StrMut::with_capacity(40),
792 right_inset: 0,
793 switch_requested: false,
794 }
795 }
796
797 pub fn handle_key(&mut self, key: Key) -> bool {
802 *self.edit_outcome.borrow_mut() = None;
803 let _ = self.editor_ui.handle_key(key);
804 let outcome = self
805 .edit_outcome
806 .borrow_mut()
807 .take()
808 .unwrap_or(EditOutcome::Ignored);
809 match outcome {
810 EditOutcome::Submitted(text) => {
811 let trimmed = text.trim();
812 if trimmed == "/switch" {
813 self.switch_requested = true;
814 return false;
815 }
816 if let Some(path) = trimmed
817 .strip_prefix("/attach")
818 .filter(|rest| rest.is_empty() || rest.starts_with(' '))
819 {
820 let path = path.trim().to_string();
821 if !path.is_empty() {
822 self.attach_image(&path);
823 }
824 return false;
825 }
826 let _ = self.attachments.take();
827 self.refresh_composer();
828 self
829 .transcript
830 .push(Entry::Submitted(Box::new(Submission::new(
831 text,
832 Self::message_width(self.last_viewport.width),
833 &self.ctx,
834 ))));
835 self.set_working(true, self.started_at.elapsed());
836 false
837 },
838 EditOutcome::Changed => {
839 self.reconcile_attachments();
840 false
841 },
842 EditOutcome::Ignored => {
843 if key == Key::Ctrl('c') {
844 return true;
845 }
846 if key != Key::Esc {
847 return false;
848 }
849 if self.work.borrow().working {
850 self.set_working(false, self.started_at.elapsed());
851 return false;
852 }
853 true
854 },
855 }
856 }
857
858 pub fn take_switch_request(&mut self) -> bool {
860 std::mem::take(&mut self.switch_requested)
861 }
862
863 pub fn handle_mouse(&mut self, report: &MouseReport) {
865 let editor_height = self.editor_ui.height();
866 let editor_y = self.frame.size().height.saturating_sub(editor_height);
867 let editor_bottom = editor_y.saturating_add(editor_height);
868 if report.row < editor_y || report.row >= editor_bottom {
869 return;
870 }
871 let _ = self
872 .editor_ui
873 .handle_mouse(report.col, report.row - editor_y, report.kind);
874 }
875
876 fn set_working(&mut self, working: bool, now: Duration) {
880 {
881 let mut work = self.work.borrow_mut();
882 if work.working == working {
883 return;
884 }
885 work.working = working;
886 work.since = now;
887 let target = if working { GREEN } else { MUTED };
888 work
889 .fade
890 .retarget(now, target, BRAND_FADE, Easing::EaseInOut);
891 }
892 self.editor_ui.invalidate(STATUS_ID);
893 }
894
895 pub fn set_model(&mut self, name: &str) {
897 *self.model.borrow_mut() = Str::from(name);
898 self.editor_ui.invalidate(STATUS_ID);
899 }
900
901 pub fn handle_paste(&mut self, text: &str) {
906 let paths = omp_tui::paste::dropped_paths(text);
907 if !paths.is_empty()
908 && paths.iter().all(|path| {
909 omp_tui::paste::is_image_path(path) && std::path::Path::new(path.as_str()).is_file()
910 }) {
911 for path in &paths {
912 self.attach_image(path);
913 }
914 return;
915 }
916 if text.lines().count() > 10 || text.len() > 1000 {
917 self.attach_paste(text);
918 return;
919 }
920 let _ = self.editor_ui.handle_paste(text);
921 }
922
923 pub fn handle_paste_raw(&mut self, text: &str) {
927 let _ = self.editor_ui.handle_paste_raw(text);
928 }
929
930 fn attach_image(&mut self, path: &str) {
933 let attachment = self.attachments.push_image(path);
934 let payload = format!("<ref image={}/>", attachment.marker);
935 self.insert_chip(&attachment, &payload);
936 }
937
938 fn attach_paste(&mut self, text: &str) {
941 let attachment = self.attachments.push_text(text);
942 self.insert_chip(&attachment, text);
943 }
944
945 fn insert_chip(&mut self, attachment: &Attachment, payload: &str) {
947 let chip = chip_label(attachment, self.ctx.charset);
948 {
949 let mut editor = self.editor.borrow_mut();
950 let _ = editor.insert_reference(&chip, payload);
951 let _ = editor.insert_text(" ");
952 }
953 self.refresh_composer();
954 }
955
956 fn reconcile_attachments(&mut self) {
960 let charset = self.ctx.charset;
961 let changed = {
962 let editor = self.editor.borrow();
963 let text = editor.text();
964 let ranges = editor.atom_ranges();
965 self.attachments.set_visible(|attachment| {
966 let chip = chip_label(attachment, charset);
967 ranges
968 .iter()
969 .any(|&(start, end)| text.get(start..end) == Some(chip.as_str()))
970 })
971 };
972 if changed {
973 self.refresh_composer();
974 }
975 }
976
977 fn refresh_composer(&mut self) {
979 let width = self.editor_ui.frame().size().width;
980 if width > 0 {
981 self.editor_ui.resize(width);
982 }
983 }
984
985 pub const fn set_right_inset(&mut self, cols: u16) {
989 self.right_inset = cols;
990 }
991
992 fn composer_width(&self, viewport: Size) -> u16 {
994 viewport.width.saturating_sub(self.right_inset).max(1)
995 }
996
997 pub fn render(&mut self, viewport: Size) -> RenderedFrame<'_> {
999 self.render_at(viewport, self.started_at.elapsed())
1000 }
1001
1002 fn render_at(&mut self, viewport: Size, elapsed: Duration) -> RenderedFrame<'_> {
1003 if viewport.width == 0 || viewport.height == 0 {
1004 self.last_viewport = viewport;
1005 self.height_floor = 0;
1006 self.drawn_entries = 0;
1007 self.transcript_rows = 0;
1008 self.live_panel = None;
1009 self.frame = Frame::new(viewport);
1010 return RenderedFrame {
1011 frame: &self.frame,
1012 stable_rows: 0,
1013 damage: SmallVec::new(),
1014 };
1015 }
1016 let composer_width = self.composer_width(viewport);
1017 if self.editor_ui.frame().size().width != composer_width {
1018 self.editor_ui.resize(composer_width);
1019 }
1020 self.editor_ui.tick(elapsed);
1023 let editor_changed = self.editor_ui.take_frame_damage();
1024
1025 let rebuild = self.last_viewport != viewport;
1030 if rebuild {
1031 self.last_viewport = viewport;
1032 self.height_floor = 0;
1033 self.drawn_entries = 0;
1034 self.transcript_rows = 0;
1035 let message_width = Self::message_width(viewport.width);
1036 for entry in &mut self.transcript {
1037 if let Entry::Submitted(submission) = entry {
1038 submission.resize(message_width, &self.ctx);
1039 }
1040 }
1041 }
1042 while self.appended_messages < Self::visible_messages(elapsed) {
1043 self.transcript.push(Entry::Message(self.appended_messages));
1044 self.appended_messages += 1;
1045 }
1046 while self.emitted_shards < Self::finished_shards(elapsed) {
1047 self.emitted_shards += 1;
1048 self.transcript.push(Entry::ShardDone(self.emitted_shards));
1049 }
1050
1051 let mut new_rows = 0_u16;
1052 for entry in &self.transcript[self.drawn_entries..] {
1053 new_rows = new_rows.saturating_add(Self::entry_height(entry, viewport.width, &self.ctx));
1054 }
1055 let transcript_rows = self.transcript_rows.saturating_add(new_rows);
1056 let editor_height = self.editor_ui.height();
1057 let natural_height = transcript_rows.saturating_add(Self::band_height(editor_height));
1062 self.height_floor = self.height_floor.max(natural_height);
1063 let document_height = self.height_floor.max(viewport.height);
1064 let transcript_damage_start = if rebuild { 0 } else { self.transcript_rows };
1065 let margin = u16::from(viewport.width >= 50);
1066 let content_width = viewport.width.saturating_sub(margin * 2);
1067 let editor_y = document_height.saturating_sub(editor_height);
1068 let title_y = editor_y.saturating_sub(1);
1069 let working_y = title_y.saturating_sub(1);
1070 let panel_height = LIVE_SHARD_ROWS + 2;
1071 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1072 let panel = Rect::new(margin, panel_y, content_width, panel_height);
1073 let repaint_suffix = rebuild || new_rows > 0 || self.live_panel != Some(panel);
1074 if rebuild {
1075 self.frame = Frame::new(Size::new(viewport.width, document_height));
1076 } else {
1077 self.frame.resize_height(document_height, base_style());
1078 }
1079 if repaint_suffix {
1080 self.frame.fill(
1081 Rect::new(
1082 0,
1083 transcript_damage_start,
1084 viewport.width,
1085 document_height.saturating_sub(transcript_damage_start),
1086 ),
1087 base_style(),
1088 );
1089 }
1090
1091 let mut y = self.transcript_rows;
1094 for index in self.drawn_entries..self.transcript.len() {
1095 let used = self.draw_entry_at(index, y, viewport.width);
1096 y = y.saturating_add(used);
1097 }
1098 self.drawn_entries = self.transcript.len();
1099 self.transcript_rows = y;
1100
1101 let animation_frame = Self::animation_frame(elapsed);
1103 let panel_changed = draw_live_panel(
1104 &mut self.frame,
1105 &mut self.live_rows,
1106 &mut self.live_label_scratch,
1107 panel,
1108 repaint_suffix,
1109 self.emitted_shards,
1110 animation_frame,
1111 self.ctx.charset,
1112 );
1113 let working = self.work.borrow().working;
1114 let working_changed = self.last_working != working;
1115 if !repaint_suffix && self.last_working && !working {
1116 self
1117 .frame
1118 .fill(Rect::new(0, working_y, viewport.width, 1), base_style());
1119 }
1120 if working {
1121 Self::draw_working(&mut self.frame, working_y, elapsed, self.cancel_hint);
1122 }
1123 Self::draw_session_title(&mut self.frame, title_y, self.right_inset);
1124 if repaint_suffix || editor_changed {
1125 self
1126 .frame
1127 .blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1128 }
1129 let mut damage = SmallVec::new();
1130 if repaint_suffix {
1131 damage.push((transcript_damage_start, document_height));
1132 } else {
1133 if panel_changed {
1134 damage.push((panel_y, panel_y.saturating_add(panel_height)));
1135 }
1136 if working || working_changed {
1137 damage.push((working_y, working_y.saturating_add(1)));
1138 }
1139 if editor_changed {
1140 damage.push((editor_y, document_height));
1141 }
1142 }
1143 self.last_working = working;
1144 self.live_panel = Some(panel);
1145
1146 RenderedFrame { frame: &self.frame, stable_rows: self.transcript_rows, damage }
1147 }
1148
1149 fn generation(elapsed: Duration) -> u64 {
1150 u64::try_from(elapsed.as_millis() / EMIT_INTERVAL.as_millis()).unwrap_or(u64::MAX)
1151 }
1152
1153 fn animation_frame(elapsed: Duration) -> u64 {
1154 u64::try_from(elapsed.as_millis() / 80).unwrap_or(u64::MAX)
1155 }
1156
1157 fn visible_messages(elapsed: Duration) -> usize {
1158 let interval = MESSAGE_INTERVAL.as_millis();
1159 usize::try_from(elapsed.as_millis() / interval + 1)
1160 .unwrap_or(usize::MAX)
1161 .min(4)
1162 }
1163
1164 fn finished_shards(elapsed: Duration) -> u16 {
1167 u16::try_from(Self::generation(elapsed).saturating_mul(2).min(60_000))
1168 .expect("finished shard count is clamped")
1169 }
1170
1171 const fn band_height(editor_height: u16) -> u16 {
1175 LIVE_SHARD_ROWS + 2 + 3 + editor_height
1176 }
1177
1178 fn entry_height(entry: &Entry, width: u16, ctx: &UiContext) -> u16 {
1180 match entry {
1181 Entry::Command => 5,
1182 Entry::Message(message) => {
1183 let mut scratch = Frame::new(Size::new(width, 48));
1184 Self::draw_message(&mut scratch, 0, *message, width, ctx.charset)
1185 },
1186 Entry::ShardDone(_) => 1,
1187 Entry::Submitted(submission) => submission.height().saturating_add(1),
1188 }
1189 }
1190
1191 const fn message_width(width: u16) -> u16 {
1192 let narrowed = width.saturating_sub(3);
1193 if narrowed == 0 { 1 } else { narrowed }
1194 }
1195
1196 fn draw_entry_at(&mut self, index: usize, y: u16, width: u16) -> u16 {
1198 Self::draw_entry(&mut self.frame, &self.transcript[index], y, width, &self.ctx)
1199 }
1200
1201 fn draw_entry(frame: &mut Frame, entry: &Entry, y: u16, width: u16, ctx: &UiContext) -> u16 {
1204 let margin = u16::from(width >= 50);
1205 let content_width = width.saturating_sub(margin * 2);
1206 match entry {
1207 Entry::Command => {
1208 draw_command_box(frame, Rect::new(margin, y, content_width, 4), ctx.charset);
1209 5
1210 },
1211 Entry::Message(message) => Self::draw_message(frame, y, *message, width, ctx.charset),
1212 Entry::ShardDone(shard) => {
1213 Self::draw_shard_done(frame, y, *shard, width, ctx.charset);
1214 1
1215 },
1216 Entry::Submitted(submission) => {
1217 draw_submission(frame, y, submission, ctx.charset);
1218 submission.height().saturating_add(1)
1219 },
1220 }
1221 }
1222
1223 pub fn render_resize_preview(&mut self, viewport: Size) -> Frame {
1231 let elapsed = self.started_at.elapsed();
1232 let mut frame = Frame::new(viewport);
1233 if viewport.width == 0 || viewport.height == 0 {
1234 return frame;
1235 }
1236 frame.fill(Rect::new(0, 0, viewport.width, viewport.height), base_style());
1237 let composer_width = self.composer_width(viewport);
1238 if self.editor_ui.frame().size().width != composer_width {
1239 self.editor_ui.resize(composer_width);
1240 }
1241 self.editor_ui.tick(elapsed);
1242
1243 let margin = u16::from(viewport.width >= 50);
1245 let content_width = viewport.width.saturating_sub(margin * 2);
1246 let editor_height = self.editor_ui.height();
1247 let editor_y = viewport.height.saturating_sub(editor_height);
1248 let title_y = editor_y.saturating_sub(1);
1249 let working_y = title_y.saturating_sub(1);
1250 let panel_height = LIVE_SHARD_ROWS + 2;
1251 let panel_y = working_y.saturating_sub(1).saturating_sub(panel_height);
1252 draw_live_panel(
1253 &mut frame,
1254 &mut self.live_rows,
1255 &mut self.live_label_scratch,
1256 Rect::new(margin, panel_y, content_width, panel_height),
1257 true,
1258 self.emitted_shards,
1259 Self::animation_frame(elapsed),
1260 self.ctx.charset,
1261 );
1262 if self.work.borrow().working {
1263 Self::draw_working(&mut frame, working_y, elapsed, self.cancel_hint);
1264 }
1265 Self::draw_session_title(&mut frame, title_y, self.right_inset);
1266 frame.blit(self.editor_ui.frame(), 0, editor_height, 0, editor_y);
1267
1268 let mut remaining = panel_y;
1270 for entry in self.transcript.iter().rev() {
1271 if remaining == 0 {
1272 break;
1273 }
1274 let height = Self::entry_height(entry, viewport.width, &self.ctx);
1275 if height == 0 {
1276 continue;
1277 }
1278 if height <= remaining {
1279 remaining -= height;
1280 Self::draw_entry(&mut frame, entry, remaining, viewport.width, &self.ctx);
1281 } else {
1282 let mut scratch = Frame::new(Size::new(viewport.width, height));
1284 scratch.fill(Rect::new(0, 0, viewport.width, height), base_style());
1285 Self::draw_entry(&mut scratch, entry, 0, viewport.width, &self.ctx);
1286 frame.blit(&scratch, height - remaining, remaining, 0, 0);
1287 remaining = 0;
1288 }
1289 }
1290 frame
1291 }
1292
1293 fn draw_message(frame: &mut Frame, y: u16, message: usize, width: u16, charset: Charset) -> u16 {
1296 let margin = u16::from(width >= 50);
1297 let content_width = width.saturating_sub(margin * 2);
1298 if message == 2 {
1299 draw_edit_box(frame, Rect::new(margin, y, content_width, EDIT_BOX_HEIGHT), charset);
1300 return EDIT_BOX_HEIGHT + 1;
1301 }
1302 let bottom = frame.size().height;
1303 let spans = Self::message_spans(message);
1304 let used = draw_flowed(frame, Rect::new(0, y, width, bottom.saturating_sub(y)), &spans);
1307 used.saturating_add(1)
1308 }
1309
1310 fn message_spans(message: usize) -> SmallVec<Span<'static>, 3> {
1311 let mut spans = SmallVec::new();
1312 match message {
1313 0 => {
1314 spans.push(Span::new("Transcript rows are ", prose_style()));
1315 spans.push(Span::new("append-only", code_style()));
1316 spans.push(Span::new(
1317 ": every line is painted once, becomes stable, and rides into native scrollback \
1318 with any selection anchored to it.",
1319 prose_style(),
1320 ));
1321 },
1322 1 => {
1323 spans.push(Span::new(
1324 "Only the bottom band repaints in place — the live shard panel, the activity \
1325 shimmer, and the composer. Rows above it are never rewritten.",
1326 prose_style(),
1327 ));
1328 },
1329 _ => {
1330 spans.push(Span::new(
1331 "On terminals that move margin-scrolled rows into scrollback, commits scroll only \
1332 the stable transcript through a ",
1333 prose_style(),
1334 ));
1335 spans.push(Span::new("DECSTBM top region", code_style()));
1336 spans.push(Span::new(", so the live band never shifts on screen.", prose_style()));
1337 },
1338 }
1339 spans
1340 }
1341
1342 fn draw_shard_done(frame: &mut Frame, y: u16, shard: u16, width: u16, charset: Charset) {
1344 let margin = u16::from(width >= 50);
1345 let prefix = fmts!(" {} shard {shard:03} passed", charset.check());
1346 let detail = fmts!(" workspace-{shard:03}.test.ts [100%]");
1347 draw_line(frame, margin + 1, y, width.saturating_sub(margin * 2).saturating_sub(2), &[
1348 Span::new(prefix.as_str(), ink(GREEN)),
1349 Span::new(detail.as_str(), ink(MUTED)),
1350 ]);
1351 }
1352
1353 fn draw_working(frame: &mut Frame, y: u16, elapsed: Duration, hint: &str) {
1356 if y >= frame.size().height || frame.size().width < 4 {
1357 return;
1358 }
1359 let start = u16::from(frame.size().width >= 50);
1360 let mut column = start;
1361 let length = xutf::graphemes_str(WORKING_MESSAGE)
1362 .count()
1363 .saturating_add(xutf::graphemes_str(hint).count())
1364 .saturating_add(1);
1365 let length = u16::try_from(length).unwrap_or(u16::MAX);
1366 let shimmer = Shimmer::new(elapsed, SHIMMER_PERIOD, length);
1367 let right = frame.size().width.saturating_sub(1);
1368 draw_shimmer(frame, &mut column, start, y, right, hint, shimmer, ink(CYAN));
1369 draw_shimmer(frame, &mut column, start, y, right, " ", shimmer, ink(GREEN));
1370 draw_shimmer(frame, &mut column, start, y, right, WORKING_MESSAGE, shimmer, ink(GREEN));
1371 }
1372
1373 fn draw_session_title(frame: &mut Frame, y: u16, right_inset: u16) {
1378 let width = frame.size().width.saturating_sub(right_inset);
1379 let title_width = visible_width(SESSION_TITLE);
1380 if y >= frame.size().height || width < title_width.saturating_add(2) {
1381 return;
1382 }
1383 let x = width.saturating_sub(title_width.saturating_add(1));
1384 draw_line(frame, x, y, title_width, &[Span::new(SESSION_TITLE, ink(FAINT).italic())]);
1385 }
1386}
1387
1388fn draw_command_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1390 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1391 if rect.width < 4 || rect.height < 4 {
1392 return;
1393 }
1394
1395 let content_x = rect.x + 2;
1396 let content_width = rect.width.saturating_sub(4);
1397 let header = [
1398 Span::new(" PARALLEL TEST RUN ", panel_ink(GREEN).bold()),
1399 Span::new("results append below · live rows in the bottom panel", panel_ink(MUTED)),
1400 ];
1401 draw_line(frame, content_x, rect.y + 1, content_width, &header);
1402 let command = [
1403 Span::new("$ ", panel_ink(MUTED)),
1404 Span::new("bun test --parallel=8", panel_ink(CYAN)),
1405 Span::new(" --timeout=30000 --all-workspaces", panel_ink(TEXT)),
1406 ];
1407 draw_line(frame, content_x, rect.y + 2, content_width, &command);
1408}
1409
1410fn draw_live_panel(
1413 frame: &mut Frame,
1414 rows: &mut [LiveRowCache; LIVE_SHARD_ROWS as usize],
1415 label_scratch: &mut StrMut,
1416 rect: Rect,
1417 repaint_chrome: bool,
1418 emitted_shards: u16,
1419 animation_frame: u64,
1420 charset: Charset,
1421) -> bool {
1422 let mut changed = repaint_chrome;
1423 if repaint_chrome {
1424 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1425 }
1426 if rect.width < 4 || rect.height < 3 {
1427 return changed;
1428 }
1429
1430 if repaint_chrome {
1431 let title = [
1432 Span::new(" LIVE SHARDS ", panel_ink(GREEN).bold()),
1433 Span::new("mutable rows repaint in place ", panel_ink(MUTED)),
1434 ];
1435 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1436 }
1437 let content_x = rect.x + 2;
1438 let content_width = rect.width.saturating_sub(4);
1439 for row in 0..rect.height.saturating_sub(2) {
1440 let shard = emitted_shards.saturating_add(row).saturating_add(1);
1441 let phase = (u64::from(row) + animation_frame) % 11;
1442 let (prefix_phase, symbol, state, state_style, progress) = match phase {
1443 0 => (
1444 0,
1445 "⠼",
1446 "running",
1447 panel_ink(GREEN).bold(),
1448 (u64::from(row) * 17 + animation_frame * 7) % 100,
1449 ),
1450 1..=7 => {
1451 (1, "·", "working", panel_ink(MUTED), (u64::from(row) * 17 + animation_frame * 7) % 100)
1452 },
1453 _ => (2, "·", "queued ", panel_ink(FAINT), 0),
1454 };
1455 let row_y = rect.y + 1 + row;
1456 let right = content_x
1457 .saturating_add(content_width)
1458 .min(frame.size().width);
1459 let cache = &mut rows[usize::from(row)];
1460 let prefix_changed = repaint_chrome
1461 || !cache.prefix_valid
1462 || cache.prefix_shard != shard
1463 || cache.prefix_phase != prefix_phase;
1464 changed |= prefix_changed;
1465 let label_x = if prefix_changed {
1466 let prefix = fmts!(" {symbol} shard {shard:03} ");
1467 let prefix_width = prefix.len().saturating_sub(symbol.len()).saturating_add(1);
1468 let next_x = content_x
1469 .saturating_add(u16::try_from(prefix_width).unwrap_or(u16::MAX))
1470 .saturating_add(u16::try_from(state.len()).unwrap_or(u16::MAX))
1471 .saturating_add(2)
1472 .min(right);
1473 if !repaint_chrome && cache.label_x != next_x {
1474 clear_cached_label(frame, cache, row_y, right);
1475 }
1476 let next_x = draw_line(frame, content_x, row_y, content_width, &[
1477 Span::new(prefix.as_str(), state_style),
1478 Span::new(state, state_style),
1479 Span::new(" ", panel_ink(FAINT)),
1480 ]);
1481 cache.prefix_shard = shard;
1482 cache.prefix_phase = prefix_phase;
1483 cache.prefix_valid = true;
1484 next_x
1485 } else {
1486 cache.label_x
1487 };
1488 let moved = cache.label_x != label_x;
1489 let label_changed = repaint_chrome
1490 || moved
1491 || !cache.label_valid
1492 || cache.label_shard != shard
1493 || cache.label_progress != progress;
1494 changed |= label_changed;
1495 if label_changed {
1496 label_scratch.truncate(0);
1497 write!(label_scratch, "workspace-{shard:03}.test.ts [{progress:>3}%]")
1498 .expect("shard label formatting is infallible");
1499 let resized = cache.label.len() != label_scratch.len();
1500 if !repaint_chrome && resized && !moved {
1501 clear_cached_label(frame, cache, row_y, right);
1502 }
1503 let width = right.saturating_sub(label_x);
1504 if repaint_chrome || moved || resized {
1505 frame.put_clipped(label_x, row_y, width, label_scratch.as_str(), panel_ink(MUTED));
1506 } else {
1507 draw_ascii_changes(
1508 frame,
1509 label_x,
1510 row_y,
1511 width,
1512 cache.label.as_str(),
1513 label_scratch.as_str(),
1514 panel_ink(MUTED),
1515 );
1516 }
1517 std::mem::swap(&mut cache.label, label_scratch);
1518 cache.label_shard = shard;
1519 cache.label_progress = progress;
1520 cache.label_valid = true;
1521 }
1522 cache.label_x = label_x;
1523 }
1524 changed
1525}
1526
1527fn clear_cached_label(frame: &mut Frame, cache: &LiveRowCache, y: u16, right: u16) {
1528 if cache.label.is_empty() {
1529 return;
1530 }
1531 let width = u16::try_from(cache.label.len())
1532 .unwrap_or(u16::MAX)
1533 .min(right.saturating_sub(cache.label_x));
1534 frame.fill(Rect::new(cache.label_x, y, width, 1), panel_style());
1535}
1536
1537fn draw_ascii_changes(
1539 frame: &mut Frame,
1540 x: u16,
1541 y: u16,
1542 width: u16,
1543 previous: &str,
1544 next: &str,
1545 style: Style,
1546) {
1547 if width == 0 || previous == next {
1548 return;
1549 }
1550 if previous.len() != next.len() || !previous.is_ascii() || !next.is_ascii() {
1551 frame.put_clipped(x, y, width, next, style);
1552 return;
1553 }
1554 let previous = previous.as_bytes();
1555 let next_bytes = next.as_bytes();
1556 let limit = previous.len().min(usize::from(width));
1557 let mut index = 0;
1558 while index < limit {
1559 while index < limit && previous[index] == next_bytes[index] {
1560 index += 1;
1561 }
1562 let start = index;
1563 while index < limit && previous[index] != next_bytes[index] {
1564 index += 1;
1565 }
1566 if start < index {
1567 let offset = u16::try_from(start).unwrap_or(u16::MAX);
1568 frame.put_clipped(
1569 x.saturating_add(offset),
1570 y,
1571 u16::try_from(index - start).unwrap_or(u16::MAX),
1572 &next[start..index],
1573 style,
1574 );
1575 }
1576 }
1577}
1578
1579fn draw_edit_box(frame: &mut Frame, rect: Rect, charset: Charset) {
1580 draw_box(frame, rect, ink(FAINT), panel_style(), charset);
1581 if rect.width < 8 || rect.height < EDIT_BOX_HEIGHT {
1582 return;
1583 }
1584
1585 let title = [
1586 Span::new(" Live ", panel_ink(GREEN).bold()),
1587 Span::new("band · selection semantics ", panel_ink(CYAN)),
1588 ];
1589 draw_line(frame, rect.x + 2, rect.y, rect.width.saturating_sub(4), &title);
1590 draw_line(frame, rect.x + 2, rect.y + 1, rect.width.saturating_sub(4), &[
1591 Span::new(charset.check(), panel_ink(GREEN).bold()),
1592 Span::new(" ", panel_ink(GREEN)),
1593 Span::new("Transcript selections ride with the text", panel_ink(TEXT)),
1594 ]);
1595 draw_line(frame, rect.x + 2, rect.y + 2, rect.width.saturating_sub(4), &[Span::new(
1596 " margin commits pin the band on kitty-class terminals",
1597 panel_ink(MUTED),
1598 )]);
1599}
1600
1601fn draw_submission(frame: &mut Frame, y: u16, submission: &Submission, charset: Charset) {
1605 if frame.size().width < 4 {
1606 return;
1607 }
1608 let Some(view) = &submission.view else {
1609 for (offset, line) in submission.text.split('\n').enumerate() {
1610 let Ok(offset) = u16::try_from(offset) else {
1611 break;
1612 };
1613 let row = y.saturating_add(offset);
1614 if row >= frame.size().height {
1615 break;
1616 }
1617 let prompt = if offset == 0 { charset.cursor() } else { " " };
1618 let text_x = frame.put(1, row, prompt, ink(GREEN).bold());
1619 let width = frame
1620 .size()
1621 .width
1622 .saturating_sub(1)
1623 .saturating_sub(text_x.saturating_sub(1));
1624 draw_submission_text(frame, text_x, row, width, line, charset);
1625 }
1626 return;
1627 };
1628 frame.put(1, y, charset.cursor(), ink(GREEN).bold());
1629 frame.blit(view.frame(), 0, view.height(), 3, y);
1630}
1631fn explicit_line_count(text: &str) -> u16 {
1632 u16::try_from(
1633 text
1634 .bytes()
1635 .filter(|byte| *byte == b'\n')
1636 .count()
1637 .saturating_add(1),
1638 )
1639 .unwrap_or(u16::MAX)
1640}
1641
1642fn draw_box(frame: &mut Frame, rect: Rect, border: Style, fill: Style, charset: Charset) {
1644 if rect.width == 0 || rect.height == 0 {
1645 return;
1646 }
1647 let (tl, tr, _, _, horizontal, vertical) = charset.border(Border::Round);
1648 frame.fill(rect, fill);
1649 let mut glyph = [0_u8; 4];
1650 if rect.width == 1 {
1651 frame.put(rect.x, rect.y, vertical.encode_utf8(&mut glyph), border);
1652 return;
1653 }
1654
1655 let right = rect.x + rect.width - 1;
1656 let bottom = rect.y + rect.height - 1;
1657 frame.put(rect.x, rect.y, tl.encode_utf8(&mut glyph), border);
1658 frame.put(right, rect.y, tr.encode_utf8(&mut glyph), border);
1659 for x in rect.x + 1..right {
1660 frame.put(x, rect.y, horizontal.encode_utf8(&mut glyph), border);
1661 }
1662
1663 if rect.height > 1 {
1664 draw_box_bottom(frame, rect, border, charset);
1665 }
1666 for row in rect.y + 1..bottom {
1667 frame.put(rect.x, row, vertical.encode_utf8(&mut glyph), border);
1668 frame.put(right, row, vertical.encode_utf8(&mut glyph), border);
1669 }
1670}
1671
1672fn draw_box_bottom(frame: &mut Frame, rect: Rect, border: Style, charset: Charset) {
1673 if rect.width < 2 || rect.height < 2 {
1674 return;
1675 }
1676 let (_, _, bl, br, horizontal, _) = charset.border(Border::Round);
1677 let mut glyph = [0_u8; 4];
1678 let right = rect.x + rect.width - 1;
1679 let bottom = rect.y + rect.height - 1;
1680 frame.put(rect.x, bottom, bl.encode_utf8(&mut glyph), border);
1681 frame.put(right, bottom, br.encode_utf8(&mut glyph), border);
1682 for x in rect.x + 1..right {
1683 frame.put(x, bottom, horizontal.encode_utf8(&mut glyph), border);
1684 }
1685}
1686
1687fn draw_line(frame: &mut Frame, x: u16, y: u16, width: u16, spans: &[Span<'_>]) -> u16 {
1688 let right = x.saturating_add(width).min(frame.size().width);
1689 let mut column = x;
1690 for span in spans {
1691 column = frame.put_clipped(column, y, right.saturating_sub(column), span.text, span.style);
1692 if column >= right {
1693 break;
1694 }
1695 }
1696 column
1697}
1698
1699fn draw_flowed(frame: &mut Frame, rect: Rect, spans: &[Span<'_>]) -> u16 {
1704 if rect.width == 0 || rect.height == 0 {
1705 return 0;
1706 }
1707 let full_row = rect.x == 0 && rect.width == frame.size().width;
1708 let mut row = 0_u16;
1709 let mut column = 0_u16;
1710 let mut drew_anything = false;
1711
1712 for span in spans {
1713 for grapheme in xutf::graphemes_str(span.text) {
1714 let grapheme_width = visible_width(grapheme);
1715 if grapheme_width == 0 || grapheme_width > rect.width {
1716 continue;
1717 }
1718 if column.saturating_add(grapheme_width) > rect.width {
1719 if full_row && column == rect.width {
1721 frame.set_soft_wrap(rect.y.saturating_add(row));
1722 }
1723 row += 1;
1724 column = 0;
1725 }
1726 if row >= rect.height {
1727 return rect.height;
1728 }
1729 frame.put(rect.x + column, rect.y + row, grapheme, span.style);
1730 column += grapheme_width;
1731 drew_anything = true;
1732 }
1733 }
1734
1735 if drew_anything { row + 1 } else { 0 }
1736}
1737
1738fn visible_width(text: &str) -> u16 {
1739 u16::try_from(xutf::width_str(text)).unwrap_or(u16::MAX)
1740}
1741
1742fn next_ref_tag(text: &str) -> Option<(usize, usize, usize)> {
1745 const HEAD: &str = "<ref image=";
1746 let mut from = 0;
1747 while let Some(at) = text[from..].find(HEAD) {
1748 let start = from + at;
1749 let body = &text[start + HEAD.len()..];
1750 let digits = body.bytes().take_while(u8::is_ascii_digit).count();
1751 if digits > 0 && body[digits..].starts_with("/>") {
1752 let marker = body[..digits].parse().unwrap_or(usize::MAX);
1753 return Some((start, start + HEAD.len() + digits + 2, marker));
1754 }
1755 from = start + HEAD.len();
1756 }
1757 None
1758}
1759
1760fn push_row_spans<'a>(
1767 editor: &Editor,
1768 row: &'a str,
1769 runs: &[SyntaxRun],
1770 spans: &mut SmallVec<Span<'a>, 16>,
1771) {
1772 let text = editor.text();
1773 let buffer_start = text.as_ptr() as usize;
1774 let row_start = (row.as_ptr() as usize).saturating_sub(buffer_start);
1775 let row_end = row_start + row.len();
1776 let mut chips: SmallVec<(usize, usize, Style), 4> = SmallVec::new();
1779 for (start, end) in editor.atom_ranges() {
1780 let from = start.max(row_start);
1781 let to = end.min(row_end);
1782 if from < to {
1783 chips.push((from - row_start, to - row_start, chip_style(&text[start..end])));
1784 }
1785 }
1786 let base = |at: usize| {
1789 runs
1790 .iter()
1791 .find(|run| run.start <= at && at < run.end)
1792 .map_or_else(
1793 || {
1794 let next = runs
1795 .iter()
1796 .map(|run| run.start)
1797 .filter(|start| *start > at)
1798 .min()
1799 .unwrap_or(row.len());
1800 (next, base_style())
1801 },
1802 |run| (run.end, run.style),
1803 )
1804 };
1805 fn emit<'a>(
1806 row: &'a str,
1807 base: &impl Fn(usize) -> (usize, Style),
1808 from: usize,
1809 to: usize,
1810 spans: &mut SmallVec<Span<'a>, 16>,
1811 ) {
1812 let mut at = from;
1813 while at < to {
1814 let (run_end, style) = base(at);
1815 let end = run_end.min(to);
1816 spans.push(Span::new(&row[at..end], style));
1817 at = end;
1818 }
1819 }
1820 let mut at = 0;
1821 for (start, end, style) in chips {
1822 emit(row, &base, at, start, spans);
1823 spans.push(Span::new(&row[start..end], style));
1824 at = end;
1825 }
1826 emit(row, &base, at, row.len(), spans);
1827}
1828
1829fn chip_style(chip: &str) -> Style {
1832 let Some(hash) = chip.rfind('#') else {
1833 return base_style();
1834 };
1835 let digits = &chip[hash + 1..];
1836 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
1837 return base_style();
1838 }
1839 match digits.parse::<usize>() {
1840 Ok(marker) if marker > 0 => ink(attachment_color(marker)).bold(),
1841 _ => base_style(),
1842 }
1843}
1844
1845fn draw_submission_text(
1848 frame: &mut Frame,
1849 x: u16,
1850 y: u16,
1851 width: u16,
1852 line: &str,
1853 charset: Charset,
1854) {
1855 let icon = charset.icon(Icon::Image);
1856 let mut chips: SmallVec<(usize, usize, String, usize), 4> = SmallVec::new();
1857 let mut base = 0;
1858 while let Some((start, end, marker)) = next_ref_tag(&line[base..]) {
1859 chips.push((base + start, base + end, format!("{icon} #{marker}"), marker));
1860 base += end;
1861 }
1862 let mut spans: SmallVec<Span<'_>, 8> = SmallVec::new();
1863 let mut at = 0;
1864 for (start, end, label, marker) in &chips {
1865 if *start > at {
1866 spans.push(Span::new(&line[at..*start], ink(TEXT)));
1867 }
1868 spans.push(Span::new(label, Style::new().fg(PANEL).bg(attachment_color(*marker)).bold()));
1869 at = *end;
1870 }
1871 if at < line.len() {
1872 spans.push(Span::new(&line[at..], ink(TEXT)));
1873 }
1874 draw_line(frame, x, y, width, &spans);
1875}
1876
1877const fn base_style() -> Style {
1881 Style::new().fg(TEXT)
1882}
1883
1884const fn panel_style() -> Style {
1885 Style::new().fg(TEXT).bg(PANEL)
1886}
1887
1888const fn ink(color: Color) -> Style {
1889 Style::new().fg(color)
1890}
1891
1892const fn panel_ink(color: Color) -> Style {
1893 Style::new().fg(color).bg(PANEL)
1894}
1895
1896const fn prose_style() -> Style {
1897 Style::new().fg(MUTED).italic()
1898}
1899
1900const fn code_style() -> Style {
1901 Style::new().fg(GREEN)
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906 use std::{hint::black_box, str, time::Instant};
1907
1908 use omp_tui::{
1909 Key, Mods, Mouse, MouseButton, MouseReport, Renderer,
1910 test_support::{TerminalModel, frame_row_text},
1911 };
1912
1913 use super::{
1914 BRAND_FADE, CANCEL_HINT, Charset, Demo, DemoInput, Duration, FAINT, GREEN, INPUT_PROMPT,
1915 MUTED, RenderedFrame, Shimmer, Size, UiContext, WORKING_MESSAGE, elapsed_label, ink,
1916 };
1917
1918 fn test_ctx() -> UiContext {
1921 UiContext { charset: Charset::NerdFont, ..UiContext::default() }
1922 }
1923 fn present<W: std::io::Write>(
1924 renderer: &mut Renderer<W>,
1925 rendered: RenderedFrame<'_>,
1926 viewport: Size,
1927 ) -> std::io::Result<omp_tui::PaintStats> {
1928 renderer.present_damaged(
1929 rendered.frame,
1930 rendered.damage.as_slice(),
1931 viewport.height,
1932 rendered.stable_rows,
1933 )
1934 }
1935
1936 fn replay_step(
1939 demo: &mut Demo,
1940 renderer: &mut Renderer<Vec<u8>>,
1941 terminal: &mut TerminalModel,
1942 viewport: Size,
1943 elapsed_ms: u64,
1944 key: Option<char>,
1945 ) {
1946 if let Some(character) = key {
1947 demo.handle_key(Key::Char(character));
1948 }
1949 let rendered = demo.render_at(viewport, Duration::from_millis(elapsed_ms));
1950 let height = rendered.frame.size().height;
1951 let rows: Vec<String> = (0..height)
1952 .map(|row| frame_row_text(rendered.frame, row))
1953 .collect();
1954 present(renderer, rendered, viewport).expect("replay paint succeeds");
1955 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("ANSI is UTF-8");
1956 terminal.apply(&output);
1957 let window_top = renderer
1958 .committed_rows()
1959 .max(height.saturating_sub(viewport.height));
1960 let expected: Vec<String> = (0..viewport.height)
1961 .map(|row| rows[usize::from(window_top.saturating_add(row))].clone())
1962 .collect();
1963 let actual = terminal.visible_rows();
1964 for (row, (have, want)) in actual.iter().zip(&expected).enumerate() {
1965 assert_eq!(
1966 have, want,
1967 "terminal row {row} diverged from the frame at {elapsed_ms}ms after key {key:?}"
1968 );
1969 }
1970 }
1971
1972 #[test]
1973 fn interactive_picker_session_replays_cell_for_cell() {
1974 let viewport = Size::new(157, 46);
1975 let mut demo = Demo::new(&test_ctx());
1976 let mut renderer = Renderer::new(Vec::new());
1977 let mut terminal = TerminalModel::new(157, 46);
1978
1979 let script: &[(u64, Option<char>)] = &[
1980 (0, None),
1981 (700, None),
1982 (2_600, Some(':')),
1983 (2_650, Some('e')),
1984 (3_500, None),
1985 (4_000, Some('m')),
1986 (4_400, Some('q')),
1987 (4_700, None),
1988 (5_400, None),
1989 (6_100, None),
1990 (6_800, None),
1991 (7_500, None),
1992 (8_200, None),
1993 ];
1994 for &(elapsed_ms, key) in script {
1995 replay_step(&mut demo, &mut renderer, &mut terminal, viewport, elapsed_ms, key);
1996 }
1997 }
1998
1999 #[test]
2003 fn interactive_session_replays_cell_for_cell_with_margin_commits() {
2004 let viewport = Size::new(157, 46);
2005 let mut demo = Demo::new(&test_ctx());
2006 let mut renderer = Renderer::new(Vec::new());
2007 renderer.set_margin_scrollback(true);
2008 let mut terminal = TerminalModel::new(157, 46);
2009
2010 let script: &[(u64, Option<char>)] = &[
2011 (0, None),
2012 (700, None),
2013 (2_600, Some(':')),
2014 (2_650, Some('e')),
2015 (3_500, None),
2016 (4_400, Some('q')),
2017 (5_400, None),
2018 (6_100, None),
2019 (6_800, None),
2020 (7_500, None),
2021 (8_200, None),
2022 ];
2023 for &(elapsed_ms, key) in script {
2024 replay_step(&mut demo, &mut renderer, &mut terminal, viewport, elapsed_ms, key);
2025 }
2026 }
2027 fn has_csi_command(output: &str, command: u8) -> bool {
2028 let bytes = output.as_bytes();
2029 let mut index = 0;
2030 while index + 1 < bytes.len() {
2031 if bytes[index] != 0x1b || bytes[index + 1] != b'[' {
2032 index += 1;
2033 continue;
2034 }
2035 index += 2;
2036 while let Some(byte) = bytes.get(index) {
2037 if (0x40..=0x7e).contains(byte) {
2038 if *byte == command {
2039 return true;
2040 }
2041 break;
2042 }
2043 index += 1;
2044 }
2045 index += 1;
2046 }
2047 false
2048 }
2049
2050 #[test]
2051 fn classic_shimmer_moves_a_bright_crest_across_the_message() {
2052 let low = ink(FAINT);
2053 let high = ink(GREEN).bold();
2054 let period = Duration::from_secs(1);
2055 let before_band = Shimmer::new(Duration::ZERO, period, 30);
2058 let at_crest = Shimmer::new(Duration::from_millis(200), period, 30);
2059
2060 assert_eq!(before_band.pick(0, low, ink(MUTED), high), low);
2061 assert_eq!(at_crest.pick(0, low, ink(MUTED), high), high);
2062 }
2063
2064 #[test]
2065 fn elapsed_label_stays_compact_across_units() {
2066 assert_eq!(elapsed_label(Duration::from_secs(20)), "20s");
2067 assert_eq!(elapsed_label(Duration::from_secs(60)), "1m");
2068 assert_eq!(elapsed_label(Duration::from_mins(15)), "15m");
2069 assert_eq!(elapsed_label(Duration::from_hours(1)), "1h");
2070 assert_eq!(elapsed_label(Duration::from_hours(10)), "10h");
2071 assert_eq!(elapsed_label(Duration::from_hours(100)), "99h");
2072 }
2073
2074 #[test]
2075 fn status_brand_swaps_spinner_for_omp_across_work_states() {
2076 let viewport = Size::new(120, 32);
2077 let mut demo = Demo::new(&test_ctx());
2078 let rows_at = |demo: &mut Demo, elapsed| {
2079 let rendered = demo.render_at(viewport, elapsed);
2080 (0..rendered.frame.size().height)
2081 .map(|row| frame_row_text(rendered.frame, row))
2082 .collect::<Vec<_>>()
2083 };
2084 fn status_of(rows: &[String]) -> &str {
2085 rows
2086 .iter()
2087 .find(|row| row.contains("Fable 5++"))
2088 .expect("status row must be present")
2089 }
2090
2091 let rows = rows_at(&mut demo, Duration::ZERO);
2092 assert!(status_of(&rows).starts_with("\u{e0b6} ⠋ 0s"), "{}", status_of(&rows));
2093 let activity = rows
2094 .iter()
2095 .find(|row| row.contains(WORKING_MESSAGE.trim()))
2096 .expect("activity row narrates while working");
2097 assert!(
2098 activity.trim_start().starts_with(CANCEL_HINT),
2099 "the cancel hint leads the activity row"
2100 );
2101
2102 let rows = rows_at(&mut demo, Duration::from_millis(80));
2103 assert!(status_of(&rows).starts_with("\u{e0b6} ⠙ 0s"), "the ticked spinner advances");
2104 let rows = rows_at(&mut demo, Duration::from_secs(65));
2105 assert!(status_of(&rows).contains(" 1m"), "the session timer stays compact");
2106
2107 demo.set_working(false, Duration::from_secs(66));
2108 let rows = rows_at(&mut demo, Duration::from_secs(67));
2109 assert!(status_of(&rows).starts_with("\u{e0b6} omp"), "{}", status_of(&rows));
2110 assert!(
2111 rows.iter().all(|row| !row.contains(WORKING_MESSAGE.trim())),
2112 "the activity row clears at rest"
2113 );
2114
2115 demo.set_working(true, Duration::from_secs(70));
2116 let rows = rows_at(&mut demo, Duration::from_secs(75));
2117 assert!(status_of(&rows).contains(" 5s"), "resuming restarts the session timer");
2118 }
2119
2120 #[test]
2121 fn esc_rests_work_then_quits_and_submit_resumes() {
2122 let mut demo = Demo::new(&test_ctx());
2123 let esc = Key::Esc;
2124
2125 assert!(!demo.handle_key(esc), "the first esc only cancels the running work");
2126 assert!(!demo.work.borrow().working);
2127
2128 for character in "go".chars() {
2129 demo.handle_key(Key::Char(character));
2130 }
2131 assert!(!demo.handle_key(Key::Enter));
2132 assert!(demo.work.borrow().working, "submitting a message resumes work");
2133
2134 assert!(!demo.handle_key(esc), "esc cancels the resumed work");
2135 assert!(demo.handle_key(esc), "esc at rest quits");
2136
2137 let mut fresh = Demo::new(&test_ctx());
2138 assert!(fresh.handle_key(Key::Ctrl('c')), "ctrl-c quits even while working");
2139 }
2140
2141 fn mouse_report(kind: Mouse, col: u16, row: u16, button: MouseButton) -> MouseReport {
2142 MouseReport { kind, col, row, button, mods: Mods::default(), pressed: true }
2143 }
2144
2145 #[test]
2146 fn editor_click_translates_document_row_and_moves_the_caret() {
2147 let viewport = Size::new(80, 24);
2148 let mut demo = Demo::new(&test_ctx());
2149 for character in "abcdef".chars() {
2150 demo.handle_key(Key::Char(character));
2151 }
2152 let document_height = demo.render_at(viewport, Duration::ZERO).frame.size().height;
2153 let editor_y = document_height.saturating_sub(demo.editor_ui.height());
2154
2155 demo.handle_mouse(&mouse_report(
2156 Mouse::Click,
2157 DemoInput::input_offset() + 2,
2158 editor_y + 1,
2159 MouseButton::Left,
2160 ));
2161
2162 let editor = demo.editor.borrow();
2163 assert_eq!(editor.text(), "abcdef");
2164 assert_eq!(editor.view(DemoInput::input_width(viewport.width))[0].cursor_column, Some(2));
2165 }
2166
2167 #[test]
2168 fn click_above_editor_block_is_ignored() {
2169 let viewport = Size::new(80, 24);
2170 let mut demo = Demo::new(&test_ctx());
2171 for character in "abcdef".chars() {
2172 demo.handle_key(Key::Char(character));
2173 }
2174 let document_height = demo.render_at(viewport, Duration::ZERO).frame.size().height;
2175 let editor_y = document_height.saturating_sub(demo.editor_ui.height());
2176 let before = demo
2177 .editor
2178 .borrow()
2179 .view(DemoInput::input_width(viewport.width))[0]
2180 .cursor_column;
2181
2182 demo.handle_mouse(&mouse_report(
2183 Mouse::Click,
2184 DemoInput::input_offset(),
2185 editor_y.saturating_sub(1),
2186 MouseButton::Left,
2187 ));
2188
2189 assert_eq!(
2190 demo
2191 .editor
2192 .borrow()
2193 .view(DemoInput::input_width(viewport.width))[0]
2194 .cursor_column,
2195 before
2196 );
2197 }
2198
2199 #[test]
2200 fn wheel_over_editor_preserves_submitted_transcript() {
2201 let viewport = Size::new(80, 24);
2202 let mut demo = Demo::new(&test_ctx());
2203 for character in "keep this".chars() {
2204 demo.handle_key(Key::Char(character));
2205 }
2206 demo.handle_key(Key::Enter);
2207 let submitted_row = |demo: &mut Demo| {
2208 let rendered = demo.render_at(viewport, Duration::ZERO);
2209 (0..rendered.frame.size().height)
2210 .find(|&row| frame_row_text(rendered.frame, row).contains("keep this"))
2211 };
2212 let before = submitted_row(&mut demo).expect("submission appended to the transcript");
2213 let document_height = demo.frame.size().height;
2214 let editor_y = document_height.saturating_sub(demo.editor_ui.height());
2215
2216 demo.handle_mouse(&mouse_report(
2217 Mouse::WheelDown,
2218 DemoInput::input_offset(),
2219 editor_y + 1,
2220 MouseButton::WheelDown,
2221 ));
2222
2223 assert_eq!(
2224 submitted_row(&mut demo),
2225 Some(before),
2226 "the submitted transcript row must survive wheel input over the editor"
2227 );
2228 }
2229
2230 #[test]
2231 fn brand_fade_is_continuous_across_rapid_cancel_and_resume() {
2232 let mut demo = Demo::new(&test_ctx());
2233 let cancel_at = Duration::from_secs(1);
2234 demo.set_working(false, cancel_at);
2235 let midway = cancel_at + BRAND_FADE / 2;
2236 let color = demo.work.borrow().fade.sample(midway);
2237 assert!(color != GREEN && color != MUTED, "midway the brand sits between its endpoints");
2238
2239 demo.set_working(true, midway);
2240 assert_eq!(
2241 demo.work.borrow().fade.sample(midway),
2242 color,
2243 "resuming mid-fade departs from the color already on screen"
2244 );
2245 }
2246
2247 #[test]
2248 fn growing_tick_commits_new_rows_without_repainting_transcript() {
2249 let viewport = Size::new(80, 24);
2250 let mut demo = Demo::new(&test_ctx());
2251 let mut renderer = Renderer::new(Vec::new());
2252 let rendered = demo.render_at(viewport, Duration::from_millis(1_500));
2253 let initial_height = rendered.frame.size().height;
2254 present(&mut renderer, rendered, viewport).expect("warm demo paint succeeds");
2255 renderer.writer_mut().clear();
2256
2257 let rendered = demo.render_at(viewport, Duration::from_millis(2_100));
2258 assert_eq!(
2259 rendered.frame.size().height,
2260 initial_height.saturating_add(2),
2261 "one emit tick appends two shard result rows"
2262 );
2263 let stats = present(&mut renderer, rendered, viewport).expect("growth tick paints");
2264 let output = String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2265
2266 assert_eq!(stats.committed_rows, 2, "appended rows commit into native scrollback");
2267 assert_eq!(output.matches("\r\n").count(), 2);
2268 assert!(!has_csi_command(&output, b'H'));
2269 assert!(
2270 !output.contains("append-only") && !output.contains("PARALLEL TEST RUN"),
2271 "stable transcript rows must never be re-emitted"
2272 );
2273 }
2274
2275 #[test]
2276 fn active_picker_stays_open_while_the_transcript_commits() {
2277 let viewport = Size::new(120, 32);
2278 let mut demo = Demo::new(&test_ctx());
2279 for character in ":joy".chars() {
2280 assert!(!demo.handle_key(Key::Char(character)));
2281 }
2282
2283 let mut renderer = Renderer::new(Vec::new());
2284 let rendered = demo.render_at(viewport, Duration::from_millis(100));
2285 present(&mut renderer, rendered, viewport).expect("initial picker paint succeeds");
2286 renderer.writer_mut().clear();
2287
2288 let rendered = demo.render_at(viewport, Duration::from_millis(800));
2289 let stats =
2290 present(&mut renderer, rendered, viewport).expect("growing picker frame succeeds");
2291 let output = String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2292
2293 assert_eq!(stats.committed_rows, 3, "the transcript keeps committing under the picker");
2294 assert!(!has_csi_command(&output, b'H'));
2295 assert_eq!(output.matches("\r\n").count(), 3);
2296 }
2297
2298 #[test]
2299 fn closing_the_picker_never_shrinks_the_document_mid_stream() {
2300 let viewport = Size::new(157, 46);
2305 let mut demo = Demo::new(&test_ctx());
2306 let mut renderer = Renderer::new(Vec::new());
2307
2308 let rendered = demo.render_at(viewport, Duration::ZERO);
2309 present(&mut renderer, rendered, viewport).expect("initial paint succeeds");
2310
2311 for character in ":e".chars() {
2312 demo.handle_key(Key::Char(character));
2313 }
2314 let rendered = demo.render_at(viewport, Duration::from_millis(100));
2315 let open_height = rendered.frame.size().height;
2316 present(&mut renderer, rendered, viewport).expect("open picker paints");
2317
2318 demo.handle_key(Key::Esc);
2319 let rendered = demo.render_at(viewport, Duration::from_millis(200));
2320 assert_eq!(
2321 rendered.frame.size().height,
2322 open_height,
2323 "closing the picker must not shrink the frame"
2324 );
2325 present(&mut renderer, rendered, viewport)
2326 .expect("closed picker paints without violating committed history");
2327
2328 for elapsed_ms in [700, 1_400, 2_100, 2_800] {
2329 let rendered = demo.render_at(viewport, Duration::from_millis(elapsed_ms));
2330 assert!(rendered.frame.size().height >= open_height);
2331 present(&mut renderer, rendered, viewport)
2332 .expect("streaming after picker close stays monotonic");
2333 }
2334 }
2335
2336 #[test]
2337 fn editor_chrome_contains_the_rounded_status_and_soft_prompt_without_a_border() {
2338 let viewport = Size::new(120, 32);
2339 let mut demo = Demo::new(&test_ctx());
2340 demo.set_working(false, Duration::ZERO);
2342 let rendered = demo.render_at(viewport, Duration::ZERO);
2343 let status_y = (0..rendered.frame.size().height)
2344 .find(|&row| frame_row_text(rendered.frame, row).contains(" omp"))
2345 .expect("status row must be present");
2346 let status_row = frame_row_text(rendered.frame, status_y);
2347 let input_row = frame_row_text(rendered.frame, status_y.saturating_add(1));
2348 for row in status_y..rendered.frame.size().height {
2349 let text = frame_row_text(rendered.frame, row);
2350 let text = text.strip_prefix(INPUT_PROMPT).unwrap_or(&text);
2351 assert!(
2352 !text
2353 .chars()
2354 .any(|glyph| matches!(glyph, '╭' | '╮' | '╰' | '╯' | '│' | '─')),
2355 "unexpected editor border on row {row}: {text}",
2356 );
2357 }
2358 assert_eq!(input_row, INPUT_PROMPT);
2359 let mut renderer = Renderer::new(Vec::new());
2360 present(&mut renderer, rendered, viewport).expect("editor frame paints");
2361 let output = String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2362
2363 let model_segment = format!("{} Fable 5++", Charset::NerdFont.icon(super::Icon::Model));
2364 for segment in
2365 [" omp", model_segment.as_str(), " main *5 +9", " 39.1%/1M", "$60.07 (sub) + $8.65 (adv)"]
2366 {
2367 assert!(output.contains(segment), "missing status segment: {segment}");
2368 }
2369 assert!(status_row.starts_with("\u{e0b6} omp"));
2370 assert!(
2371 status_row.contains('\u{e0b2}'),
2372 "the right group opens with a mirrored cap: {status_row}"
2373 );
2374 assert!(
2375 status_row.ends_with("(adv)"),
2376 "the right group ends flat against the margin: {status_row}"
2377 );
2378 assert!(!status_row.contains('─'), "status row must not retain the editor border");
2379 assert!(
2380 output.contains("\r\x1b[3C\x1b[?25h"),
2381 "focused editor caret must sit beyond the continuation prompt"
2382 );
2383 assert!(output.contains("48;2;18;18;18"), "status group must paint its dark background band");
2384 }
2385
2386 #[test]
2387 fn right_docked_chrome_reserves_the_rail_inset() {
2388 let viewport = Size::new(140, 40);
2389 let mut demo = Demo::new(&test_ctx());
2390 demo.set_right_inset(30);
2391 let rendered = demo.render_at(viewport, Duration::ZERO);
2392 let rows: Vec<String> = (0..rendered.frame.size().height)
2393 .map(|row| frame_row_text(rendered.frame, row))
2394 .collect();
2395 let visible = viewport.width - 30;
2396 let band = rows
2397 .iter()
2398 .find(|row| row.contains("(adv)"))
2399 .expect("split band row");
2400 let title = rows
2401 .iter()
2402 .find(|row| row.contains(super::SESSION_TITLE))
2403 .expect("session title row");
2404 for (label, row) in [("band", band), ("title", title)] {
2405 assert!(
2406 super::visible_width(row.trim_end()) <= visible,
2407 "{label} must dock inside the rail reservation: {row}"
2408 );
2409 }
2410 assert!(
2411 super::visible_width(band.trim_end()) > visible.saturating_sub(2),
2412 "the band still docks flush against the visible bound: {band}"
2413 );
2414 }
2415
2416 #[test]
2417 fn shifted_text_and_picker_keys_stay_owned_by_the_editor() {
2418 let viewport = Size::new(120, 32);
2419 let mut demo = Demo::new(&test_ctx());
2420 for character in "Hello World!".chars() {
2421 assert!(!demo.handle_key(Key::Char(character)));
2422 }
2423 let mut renderer = Renderer::new(Vec::new());
2424 let rendered = demo.render_at(viewport, Duration::ZERO);
2425 present(&mut renderer, rendered, viewport).expect("shifted text paints");
2426 let output =
2427 str::from_utf8(renderer.writer_mut().as_slice()).expect("renderer output is UTF-8");
2428 assert!(output.contains("Hello World!"));
2429 assert!(!demo.handle_key(Key::Enter));
2430
2431 assert!(!demo.handle_key(Key::Char('/')));
2432 assert!(!demo.handle_key(Key::Char('s')));
2433 assert!(!demo.handle_key(Key::Char('e')));
2434 assert!(!demo.handle_key(Key::BackTab));
2435 renderer.writer_mut().clear();
2436 let rendered = demo.render_at(viewport, Duration::ZERO);
2437 present(&mut renderer, rendered, viewport).expect("picker paints after back-tab");
2438 let picker_output =
2439 str::from_utf8(renderer.writer_mut().as_slice()).expect("renderer output is UTF-8");
2440 assert!(picker_output.contains("security"), "picker remains open after back-tab");
2441
2442 assert!(!demo.handle_key(Key::Tab));
2443 renderer.writer_mut().clear();
2444 let rendered = demo.render_at(viewport, Duration::ZERO);
2445 let accepted_in_frame = (0..rendered.frame.size().height)
2446 .any(|row| frame_row_text(rendered.frame, row).contains("/security"));
2447 present(&mut renderer, rendered, viewport).expect("accepted completion paints");
2448 assert!(accepted_in_frame, "tab accepts the selected slash command");
2449
2450 assert!(!demo.handle_key(Key::Esc));
2451 assert!(!demo.handle_key(Key::Esc), "the demo-level esc cancels the running work first");
2452 assert!(demo.handle_key(Key::Esc));
2453 }
2454
2455 #[test]
2456 fn multiline_input_grows_inside_the_editor_and_keeps_the_caret_visible() {
2457 let viewport = Size::new(120, 32);
2458 let mut demo = Demo::new(&test_ctx());
2459 demo.set_working(false, Duration::ZERO);
2460 let (initial_height, initial_status_y) = {
2461 let rendered = demo.render_at(viewport, Duration::ZERO);
2462 let height = rendered.frame.size().height;
2463 let status_y = (0..height)
2464 .find(|&row| frame_row_text(rendered.frame, row).contains(" omp"))
2465 .expect("initial editor status row");
2466 (height, status_y)
2467 };
2468 for text in ["first", "second", "third"] {
2469 for character in text.chars() {
2470 demo.handle_key(Key::Char(character));
2471 }
2472 if text != "third" {
2473 demo.handle_key(Key::ShiftEnter);
2474 }
2475 }
2476
2477 let mut renderer = Renderer::new(Vec::new());
2478 let rendered = demo.render_at(viewport, Duration::ZERO);
2479 let height = rendered.frame.size().height;
2480 let rows: Vec<String> = (0..height)
2481 .map(|row| frame_row_text(rendered.frame, row))
2482 .collect();
2483 let status_y = rows
2484 .iter()
2485 .position(|row| row.contains(" omp"))
2486 .and_then(|row| u16::try_from(row).ok())
2487 .expect("grown editor status row");
2488 let input_rows: Vec<u16> = ["first", "second", "third"]
2489 .into_iter()
2490 .map(|text| {
2491 rows
2492 .iter()
2493 .position(|row| row.contains(text))
2494 .and_then(|row| u16::try_from(row).ok())
2495 .unwrap_or_else(|| panic!("missing input row {text:?}"))
2496 })
2497 .collect();
2498
2499 assert_eq!(height, initial_height, "editor growth is absorbed by the blank band padding");
2500 assert_eq!(
2501 status_y,
2502 initial_status_y.saturating_sub(2),
2503 "the editor chrome rises as it grows"
2504 );
2505 assert_eq!(
2506 input_rows,
2507 [status_y + 1, status_y + 2, status_y + 3],
2508 "input lines must occupy distinct rows below the status chrome",
2509 );
2510 assert_eq!(input_rows[2].saturating_add(1), height, "third line stays inside the document");
2511 assert!(
2512 ["first", "second", "third"]
2513 .into_iter()
2514 .all(|text| !rows[usize::from(status_y)].contains(text)),
2515 "input must not overwrite the status chrome",
2516 );
2517 assert!(rows[usize::from(input_rows[0])].starts_with("╰─ first"));
2518 assert!(rows[usize::from(input_rows[1])].starts_with(" second"));
2519 assert!(rows[usize::from(input_rows[2])].starts_with(" third"));
2520
2521 present(&mut renderer, rendered, viewport).expect("multiline editor paints");
2522 let editor_output =
2523 str::from_utf8(renderer.writer_mut().as_slice()).expect("renderer output is UTF-8");
2524 assert!(
2525 editor_output.contains("\r\x1b[8C\x1b[?25h"),
2526 "presented caret must account for the prompt before `third`",
2527 );
2528 renderer.writer_mut().clear();
2529
2530 assert!(!demo.handle_key(Key::Enter));
2531 let rendered = demo.render_at(viewport, Duration::ZERO);
2532 present(&mut renderer, rendered, viewport)
2533 .expect("multiline submission preserves the immutable seam");
2534 let submission_output =
2535 String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2536 for text in ["first", "second", "third"] {
2537 assert!(submission_output.contains(text), "submission lost {text:?}");
2538 }
2539 }
2540
2541 #[test]
2542 fn transcript_header_is_emitted_once_across_commits() {
2543 let viewport = Size::new(80, 24);
2544 let mut renderer = Renderer::new(Vec::new());
2545 let mut demo = Demo::new(&test_ctx());
2546
2547 for elapsed_ms in [0, 80, 699, 700, 780, 1_400, 2_100] {
2548 let rendered = demo.render_at(viewport, Duration::from_millis(elapsed_ms));
2549 present(&mut renderer, rendered, viewport)
2550 .expect("demo frame satisfies the immutable seam contract");
2551 }
2552
2553 let output = String::from_utf8(renderer.into_inner()).expect("renderer output is UTF-8");
2554 assert_eq!(output.matches("PARALLEL TEST RUN").count(), 1);
2555 assert!(!output.contains("\x1b[3J"));
2556 }
2557 #[test]
2558 fn ten_minute_session_repaints_only_the_live_suffix() {
2559 let viewport = Size::new(120, 32);
2560 let mut demo = Demo::new(&test_ctx());
2561 let warm = demo.render_at(viewport, Duration::from_millis(599_900));
2562 let previous_stable_rows = warm.stable_rows;
2563
2564 let rendered = demo.render_at(viewport, Duration::from_mins(10));
2565
2566 assert_eq!(rendered.damage.first().map(|range| range.0), Some(previous_stable_rows));
2567 let damaged_rows: u16 = rendered
2568 .damage
2569 .iter()
2570 .map(|&(start, end)| end.saturating_sub(start))
2571 .sum();
2572 assert!(
2573 damaged_rows <= viewport.height + 8,
2574 "damaged rows must stay bounded to the live suffix: {:?}",
2575 rendered.damage
2576 );
2577 }
2578
2579 #[test]
2583 #[ignore = "release-mode perf smoke, run explicitly"]
2584 fn perf_render_cost_does_not_grow_with_history() {
2585 let viewport = Size::new(120, 32);
2586 let frame_cost = |elapsed| {
2587 let mut demo = Demo::new(&test_ctx());
2588 let mut renderer = Renderer::new(std::io::sink());
2589 let rendered = demo.render_at(viewport, elapsed);
2590 present(&mut renderer, rendered, viewport).expect("warm-up presentation succeeds");
2591 const FRAMES: u32 = 100;
2592 let started_at = Instant::now();
2593 for frame in 0..FRAMES {
2594 let rendered =
2595 demo.render_at(viewport, elapsed + Duration::from_millis(u64::from(frame)));
2596 black_box(
2597 present(&mut renderer, rendered, viewport).expect("steady presentation succeeds"),
2598 );
2599 }
2600 started_at.elapsed() / FRAMES
2601 };
2602
2603 let short = frame_cost(Duration::from_secs(30));
2604 let long = frame_cost(Duration::from_mins(10));
2605 println!("steady frame: {short:?} at 30s vs {long:?} at 10m");
2606 assert!(
2607 long.as_nanos() < short.as_nanos() * 3,
2608 "frame cost still scales with immutable history: {short:?} -> {long:?}"
2609 );
2610 }
2611
2612 fn rows_of(demo: &mut Demo, viewport: Size) -> Vec<String> {
2613 let rendered = demo.render_at(viewport, Duration::ZERO);
2614 (0..rendered.frame.size().height)
2615 .map(|row| frame_row_text(rendered.frame, row))
2616 .collect()
2617 }
2618
2619 #[test]
2620 fn attach_command_stages_a_framed_preview_and_a_colored_chip() {
2621 let dir = std::env::temp_dir().join(format!("omp-chat-attach-cmd-{}", std::process::id()));
2622 std::fs::create_dir_all(&dir).unwrap();
2623 let path = dir.join("shot.png");
2624 let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
2625 png.extend(528_u32.to_be_bytes());
2626 png.extend(200_u32.to_be_bytes());
2627 std::fs::write(&path, png).unwrap();
2628
2629 let viewport = Size::new(120, 40);
2630 let mut demo = Demo::new(&test_ctx());
2631 for character in format!("/attach {}", path.display()).chars() {
2632 assert!(!demo.handle_key(Key::Char(character)));
2633 }
2634 assert!(!demo.handle_key(Key::Enter));
2635 let rows = rows_of(&mut demo, viewport);
2636 let caption_row = rows
2637 .iter()
2638 .position(|row| row.contains("#1"))
2639 .expect("preview frame caption above the composer");
2640 assert!(
2641 rows.iter().any(|row| row.contains("528x200")),
2642 "the frame's bottom edge captions the probed resolution"
2643 );
2644 let status_row = rows
2645 .iter()
2646 .position(|row| row.contains("Fable 5++"))
2647 .expect("status row");
2648 assert!(caption_row < status_row, "the preview band sits above the status line");
2649 assert_eq!(
2650 rows.iter().filter(|row| row.contains("#1")).count(),
2651 2,
2652 "the chip is mentioned in the prompt as well as the frame caption"
2653 );
2654
2655 let mut renderer = Renderer::new(Vec::new());
2657 let rendered = demo.render_at(viewport, Duration::ZERO);
2658 present(&mut renderer, rendered, viewport).expect("chip paints");
2659 let output =
2660 str::from_utf8(renderer.writer_mut().as_slice()).expect("renderer output is UTF-8");
2661 assert!(output.contains("38;2;255;179;102"), "composer chip and frame use identity color #1");
2662
2663 for character in "ship it".chars() {
2664 demo.handle_key(Key::Char(character));
2665 }
2666 assert!(!demo.handle_key(Key::Enter));
2667 let rows = rows_of(&mut demo, viewport);
2668 assert!(
2669 rows.iter().any(|row| row.contains("#1 ship it")),
2670 "the transcript renders the ref tag as a compact chip pill"
2671 );
2672 assert!(
2673 !rows.iter().any(|row| row.contains("<ref image=1/>")),
2674 "the raw ref tag never renders"
2675 );
2676 assert_eq!(
2677 rows.iter().filter(|row| row.contains("#1")).count(),
2678 1,
2679 "the preview band collapsed after submit"
2680 );
2681 std::fs::remove_dir_all(&dir).ok();
2682 }
2683
2684 #[test]
2685 fn deleting_a_chip_hides_its_card_and_undo_restores_it() {
2686 let dir = std::env::temp_dir().join(format!("omp-chat-attach-del-{}", std::process::id()));
2687 std::fs::create_dir_all(&dir).unwrap();
2688 let path = dir.join("gone.png");
2689 std::fs::write(&path, b"\x89PNG\r\n\x1a\n").unwrap();
2690
2691 let viewport = Size::new(120, 40);
2692 let mut demo = Demo::new(&test_ctx());
2693 demo.handle_paste(path.to_str().expect("temp path is UTF-8"));
2694 assert!(
2695 rows_of(&mut demo, viewport)
2696 .iter()
2697 .any(|row| row.contains("#1"))
2698 );
2699
2700 demo.handle_key(Key::Backspace);
2702 demo.handle_key(Key::Backspace);
2703 let rows = rows_of(&mut demo, viewport);
2704 assert!(
2705 !rows.iter().any(|row| row.contains("#1")),
2706 "deleting the chip removes the card from the band"
2707 );
2708
2709 demo.handle_key(Key::Ctrl('_'));
2711 assert!(
2712 rows_of(&mut demo, viewport)
2713 .iter()
2714 .any(|row| row.contains("#1"))
2715 );
2716
2717 demo.handle_key(Key::Ctrl('_'));
2719 demo.handle_key(Key::Backspace);
2720 demo.handle_key(Key::Backspace);
2721 for character in "done".chars() {
2722 demo.handle_key(Key::Char(character));
2723 }
2724 demo.handle_key(Key::Enter);
2725 let rows = rows_of(&mut demo, viewport);
2726 assert!(rows.iter().any(|row| row.contains("done")));
2727 assert!(
2728 !rows
2729 .iter()
2730 .any(|row| row.contains("#1") || row.contains("<ref image=1/>")),
2731 "a deleted attachment never reaches the transcript"
2732 );
2733 std::fs::remove_dir_all(&dir).ok();
2734 }
2735
2736 #[test]
2737 fn large_pastes_collapse_into_text_cards_and_expand_on_submit() {
2738 let viewport = Size::new(120, 44);
2739 let mut demo = Demo::new(&test_ctx());
2740 let paste = (0..12)
2741 .map(|n| format!("line{n}"))
2742 .collect::<Vec<_>>()
2743 .join("\n");
2744 demo.handle_paste(&paste);
2745 let rows = rows_of(&mut demo, viewport);
2746 assert!(rows.iter().any(|row| row.contains("#1")), "paste stages a numbered card");
2747 assert!(rows.iter().any(|row| row.contains("+12 lines")), "the card captions the paste size");
2748 assert!(
2749 rows
2750 .iter()
2751 .any(|row| row.contains("line0") && !row.contains("line10")),
2752 "the card previews the leading paste text"
2753 );
2754
2755 demo.handle_key(Key::Enter);
2756 let rows = rows_of(&mut demo, viewport);
2757 assert!(
2758 rows.iter().any(|row| row.contains("line11")),
2759 "the submitted transcript expands the full paste"
2760 );
2761 assert!(!rows.iter().any(|row| row.contains("+12 lines")), "the card collapsed");
2762 }
2763
2764 #[test]
2765 fn pasting_an_image_path_stages_an_attachment_instead_of_inserting() {
2766 let dir = std::env::temp_dir().join(format!("omp-chat attach-{}", std::process::id()));
2767 std::fs::create_dir_all(&dir).unwrap();
2768 let path = dir.join("paste drop.png");
2769 let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
2770 png.extend([0; 16]);
2771 std::fs::write(&path, png).unwrap();
2772
2773 let viewport = Size::new(120, 40);
2774 let mut demo = Demo::new(&test_ctx());
2775 demo.handle_paste(&format!("'{}'", path.display()));
2778 let rows = rows_of(&mut demo, viewport);
2779 assert!(
2780 rows.iter().any(|row| row.contains("#1")),
2781 "pasted image is staged as a framed preview"
2782 );
2783 let input_row = rows
2784 .iter()
2785 .rev()
2786 .find(|row| row.contains(INPUT_PROMPT))
2787 .expect("composer prompt row");
2788 assert!(input_row.contains("#1"), "the attachment is mentioned in the prompt: {input_row}");
2789 assert!(
2790 !input_row.contains("drop.png"),
2791 "the path must not be inserted into the input: {input_row}"
2792 );
2793 std::fs::remove_dir_all(&dir).ok();
2794 }
2795}