Synced from monorepo
Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
|
|
@ -30,6 +30,7 @@ const TIMESTAMPS_DEFAULT: bool = true;
|
|||
/// [`UiConfig::SHOW_TIMELINE_DEFAULT`]; aliased here for the `Cell::new`
|
||||
/// const context and the effective-config fallback read.
|
||||
const TIMELINE_DEFAULT: bool = UiConfig::SHOW_TIMELINE_DEFAULT;
|
||||
const PAGE_FLIP_ON_SEND_DEFAULT: bool = UiConfig::PAGE_FLIP_ON_SEND_DEFAULT;
|
||||
const SIMPLE_MODE_DEFAULT: bool = true;
|
||||
/// Vim-mode scrollback default — matches the previous on-disk default.
|
||||
const VIM_MODE_DEFAULT: bool = false;
|
||||
|
|
@ -135,6 +136,34 @@ pub fn set_show_timeline(enabled: bool) {
|
|||
TIMELINE_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Page-flip on send ---------------------------------------------------------
|
||||
|
||||
thread_local! {
|
||||
static PAGE_FLIP_ON_SEND_CURRENT: Cell<bool> = const { Cell::new(PAGE_FLIP_ON_SEND_DEFAULT) };
|
||||
static PAGE_FLIP_ON_SEND_LOADED: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Cached `page_flip_on_send`, seeding from `[ui]` on first call.
|
||||
pub fn load_page_flip_on_send() -> bool {
|
||||
PAGE_FLIP_ON_SEND_LOADED.with(|loaded| {
|
||||
if !loaded.get() {
|
||||
PAGE_FLIP_ON_SEND_CURRENT.with(|c| {
|
||||
c.set(load_bool_from_effective_config(
|
||||
"page_flip_on_send",
|
||||
PAGE_FLIP_ON_SEND_DEFAULT,
|
||||
))
|
||||
});
|
||||
loaded.set(true);
|
||||
}
|
||||
});
|
||||
PAGE_FLIP_ON_SEND_CURRENT.with(|c| c.get())
|
||||
}
|
||||
|
||||
pub fn set_page_flip_on_send(enabled: bool) {
|
||||
PAGE_FLIP_ON_SEND_CURRENT.with(|c| c.set(enabled));
|
||||
PAGE_FLIP_ON_SEND_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Simple mode --------------------------------------------------------------
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -545,6 +574,7 @@ pub fn prime(ui: &UiConfig) {
|
|||
set(ui.compact_mode);
|
||||
set_timestamps(ui.show_timestamps.unwrap_or(TIMESTAMPS_DEFAULT));
|
||||
set_show_timeline(ui.show_timeline_enabled());
|
||||
set_page_flip_on_send(ui.page_flip_on_send_enabled());
|
||||
set_simple_mode(ui.simple_mode.unwrap_or(SIMPLE_MODE_DEFAULT));
|
||||
set_keep_text_selection(text_selection_from_ui(ui));
|
||||
// Layered-config keys (not the `UiConfig` arg) — seed so the first frame
|
||||
|
|
@ -656,6 +686,7 @@ mod tests {
|
|||
assert_eq!(COMPACT_DEFAULT, ui.compact_mode);
|
||||
assert_eq!(TIMESTAMPS_DEFAULT, ui.show_timestamps.unwrap_or(true));
|
||||
assert_eq!(TIMELINE_DEFAULT, ui.show_timeline_enabled());
|
||||
assert_eq!(PAGE_FLIP_ON_SEND_DEFAULT, ui.page_flip_on_send_enabled());
|
||||
assert_eq!(SIMPLE_MODE_DEFAULT, ui.simple_mode.unwrap_or(true));
|
||||
assert_eq!(VIM_MODE_DEFAULT, ui.vim_mode.unwrap_or(false));
|
||||
assert_eq!(
|
||||
|
|
@ -726,6 +757,18 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_then_load_round_trips_page_flip_on_send() {
|
||||
std::thread::spawn(|| {
|
||||
set_page_flip_on_send(true);
|
||||
assert!(load_page_flip_on_send());
|
||||
set_page_flip_on_send(false);
|
||||
assert!(!load_page_flip_on_send());
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_then_load_round_trips_simple_mode() {
|
||||
std::thread::spawn(|| {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
mod trust;
|
||||
|
||||
pub use trust::{
|
||||
ClipboardDelivery, NativeClipboardPreflight, expected_delivery, native_clipboard_preflight,
|
||||
ClipboardDelivery, ClipboardEnvironment, NativeClipboardPreflight, Osc52Capability,
|
||||
expected_delivery, native_clipboard_preflight,
|
||||
};
|
||||
pub use xai_ratatui_textarea::{ClipboardProvider, InternalClipboard};
|
||||
|
||||
|
|
@ -188,7 +189,7 @@ impl SystemClipboard {
|
|||
/// Full write route classified by the environment-based delivery policy.
|
||||
pub fn try_set(text: &str) -> ClipboardDelivery {
|
||||
let legs = clipboard_write_with_route(text, clipboard_route());
|
||||
decision_for_legs(&legs, text).delivery
|
||||
decision_for_legs(&legs, text).delivery()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,6 +302,20 @@ pub(crate) enum ClipboardFeedback {
|
|||
}
|
||||
|
||||
impl ClipboardFeedback {
|
||||
pub(crate) fn delivery(self) -> ClipboardDelivery {
|
||||
match self {
|
||||
Self::Copied
|
||||
| Self::CopiedTmux
|
||||
| Self::CopiedOscContainer
|
||||
| Self::CopiedOscRemote
|
||||
| Self::VsCodeSshNonAscii => ClipboardDelivery::Confirmed,
|
||||
Self::UnverifiedOscRemote | Self::UnverifiedOscContainer => {
|
||||
ClipboardDelivery::Unverified
|
||||
}
|
||||
Self::FailedRemote | Self::Failed => ClipboardDelivery::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing toast message for this kind.
|
||||
fn message(self) -> &'static str {
|
||||
match self {
|
||||
|
|
@ -333,32 +348,30 @@ impl ClipboardFeedback {
|
|||
}
|
||||
}
|
||||
|
||||
fn to_result(self, delivery: ClipboardDelivery) -> CopyResult {
|
||||
fn to_result(self) -> CopyResult {
|
||||
CopyResult {
|
||||
message: self.message(),
|
||||
ticks: self.ticks(),
|
||||
delivery,
|
||||
delivery: self.delivery(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decision_for_legs(legs: &ClipboardWriteLegs, text: &str) -> trust::ClipboardDecision {
|
||||
let remote = is_remote();
|
||||
let container = is_container_no_display();
|
||||
let mut decision = trust::resolve_copy_decision(
|
||||
legs,
|
||||
text,
|
||||
crate::terminal::terminal_context().brand,
|
||||
crate::host::HostOs::current(),
|
||||
crate::host::DisplayServer::current(),
|
||||
remote,
|
||||
container,
|
||||
osc52_sink_active(),
|
||||
);
|
||||
if decision.delivery == ClipboardDelivery::Failed && (remote || container) {
|
||||
decision.feedback = ClipboardFeedback::FailedRemote;
|
||||
fn clipboard_environment(legs: &ClipboardWriteLegs) -> ClipboardEnvironment {
|
||||
ClipboardEnvironment {
|
||||
brand: crate::terminal::terminal_context().brand,
|
||||
host_os: crate::host::HostOs::current(),
|
||||
display_server: crate::host::DisplayServer::current(),
|
||||
remote: is_remote(),
|
||||
container: is_container_no_display(),
|
||||
osc52_sink: osc52_sink_active(),
|
||||
wayland_data_control: legs.data_control,
|
||||
wl_copy_available: legs.wl_copy_ok,
|
||||
}
|
||||
decision
|
||||
}
|
||||
|
||||
fn decision_for_legs(legs: &ClipboardWriteLegs, text: &str) -> ClipboardFeedback {
|
||||
trust::resolve_copy_decision(legs, text, clipboard_environment(legs))
|
||||
}
|
||||
|
||||
/// Write text and return a toast; emits `grok-shell-clipboard_copy` when enabled.
|
||||
|
|
@ -366,17 +379,17 @@ pub fn copy_text(text: &str) -> CopyResult {
|
|||
let started = std::time::Instant::now();
|
||||
let route = clipboard_route();
|
||||
let legs = clipboard_write_with_route(text, route);
|
||||
let decision = decision_for_legs(&legs, text);
|
||||
if decision.delivery.is_failed() {
|
||||
let feedback = decision_for_legs(&legs, text);
|
||||
if feedback.delivery().is_failed() {
|
||||
tracing::warn!(
|
||||
len = text.len(),
|
||||
display_server = %crate::host::DisplayServer::current(),
|
||||
"clipboard write failed on all trusted backends"
|
||||
);
|
||||
}
|
||||
let result = decision.feedback.to_result(decision.delivery);
|
||||
let toast_kind: &'static str = decision.feedback.into();
|
||||
log_clipboard_copy_event(text, route, &legs, decision, toast_kind, started);
|
||||
let result = feedback.to_result();
|
||||
let toast_kind: &'static str = feedback.into();
|
||||
log_clipboard_copy_event(text, route, &legs, feedback, toast_kind, started);
|
||||
result
|
||||
}
|
||||
|
||||
|
|
@ -384,7 +397,7 @@ fn log_clipboard_copy_event(
|
|||
text: &str,
|
||||
route: &ClipboardRoute,
|
||||
legs: &ClipboardWriteLegs,
|
||||
decision: trust::ClipboardDecision,
|
||||
feedback: ClipboardFeedback,
|
||||
toast_kind: &'static str,
|
||||
started: std::time::Instant,
|
||||
) {
|
||||
|
|
@ -406,10 +419,10 @@ fn log_clipboard_copy_event(
|
|||
data_control: legs.data_control,
|
||||
tmux_ok: legs.tmux_ok,
|
||||
osc52_ok: legs.osc52_ok,
|
||||
delivery: decision.delivery.telemetry_label(),
|
||||
delivery: feedback.delivery().telemetry_label(),
|
||||
osc52_sink: osc52_sink_active(),
|
||||
container_no_display: is_container_no_display(),
|
||||
reported_success: decision.delivery.reported_success(),
|
||||
reported_success: feedback.delivery().reported_success(),
|
||||
toast_kind,
|
||||
duration_ms: started.elapsed().as_millis() as u64,
|
||||
});
|
||||
|
|
@ -1755,7 +1768,8 @@ mod tests {
|
|||
),
|
||||
];
|
||||
for (feedback, delivery, message, telemetry, ticks) in cases {
|
||||
let result = feedback.to_result(delivery);
|
||||
let result = feedback.to_result();
|
||||
assert_eq!(feedback.delivery(), delivery);
|
||||
assert_eq!(feedback.message(), message);
|
||||
assert_eq!(Into::<&'static str>::into(feedback), telemetry);
|
||||
assert_eq!(result.message, message);
|
||||
|
|
@ -1763,24 +1777,4 @@ mod tests {
|
|||
assert_eq!(result.delivery, delivery);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_fallbacks_are_short_and_actionable() {
|
||||
for feedback in [
|
||||
ClipboardFeedback::UnverifiedOscRemote,
|
||||
ClipboardFeedback::UnverifiedOscContainer,
|
||||
ClipboardFeedback::FailedRemote,
|
||||
ClipboardFeedback::Failed,
|
||||
] {
|
||||
assert!(feedback.message().chars().count() + 4 < 80, "{feedback:?}");
|
||||
assert!(!feedback.message().contains("Shift"), "{feedback:?}");
|
||||
assert!(!feedback.message().contains("Fn"), "{feedback:?}");
|
||||
assert!(feedback.message().contains("/minimal"), "{feedback:?}");
|
||||
}
|
||||
assert!(
|
||||
ClipboardFeedback::UnverifiedOscRemote
|
||||
.message()
|
||||
.contains("grok wrap")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,16 @@ pub enum ClipboardDelivery {
|
|||
}
|
||||
|
||||
impl ClipboardDelivery {
|
||||
pub fn is_confirmed(self) -> bool {
|
||||
self == Self::Confirmed
|
||||
}
|
||||
|
||||
pub fn is_failed(self) -> bool {
|
||||
self == Self::Failed
|
||||
}
|
||||
|
||||
pub fn reported_success(self) -> bool {
|
||||
!self.is_failed()
|
||||
matches!(self, Self::Confirmed | Self::Unverified)
|
||||
}
|
||||
|
||||
pub fn telemetry_label(self) -> &'static str {
|
||||
|
|
@ -34,6 +38,53 @@ impl ClipboardDelivery {
|
|||
}
|
||||
}
|
||||
|
||||
/// Clipboard-relevant facts about the terminal and host environment.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
#[doc(hidden)]
|
||||
pub struct ClipboardEnvironment {
|
||||
pub brand: TerminalName,
|
||||
pub host_os: HostOs,
|
||||
pub display_server: DisplayServer,
|
||||
pub remote: bool,
|
||||
pub container: bool,
|
||||
pub osc52_sink: bool,
|
||||
pub wayland_data_control: bool,
|
||||
pub wl_copy_available: bool,
|
||||
}
|
||||
|
||||
/// The terminal's advertised OSC 52 clipboard capability.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
#[doc(hidden)]
|
||||
pub enum Osc52Capability {
|
||||
Supported,
|
||||
Unsupported,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Osc52Capability {
|
||||
#[doc(hidden)]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Supported => "supported",
|
||||
Self::Unsupported => "unsupported",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipboardEnvironment {
|
||||
#[doc(hidden)]
|
||||
pub fn osc52_capability(self) -> Osc52Capability {
|
||||
if self.osc52_sink || self.brand.supports_osc52_clipboard() {
|
||||
Osc52Capability::Supported
|
||||
} else if self.brand == TerminalName::Unknown {
|
||||
Osc52Capability::Unknown
|
||||
} else {
|
||||
Osc52Capability::Unsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native clipboard route evidence available before a copy is attempted.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum NativeClipboardPreflight {
|
||||
|
|
@ -50,23 +101,18 @@ fn trusted_wayland_native(wl_copy: bool, arboard: bool, data_control: bool) -> b
|
|||
/// Classify the configured native route without claiming that a write succeeded.
|
||||
pub fn native_clipboard_preflight(
|
||||
route_native: bool,
|
||||
host_os: HostOs,
|
||||
display_server: DisplayServer,
|
||||
remote: bool,
|
||||
container: bool,
|
||||
wayland_data_control: bool,
|
||||
wl_copy_available: bool,
|
||||
environment: ClipboardEnvironment,
|
||||
) -> NativeClipboardPreflight {
|
||||
if !route_native {
|
||||
return NativeClipboardPreflight::Disabled;
|
||||
}
|
||||
if remote || container {
|
||||
if environment.remote || environment.container {
|
||||
return NativeClipboardPreflight::RemoteOnly;
|
||||
}
|
||||
match host_os {
|
||||
HostOs::Linux => match display_server {
|
||||
match environment.host_os {
|
||||
HostOs::Linux => match environment.display_server {
|
||||
DisplayServer::Wayland
|
||||
if trusted_wayland_native(wl_copy_available, true, wayland_data_control) =>
|
||||
if environment.wl_copy_available || environment.wayland_data_control =>
|
||||
{
|
||||
NativeClipboardPreflight::LocalAvailable
|
||||
}
|
||||
|
|
@ -81,25 +127,15 @@ pub fn native_clipboard_preflight(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) struct ClipboardDecision {
|
||||
pub(crate) delivery: ClipboardDelivery,
|
||||
pub(crate) feedback: ClipboardFeedback,
|
||||
}
|
||||
|
||||
/// Classify one emitted OSC 52 write using the existing environment policy.
|
||||
pub(crate) fn osc52_delivery(
|
||||
brand: TerminalName,
|
||||
remote: bool,
|
||||
container: bool,
|
||||
osc52_sink: bool,
|
||||
) -> ClipboardDelivery {
|
||||
if osc52_sink || brand.supports_osc52_clipboard() {
|
||||
ClipboardDelivery::Confirmed
|
||||
} else if brand == TerminalName::Unknown && (remote || container) {
|
||||
ClipboardDelivery::Unverified
|
||||
} else {
|
||||
ClipboardDelivery::Failed
|
||||
/// Classify one emitted OSC 52 write.
|
||||
/// Unknown SSH/container boundaries strip brand markers, so missing capability evidence is Unverified rather than Failed.
|
||||
pub(crate) fn osc52_delivery(environment: ClipboardEnvironment) -> ClipboardDelivery {
|
||||
match environment.osc52_capability() {
|
||||
Osc52Capability::Supported => ClipboardDelivery::Confirmed,
|
||||
Osc52Capability::Unknown if environment.remote || environment.container => {
|
||||
ClipboardDelivery::Unverified
|
||||
}
|
||||
Osc52Capability::Unknown | Osc52Capability::Unsupported => ClipboardDelivery::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,15 +144,12 @@ pub fn expected_delivery(
|
|||
native: NativeClipboardPreflight,
|
||||
route_tmux: bool,
|
||||
route_osc52: bool,
|
||||
brand: TerminalName,
|
||||
remote: bool,
|
||||
container: bool,
|
||||
osc52_sink: bool,
|
||||
environment: ClipboardEnvironment,
|
||||
) -> ClipboardDelivery {
|
||||
if native == NativeClipboardPreflight::LocalAvailable {
|
||||
return ClipboardDelivery::Confirmed;
|
||||
}
|
||||
let osc52 = route_osc52.then(|| osc52_delivery(brand, remote, container, osc52_sink));
|
||||
let osc52 = route_osc52.then(|| osc52_delivery(environment));
|
||||
if osc52 == Some(ClipboardDelivery::Confirmed) || route_tmux {
|
||||
return ClipboardDelivery::Confirmed;
|
||||
}
|
||||
|
|
@ -127,18 +160,12 @@ pub fn expected_delivery(
|
|||
}
|
||||
|
||||
/// True when native legs wrote the local OS clipboard rather than a remote host.
|
||||
pub(crate) fn trusted_native(
|
||||
legs: &ClipboardWriteLegs,
|
||||
host_os: HostOs,
|
||||
display_server: DisplayServer,
|
||||
remote: bool,
|
||||
container: bool,
|
||||
) -> bool {
|
||||
if remote || container || !legs.route_native {
|
||||
pub(crate) fn trusted_native(legs: &ClipboardWriteLegs, environment: ClipboardEnvironment) -> bool {
|
||||
if environment.remote || environment.container || !legs.route_native {
|
||||
return false;
|
||||
}
|
||||
match host_os {
|
||||
HostOs::Linux => match display_server {
|
||||
match environment.host_os {
|
||||
HostOs::Linux => match environment.display_server {
|
||||
DisplayServer::Wayland => {
|
||||
trusted_wayland_native(legs.wl_copy_ok, legs.arboard_ok, legs.data_control)
|
||||
}
|
||||
|
|
@ -148,51 +175,46 @@ pub(crate) fn trusted_native(
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve the user-visible branch and delivery classification together.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Resolve the user-visible feedback; each feedback variant owns its delivery state.
|
||||
pub(crate) fn resolve_copy_decision(
|
||||
legs: &ClipboardWriteLegs,
|
||||
text: &str,
|
||||
brand: TerminalName,
|
||||
host_os: HostOs,
|
||||
display_server: DisplayServer,
|
||||
remote: bool,
|
||||
container: bool,
|
||||
osc52_sink: bool,
|
||||
) -> ClipboardDecision {
|
||||
let decision = |delivery, feedback| ClipboardDecision { delivery, feedback };
|
||||
if trusted_native(legs, host_os, display_server, remote, container) {
|
||||
return decision(ClipboardDelivery::Confirmed, ClipboardFeedback::Copied);
|
||||
environment: ClipboardEnvironment,
|
||||
) -> ClipboardFeedback {
|
||||
if trusted_native(legs, environment) {
|
||||
return ClipboardFeedback::Copied;
|
||||
}
|
||||
if legs.osc52_ok {
|
||||
match osc52_delivery(brand, remote, container, osc52_sink) {
|
||||
match osc52_delivery(environment) {
|
||||
ClipboardDelivery::Confirmed => {
|
||||
let feedback = if remote && brand.is_vscode_family() && !text.is_ascii() {
|
||||
ClipboardFeedback::VsCodeSshNonAscii
|
||||
} else if container {
|
||||
ClipboardFeedback::CopiedOscContainer
|
||||
} else if remote {
|
||||
ClipboardFeedback::CopiedOscRemote
|
||||
} else {
|
||||
ClipboardFeedback::Copied
|
||||
};
|
||||
return decision(ClipboardDelivery::Confirmed, feedback);
|
||||
if environment.container {
|
||||
return ClipboardFeedback::CopiedOscContainer;
|
||||
}
|
||||
if environment.remote && environment.brand.is_vscode_family() && !text.is_ascii() {
|
||||
return ClipboardFeedback::VsCodeSshNonAscii;
|
||||
}
|
||||
if environment.remote {
|
||||
return ClipboardFeedback::CopiedOscRemote;
|
||||
}
|
||||
return ClipboardFeedback::Copied;
|
||||
}
|
||||
ClipboardDelivery::Unverified if !legs.tmux_ok => {
|
||||
let feedback = if remote {
|
||||
ClipboardFeedback::UnverifiedOscRemote
|
||||
} else {
|
||||
ClipboardFeedback::UnverifiedOscContainer
|
||||
};
|
||||
return decision(ClipboardDelivery::Unverified, feedback);
|
||||
if environment.container {
|
||||
return ClipboardFeedback::UnverifiedOscContainer;
|
||||
}
|
||||
return ClipboardFeedback::UnverifiedOscRemote;
|
||||
}
|
||||
ClipboardDelivery::Unverified | ClipboardDelivery::Failed => {}
|
||||
}
|
||||
}
|
||||
if legs.tmux_ok {
|
||||
return decision(ClipboardDelivery::Confirmed, ClipboardFeedback::CopiedTmux);
|
||||
return ClipboardFeedback::CopiedTmux;
|
||||
}
|
||||
if environment.remote || environment.container {
|
||||
ClipboardFeedback::FailedRemote
|
||||
} else {
|
||||
ClipboardFeedback::Failed
|
||||
}
|
||||
decision(ClipboardDelivery::Failed, ClipboardFeedback::Failed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -221,243 +243,260 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve(
|
||||
legs: &ClipboardWriteLegs,
|
||||
text: &str,
|
||||
brand: TerminalName,
|
||||
host_os: HostOs,
|
||||
display_server: DisplayServer,
|
||||
remote: bool,
|
||||
container: bool,
|
||||
osc52_sink: bool,
|
||||
) -> ClipboardDecision {
|
||||
resolve_copy_decision(
|
||||
legs,
|
||||
text,
|
||||
fn environment(brand: TerminalName) -> ClipboardEnvironment {
|
||||
ClipboardEnvironment {
|
||||
brand,
|
||||
host_os,
|
||||
display_server,
|
||||
remote,
|
||||
container,
|
||||
osc52_sink,
|
||||
)
|
||||
host_os: HostOs::Linux,
|
||||
display_server: DisplayServer::Unknown,
|
||||
remote: false,
|
||||
container: false,
|
||||
osc52_sink: false,
|
||||
wayland_data_control: false,
|
||||
wl_copy_available: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_projection_labels_and_historical_boolean_are_pinned() {
|
||||
for (delivery, label, reported_success) in [
|
||||
(ClipboardDelivery::Confirmed, "confirmed", true),
|
||||
(ClipboardDelivery::Unverified, "unverified", true),
|
||||
(ClipboardDelivery::Failed, "failed", false),
|
||||
for (delivery, label, confirmed, failed, reported_success) in [
|
||||
(ClipboardDelivery::Confirmed, "confirmed", true, false, true),
|
||||
(
|
||||
ClipboardDelivery::Unverified,
|
||||
"unverified",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
(ClipboardDelivery::Failed, "failed", false, true, false),
|
||||
] {
|
||||
assert_eq!(delivery.telemetry_label(), label);
|
||||
assert_eq!(delivery.is_confirmed(), confirmed);
|
||||
assert_eq!(delivery.is_failed(), failed);
|
||||
assert_eq!(delivery.reported_success(), reported_success);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_trusted_native_is_confirmed() {
|
||||
let decision = resolve(
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(true, false, false, false, false, "pbcopy"),
|
||||
"hello",
|
||||
TerminalName::Ghostty,
|
||||
HostOs::Macos,
|
||||
DisplayServer::Quartz,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
host_os: HostOs::Macos,
|
||||
display_server: DisplayServer::Quartz,
|
||||
..environment(TerminalName::Ghostty)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Confirmed);
|
||||
assert_eq!(decision.feedback, ClipboardFeedback::Copied);
|
||||
assert_eq!(feedback, ClipboardFeedback::Copied);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_native_requires_verified_destination() {
|
||||
let environment = ClipboardEnvironment {
|
||||
display_server: DisplayServer::Wayland,
|
||||
..environment(TerminalName::Vte)
|
||||
};
|
||||
let unverified = legs(false, true, false, false, false, "");
|
||||
assert!(!trusted_native(
|
||||
&unverified,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Wayland,
|
||||
false,
|
||||
false
|
||||
));
|
||||
assert!(!trusted_native(&unverified, environment));
|
||||
let data_control = legs(false, true, true, false, false, "");
|
||||
assert!(trusted_native(
|
||||
&data_control,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Wayland,
|
||||
false,
|
||||
false
|
||||
));
|
||||
assert!(trusted_native(&data_control, environment));
|
||||
let wl_copy = legs(true, false, false, false, false, "wl-copy");
|
||||
assert!(trusted_native(
|
||||
&wl_copy,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Wayland,
|
||||
false,
|
||||
false
|
||||
));
|
||||
assert!(trusted_native(&wl_copy, environment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_native_write_only_is_failed() {
|
||||
let decision = resolve(
|
||||
fn remote_native_write_only_uses_failed_remote() {
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(true, true, false, false, false, "xclip"),
|
||||
"hello",
|
||||
TerminalName::Ghostty,
|
||||
HostOs::Linux,
|
||||
DisplayServer::X11,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
display_server: DisplayServer::X11,
|
||||
remote: true,
|
||||
..environment(TerminalName::Ghostty)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Failed);
|
||||
assert_eq!(feedback, ClipboardFeedback::FailedRemote);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_osc_capable_terminal_is_confirmed() {
|
||||
let decision = resolve(
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"hello",
|
||||
TerminalName::Ghostty,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
..environment(TerminalName::Ghostty)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Confirmed);
|
||||
assert_eq!(decision.feedback, ClipboardFeedback::CopiedOscRemote);
|
||||
assert_eq!(feedback, ClipboardFeedback::CopiedOscRemote);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_unknown_brand_osc_is_unverified() {
|
||||
let decision = resolve(
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"hello",
|
||||
TerminalName::Unknown,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
..environment(TerminalName::Unknown)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Unverified);
|
||||
assert_eq!(decision.feedback, ClipboardFeedback::UnverifiedOscRemote);
|
||||
assert_eq!(feedback, ClipboardFeedback::UnverifiedOscRemote);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Unverified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_unknown_brand_osc_is_unverified() {
|
||||
let decision = resolve(
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"hello",
|
||||
TerminalName::Unknown,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
container: true,
|
||||
..environment(TerminalName::Unknown)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Unverified);
|
||||
assert_eq!(decision.feedback, ClipboardFeedback::UnverifiedOscContainer);
|
||||
assert_eq!(feedback, ClipboardFeedback::UnverifiedOscContainer);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Unverified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_unsupported_terminal_osc_is_failed() {
|
||||
for brand in [TerminalName::AppleTerminal, TerminalName::Vte] {
|
||||
let decision = resolve(
|
||||
fn known_unsupported_terminal_osc_is_failed_remote() {
|
||||
for (brand, remote, container) in [
|
||||
(TerminalName::AppleTerminal, true, false),
|
||||
(TerminalName::Vte, true, false),
|
||||
(TerminalName::AppleTerminal, true, true),
|
||||
] {
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"hello",
|
||||
brand,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
remote,
|
||||
container,
|
||||
..environment(brand)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Failed, "{brand:?}");
|
||||
assert_eq!(feedback, ClipboardFeedback::FailedRemote, "{brand:?}");
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Failed, "{brand:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_with_detected_unsupported_brand_is_failed_remote() {
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"hello",
|
||||
ClipboardEnvironment {
|
||||
container: true,
|
||||
..environment(TerminalName::AppleTerminal)
|
||||
},
|
||||
);
|
||||
assert_eq!(feedback, ClipboardFeedback::FailedRemote);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_wrap_sink_with_osc_is_confirmed_for_any_brand() {
|
||||
for brand in [TerminalName::Unknown, TerminalName::AppleTerminal] {
|
||||
let decision = resolve(
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"hello",
|
||||
brand,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
osc52_sink: true,
|
||||
..environment(brand)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Confirmed, "{brand:?}");
|
||||
assert!(feedback.delivery().is_confirmed(), "{brand:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_sink_without_osc_write_is_failed() {
|
||||
let decision = resolve(
|
||||
fn wrap_sink_without_osc_write_is_failed_remote() {
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, false, ""),
|
||||
"hello",
|
||||
TerminalName::Unknown,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
osc52_sink: true,
|
||||
..environment(TerminalName::Unknown)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Failed);
|
||||
assert_eq!(feedback, ClipboardFeedback::FailedRemote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_success_wins_over_unverified_osc() {
|
||||
let decision = resolve(
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, true, true, ""),
|
||||
"hello",
|
||||
TerminalName::Unknown,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
..environment(TerminalName::Unknown)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Confirmed);
|
||||
assert_eq!(decision.feedback, ClipboardFeedback::CopiedTmux);
|
||||
assert_eq!(feedback, ClipboardFeedback::CopiedTmux);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_successful_leg_is_failed() {
|
||||
let decision = resolve(
|
||||
fn no_successful_local_leg_is_failed() {
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, false, ""),
|
||||
"hello",
|
||||
TerminalName::Ghostty,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
environment(TerminalName::Ghostty),
|
||||
);
|
||||
assert_eq!(feedback, ClipboardFeedback::Failed);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_and_container_prefer_container_feedback_and_telemetry_branch() {
|
||||
let confirmed = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"hello",
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
container: true,
|
||||
..environment(TerminalName::Ghostty)
|
||||
},
|
||||
);
|
||||
assert_eq!(confirmed, ClipboardFeedback::CopiedOscContainer);
|
||||
assert_eq!(
|
||||
Into::<&'static str>::into(confirmed),
|
||||
"copied_osc_container"
|
||||
);
|
||||
|
||||
let unverified = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"hello",
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
container: true,
|
||||
..environment(TerminalName::Unknown)
|
||||
},
|
||||
);
|
||||
assert_eq!(unverified, ClipboardFeedback::UnverifiedOscContainer);
|
||||
assert_eq!(
|
||||
Into::<&'static str>::into(unverified),
|
||||
"unverified_osc_container"
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Failed);
|
||||
assert_eq!(decision.feedback, ClipboardFeedback::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vscode_ssh_non_ascii_stays_confirmed_with_warning_toast() {
|
||||
let decision = resolve(
|
||||
let feedback = resolve_copy_decision(
|
||||
&legs(false, false, false, false, true, ""),
|
||||
"café",
|
||||
TerminalName::VsCode,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Unknown,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
..environment(TerminalName::VsCode)
|
||||
},
|
||||
);
|
||||
assert_eq!(decision.delivery, ClipboardDelivery::Confirmed);
|
||||
assert_eq!(decision.feedback, ClipboardFeedback::VsCodeSshNonAscii);
|
||||
assert_eq!(feedback, ClipboardFeedback::VsCodeSshNonAscii);
|
||||
assert_eq!(feedback.delivery(), ClipboardDelivery::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -471,42 +510,48 @@ mod tests {
|
|||
assert_eq!(
|
||||
native_clipboard_preflight(
|
||||
true,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Wayland,
|
||||
false,
|
||||
false,
|
||||
data_control,
|
||||
wl_copy,
|
||||
ClipboardEnvironment {
|
||||
display_server: DisplayServer::Wayland,
|
||||
wayland_data_control: data_control,
|
||||
wl_copy_available: wl_copy,
|
||||
..environment(TerminalName::Vte)
|
||||
},
|
||||
),
|
||||
expected,
|
||||
"data_control={data_control} wl_copy={wl_copy}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
native_clipboard_preflight(
|
||||
true,
|
||||
HostOs::Linux,
|
||||
DisplayServer::Wayland,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
NativeClipboardPreflight::RemoteOnly
|
||||
);
|
||||
for (remote, container) in [(true, false), (false, true), (true, true)] {
|
||||
assert_eq!(
|
||||
native_clipboard_preflight(
|
||||
true,
|
||||
ClipboardEnvironment {
|
||||
display_server: DisplayServer::Wayland,
|
||||
remote,
|
||||
container,
|
||||
wayland_data_control: true,
|
||||
wl_copy_available: true,
|
||||
..environment(TerminalName::Vte)
|
||||
},
|
||||
),
|
||||
NativeClipboardPreflight::RemoteOnly,
|
||||
"remote={remote} container={container}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_delivery_matches_preflight_routes() {
|
||||
let unknown_remote = ClipboardEnvironment {
|
||||
remote: true,
|
||||
..environment(TerminalName::Unknown)
|
||||
};
|
||||
assert_eq!(
|
||||
expected_delivery(
|
||||
NativeClipboardPreflight::RemoteOnly,
|
||||
false,
|
||||
true,
|
||||
TerminalName::Unknown,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
unknown_remote,
|
||||
),
|
||||
ClipboardDelivery::Unverified
|
||||
);
|
||||
|
|
@ -515,10 +560,10 @@ mod tests {
|
|||
NativeClipboardPreflight::RemoteOnly,
|
||||
false,
|
||||
true,
|
||||
TerminalName::Vte,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
..environment(TerminalName::Vte)
|
||||
},
|
||||
),
|
||||
ClipboardDelivery::Failed
|
||||
);
|
||||
|
|
@ -527,10 +572,11 @@ mod tests {
|
|||
NativeClipboardPreflight::RemoteOnly,
|
||||
false,
|
||||
true,
|
||||
TerminalName::Vte,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
ClipboardEnvironment {
|
||||
remote: true,
|
||||
osc52_sink: true,
|
||||
..environment(TerminalName::Vte)
|
||||
},
|
||||
),
|
||||
ClipboardDelivery::Confirmed
|
||||
);
|
||||
|
|
@ -539,10 +585,7 @@ mod tests {
|
|||
NativeClipboardPreflight::RemoteOnly,
|
||||
true,
|
||||
false,
|
||||
TerminalName::Unknown,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
unknown_remote,
|
||||
),
|
||||
ClipboardDelivery::Confirmed
|
||||
);
|
||||
|
|
@ -551,10 +594,7 @@ mod tests {
|
|||
NativeClipboardPreflight::Unavailable,
|
||||
false,
|
||||
false,
|
||||
TerminalName::Vte,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
environment(TerminalName::Vte),
|
||||
),
|
||||
ClipboardDelivery::Failed
|
||||
);
|
||||
|
|
|
|||
|
|
@ -46,70 +46,115 @@ use crossterm::{QueueableCommand, cursor};
|
|||
use ratatui::Frame;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use std::io::Write;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
use xai_ratatui_inline::LinkSpan;
|
||||
/// 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 = xai_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.
|
||||
#[derive(Debug)]
|
||||
pub enum WriterEvent {
|
||||
Written(u64),
|
||||
Failed(std::io::Error),
|
||||
}
|
||||
/// Outcome of a bounded writer drain attempt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WriterDrain {
|
||||
Drained,
|
||||
TimedOut,
|
||||
}
|
||||
/// Tracks submitted and successfully flushed presentation sequences.
|
||||
///
|
||||
/// 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)]
|
||||
/// During a child handoff, input is parked before this state is drained. Since
|
||||
/// a sequence is reserved before its payload is sent, an accepted frame blocks
|
||||
/// the drain before it is visible to the writer; no queued frame can land after
|
||||
/// the child takes the tty.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WriterSync {
|
||||
queued: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
written: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
queued: Arc<AtomicU64>,
|
||||
written: Arc<AtomicU64>,
|
||||
failed: Arc<AtomicBool>,
|
||||
writer_active: Arc<AtomicBool>,
|
||||
event_tx: Option<tokio::sync::mpsc::UnboundedSender<WriterEvent>>,
|
||||
}
|
||||
impl Default for WriterSync {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl WriterSync {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
Self {
|
||||
queued: Arc::new(AtomicU64::new(0)),
|
||||
written: Arc::new(AtomicU64::new(0)),
|
||||
failed: Arc::new(AtomicBool::new(false)),
|
||||
writer_active: Arc::new(AtomicBool::new(false)),
|
||||
event_tx: None,
|
||||
}
|
||||
}
|
||||
/// 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);
|
||||
fn with_event_sender(event_tx: tokio::sync::mpsc::UnboundedSender<WriterEvent>) -> Self {
|
||||
Self {
|
||||
queued: Arc::new(AtomicU64::new(0)),
|
||||
written: Arc::new(AtomicU64::new(0)),
|
||||
failed: Arc::new(AtomicBool::new(false)),
|
||||
writer_active: Arc::new(AtomicBool::new(false)),
|
||||
event_tx: Some(event_tx),
|
||||
}
|
||||
}
|
||||
/// 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);
|
||||
#[cfg(test)]
|
||||
fn new_for_test() -> (Self, tokio::sync::mpsc::UnboundedReceiver<WriterEvent>) {
|
||||
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Self::with_event_sender(event_tx), event_rx)
|
||||
}
|
||||
/// 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)
|
||||
fn reserve_sequence(&self) -> u64 {
|
||||
self.queued.fetch_add(1, Ordering::Release) + 1
|
||||
}
|
||||
/// 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 {
|
||||
fn mark_written(&self, sequence: u64) {
|
||||
self.written.store(sequence, Ordering::Release);
|
||||
if let Some(event_tx) = &self.event_tx {
|
||||
let _ = event_tx.send(WriterEvent::Written(sequence));
|
||||
}
|
||||
}
|
||||
fn mark_failed(&self, error: std::io::Error) {
|
||||
if self
|
||||
.failed
|
||||
.compare_exchange(false, true, Ordering::Release, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(event_tx) = &self.event_tx {
|
||||
let _ = event_tx.send(WriterEvent::Failed(error));
|
||||
}
|
||||
}
|
||||
pub fn queued(&self) -> u64 {
|
||||
self.queued.load(Ordering::Acquire)
|
||||
}
|
||||
pub fn written(&self) -> u64 {
|
||||
self.written.load(Ordering::Acquire)
|
||||
}
|
||||
pub fn failed(&self) -> bool {
|
||||
self.failed.load(Ordering::Acquire)
|
||||
}
|
||||
fn is_drained(&self) -> bool {
|
||||
!self.failed() && self.written() >= self.queued()
|
||||
}
|
||||
/// Block until the writer flushes every accepted payload, output fails, or
|
||||
/// the deadline passes.
|
||||
pub fn wait_drained(&self, timeout: Duration) -> std::io::Result<WriterDrain> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while !self.is_drained() {
|
||||
if self.failed() {
|
||||
return Err(std::io::Error::other("terminal output failed"));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
return Ok(WriterDrain::TimedOut);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
true
|
||||
Ok(WriterDrain::Drained)
|
||||
}
|
||||
}
|
||||
/// A writer that buffers frame output and sends it to a background thread
|
||||
|
|
@ -124,25 +169,41 @@ impl WriterSync {
|
|||
/// 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 WriterPayload {
|
||||
pub(crate) sequence: u64,
|
||||
pub(crate) data: Vec<u8>,
|
||||
}
|
||||
pub type WriterSender = mpsc::Sender<WriterPayload>;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WriterAlreadyActive;
|
||||
impl std::fmt::Display for WriterAlreadyActive {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("WriterSync already owns a live TermWriter")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for WriterAlreadyActive {}
|
||||
pub struct TermWriter {
|
||||
buf: Vec<u8>,
|
||||
tx: mpsc::Sender<Vec<u8>>,
|
||||
tx: WriterSender,
|
||||
sync: WriterSync,
|
||||
}
|
||||
impl TermWriter {
|
||||
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
|
||||
Self {
|
||||
pub fn new(tx: WriterSender, sync: WriterSync) -> Result<Self, WriterAlreadyActive> {
|
||||
sync.writer_active
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map_err(|_| WriterAlreadyActive)?;
|
||||
Ok(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.
|
||||
/// Shared writer progress used by the suspend path to
|
||||
/// [`WriterSync::wait_drained`] before a child takes the tty.
|
||||
pub fn writer_sync(&self) -> &WriterSync {
|
||||
&self.sync
|
||||
}
|
||||
|
|
@ -153,10 +214,19 @@ impl Write for TermWriter {
|
|||
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);
|
||||
if self.buf.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let sequence = self.sync.reserve_sequence();
|
||||
let data = std::mem::take(&mut self.buf);
|
||||
if self.tx.send(WriterPayload { sequence, data }).is_err() {
|
||||
let error = std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"terminal writer thread exited",
|
||||
);
|
||||
self.sync
|
||||
.mark_failed(std::io::Error::new(error.kind(), error.to_string()));
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -164,6 +234,7 @@ impl Write for TermWriter {
|
|||
impl Drop for TermWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.flush();
|
||||
self.sync.writer_active.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
/// Handle for the background writer thread.
|
||||
|
|
@ -171,17 +242,25 @@ impl Drop for TermWriter {
|
|||
/// 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<()>>,
|
||||
handle: Option<std::thread::JoinHandle<std::io::Result<()>>>,
|
||||
sync: WriterSync,
|
||||
}
|
||||
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();
|
||||
pub fn join(mut self) -> std::io::Result<()> {
|
||||
let Some(handle) = self.handle.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
match handle.join() {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(std::io::Error::other("terminal writer thread panicked")),
|
||||
}
|
||||
}
|
||||
pub fn writer_sync(&self) -> &WriterSync {
|
||||
&self.sync
|
||||
}
|
||||
}
|
||||
impl Drop for WriterThread {
|
||||
fn drop(&mut self) {
|
||||
|
|
@ -190,25 +269,47 @@ impl Drop for WriterThread {
|
|||
}
|
||||
}
|
||||
}
|
||||
fn write_payload(
|
||||
writer: &mut impl Write,
|
||||
payload: &WriterPayload,
|
||||
sync: &WriterSync,
|
||||
) -> std::io::Result<()> {
|
||||
match writer
|
||||
.write_all(&payload.data)
|
||||
.and_then(|()| writer.flush())
|
||||
{
|
||||
Ok(()) => {
|
||||
sync.mark_written(payload.sequence);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
sync.mark_failed(std::io::Error::new(error.kind(), error.to_string()));
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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();
|
||||
/// Returns the frame sender, shared writer state, completion-event receiver,
|
||||
/// and the thread handle that must be joined during terminal teardown.
|
||||
pub fn spawn_writer_thread() -> (
|
||||
WriterSender,
|
||||
WriterSync,
|
||||
tokio::sync::mpsc::UnboundedReceiver<WriterEvent>,
|
||||
WriterThread,
|
||||
) {
|
||||
let (tx, rx) = mpsc::channel::<WriterPayload>();
|
||||
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let sync = WriterSync::with_event_sender(event_tx);
|
||||
let thread_sync = sync.clone();
|
||||
let writer_thread_sync = sync.clone();
|
||||
let test_delay = std::env::var("GROK_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 || {
|
||||
.spawn(move || -> std::io::Result<()> {
|
||||
#[cfg(not(windows))]
|
||||
let mut writer: Box<dyn std::io::Write> = {
|
||||
let tui_out = xai_tty_utils::dup_tui_stderr().unwrap_or_else(|_| {
|
||||
|
|
@ -223,24 +324,33 @@ pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread
|
|||
64 * 1024,
|
||||
std::io::stderr(),
|
||||
));
|
||||
while let Ok(data) = rx.recv() {
|
||||
while let Ok(payload) = rx.recv() {
|
||||
if let Some(delay) = test_delay {
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
{
|
||||
let result = {
|
||||
let _guard = xai_grok_shared::stderr::stderr_lock();
|
||||
let _ = writer.write_all(&data);
|
||||
let _ = writer.flush();
|
||||
write_payload(&mut writer, &payload, &thread_sync)
|
||||
};
|
||||
if let Err(error) = result {
|
||||
tracing::error!(% error, "terminal output failed");
|
||||
return Err(error);
|
||||
}
|
||||
thread_sync.mark_written();
|
||||
}
|
||||
if thread_sync.failed() {
|
||||
Err(std::io::Error::other("terminal output failed"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.expect("failed to spawn term-writer thread");
|
||||
(
|
||||
tx,
|
||||
sync,
|
||||
event_rx,
|
||||
WriterThread {
|
||||
handle: Some(handle),
|
||||
sync: writer_thread_sync,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -386,8 +496,10 @@ mod tests {
|
|||
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 (tx, rx) = mpsc::channel::<WriterPayload>();
|
||||
let backend = CrosstermBackend::new(
|
||||
TermWriter::new(tx, WriterSync::new()).expect("single test writer"),
|
||||
);
|
||||
let mut terminal = xai_ratatui_inline::Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
|
|
@ -397,10 +509,10 @@ mod tests {
|
|||
.expect("build terminal");
|
||||
let mut cursor = CursorState::new();
|
||||
draw_frame(&mut terminal, &mut cursor, render);
|
||||
let first: Vec<u8> = rx.try_iter().flatten().collect();
|
||||
let first: Vec<u8> = rx.try_iter().flat_map(|payload| payload.data).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();
|
||||
let second: Vec<u8> = rx.try_iter().flat_map(|payload| payload.data).collect();
|
||||
assert!(
|
||||
second.is_empty(),
|
||||
"idle (unchanged) frame must emit 0 bytes, got {}: {:?}",
|
||||
|
|
@ -408,41 +520,127 @@ mod tests {
|
|||
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");
|
||||
fn writer_success_is_acknowledged_after_flush() {
|
||||
let (sync, mut events) = WriterSync::new_for_test();
|
||||
let sequence = sync.reserve_sequence();
|
||||
let payload = WriterPayload {
|
||||
sequence,
|
||||
data: b"frame bytes".to_vec(),
|
||||
};
|
||||
let mut sink = Vec::new();
|
||||
write_payload(&mut sink, &payload, &sync).expect("write payload");
|
||||
assert_eq!(sink, b"frame bytes");
|
||||
assert_eq!(sync.written(), sequence);
|
||||
assert!(
|
||||
matches!(events.try_recv(), Ok(WriterEvent::Written(written)) if written ==
|
||||
sequence)
|
||||
);
|
||||
assert_eq!(
|
||||
sync.wait_drained(Duration::from_secs(1)).unwrap(),
|
||||
WriterDrain::Drained
|
||||
);
|
||||
}
|
||||
/// 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>>();
|
||||
fn writer_flush_failure_is_not_acknowledged() {
|
||||
struct FlushFailWriter {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
impl Write for FlushFailWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.data.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("flush failed"))
|
||||
}
|
||||
}
|
||||
let (sync, mut events) = WriterSync::new_for_test();
|
||||
let sequence = sync.reserve_sequence();
|
||||
let payload = WriterPayload {
|
||||
sequence,
|
||||
data: b"frame bytes".to_vec(),
|
||||
};
|
||||
let mut sink = FlushFailWriter { data: Vec::new() };
|
||||
assert!(write_payload(&mut sink, &payload, &sync).is_err());
|
||||
assert_eq!(sink.data, b"frame bytes");
|
||||
assert_eq!(sync.written(), 0);
|
||||
assert!(sync.failed());
|
||||
assert!(matches!(events.try_recv(), Ok(WriterEvent::Failed(_))));
|
||||
}
|
||||
#[test]
|
||||
fn writer_failure_is_not_acknowledged() {
|
||||
struct FailingWriter;
|
||||
impl Write for FailingWriter {
|
||||
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::Error::other("write failed"))
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let (sync, mut events) = WriterSync::new_for_test();
|
||||
let sequence = sync.reserve_sequence();
|
||||
let payload = WriterPayload {
|
||||
sequence,
|
||||
data: b"frame bytes".to_vec(),
|
||||
};
|
||||
assert!(write_payload(&mut FailingWriter, &payload, &sync).is_err());
|
||||
assert_eq!(sync.written(), 0);
|
||||
assert!(sync.failed());
|
||||
assert!(matches!(events.try_recv(), Ok(WriterEvent::Failed(_))));
|
||||
assert!(sync.wait_drained(Duration::from_secs(1)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn writer_drain_timeout_is_bounded_and_retryable() {
|
||||
let sync = WriterSync::new();
|
||||
let mut writer = TermWriter::new(tx, sync.clone());
|
||||
let sequence = sync.reserve_sequence();
|
||||
let started = Instant::now();
|
||||
assert_eq!(
|
||||
sync.wait_drained(Duration::from_millis(5)).unwrap(),
|
||||
WriterDrain::TimedOut
|
||||
);
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
sync.mark_written(sequence);
|
||||
assert_eq!(
|
||||
sync.wait_drained(Duration::ZERO).unwrap(),
|
||||
WriterDrain::Drained
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn term_writer_send_failure_is_published_and_not_acknowledged() {
|
||||
let (tx, rx) = mpsc::channel::<WriterPayload>();
|
||||
drop(rx);
|
||||
let (sync, mut events) = WriterSync::new_for_test();
|
||||
let mut writer = TermWriter::new(tx, sync.clone()).expect("single test writer");
|
||||
writer.write_all(b"frame bytes").expect("buffer write");
|
||||
assert!(writer.flush().is_err());
|
||||
assert_eq!(sync.queued(), 1);
|
||||
assert_eq!(sync.written(), 0);
|
||||
assert!(matches!(events.try_recv(), Ok(WriterEvent::Failed(_))));
|
||||
}
|
||||
#[test]
|
||||
fn writer_sync_rejects_multiple_live_producers() {
|
||||
let (tx, _rx) = mpsc::channel::<WriterPayload>();
|
||||
let sync = WriterSync::new();
|
||||
let first = TermWriter::new(tx.clone(), sync.clone()).expect("first writer");
|
||||
assert!(matches!(
|
||||
TermWriter::new(tx.clone(), sync.clone()),
|
||||
Err(WriterAlreadyActive)
|
||||
));
|
||||
drop(first);
|
||||
assert!(TermWriter::new(tx, sync).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn drain_observes_reservation_before_payload_is_consumed() {
|
||||
let (tx, rx) = mpsc::channel::<WriterPayload>();
|
||||
let sync = WriterSync::new();
|
||||
let mut writer = TermWriter::new(tx, sync.clone()).expect("single test writer");
|
||||
writer.write_all(b"frame bytes").expect("buffer write");
|
||||
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());
|
||||
assert_eq!(sync.queued(), 1);
|
||||
assert!(!sync.is_drained());
|
||||
assert_eq!(rx.recv().expect("payload").sequence, 1);
|
||||
}
|
||||
fn state_hidden() -> CursorState {
|
||||
CursorState { last_pos: None }
|
||||
|
|
|
|||
Loading…
Reference in a new issue