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:
@@ -0,0 +1,573 @@
|
||||
//! Frame drawing with cursor blink preservation.
|
||||
//!
|
||||
//! # Problem
|
||||
//!
|
||||
//! Ratatui's [`Terminal::draw()`] (internally `try_draw()`) unconditionally
|
||||
//! sends cursor escape sequences on every frame:
|
||||
//!
|
||||
//! - If `frame.set_cursor_position()` was called: `Show` + `MoveTo` every frame
|
||||
//! - If not called: `Hide` every frame
|
||||
//!
|
||||
//! Both reset the terminal's cursor blink timer (`Show` restarts the blink
|
||||
//! cycle, `MoveTo` resets the blink phase). At 30fps, the 500ms blink interval
|
||||
//! never completes, so the cursor appears solid.
|
||||
//!
|
||||
//! # Solution
|
||||
//!
|
||||
//! We bypass `try_draw()` and use ratatui's lower-level API directly:
|
||||
//!
|
||||
//! ```text
|
||||
//! terminal.autoresize() — handle terminal size changes
|
||||
//! terminal.get_frame() — get a fresh buffer to render into
|
||||
//! terminal.flush() — diff old/new buffers, write only changed cells
|
||||
//! terminal.swap_buffers() — prepare for next frame
|
||||
//! ```
|
||||
//!
|
||||
//! Cursor is managed entirely by [`CursorState`] with de-duplication:
|
||||
//!
|
||||
//! - **No cell changes + same position**: zero cursor commands → blink preserved
|
||||
//! - **Cells changed + same position**: `MoveTo` to fix cursor after cell writes
|
||||
//! - **Position changed**: `MoveTo` (blink resets — expected, user just typed)
|
||||
//! - **Visibility transition**: `Show`/`Hide` (only on actual transition)
|
||||
//! - **Idle (no draw calls)**: nothing sent → blink runs undisturbed
|
||||
//!
|
||||
//! The "no cell changes" optimization is possible because we use
|
||||
//! [`kigi_ratatui_inline::Terminal`] whose `flush()` returns `bool` indicating
|
||||
//! whether any cells were written. When animated entries are off-screen, the
|
||||
//! buffer diff is empty and we skip all cursor commands.
|
||||
//!
|
||||
//! # Synchronized output
|
||||
//!
|
||||
//! Each frame is wrapped in `BeginSynchronizedUpdate` / `EndSynchronizedUpdate`
|
||||
//! so the terminal processes all escape sequences atomically. This prevents
|
||||
//! flicker and is critical for multiplexers like zellij and tmux.
|
||||
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
|
||||
use crossterm::{QueueableCommand, cursor};
|
||||
use kigi_ratatui_inline::LinkSpan;
|
||||
use ratatui::Frame;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use std::io::Write;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
/// Terminal type for the pager. Defined here (beside [`TermWriter`]) so the
|
||||
/// `render` module does not depend on `app`. Re-exported from `app` as
|
||||
/// `crate::app::PagerTerminal` for existing call sites.
|
||||
pub type PagerTerminal = kigi_ratatui_inline::Terminal<CrosstermBackend<TermWriter>>;
|
||||
/// Shared queued/written frame counters linking [`TermWriter`] to the writer
|
||||
/// thread, so callers can wait for the output pipeline to drain.
|
||||
///
|
||||
/// The channel between them is fire-and-forget by design (the event loop must
|
||||
/// never block on pty I/O), but a few operations need a *happens-before* on
|
||||
/// terminal bytes: suspending into a tty-taking child (`$EDITOR` / `$PAGER`)
|
||||
/// while a frame is still queued lets that frame race the child's own output —
|
||||
/// it can land on the child's alternate screen (so the main screen never
|
||||
/// receives it) or tear mid-escape-sequence around the alt-screen switch,
|
||||
/// leaving the restored screen out of sync with the renderer's diff buffer
|
||||
/// (stale rows, one-line offsets, literal `[` fragments). [`wait_drained`]
|
||||
/// closes that window.
|
||||
///
|
||||
/// `queued` is incremented *before* the frame is sent and `written` after the
|
||||
/// writer thread has flushed it to the tty, so `written == queued` ⇒ every
|
||||
/// frame handed to the channel has reached the terminal fd.
|
||||
///
|
||||
/// [`wait_drained`]: WriterSync::wait_drained
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct WriterSync {
|
||||
queued: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
written: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
impl WriterSync {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
/// Record a frame handed to the channel. Called by [`TermWriter::flush`]
|
||||
/// *before* the send so `written` can never observably exceed `queued`.
|
||||
fn mark_queued(&self) {
|
||||
self.queued
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
/// Record a frame fully written + flushed to the tty (writer thread).
|
||||
fn mark_written(&self) {
|
||||
self.written
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
/// Whether every queued frame has been written to the tty.
|
||||
pub fn is_drained(&self) -> bool {
|
||||
self.written.load(std::sync::atomic::Ordering::SeqCst)
|
||||
>= self.queued.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
/// Block (bounded) until the writer thread has flushed every queued frame.
|
||||
///
|
||||
/// Returns `true` when drained, `false` on timeout (wedged pty / dead
|
||||
/// writer thread — callers proceed anyway, matching the bounded
|
||||
/// reader-park in the suspend path).
|
||||
pub fn wait_drained(&self, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while !self.is_drained() {
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
/// A writer that buffers frame output and sends it to a background thread
|
||||
/// for non-blocking terminal I/O.
|
||||
///
|
||||
/// All escape sequences produced during a frame are collected in an internal
|
||||
/// `Vec<u8>`. When [`flush()`](Write::flush) is called, the accumulated bytes
|
||||
/// are sent through a channel to a dedicated writer thread that performs the
|
||||
/// actual (potentially blocking) `write()` to stderr / the pty fd.
|
||||
///
|
||||
/// This decouples the tokio event loop from pty back-pressure: if the
|
||||
/// terminal emulator is slow to read (e.g. Ghostty busy with another pane),
|
||||
/// only the writer thread stalls — the event loop keeps processing timers,
|
||||
/// events, and ACP messages.
|
||||
pub struct TermWriter {
|
||||
buf: Vec<u8>,
|
||||
tx: mpsc::Sender<Vec<u8>>,
|
||||
sync: WriterSync,
|
||||
}
|
||||
impl TermWriter {
|
||||
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
|
||||
Self {
|
||||
buf: Vec::with_capacity(32 * 1024),
|
||||
tx,
|
||||
sync,
|
||||
}
|
||||
}
|
||||
/// Drop the current frame's buffered bytes without sending them.
|
||||
pub fn discard(&mut self) {
|
||||
self.buf.clear();
|
||||
}
|
||||
/// The queued/written counters shared with the writer thread. Used by the
|
||||
/// suspend path to [`WriterSync::wait_drained`] before a child takes the tty.
|
||||
pub fn writer_sync(&self) -> &WriterSync {
|
||||
&self.sync
|
||||
}
|
||||
}
|
||||
impl Write for TermWriter {
|
||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||
self.buf.extend_from_slice(data);
|
||||
Ok(data.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
if !self.buf.is_empty() {
|
||||
let data = std::mem::take(&mut self.buf);
|
||||
self.sync.mark_queued();
|
||||
let _ = self.tx.send(data);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for TermWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.flush();
|
||||
}
|
||||
}
|
||||
/// Handle for the background writer thread.
|
||||
///
|
||||
/// Joining ensures all queued frames have been written to the terminal
|
||||
/// before proceeding with teardown (e.g. `LeaveAlternateScreen`).
|
||||
pub struct WriterThread {
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
impl WriterThread {
|
||||
/// Block until the writer thread has processed all pending frames and
|
||||
/// exited. The [`mpsc::Sender`] must be dropped *before* calling this,
|
||||
/// otherwise the thread will never see the channel close.
|
||||
pub fn join(mut self) {
|
||||
if let Some(h) = self.handle.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for WriterThread {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.handle.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Spawn a background OS thread that writes frame data to stderr.
|
||||
///
|
||||
/// Returns `(Sender, WriterSync, WriterThread)`. Send `Vec<u8>` frame data
|
||||
/// through the sender; the thread writes each frame to stderr via a 64 KiB
|
||||
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
|
||||
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
|
||||
/// Drop the sender to signal the thread to exit, then call
|
||||
/// [`WriterThread::join`] to wait for it.
|
||||
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||
let sync = WriterSync::new();
|
||||
let thread_sync = sync.clone();
|
||||
let test_delay = std::env::var("KIGI_TEST_FRAME_WRITE_DELAY_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(Duration::from_millis);
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("term-writer".into())
|
||||
.spawn(move || {
|
||||
#[cfg(not(windows))]
|
||||
let mut writer: Box<dyn std::io::Write> = {
|
||||
let tui_out = kigi_tty_utils::dup_tui_stderr().unwrap_or_else(|_| {
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd};
|
||||
let fd = unsafe { libc::dup(std::io::stderr().as_raw_fd()) };
|
||||
unsafe { std::fs::File::from_raw_fd(fd) }
|
||||
});
|
||||
Box::new(std::io::BufWriter::with_capacity(64 * 1024, tui_out))
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut writer: Box<dyn std::io::Write> = Box::new(std::io::BufWriter::with_capacity(
|
||||
64 * 1024,
|
||||
std::io::stderr(),
|
||||
));
|
||||
while let Ok(data) = rx.recv() {
|
||||
if let Some(delay) = test_delay {
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
{
|
||||
let _guard = kigi_shared::stderr::stderr_lock();
|
||||
let _ = writer.write_all(&data);
|
||||
let _ = writer.flush();
|
||||
}
|
||||
thread_sync.mark_written();
|
||||
}
|
||||
})
|
||||
.expect("failed to spawn term-writer thread");
|
||||
(
|
||||
tx,
|
||||
sync,
|
||||
WriterThread {
|
||||
handle: Some(handle),
|
||||
},
|
||||
)
|
||||
}
|
||||
/// Cursor state tracker for blink-preserving cursor management.
|
||||
///
|
||||
/// Tracks the last cursor position written to the terminal. By comparing
|
||||
/// with the desired position each frame, we emit the minimum cursor escape
|
||||
/// sequences necessary — avoiding redundant `Show`/`Hide`/`MoveTo` that
|
||||
/// would reset the terminal's blink timer.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CursorState {
|
||||
/// Last cursor position written to the terminal.
|
||||
/// `None` = cursor is hidden; `Some((x, y))` = cursor visible at (x, y).
|
||||
last_pos: Option<(u16, u16)>,
|
||||
}
|
||||
/// What cursor commands to emit after a frame render.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CursorAction {
|
||||
/// No cursor commands needed — blink timer preserved.
|
||||
None,
|
||||
/// Cursor is visible and cells changed — reposition after cell writes
|
||||
/// disturbed the terminal cursor. Resets blink (unavoidable when cells
|
||||
/// change on screen).
|
||||
Reposition(u16, u16),
|
||||
/// Cursor becoming visible at (x, y) — needs `MoveTo` + `Show`.
|
||||
Show(u16, u16),
|
||||
/// Cursor becoming hidden — needs `Hide`.
|
||||
Hide,
|
||||
}
|
||||
impl CursorState {
|
||||
pub fn new() -> Self {
|
||||
Self { last_pos: None }
|
||||
}
|
||||
/// Determine what cursor action to take for this frame.
|
||||
///
|
||||
/// Pure function — computes the action from current state without
|
||||
/// side effects. Call [`apply`] to execute it.
|
||||
pub fn action(&self, cursor_pos: Option<(u16, u16)>, has_changes: bool) -> CursorAction {
|
||||
if cursor_pos == self.last_pos {
|
||||
if has_changes && let Some((x, y)) = cursor_pos {
|
||||
return CursorAction::Reposition(x, y);
|
||||
}
|
||||
CursorAction::None
|
||||
} else {
|
||||
match (cursor_pos, self.last_pos) {
|
||||
(Some((x, y)), Some(_)) => CursorAction::Reposition(x, y),
|
||||
(Some((x, y)), None) => CursorAction::Show(x, y),
|
||||
(None, Some(_)) => CursorAction::Hide,
|
||||
(None, None) => CursorAction::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Execute a cursor action by queuing escape sequences into `w`.
|
||||
///
|
||||
/// Uses `queue!` (buffered) instead of `execute!` (immediate flush) so
|
||||
/// that cursor commands are batched with the rest of the frame data and
|
||||
/// written to the terminal atomically by the writer thread.
|
||||
pub fn apply<W: Write>(&mut self, action: CursorAction, w: &mut W) {
|
||||
match action {
|
||||
CursorAction::None => {}
|
||||
CursorAction::Reposition(x, y) => {
|
||||
let _ = w.queue(cursor::MoveTo(x, y));
|
||||
self.last_pos = Some((x, y));
|
||||
}
|
||||
CursorAction::Show(x, y) => {
|
||||
let _ = w.queue(cursor::MoveTo(x, y));
|
||||
let _ = w.queue(cursor::Show);
|
||||
self.last_pos = Some((x, y));
|
||||
}
|
||||
CursorAction::Hide => {
|
||||
let _ = w.queue(cursor::Hide);
|
||||
self.last_pos = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Render a frame to the terminal with cursor blink preservation.
|
||||
///
|
||||
/// Bypasses ratatui's `try_draw()` to avoid its unconditional cursor
|
||||
/// management. See [module docs](self) for the full rationale.
|
||||
///
|
||||
/// The `render_fn` receives a [`Frame`] and a `&mut Vec<LinkSpan>` to populate
|
||||
/// with the frame's OSC 8 hyperlink regions (absolute viewport coordinates).
|
||||
/// Those spans are handed to the terminal before the diff so hyperlinks
|
||||
/// participate in the cell diff (emitted/cleared in lockstep with content) —
|
||||
/// no out-of-band post-flush repaint. It returns a tuple of:
|
||||
/// - `Option<(u16, u16)>` — cursor position (or `None` to hide cursor)
|
||||
/// - `Option<PostFlush>` — escape sequences to write after cell flush (e.g.
|
||||
/// Kitty graphics protocol image data). Written inside the synchronized
|
||||
/// update block so the image appears atomically with the cell diff.
|
||||
pub fn draw_frame(
|
||||
terminal: &mut PagerTerminal,
|
||||
cursor: &mut CursorState,
|
||||
render_fn: impl FnOnce(
|
||||
&mut Frame,
|
||||
&mut Vec<LinkSpan>,
|
||||
) -> (
|
||||
Option<(u16, u16)>,
|
||||
Option<crate::terminal::overlay::PostFlush>,
|
||||
),
|
||||
) {
|
||||
let _ = terminal.backend_mut().queue(BeginSynchronizedUpdate);
|
||||
let _ = terminal.autoresize();
|
||||
let mut link_spans: Vec<LinkSpan> = Vec::new();
|
||||
let (cursor_pos, post_flush_escapes) = {
|
||||
let mut frame = terminal.get_frame();
|
||||
render_fn(&mut frame, &mut link_spans)
|
||||
};
|
||||
terminal.set_frame_links(&link_spans);
|
||||
let has_changes = terminal.flush_with_links().unwrap_or(false);
|
||||
terminal.swap_buffers();
|
||||
let post_flush_wrote_cursor = post_flush_escapes.is_some();
|
||||
let action = cursor.action(cursor_pos, has_changes || post_flush_wrote_cursor);
|
||||
if !has_changes && !post_flush_wrote_cursor && action == CursorAction::None {
|
||||
terminal.backend_mut().writer_mut().discard();
|
||||
return;
|
||||
}
|
||||
if let Some(post_flush) = post_flush_escapes {
|
||||
let _ = post_flush.write_to(terminal.backend_mut());
|
||||
}
|
||||
cursor.apply(action, terminal.backend_mut());
|
||||
let _ = terminal.backend_mut().queue(EndSynchronizedUpdate);
|
||||
let _ = terminal.backend_mut().flush();
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// An unchanged frame must emit zero bytes to the PTY.
|
||||
#[test]
|
||||
fn idle_frame_emits_zero_bytes() {
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{TerminalOptions, Viewport};
|
||||
use std::sync::mpsc;
|
||||
fn render(
|
||||
frame: &mut ratatui::Frame,
|
||||
_links: &mut Vec<LinkSpan>,
|
||||
) -> (
|
||||
Option<(u16, u16)>,
|
||||
Option<crate::terminal::overlay::PostFlush>,
|
||||
) {
|
||||
frame.render_widget(Paragraph::new("hello world"), frame.area());
|
||||
(None, None)
|
||||
}
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||
let backend = CrosstermBackend::new(TermWriter::new(tx, WriterSync::new()));
|
||||
let mut terminal = kigi_ratatui_inline::Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Fixed(Rect::new(0, 0, 80, 24)),
|
||||
},
|
||||
)
|
||||
.expect("build terminal");
|
||||
let mut cursor = CursorState::new();
|
||||
draw_frame(&mut terminal, &mut cursor, render);
|
||||
let first: Vec<u8> = rx.try_iter().flatten().collect();
|
||||
assert!(!first.is_empty(), "first frame should emit bytes");
|
||||
draw_frame(&mut terminal, &mut cursor, render);
|
||||
let second: Vec<u8> = rx.try_iter().flatten().collect();
|
||||
assert!(
|
||||
second.is_empty(),
|
||||
"idle (unchanged) frame must emit 0 bytes, got {}: {:?}",
|
||||
second.len(),
|
||||
String::from_utf8_lossy(&second),
|
||||
);
|
||||
}
|
||||
/// `wait_drained` semantics: drained when `written` has caught up with
|
||||
/// `queued` — immediately when nothing is pending, after the consumer
|
||||
/// marks the frame written, and a bounded `false` when it never does.
|
||||
/// This is the happens-before the suspend path relies on so no queued
|
||||
/// frame can race a tty-taking `$EDITOR` / `$PAGER` child.
|
||||
#[test]
|
||||
fn writer_sync_drains_when_written_catches_queued() {
|
||||
let sync = WriterSync::new();
|
||||
assert!(sync.wait_drained(Duration::from_millis(1)));
|
||||
sync.mark_queued();
|
||||
assert!(!sync.is_drained());
|
||||
assert!(!sync.wait_drained(Duration::from_millis(5)));
|
||||
let consumer_sync = sync.clone();
|
||||
let consumer = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
consumer_sync.mark_written();
|
||||
});
|
||||
assert!(sync.wait_drained(Duration::from_secs(5)));
|
||||
consumer.join().expect("consumer thread");
|
||||
}
|
||||
/// A `TermWriter::flush` with buffered bytes marks the frame queued; the
|
||||
/// writer-thread side marking it written restores the drained state.
|
||||
#[test]
|
||||
fn term_writer_flush_marks_queued() {
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||
let sync = WriterSync::new();
|
||||
let mut writer = TermWriter::new(tx, sync.clone());
|
||||
writer.flush().expect("flush");
|
||||
assert!(sync.is_drained());
|
||||
writer.write_all(b"frame bytes").expect("write");
|
||||
writer.flush().expect("flush");
|
||||
assert!(!sync.is_drained(), "queued frame not yet written");
|
||||
assert_eq!(rx.try_recv().expect("frame on channel"), b"frame bytes");
|
||||
sync.mark_written();
|
||||
assert!(sync.is_drained());
|
||||
}
|
||||
fn state_hidden() -> CursorState {
|
||||
CursorState { last_pos: None }
|
||||
}
|
||||
fn state_at(x: u16, y: u16) -> CursorState {
|
||||
CursorState {
|
||||
last_pos: Some((x, y)),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn hidden_no_changes_stays_hidden() {
|
||||
let s = state_hidden();
|
||||
assert_eq!(s.action(None, false), CursorAction::None);
|
||||
}
|
||||
#[test]
|
||||
fn visible_same_pos_no_changes_preserves_blink() {
|
||||
let s = state_at(5, 10);
|
||||
assert_eq!(s.action(Some((5, 10)), false), CursorAction::None);
|
||||
}
|
||||
#[test]
|
||||
fn visible_new_pos_no_changes_repositions() {
|
||||
let s = state_at(5, 10);
|
||||
assert_eq!(
|
||||
s.action(Some((6, 10)), false),
|
||||
CursorAction::Reposition(6, 10)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hidden_with_changes_stays_hidden() {
|
||||
let s = state_hidden();
|
||||
assert_eq!(s.action(None, true), CursorAction::None);
|
||||
}
|
||||
#[test]
|
||||
fn visible_same_pos_with_changes_repositions() {
|
||||
let s = state_at(5, 10);
|
||||
assert_eq!(
|
||||
s.action(Some((5, 10)), true),
|
||||
CursorAction::Reposition(5, 10)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn visible_new_pos_with_changes_repositions() {
|
||||
let s = state_at(5, 10);
|
||||
assert_eq!(
|
||||
s.action(Some((8, 10)), true),
|
||||
CursorAction::Reposition(8, 10)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hidden_to_visible_shows() {
|
||||
let s = state_hidden();
|
||||
assert_eq!(s.action(Some((5, 10)), false), CursorAction::Show(5, 10));
|
||||
}
|
||||
#[test]
|
||||
fn hidden_to_visible_with_changes_shows() {
|
||||
let s = state_hidden();
|
||||
assert_eq!(s.action(Some((5, 10)), true), CursorAction::Show(5, 10));
|
||||
}
|
||||
#[test]
|
||||
fn visible_to_hidden_hides() {
|
||||
let s = state_at(5, 10);
|
||||
assert_eq!(s.action(None, false), CursorAction::Hide);
|
||||
}
|
||||
#[test]
|
||||
fn visible_to_hidden_with_changes_hides() {
|
||||
let s = state_at(5, 10);
|
||||
assert_eq!(s.action(None, true), CursorAction::Hide);
|
||||
}
|
||||
#[test]
|
||||
fn apply_show_updates_last_pos() {
|
||||
let mut s = state_hidden();
|
||||
let mut sink = Vec::new();
|
||||
s.apply(CursorAction::Show(3, 7), &mut sink);
|
||||
assert_eq!(s.last_pos, Some((3, 7)));
|
||||
}
|
||||
#[test]
|
||||
fn apply_hide_clears_last_pos() {
|
||||
let mut s = state_at(3, 7);
|
||||
let mut sink = Vec::new();
|
||||
s.apply(CursorAction::Hide, &mut sink);
|
||||
assert_eq!(s.last_pos, None);
|
||||
}
|
||||
#[test]
|
||||
fn apply_reposition_updates_last_pos() {
|
||||
let mut s = state_at(3, 7);
|
||||
let mut sink = Vec::new();
|
||||
s.apply(CursorAction::Reposition(5, 9), &mut sink);
|
||||
assert_eq!(s.last_pos, Some((5, 9)));
|
||||
}
|
||||
#[test]
|
||||
fn apply_none_preserves_state() {
|
||||
let mut s = state_at(3, 7);
|
||||
let mut sink = Vec::new();
|
||||
s.apply(CursorAction::None, &mut sink);
|
||||
assert_eq!(s.last_pos, Some((3, 7)));
|
||||
}
|
||||
/// Verify the writer thread correctly round-trips multi-byte UTF-8
|
||||
/// through the channel. This catches encoding issues where the writer
|
||||
/// silently corrupts Braille/emoji/CJK characters.
|
||||
#[test]
|
||||
fn writer_thread_preserves_multibyte_utf8() {
|
||||
let test_payload = "⣀⣾⠿⠛\u{e0a0}\u{1F600}";
|
||||
let expected_bytes = test_payload.as_bytes().to_vec();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let capture = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let capture2 = capture.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
while let Ok(data) = rx.recv() {
|
||||
buf.extend_from_slice(&data);
|
||||
}
|
||||
*capture2.lock().unwrap() = buf;
|
||||
});
|
||||
tx.send(expected_bytes.clone()).unwrap();
|
||||
drop(tx);
|
||||
handle.join().unwrap();
|
||||
let captured = capture.lock().unwrap();
|
||||
assert_eq!(
|
||||
*captured, expected_bytes,
|
||||
"Writer thread corrupted multi-byte UTF-8 payload"
|
||||
);
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&captured).unwrap(),
|
||||
test_payload,
|
||||
"Round-tripped bytes do not decode to original UTF-8 string"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user