M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,135 @@
use std::io::{self, Write};
use crossterm::{
QueueableCommand as _,
terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate},
};
use ratatui::{
layout::{Rect, Size},
prelude::Backend,
};
use crate::Terminal;
/// Trait for terminal operations needed by emit_to_scrollback and other functions.
pub trait TerminalLike {
/// The writer type that will be used for output
type Writer: Write;
/// Get the terminal size
fn size(&self) -> io::Result<Size>;
/// Get the current viewport area
fn viewport_area(&self) -> Rect;
/// Clear the terminal
fn clear(&mut self) -> io::Result<()>;
/// Reset the back buffer without clearing the screen
fn reset_back_buffer(&mut self);
/// Set the viewport area
fn set_viewport_area(&mut self, area: Rect);
/// Get a mutable reference to the writer
fn writer_mut(&mut self) -> &mut Self::Writer;
}
// Implementation for our Terminal with any Backend that implements Write
impl<B: Backend + Write> TerminalLike for Terminal<B> {
type Writer = B;
fn size(&self) -> io::Result<Size> {
self.backend().size()
}
fn viewport_area(&self) -> Rect {
self.viewport_area()
}
fn clear(&mut self) -> io::Result<()> {
self.clear()
}
fn reset_back_buffer(&mut self) {
self.reset_back_buffer()
}
fn set_viewport_area(&mut self, area: Rect) {
self.set_viewport_area(area)
}
fn writer_mut(&mut self) -> &mut Self::Writer {
self.backend_mut()
}
}
/// Execute a function with synchronized terminal output to prevent flicker
///
/// This wraps the provided function with terminal synchronized output mode,
/// making all terminal operations within the function atomic.
/// Supported by most modern terminals (iTerm2, kitty, WezTerm, Windows Terminal, etc.)
/// Gracefully ignored by terminals that don't support it.
///
/// IMPORTANT: if the closure panics, it is responsibility of the caller to clean
/// this up, otherwise the terminal may hang forever (depends on the terminal / mux).
pub fn with_synchronized_output<T, F, R>(terminal: &mut T, f: F) -> io::Result<R>
where
T: TerminalLike,
F: FnOnce(&mut T) -> io::Result<R>,
{
// Begin synchronized output
terminal.writer_mut().queue(BeginSynchronizedUpdate)?;
// Execute the provided function
let result = f(terminal);
// End synchronized output and flush
terminal.writer_mut().queue(EndSynchronizedUpdate)?;
terminal.writer_mut().flush()?;
result
}
#[cfg(test)]
mod tests {
use std::io::Write;
use crate::tests::MockTerminal;
use super::*;
#[test]
fn test_synchronized_output() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Use synchronized output wrapper
let result = with_synchronized_output(&mut terminal, |terminal| {
_ = terminal.writer_mut().write(b"Test content")?;
terminal.writer_mut().flush()?;
Ok(())
});
assert!(result.is_ok());
// Check that synchronized output markers were written
let buffer = &terminal.writer.buffer;
let text = String::from_utf8_lossy(buffer);
// Should contain begin and end synchronized update sequences
assert!(
text.contains("\x1b[?2026h"),
"Should have begin synchronized update"
);
assert!(
text.contains("\x1b[?2026l"),
"Should have end synchronized update"
);
// Content should be between the markers
assert!(text.contains("Test content"));
// Should have flushed (once in emit_to_scrollback, once in with_synchronized_output)
assert_eq!(terminal.writer.flush_count, 2);
}
}
@@ -0,0 +1,16 @@
mod common;
mod resize;
mod scrollback;
mod segment;
mod terminal;
#[cfg(test)]
mod tests;
pub use self::{
common::{TerminalLike, with_synchronized_output},
resize::{resize_purge_rerender, resize_viewport_height},
scrollback::emit_to_scrollback,
segment::split_into_line_segments,
terminal::{LinkSpan, Terminal},
};
@@ -0,0 +1,357 @@
use std::io::{self, Write as _};
use crossterm::{cursor::MoveTo, queue, style::Print};
use ratatui::layout::Rect;
use crate::{common::TerminalLike, segment::split_into_line_segments};
/// Handles terminal resize by completely re-rendering the scrollback history.
///
/// This function uses a "nuclear option" approach: it sends RIS (Reset to Initial State)
/// to clear the entire terminal, then re-outputs all scrollback history and positions
/// the viewport appropriately.
///
/// # Why this approach?
///
/// When the terminal is resized, text reflow happens automatically *before* our application
/// receives the resize signal (SIGWINCH). This creates several problems:
///
/// 1. **Scrollback corruption**: The built-in `terminal.autoresize()` doesn't handle reflowed
/// content properly, often damaging scrollback history or leaving visual artifacts.
///
/// 2. **Viewport artifacts**: The old viewport borders get reflowed along with regular text,
/// appearing as garbage above the new viewport position. While we could try to move the
/// viewport up to avoid this, it becomes impossible when the viewport is already near the top.
///
/// 3. **Unpredictable reflow**: Different terminals handle text reflow differently, making it
/// nearly impossible to predict exactly where content will end up after resize. We tried
/// calculating reflow based on character counts, but edge cases and terminal-specific
/// behaviors made this unreliable.
///
/// The RIS + re-render approach is more drastic but provides consistency across all terminals
/// and resize scenarios. It's especially important for horizontal resizing where text reflow
/// is most problematic.
///
/// # Arguments
///
/// * `terminal` - The terminal instance to resize
/// * `history` - The complete scrollback history (with CRLF line endings)
///
/// # Returns
///
/// Returns `Ok(())` on success, or an I/O error if terminal operations fail.
pub fn resize_purge_rerender<T: TerminalLike>(terminal: &mut T, history: &str) -> io::Result<()> {
let viewport = terminal.viewport_area();
let size = terminal.size()?;
// Clear current screen, clear scrollbackhistory and move the cursor to the top left corner
// note: we could've also used RIS (\x1bc) hard reset, but it doesn't clear scrollback in iterm/terminal.app
terminal.writer_mut().write_all(b"\x1b[2J\x1b[3J\x1b[H")?;
terminal.writer_mut().flush()?;
// Count newlines in history as a quick check for whether we have enough content
// The +1 accounts for content on the first line (before any newlines)
let num_newlines = 1 + history
.as_bytes()
.iter()
.filter(|&&c| c == b'\n')
.take(size.height.into()) // Only count up to screen height for efficiency
.count() as u16;
// Re-output the entire scrollback history
queue!(terminal.writer_mut(), Print(history))?;
// Add blank lines to reserve space for the viewport
for _ in 0..viewport.height {
queue!(terminal.writer_mut(), Print("\r\n"))?;
}
// Calculate where to position the viewport
let viewport_y = if num_newlines + viewport.height >= size.height {
// We have enough content to fill the screen, viewport goes at the bottom
size.height.saturating_sub(viewport.height)
} else {
// Not enough content to fill the screen, need to calculate exact position
// Use split_into_line_segments to account for line wrapping
let segments = split_into_line_segments(history, size.width.into());
let num_visible_lines = segments.len().min(u16::MAX as _) as u16;
// Position viewport right after the content, but not beyond screen bottom
num_visible_lines.min(size.height.saturating_sub(viewport.height))
};
// Flush all queued commands
terminal.writer_mut().flush()?;
// Resize and clear the viewport
terminal.set_viewport_area(ratatui::layout::Rect {
x: 0,
y: viewport_y,
width: size.width,
height: viewport.height,
});
terminal.clear()?;
Ok(())
}
/// Resize the viewport to a new height with terminal dimensions being the same.
///
/// When shrinking: Always anchors to top (gap appears at bottom)
/// When growing: Tries to expand down first, then pushes content up if needed
pub fn resize_viewport_height<T: TerminalLike>(
terminal: &mut T,
new_height: u16,
) -> io::Result<()> {
macro_rules! queue {
($($command:expr),* $(,)?) => {{
$(crossterm::queue!(terminal.writer_mut(), $command)?;)*
Ok::<(), io::Error>(())
}};
}
let size = terminal.size()?;
let current_viewport = terminal.viewport_area();
let old_height = current_viewport.height;
if new_height == old_height {
return Ok(());
}
// Ensure new height is valid
if new_height == 0 || new_height >= size.height {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"Invalid viewport height: {} (terminal height: {})",
new_height, size.height
),
));
}
if new_height > old_height {
// Growing: Smart expansion - try to expand down first, then push content up if needed
let growth = new_height - old_height;
let bottom_edge = current_viewport.y + current_viewport.height;
let space_below = size.height.saturating_sub(bottom_edge);
// Calculate the new y position
let new_y = if space_below >= growth {
// We have enough space below - expand down, keep same y
current_viewport.y
} else {
// Need to push content up
// Either use all space below and push up the rest, or anchor to bottom
if space_below > 0 {
// Use available space below and push up for the remainder
current_viewport.y.saturating_sub(growth - space_below)
} else {
// Already at bottom, push everything up
size.height.saturating_sub(new_height)
}
};
// If we need to scroll content up
if new_y < current_viewport.y {
let scroll_amount = current_viewport.y - new_y;
// Move to bottom and emit newlines to push content into scrollback
queue!(MoveTo(0, size.height - 1))?;
for _ in 0..scroll_amount {
queue!(Print("\r\n"))?;
}
terminal.writer_mut().flush()?;
}
// Clear the old viewport
terminal.clear()?;
// Set the new viewport area
terminal.set_viewport_area(Rect::new(0, new_y, current_viewport.width, new_height));
} else {
// Shrinking: Always anchor to top (gap appears at bottom)
terminal.clear()?;
terminal.set_viewport_area(Rect::new(
0,
current_viewport.y,
current_viewport.width,
new_height,
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::tests::MockTerminal;
use super::*;
#[test]
fn test_viewport_resize_shrink() {
let mut terminal = MockTerminal::new(80, 25, 5);
let original_y = terminal.viewport_area.y; // Should be 20 (25-5)
// Shrink viewport from 5 to 3 (always anchors at top)
resize_viewport_height(&mut terminal, 3).unwrap();
// Check viewport was updated - y should stay the same
assert_eq!(terminal.viewport_area.height, 3);
assert_eq!(terminal.viewport_area.y, original_y); // Should still be 20
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_viewport_resize_smart_expand() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Start at position 20 (not at bottom)
terminal.viewport_area.y = 20;
// Expand viewport from 3 to 5 - should expand downward first
resize_viewport_height(&mut terminal, 5).unwrap();
// Check that it expanded down (kept same y)
assert_eq!(terminal.viewport_area.height, 5);
assert_eq!(terminal.viewport_area.y, 20); // Should stay at 20
assert_eq!(terminal.clear_count, 1);
// Now expand more - should hit bottom and push content up
resize_viewport_height(&mut terminal, 6).unwrap();
assert_eq!(terminal.viewport_area.height, 6);
assert_eq!(terminal.viewport_area.y, 19); // Should move up to 19
assert_eq!(terminal.clear_count, 2);
}
#[test]
fn test_viewport_resize_invalid() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Try invalid heights
assert!(resize_viewport_height(&mut terminal, 0).is_err());
assert!(resize_viewport_height(&mut terminal, 25).is_err());
assert!(resize_viewport_height(&mut terminal, 26).is_err());
// Valid edge cases
assert!(resize_viewport_height(&mut terminal, 1).is_ok());
assert!(resize_viewport_height(&mut terminal, 24).is_ok());
}
#[test]
fn test_viewport_resize_no_op() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Resize to same height
resize_viewport_height(&mut terminal, 3).unwrap();
// Should not have cleared
assert_eq!(terminal.clear_count, 0);
assert_eq!(terminal.viewport_area.height, 3);
}
#[test]
fn test_resize_purge_rerender_empty_history() {
let mut terminal = MockTerminal::new(80, 25, 3);
terminal.viewport_area.y = 22; // Bottom position
// Test with empty history
resize_purge_rerender(&mut terminal, "").unwrap();
// Viewport should be at top since there's no content
assert_eq!(terminal.viewport_area.y, 0);
assert_eq!(terminal.viewport_area.height, 3);
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_resize_purge_rerender_small_history() {
let mut terminal = MockTerminal::new(80, 25, 3);
terminal.viewport_area.y = 22; // Bottom position
// Test with small history (just a few lines)
let history = "Line 1\r\nLine 2\r\nLine 3\r\n";
resize_purge_rerender(&mut terminal, history).unwrap();
// split_into_line_segments will count this as 3 segments (one per line)
// So viewport should be positioned at y=3
assert_eq!(terminal.viewport_area.y, 3);
assert_eq!(terminal.viewport_area.height, 3);
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_resize_purge_rerender_full_screen_history() {
let mut terminal = MockTerminal::new(80, 25, 3);
terminal.viewport_area.y = 22; // Bottom position
// Create history with more lines than screen height
let mut history = String::new();
for i in 1..=30 {
history.push_str(&format!("Line {}\r\n", i));
}
resize_purge_rerender(&mut terminal, &history).unwrap();
// With full screen of content, viewport should be at bottom
assert_eq!(terminal.viewport_area.y, 25 - 3); // screen_height - viewport_height
assert_eq!(terminal.viewport_area.height, 3);
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_resize_purge_rerender_with_wrapped_lines() {
let mut terminal = MockTerminal::new(40, 10, 2); // Narrow terminal
terminal.viewport_area.y = 8;
// Create a line that will wrap
let long_line = "A".repeat(100); // Will wrap to ~3 lines on 40-column terminal
let history = format!("{}\r\nShort line\r\n", long_line);
resize_purge_rerender(&mut terminal, &history).unwrap();
// The actual position depends on split_into_line_segments calculation
// But it should position the viewport appropriately
assert!(terminal.viewport_area.y <= 10 - 2);
assert_eq!(terminal.viewport_area.height, 2);
assert_eq!(terminal.clear_count, 1);
}
#[test]
fn test_resize_purge_rerender_preserves_viewport_dimensions() {
let mut terminal = MockTerminal::new(100, 30, 5);
let original_width = terminal.viewport_area.width;
let original_height = terminal.viewport_area.height;
let history = "Some content\r\n";
resize_purge_rerender(&mut terminal, history).unwrap();
// Width and height should be preserved, only y position changes
assert_eq!(terminal.viewport_area.width, original_width);
assert_eq!(terminal.viewport_area.height, original_height);
}
#[test]
fn test_resize_purge_rerender_captures_output() {
let mut terminal = MockTerminal::new(80, 25, 3);
let history = "Test line\r\n";
resize_purge_rerender(&mut terminal, history).unwrap();
// Verify RIS command was sent to writer (not real stdout)
let output = String::from_utf8_lossy(&terminal.writer.buffer);
assert!(
output.contains("\x1b[2J\x1b[3J\x1b[H"),
"Should contain reset commands"
);
assert!(output.contains("Test line"), "Should contain history");
// Ensure we flushed the writer
assert!(
terminal.writer.flush_count > 0,
"Should have flushed writer"
);
}
}
@@ -0,0 +1,234 @@
use std::io::{self, Write};
use crossterm::{cursor::MoveTo, style::Print};
use ratatui::layout::Rect;
use crate::{common::TerminalLike, segment::split_into_line_segments};
// ANSI escape sequence constants.
// CSI J with the default parameter (0): erase from cursor to end of display.
// Byte-identical to what the previous termwiz constant
// (`CSI::Edit(Edit::EraseInDisplay(EraseInDisplay::EraseToEndOfDisplay))`)
// rendered, and to crossterm's `Clear(ClearType::FromCursorDown)`.
const ANSI_CLEAR_FROM_CURSOR_DOWN: &str = "\x1b[J";
pub fn emit_to_scrollback<T: TerminalLike>(terminal: &mut T, content: &str) -> io::Result<()> {
macro_rules! queue {
($($command:expr),* $(,)?) => {{
$(crossterm::queue!(terminal.writer_mut(), $command)?;)*
Ok::<(), io::Error>(())
}};
}
let size = terminal.size()?;
let viewport_area = terminal.viewport_area();
let terminal_width = size.width as usize;
debug_assert!(viewport_area.bottom() <= size.height);
// Use zero-copy line segmentation
let segments = split_into_line_segments(content, terminal_width);
// Calculate where viewport will end up after content
let new_viewport_y =
(viewport_area.y + segments.len() as u16).min(size.height - viewport_area.height);
// Position from viewport top and clear from this position down
queue!(
MoveTo(0, viewport_area.y),
Print(ANSI_CLEAR_FROM_CURSOR_DOWN),
)?;
// Now print the content
queue!(MoveTo(0, viewport_area.y))?;
for segment in &segments {
queue!(Print(segment))?; // this already includes crlfs if there's any
}
// Create exact viewport space
for _ in 0..viewport_area.height {
queue!(Print("\r\n"))?;
}
// Clear the new viewport area for rendering
queue!(
MoveTo(0, new_viewport_y),
Print(ANSI_CLEAR_FROM_CURSOR_DOWN),
)?;
// We'll flush by default; the caller is expected to have this in sync block anyway
terminal.writer_mut().flush()?;
// Reset the back buffer so next render knows viewport is empty
terminal.reset_back_buffer();
// Reposition viewport if needed
if new_viewport_y != viewport_area.y {
terminal.set_viewport_area(Rect {
y: new_viewport_y,
..viewport_area
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::tests::MockTerminal;
use super::*;
// Helper to parse ANSI sequences from the captured buffer
fn parse_ansi_sequences(buffer: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(buffer);
let mut sequences = Vec::new();
let mut current = String::new();
let mut in_escape = false;
for ch in text.chars() {
if ch == '\x1b' {
if !current.is_empty() {
sequences.push(current.clone());
current.clear();
}
in_escape = true;
current.push(ch);
} else if in_escape {
current.push(ch);
// Simple heuristic: most ANSI sequences end with a letter
if ch.is_alphabetic() {
sequences.push(current.clone());
current.clear();
in_escape = false;
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
sequences.push(current);
}
sequences
}
#[test]
fn test_simple_content() {
let mut terminal = MockTerminal::new(80, 25, 3);
let content = "Hello, World!";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
// Check that content was written
let buffer = &terminal.writer.buffer;
assert!(!buffer.is_empty());
// Should have flushed
assert_eq!(terminal.writer.flush_count, 1);
}
#[test]
fn test_tall_content() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Create content that will span more lines than viewport height
let content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
// Check that content was written
let buffer = &terminal.writer.buffer;
assert!(!buffer.is_empty());
// Should contain the content
let text = String::from_utf8_lossy(buffer);
assert!(text.contains("Line 1"));
assert!(text.contains("Line 5"));
// Should have flushed
assert_eq!(terminal.writer.flush_count, 1);
}
#[test]
fn test_content_with_viewport_at_bottom() {
let mut terminal = MockTerminal::new(80, 25, 3);
let content = "Hello, Multiplexer!";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
// Check that content was written
let buffer = &terminal.writer.buffer;
assert!(!buffer.is_empty());
// Should have flushed
assert_eq!(terminal.writer.flush_count, 1);
// Viewport should remain at bottom
assert_eq!(terminal.viewport_area.y, 22); // 25 - 3
}
#[test]
fn test_viewport_not_at_bottom() {
let mut terminal = MockTerminal::new(80, 25, 3);
// Move viewport away from bottom
terminal.viewport_area.y = 10;
let content = "Test content";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared
assert_eq!(terminal.clear_count, 1);
// Viewport should have moved down
assert_eq!(terminal.viewport_updates.len(), 1);
assert!(terminal.viewport_updates[0].y > 10);
}
#[test]
fn test_long_lines_wrapping() {
let mut terminal = MockTerminal::new(20, 10, 2);
// Content longer than terminal width
let content = "This is a very long line that should wrap at terminal boundaries";
emit_to_scrollback(&mut terminal, content).unwrap();
// Should have cleared once
assert_eq!(terminal.clear_count, 1);
// Should have written content
let buffer = &terminal.writer.buffer;
assert!(!buffer.is_empty());
// Should have flushed
assert_eq!(terminal.writer.flush_count, 1);
}
#[test]
fn test_ansi_color_preservation() {
let mut terminal = MockTerminal::new(80, 25, 3);
let content = "\x1b[31mRed Text\x1b[0m";
emit_to_scrollback(&mut terminal, content).unwrap();
// Check that ANSI codes are preserved in output
let buffer = &terminal.writer.buffer;
let text = String::from_utf8_lossy(buffer);
assert!(text.contains("Red Text"), "Text should be in output");
// The ANSI codes might be in the segment's content
let sequences = parse_ansi_sequences(buffer);
let has_color = sequences.iter().any(|s| s.contains("Red Text"));
assert!(has_color, "Colored text should be present");
}
}
@@ -0,0 +1,372 @@
use std::fmt;
use anstyle_parse::{DefaultCharAccumulator, Params, Parser, Perform};
use unicode_width::UnicodeWidthChar as _;
/// Represents a line segment (physical row) with its content and ANSI state
#[derive(Debug, Clone)]
pub struct LineSegment<'a> {
/// Contiguous string content
pub content: &'a str,
/// Has a trailing crlf at the end of it
pub ends_with_crlf: bool,
}
impl fmt::Display for LineSegment<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// To write without crlf, can simply write segment.content
write!(f, "{}", self.content)?;
if self.ends_with_crlf {
write!(f, "\r\n")?;
}
Ok(())
}
}
/// The parse events `split_into_line_segments` distinguishes. Everything the
/// splitter cares about: printable characters (visual width), CR, LF; every
/// other action (SGR colors, cursor moves, OSC, …) merely extends the current
/// segment byte range.
enum SegmentEvent {
Print(char),
CarriageReturn,
LineFeed,
/// Any other complete escape/control action.
Other,
}
/// `anstyle_parse::Perform` implementor that records the single event (if
/// any) produced by the byte just fed to the parser.
///
/// The VTE state machine dispatches at most one action per input byte, so a
/// one-slot buffer is sufficient. Print events are dispatched on the *final*
/// byte of a UTF-8 sequence; the char itself tells us how many bytes it spans.
#[derive(Default)]
struct EventCollector {
event: Option<SegmentEvent>,
}
impl Perform for EventCollector {
fn print(&mut self, c: char) {
self.event = Some(SegmentEvent::Print(c));
}
fn execute(&mut self, byte: u8) {
self.event = Some(match byte {
b'\r' => SegmentEvent::CarriageReturn,
b'\n' => SegmentEvent::LineFeed,
_ => SegmentEvent::Other,
});
}
fn csi_dispatch(&mut self, _: &Params, _: &[u8], _: bool, _: u8) {
self.event = Some(SegmentEvent::Other);
}
fn esc_dispatch(&mut self, _: &[u8], _: bool, _: u8) {
self.event = Some(SegmentEvent::Other);
}
fn osc_dispatch(&mut self, _: &[&[u8]], _: bool) {
self.event = Some(SegmentEvent::Other);
}
fn hook(&mut self, _: &Params, _: &[u8], _: bool, _: u8) {
self.event = Some(SegmentEvent::Other);
}
fn put(&mut self, _: u8) {
self.event = Some(SegmentEvent::Other);
}
fn unhook(&mut self) {
self.event = Some(SegmentEvent::Other);
}
}
/// Main function for splitting text into line segments with zero-copy slices
pub fn split_into_line_segments<'a>(input: &'a str, term_width: usize) -> Vec<LineSegment<'a>> {
let mut parser = Parser::<DefaultCharAccumulator>::new();
let mut performer = EventCollector::default();
let mut segments = Vec::<LineSegment>::new();
let mut segment_start = 0_usize;
let mut segment_end = 0_usize;
let mut visual_width = 0_usize;
let mut has_visual = false;
let mut prev_is_cr = false;
macro_rules! push_segment {
($end:expr, $crlf:expr) => {
#[allow(unused_assignments)]
{
segments.push(LineSegment {
content: &input[segment_start..$end],
ends_with_crlf: $crlf,
});
visual_width = 0;
has_visual = false;
}
};
}
for (index, byte) in input.bytes().enumerate() {
parser.advance(&mut performer, byte);
let Some(event) = performer.event.take() else {
// Mid-sequence byte (escape params, UTF-8 continuation, …): the
// action it belongs to is dispatched on the sequence's final byte
// and its bytes are claimed then.
continue;
};
let mut is_cr = false;
match event {
SegmentEvent::LineFeed => {
// Emit current segment but strip \r if the segment ended with it.
// Note: `segment_end` (not `index`) is deliberate — a LF can
// fire mid-escape-sequence ("\x1b[3\n1m"), and the pending
// escape bytes must not leak into the emitted segment.
push_segment!(segment_end - usize::from(prev_is_cr), true);
// We skip \n itself (and possibly the preceding \r, and any
// pending escape bytes) so they don't end up in segments
segment_end = index + 1;
segment_start = segment_end;
}
SegmentEvent::CarriageReturn => {
// Reset visual width and continue with the current segment
segment_end = index + 1;
visual_width = 0;
is_cr = true;
}
SegmentEvent::Print(ch) => {
// Input is a valid &str, so print fires on the last byte of
// the char's UTF-8 encoding; anything unclaimed before the
// char (e.g. an aborted escape) folds into the current
// segment so the wrap point lands on the char boundary.
let char_bytes = ch.len_utf8();
segment_end = index + 1 - char_bytes;
// The only case where visual width actually grows
// (assuming we don't have cursor move etc, only CSI::Sgr/Control/Print)
let char_width = ch.width().unwrap_or(0);
let new_width = visual_width + char_width;
if new_width > term_width && has_visual {
// We're beyond term width, emit current segment and start next one from this char
push_segment!(segment_end, false);
segment_start = segment_end;
segment_end += char_bytes;
visual_width = char_width; // Reset to just this character's width
has_visual = true;
// Very unlikely edge case: char_width > term size and we have to flush it again
if char_width > term_width {
push_segment!(segment_end, false);
segment_start = segment_end;
}
} else {
// We can safely extend our current pending segment
segment_end += char_bytes;
visual_width = new_width;
has_visual = true;
}
}
SegmentEvent::Other => {
// Extend current segment with other ansi markers
segment_end = index + 1;
}
}
prev_is_cr = is_cr;
}
// Trailing bytes that never completed an action (e.g. a dangling "\x1b[")
// are left out of `segment_end`, matching the previous termwiz-based
// implementation which never consumed incomplete sequences.
// We have pending segment that hasn't been pushed, without crlf
if segment_end > segment_start {
let input_start = input.as_ptr();
if let Some(last) = segments.last_mut() {
// There's at least one segment
let last_start = last.content.as_ptr();
let last_end = unsafe { last_start.add(last.content.len()) };
if !last.ends_with_crlf && !has_visual {
// Last segment doesn't end with crlf and the current one has no visual actions, concatenate
debug_assert_eq!(segment_start, (last_end as usize - input_start as usize));
let last_offset = last_start as usize - input_start as usize;
last.content = &input[last_offset..segment_end];
} else {
// There's last segment but either it ends with lf or pending segment has visual width
// note: pending segment can't have lf because otherwise we would have matched on it
push_segment!(segment_end, false);
}
} else {
// There's no segments, this is the only one (and with no lf)
push_segment!(segment_end, false);
}
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_string() {
let segments = split_into_line_segments("", 10);
assert_eq!(segments.len(), 0);
}
#[test]
fn test_simple_text() {
let input = "hello";
let segments = split_into_line_segments(input, 10);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "hello");
assert!(!segments[0].ends_with_crlf);
}
#[test]
fn test_text_wrapping() {
let input = "hello world";
let segments = split_into_line_segments(input, 8);
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].content, "hello wo");
assert!(!segments[0].ends_with_crlf);
assert_eq!(segments[1].content, "rld");
assert!(!segments[1].ends_with_crlf);
}
#[test]
fn test_newline_handling() {
let input = "line1\nline2";
let segments = split_into_line_segments(input, 20);
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].content, "line1");
assert!(segments[0].ends_with_crlf);
assert_eq!(segments[1].content, "line2");
assert!(!segments[1].ends_with_crlf);
}
#[test]
fn test_crlf_handling() {
let input = "line1\r\nline2\nline3";
let segments = split_into_line_segments(input, 20);
assert_eq!(segments.len(), 3);
// First segment: "line1" (the \r\n is stripped)
assert_eq!(segments[0].content, "line1");
assert!(segments[0].ends_with_crlf);
// Second segment: "line2"
assert_eq!(segments[1].content, "line2");
assert!(segments[1].ends_with_crlf);
// Third segment: "line3"
assert_eq!(segments[2].content, "line3");
assert!(!segments[2].ends_with_crlf);
}
#[test]
fn test_bare_cr_resets_width() {
// CR resets visual position, so "12345\r67" fits in width 10
let input = "12345\r67";
let segments = split_into_line_segments(input, 10);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "12345\r67");
assert!(!segments[0].ends_with_crlf);
}
#[test]
fn test_edge_case_char_wider_than_terminal() {
// Emoji is 2 wide, terminal is 1 wide
let input = "😊";
let segments = split_into_line_segments(input, 1);
// Should still create one segment even though it exceeds width
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "😊");
}
#[test]
fn test_zero_width_segment_merging() {
// Test merging of trailing zero-width content (no newline at end)
let input = "line1\x1b[31m";
let segments = split_into_line_segments(input, 20);
// The color code should be in the same segment
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "line1\x1b[31m");
assert!(!segments[0].ends_with_crlf);
// Test that ANSI after newline creates a separate segment
let input2 = "line1\n\x1b[31m";
let segments2 = split_into_line_segments(input2, 20);
assert_eq!(segments2.len(), 2);
assert_eq!(segments2[0].content, "line1");
assert!(segments2[0].ends_with_crlf);
assert_eq!(segments2[1].content, "\x1b[31m");
assert!(!segments2[1].ends_with_crlf);
}
#[test]
fn test_multiple_ansi_codes() {
let input = "\x1b[1m\x1b[31mBold Red\x1b[0m";
let segments = split_into_line_segments(input, 20);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, input);
}
#[test]
fn test_wrap_at_exact_width() {
let input = "12345678"; // exactly 8 chars
let segments = split_into_line_segments(input, 8);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "12345678");
}
#[test]
fn test_wrap_with_trailing_ansi() {
// Text fills line, then ANSI codes
let input = "12345678\x1b[0m90";
let segments = split_into_line_segments(input, 8);
assert_eq!(segments.len(), 2);
// First segment gets the reset code since no visual content follows it on same line
assert_eq!(segments[0].content, "12345678\x1b[0m");
assert_eq!(segments[1].content, "90");
}
#[test]
fn test_cr_before_lf() {
// Make sure \r right before \n is stripped
let input = "test\r\n";
let segments = split_into_line_segments(input, 10);
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].content, "test");
assert!(segments[0].ends_with_crlf);
}
#[test]
fn test_multiple_segments_with_ansi() {
let input = "\x1b[32mline1\nline2\nline3\x1b[0m";
let segments = split_into_line_segments(input, 20);
assert_eq!(segments.len(), 3);
assert!(segments[0].content.starts_with("\x1b[32m"));
assert!(segments[0].ends_with_crlf);
assert_eq!(segments[1].content, "line2");
assert!(segments[1].ends_with_crlf);
assert!(segments[2].content.ends_with("\x1b[0m"));
assert!(!segments[2].ends_with_crlf);
}
#[test]
fn test_visual_width_calculation_with_unicode() {
// "你好" is 4 visual width (2 per character)
let input = "hello 你好";
let segments = split_into_line_segments(input, 10);
assert_eq!(segments.len(), 1); // "hello 你好" = 6 + 4 = 10, exactly fits
let segments2 = split_into_line_segments(input, 9);
assert_eq!(segments2.len(), 2); // Doesn't fit, must wrap
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,417 @@
use std::{
collections::VecDeque,
io::{self, Write},
};
use ratatui::layout::{Rect, Size};
use crate::common::TerminalLike;
/// Mock terminal for testing
#[derive(Debug, Clone)]
pub struct MockTerminal {
pub size: Size,
pub viewport_area: Rect,
pub clear_count: usize,
pub viewport_updates: Vec<Rect>,
pub writer: MockWriter,
}
/// Mock writer that captures all output
#[derive(Debug, Clone)]
pub struct MockWriter {
pub buffer: Vec<u8>,
pub flush_count: usize,
pub commands: VecDeque<String>,
}
impl Write for MockWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buf);
// Parse and store readable command representation
if let Ok(s) = std::str::from_utf8(buf) {
self.commands.push_back(s.to_string());
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.flush_count += 1;
Ok(())
}
}
impl MockTerminal {
pub fn new(width: u16, height: u16, viewport_height: u16) -> Self {
let viewport_y = height - viewport_height;
Self {
size: Size { width, height },
viewport_area: Rect::new(0, viewport_y, width, viewport_height),
clear_count: 0,
viewport_updates: Vec::new(),
writer: MockWriter {
buffer: Vec::new(),
flush_count: 0,
commands: VecDeque::new(),
},
}
}
}
impl TerminalLike for MockTerminal {
type Writer = MockWriter;
fn size(&self) -> io::Result<Size> {
Ok(self.size)
}
fn viewport_area(&self) -> Rect {
self.viewport_area
}
fn clear(&mut self) -> io::Result<()> {
self.clear_count += 1;
Ok(())
}
fn set_viewport_area(&mut self, area: Rect) {
self.viewport_updates.push(area);
self.viewport_area = area;
}
fn writer_mut(&mut self) -> &mut Self::Writer {
&mut self.writer
}
fn reset_back_buffer(&mut self) {
// Mock implementation - just track that it was called
self.clear_count += 1;
}
}
/// Tests for the diffed OSC 8 hyperlink layer (`set_frame_links` /
/// `flush_with_links`).
mod links {
use std::io::{self, Write};
use ratatui::backend::{Backend, WindowSize};
use ratatui::buffer::Cell;
use ratatui::layout::{Position, Rect, Size};
use ratatui::style::Style;
use ratatui::{TerminalOptions, Viewport};
use crate::{LinkSpan, Terminal};
/// Backend that records the raw byte stream and renders each drawn cell as
/// its bare symbol, so tests can assert on OSC 8 sequences interleaved with
/// cell content without depending on crossterm's exact SGR output.
#[derive(Default)]
struct RecordingBackend {
buf: Vec<u8>,
/// Total lines passed to `append_lines` (used by the
/// `set_viewport_height` grow-path test).
appended_lines: u16,
}
impl Write for RecordingBackend {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.buf.extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Backend for RecordingBackend {
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
for (_x, _y, cell) in content {
self.buf.extend_from_slice(cell.symbol().as_bytes());
}
Ok(())
}
fn hide_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn show_cursor(&mut self) -> io::Result<()> {
Ok(())
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
Ok(Position::ORIGIN)
}
fn set_cursor_position<P: Into<Position>>(&mut self, _position: P) -> io::Result<()> {
Ok(())
}
fn clear(&mut self) -> io::Result<()> {
Ok(())
}
fn clear_region(&mut self, _clear_type: ratatui::backend::ClearType) -> io::Result<()> {
Ok(())
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
self.appended_lines += n;
Ok(())
}
fn size(&self) -> io::Result<Size> {
Ok(Size::new(80, 24))
}
fn window_size(&mut self) -> io::Result<WindowSize> {
Ok(WindowSize {
columns_rows: Size::new(80, 24),
pixels: Size::new(0, 0),
})
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn term(w: u16, h: u16) -> Terminal<RecordingBackend> {
Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(0, 0, w, h)),
},
)
.unwrap()
}
fn span(col_start: u16, col_end: u16, url: &str, id: Option<u32>) -> LinkSpan {
LinkSpan {
row: 0,
col_start,
col_end,
url: url.into(),
id,
}
}
/// Render `text` at (0,0), set `spans`, flush, and return the bytes emitted
/// during this single frame.
fn frame(t: &mut Terminal<RecordingBackend>, text: &str, spans: &[LinkSpan]) -> String {
t.backend_mut().buf.clear();
{
let mut f = t.get_frame();
f.buffer_mut().set_string(0, 0, text, Style::default());
}
t.set_frame_links(spans);
t.flush_with_links().unwrap();
t.swap_buffers();
String::from_utf8(t.backend().buf.clone()).unwrap()
}
#[test]
fn emits_osc8_around_linked_cells() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
assert!(
out.contains("\x1b]8;;https://x.ai\x07"),
"missing open: {out:?}"
);
assert!(out.contains("AB"));
assert!(out.contains("\x1b]8;;\x07"), "missing close: {out:?}");
}
#[test]
fn no_link_emits_no_osc8() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[]);
assert!(!out.contains("\x1b]8;"), "unexpected OSC8: {out:?}");
}
#[test]
fn grow_viewport_scrolls_committed_lines_into_history() {
// A small inline viewport near the bottom of the screen, grown to full
// height, must scroll the rows it will cover up into native scrollback
// (append_lines) instead of overwriting them. Regression guard for the
// previously-commented-out scroll_up in set_viewport_height's grow path
// (the overlay host depends on this in minimal mode).
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Inline(3),
},
)
.unwrap();
// Pin the 3-row viewport near the bottom of the 24-row screen.
t.set_viewport_area(Rect::new(0, 21, 80, 3));
let before = t.backend().appended_lines;
// Grow to full height: overflow = (21 + 24) - 24 = 21 rows must scroll up.
t.set_viewport_height(24).unwrap();
let scrolled = t.backend().appended_lines - before;
assert!(
scrolled >= 21,
"expected >= 21 lines scrolled into history, got {scrolled}"
);
}
/// Regression: `set_viewport_height` must judge grow-vs-shrink against the
/// live `viewport_area.height`, not the stored `Viewport::Inline(height)`.
///
/// Minimal mode resizes the viewport out-of-band via `set_viewport_area`
/// (its content-anchored commit path shrinks the region before
/// `insert_before`), which leaves the stored `Inline` height STALE. If the
/// next `set_viewport_height` compared against that stale (larger) height, a
/// genuine grow would be misread as a shrink: the grow-time `scroll_up`
/// would be skipped and the viewport's top would not move up, so the taller
/// viewport would run off the bottom of the screen (dropdown items rendered
/// off-screen — the "empty dropdown over a full screen" bug).
#[test]
fn grow_after_out_of_band_area_shrink_still_scrolls() {
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
// Stored Inline height starts tall (mimics a streaming turn that
// grew the viewport to near full screen).
viewport: Viewport::Inline(21),
},
)
.unwrap();
// Out-of-band shrink to a 3-row viewport pinned at the bottom of the
// 24-row screen — as the commit path does. This does NOT update the
// stored Inline height (still 21), creating the drift.
t.set_viewport_area(Rect::new(0, 21, 80, 3));
let before = t.backend().appended_lines;
// Grow to 10 rows. Against the real height (3) this is a GROW that
// overflows the bottom by (21 + 10) - 24 = 7 rows, which must scroll up.
// Against the stale stored height (21) it would look like a shrink and
// scroll nothing.
t.set_viewport_height(10).unwrap();
let scrolled = t.backend().appended_lines - before;
assert!(
scrolled >= 7,
"grow after an out-of-band area shrink must scroll the covered rows \
into history (expected >= 7, got {scrolled})"
);
// The viewport top moved up so the whole 10-row region fits on screen.
let area = t.viewport_area();
assert_eq!(area.height, 10, "height should be the requested 10");
assert!(
area.y + area.height <= 24,
"viewport must fit on screen, got y={} h={}",
area.y,
area.height
);
}
#[test]
fn link_removed_next_frame_rewrites_cells_without_osc8() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
// Same glyphs, but the link is gone: the cells must be rewritten (so the
// terminal's hyperlink clears) and carry no OSC 8. This is the `/new`
// regression — clearing is driven purely by the diff.
let out = frame(&mut t, "AB", &[]);
assert!(out.contains("AB"), "cells should be redrawn: {out:?}");
assert!(!out.contains("\x1b]8;"), "stale OSC8 leaked: {out:?}");
}
#[test]
fn unchanged_link_and_content_emits_nothing() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
// Identical glyphs AND identical link → empty diff → no output at all.
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", None)]);
assert!(out.is_empty(), "expected empty diff, got: {out:?}");
}
#[test]
fn retargeted_link_rewrites_cells() {
let mut t = term(20, 3);
let _ = frame(&mut t, "AB", &[span(0, 2, "https://a", None)]);
let out = frame(&mut t, "AB", &[span(0, 2, "https://b", None)]);
assert!(
out.contains("\x1b]8;;https://b\x07"),
"new url not emitted: {out:?}"
);
}
#[test]
fn emit_id_param_included() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x.ai", Some(7))]);
assert!(
out.contains("\x1b]8;id=7;https://x.ai\x07"),
"id param missing: {out:?}"
);
}
#[test]
fn url_control_chars_sanitized() {
let mut t = term(20, 3);
let out = frame(&mut t, "AB", &[span(0, 2, "https://x\x07\x1b/y", None)]);
assert!(
out.contains("\x1b]8;;https://x/y\x07"),
"url not sanitized: {out:?}"
);
}
#[test]
fn distinct_links_split_into_separate_runs() {
let mut t = term(20, 3);
// "AxB": A→a, gap x (no link), B→b.
let out = frame(
&mut t,
"AxB",
&[span(0, 1, "https://a", None), span(2, 3, "https://b", None)],
);
// Each link wraps exactly its own cell; the gap is not wrapped.
assert!(
out.contains("\x1b]8;;https://a\x07A\x1b]8;;\x07"),
"a-run: {out:?}"
);
assert!(
out.contains("\x1b]8;;https://b\x07B\x1b]8;;\x07"),
"b-run: {out:?}"
);
}
#[test]
fn wide_char_under_link_wraps_lead_cell_only() {
let mut t = term(20, 3);
// A width-2 char occupies two cells; only the lead cell is drawn, and
// the OSC 8 wraps it.
let out = frame(&mut t, "", &[span(0, 2, "https://x.ai", None)]);
assert!(
out.contains("\x1b]8;;https://x.ai\x07\x1b]8;;\x07"),
"wide-char run: {out:?}"
);
}
#[test]
fn nonzero_origin_viewport_maps_links() {
// The screen→cell mapping subtracts the viewport offset; verify a link
// at an absolute (row, col) inside a non-origin viewport wraps the right
// cells (regression guard for `(y - area.y)` / `(x - area.x)`).
let area = Rect::new(2, 5, 20, 4);
let mut t = Terminal::with_options(
RecordingBackend::default(),
TerminalOptions {
viewport: Viewport::Fixed(area),
},
)
.unwrap();
{
let mut f = t.get_frame();
f.buffer_mut().set_string(2, 5, "AB", Style::default());
}
t.set_frame_links(&[LinkSpan {
row: 5,
col_start: 2,
col_end: 4,
url: "https://x.ai".into(),
id: None,
}]);
t.flush_with_links().unwrap();
let out = String::from_utf8(t.backend().buf.clone()).unwrap();
assert!(
out.contains("\x1b]8;;https://x.ai\x07AB\x1b]8;;\x07"),
"non-origin mapping: {out:?}"
);
}
}