Synced from monorepo
Changes: - Stop hooks for session lifecycle - Add x.ai/session/state and x.ai/session/import ACP methods - Deny-and-continue for auto-mode classifier blocks with denial limits - Drop codebase-upload from dhat soak test - scheduler_create upsert via task_id; retire one-shot tasks - Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal - Polarity-safe syntax colors in minimal mode - Auto mode classifies unvetted env prefixes instead of hard-prompting - Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
This commit is contained in:
parent
7cfcb20d2b
commit
ba76b0a683
143 changed files with 9465 additions and 3419 deletions
|
|
@ -17,6 +17,10 @@ use std::sync::OnceLock;
|
|||
|
||||
use crate::terminal::{MultiplexerKind, TerminalContext};
|
||||
|
||||
/// Env var overriding where the copy backup file is written (supports `~`).
|
||||
/// Documented in `xai-grok-pager/docs/internal/22-environment-variables.md`.
|
||||
pub const GROK_COPY_FILE_ENV: &str = "GROK_COPY_FILE";
|
||||
|
||||
/// Cached result of the remote-session check (env vars don't change at runtime).
|
||||
fn is_remote() -> bool {
|
||||
static REMOTE: OnceLock<bool> = OnceLock::new();
|
||||
|
|
@ -51,6 +55,21 @@ pub fn osc52_sink_active() -> bool {
|
|||
})
|
||||
}
|
||||
|
||||
/// Kill switch: never emit OSC 52 clipboard sequences.
|
||||
///
|
||||
/// Set `GROK_CLIPBOARD_NO_OSC52` (any value) before starting Grok. Presence
|
||||
/// forces the OSC 52 leg off for the whole process — including Linux "always
|
||||
/// emit", tmux, SSH, container, and `GROK_OSC52_SINK` paths. Use this when the
|
||||
/// host terminal paints OSC 52 payloads as visible garbage (e.g. OpenText
|
||||
/// Exceed and other non-supporting emulators).
|
||||
///
|
||||
/// Same convention as `GROK_CLIPBOARD_NO_DATA_CONTROL`: env presence enables
|
||||
/// the kill switch; resolved once and cached for the process lifetime.
|
||||
pub fn osc52_disabled() -> bool {
|
||||
static DISABLED: OnceLock<bool> = OnceLock::new();
|
||||
*DISABLED.get_or_init(|| std::env::var_os("GROK_CLIPBOARD_NO_OSC52").is_some())
|
||||
}
|
||||
|
||||
/// Cached clipboard route resolved at first use from the terminal context.
|
||||
pub fn clipboard_route() -> &'static ClipboardRoute {
|
||||
static ROUTE: OnceLock<ClipboardRoute> = OnceLock::new();
|
||||
|
|
@ -114,29 +133,40 @@ impl std::fmt::Display for ClipboardRoute {
|
|||
|
||||
/// Resolve the clipboard route from a terminal context.
|
||||
///
|
||||
/// Note: the `osc52` field depends on [`is_remote()`] and
|
||||
/// [`is_container_no_display()`] which read ambient env vars / filesystem
|
||||
/// markers (cached in `OnceLock`s). In tmux-backed environments `osc52` is
|
||||
/// Note: the `osc52` field depends on [`is_remote()`],
|
||||
/// [`is_container_no_display()`], [`osc52_sink_active()`], and
|
||||
/// [`osc52_disabled()`] which read ambient env vars / filesystem markers
|
||||
/// (cached in `OnceLock`s). In tmux-backed environments `osc52` is normally
|
||||
/// unconditionally `true` regardless of SSH/container state, so this only
|
||||
/// matters for non-tmux contexts. Tests that cannot control SSH env vars
|
||||
/// matters for non-tmux contexts — unless `GROK_CLIPBOARD_NO_OSC52` is set,
|
||||
/// which forces OSC 52 off everywhere. Tests that cannot control SSH env vars
|
||||
/// should skip asserting `osc52` for non-tmux cases.
|
||||
pub fn resolve_clipboard_route(ctx: &TerminalContext) -> ClipboardRoute {
|
||||
resolve_clipboard_route_with(ctx, osc52_disabled())
|
||||
}
|
||||
|
||||
/// Pure clipboard-route resolution (kill-switch injected for tests).
|
||||
fn resolve_clipboard_route_with(ctx: &TerminalContext, no_osc52: bool) -> ClipboardRoute {
|
||||
let is_tmux = ctx.multiplexer == MultiplexerKind::Tmux;
|
||||
ClipboardRoute {
|
||||
native: true,
|
||||
tmux_buffer: is_tmux,
|
||||
// Linux: always emit OSC 52 as a safety net. This matches other
|
||||
// terminal agent CLIs which emit OSC 52 on every copy.
|
||||
// macOS/Windows: only in tmux/SSH/container contexts, or when an
|
||||
// upstream `grok wrap` sink is capturing our output and will forward
|
||||
// the sequence to the real clipboard.
|
||||
osc52: cfg!(target_os = "linux")
|
||||
// Linux: always emit OSC 52 as a safety net. This matches other
|
||||
// terminal agent CLIs which emit OSC 52 on every copy.
|
||||
// macOS/Windows: only in tmux/SSH/container contexts, or when an
|
||||
// upstream `grok wrap` sink is capturing our output and will forward
|
||||
// the sequence to the real clipboard.
|
||||
// `GROK_CLIPBOARD_NO_OSC52` wins over every automatic path.
|
||||
let osc52 = !no_osc52
|
||||
&& (cfg!(target_os = "linux")
|
||||
|| is_tmux
|
||||
|| is_remote()
|
||||
|| is_container_no_display()
|
||||
|| osc52_sink_active(),
|
||||
|| osc52_sink_active());
|
||||
ClipboardRoute {
|
||||
native: true,
|
||||
tmux_buffer: is_tmux,
|
||||
osc52,
|
||||
// Editor :terminal's immediate emulator is libvterm, not tmux — don't wrap there.
|
||||
osc52_tmux_passthrough: is_tmux && ctx.embedded_editor.is_none(),
|
||||
// No point in tmux passthrough when OSC 52 itself is disabled.
|
||||
osc52_tmux_passthrough: osc52 && is_tmux && ctx.embedded_editor.is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,9 +295,14 @@ fn clipboard_write_with_route(text: &str, route: &ClipboardRoute) -> ClipboardWr
|
|||
}
|
||||
|
||||
/// Result of a clipboard write with toast info for the caller to display.
|
||||
#[derive(Debug)]
|
||||
pub struct CopyResult {
|
||||
/// User-facing toast message.
|
||||
/// Full user-facing toast message (used when no backup file exists).
|
||||
pub message: &'static str,
|
||||
/// Leading phrase of `message` without the trailing guidance sentence.
|
||||
/// [`CopyDelivery::toast_message`] appends the dynamic backup-file path
|
||||
/// to this compact lead instead of the full message.
|
||||
pub message_lead: &'static str,
|
||||
/// Toast duration in ticks (30fps: 30 = ~1s, 120 = ~4s).
|
||||
pub ticks: u8,
|
||||
/// Evidence that the write reached the destination named by the UI.
|
||||
|
|
@ -317,6 +352,9 @@ impl ClipboardFeedback {
|
|||
}
|
||||
|
||||
/// User-facing toast message for this kind.
|
||||
///
|
||||
/// Must start with [`Self::message_lead`] (asserted in tests) so the
|
||||
/// path-bearing toast built from the lead never rewords the static copy.
|
||||
fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::Copied => "Copied!",
|
||||
|
|
@ -333,6 +371,22 @@ impl ClipboardFeedback {
|
|||
}
|
||||
}
|
||||
|
||||
/// Leading phrase of [`Self::message`] (no trailing period). When a
|
||||
/// backup file exists, the toast is just this lead plus the path — the
|
||||
/// guidance tail is dropped because the file already is the recovery
|
||||
/// path and the full sentence overflows narrow terminals.
|
||||
fn message_lead(self) -> &'static str {
|
||||
match self {
|
||||
Self::Copied => "Copied!",
|
||||
Self::CopiedTmux => "Copied to tmux buffer, paste with prefix + ]",
|
||||
Self::CopiedOscContainer => "Copied via OSC 52 from the container",
|
||||
Self::CopiedOscRemote => "Copied via OSC 52",
|
||||
Self::UnverifiedOscRemote | Self::UnverifiedOscContainer => "Copy sent",
|
||||
Self::VsCodeSshNonAscii => "Copied",
|
||||
Self::FailedRemote | Self::Failed => "Copy failed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Toast duration in ticks (30fps: 30 = ~1s, 120 = ~4s).
|
||||
fn ticks(self) -> u8 {
|
||||
match self {
|
||||
|
|
@ -351,6 +405,7 @@ impl ClipboardFeedback {
|
|||
fn to_result(self) -> CopyResult {
|
||||
CopyResult {
|
||||
message: self.message(),
|
||||
message_lead: self.message_lead(),
|
||||
ticks: self.ticks(),
|
||||
delivery: self.delivery(),
|
||||
}
|
||||
|
|
@ -393,6 +448,225 @@ pub fn copy_text(text: &str) -> CopyResult {
|
|||
result
|
||||
}
|
||||
|
||||
/// Where a copy landed after [`copy_text_or_file`].
|
||||
#[derive(Debug)]
|
||||
pub enum CopyDelivery {
|
||||
/// Trusted clipboard backend accepted the write. `file` is the
|
||||
/// always-written backup copy (`None` only when the file write itself
|
||||
/// failed — that never fails the copy).
|
||||
Clipboard {
|
||||
result: CopyResult,
|
||||
file: Option<std::path::PathBuf>,
|
||||
},
|
||||
/// Clipboard failed; text was written to this path instead.
|
||||
File { path: std::path::PathBuf },
|
||||
/// Clipboard and file fallback both failed.
|
||||
Failed {
|
||||
clipboard: CopyResult,
|
||||
file_error: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl CopyDelivery {
|
||||
/// `true` when the user can retrieve the text (clipboard or file).
|
||||
pub fn success(&self) -> bool {
|
||||
!matches!(self, Self::Failed { .. })
|
||||
}
|
||||
|
||||
/// User-facing toast line for this delivery. Every clipboard success with
|
||||
/// a backup file names its path. The guidance tail is dropped in that
|
||||
/// case — the file already is the recovery path, and lead + path + tail
|
||||
/// overflows narrow terminals (the toast renderer would truncate it).
|
||||
pub fn toast_message(&self) -> std::borrow::Cow<'static, str> {
|
||||
use std::borrow::Cow;
|
||||
match self {
|
||||
Self::Clipboard { result, file } => match file {
|
||||
Some(path) => Cow::Owned(format!(
|
||||
"{} — saved to {}",
|
||||
result.message_lead,
|
||||
display_copy_path(path)
|
||||
)),
|
||||
None => Cow::Borrowed(result.message),
|
||||
},
|
||||
Self::File { path } => Cow::Owned(format!(
|
||||
"Clipboard unreachable — wrote {}",
|
||||
display_copy_path(path)
|
||||
)),
|
||||
Self::Failed { clipboard, .. } => Cow::Borrowed(clipboard.message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Toast duration in ticks for [`Self::toast_message`].
|
||||
pub fn toast_ticks(&self) -> u8 {
|
||||
match self {
|
||||
Self::Clipboard { result, .. } => result.ticks,
|
||||
Self::File { .. } => 120,
|
||||
Self::Failed { clipboard, .. } => clipboard.ticks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default path for the always-written copy backup file.
|
||||
///
|
||||
/// Override with [`GROK_COPY_FILE_ENV`] (supports `~`). Otherwise
|
||||
/// `~/.grok/last-copy.txt` (grok's per-user home — short, stable, and
|
||||
/// readable in a toast, unlike macOS's `/var/folders/...` temp dir).
|
||||
///
|
||||
/// `None` when no grok home resolves and the env var is unset: rather than
|
||||
/// writing to a predictable world-visible temp path, the backup file is
|
||||
/// simply skipped (the clipboard legs still fire).
|
||||
pub fn default_copy_fallback_path() -> Option<std::path::PathBuf> {
|
||||
if let Ok(raw) = std::env::var(GROK_COPY_FILE_ENV) {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(std::path::PathBuf::from(
|
||||
shellexpand::tilde(trimmed).as_ref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
xai_grok_config::user_grok_home().map(|grok_home| grok_home.join("last-copy.txt"))
|
||||
}
|
||||
|
||||
/// Render a backup-file path for user-facing messages using the codebase-wide
|
||||
/// abbreviation convention ([`crate::util::abbreviate_path`]): a grok-home
|
||||
/// prefix collapses to `~/.grok` (or `$GROK_HOME` when overridden), and a
|
||||
/// plain home prefix collapses to `~` — so toasts stay short.
|
||||
pub fn display_copy_path(path: &std::path::Path) -> String {
|
||||
crate::util::abbreviate_path(&path.to_string_lossy()).into_owned()
|
||||
}
|
||||
|
||||
/// Write `text` to `path` (tilde-expand, create parent dirs). Returns the
|
||||
/// expanded path on success.
|
||||
///
|
||||
/// On unix the file is written `0600` (owner-only): copied text can be
|
||||
/// sensitive and the default fallback path is predictable, so other local
|
||||
/// users must not be able to read it.
|
||||
pub fn write_text_to_copy_file(
|
||||
text: &str,
|
||||
path: &std::path::Path,
|
||||
) -> std::io::Result<std::path::PathBuf> {
|
||||
let expanded = std::path::PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref());
|
||||
if let Some(parent) = expanded.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
write_owner_only(&expanded, text)?;
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
/// Write `text` to `path`, owner-readable only (`0600`) on unix.
|
||||
///
|
||||
/// A pre-existing file (e.g. a `last-copy.txt` created `0644` by an older
|
||||
/// grok) is tightened via `set_permissions` since the create-time `mode`
|
||||
/// only applies to newly created files. Non-unix falls back to a plain write.
|
||||
fn write_owner_only(path: &std::path::Path, text: &str) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
file.write_all(text.as_bytes())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
|
||||
/// Write to the default fallback path ([`default_copy_fallback_path`]).
|
||||
///
|
||||
/// Errors with `NotFound` when no fallback path resolves (no home and no
|
||||
/// `GROK_COPY_FILE`) — the backup file is skipped rather than written to a
|
||||
/// predictable temp location.
|
||||
///
|
||||
/// On Unix a missing parent directory is created `0700` (a custom
|
||||
/// `GROK_COPY_FILE` may point at a not-yet-created private directory;
|
||||
/// `~/.grok` normally already exists).
|
||||
pub fn write_copy_fallback(text: &str) -> std::io::Result<std::path::PathBuf> {
|
||||
let Some(path) = default_copy_fallback_path() else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no home directory resolves; set GROK_COPY_FILE to enable the copy backup file",
|
||||
));
|
||||
};
|
||||
#[cfg(unix)]
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(parent)?;
|
||||
}
|
||||
write_text_to_copy_file(text, &path)
|
||||
}
|
||||
|
||||
/// Compose a [`CopyDelivery`] from the clipboard toast and the (always
|
||||
/// attempted) backup-file write. Pure so the matrix is unit-testable without
|
||||
/// firing real clipboard legs.
|
||||
fn resolve_delivery(
|
||||
clipboard: CopyResult,
|
||||
file: std::io::Result<std::path::PathBuf>,
|
||||
) -> CopyDelivery {
|
||||
if clipboard.delivery.reported_success() {
|
||||
return CopyDelivery::Clipboard {
|
||||
result: clipboard,
|
||||
file: file.ok(),
|
||||
};
|
||||
}
|
||||
match file {
|
||||
Ok(path) => CopyDelivery::File { path },
|
||||
Err(file_error) => CopyDelivery::Failed {
|
||||
clipboard,
|
||||
file_error,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the normal clipboard route AND always write the backup file
|
||||
/// (Claude Code parity: every copy lands in a file too).
|
||||
///
|
||||
/// The file is the recovery path for terminals that cannot reach the local
|
||||
/// clipboard over SSH (notably Apple Terminal without `grok wrap`); a failed
|
||||
/// file write never fails a copy whose clipboard leg succeeded.
|
||||
pub fn copy_text_or_file(text: &str) -> CopyDelivery {
|
||||
let clipboard = copy_text(text);
|
||||
let file = write_copy_fallback(text);
|
||||
match &file {
|
||||
Ok(path) => {
|
||||
if !clipboard.delivery.reported_success() {
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
len = text.len(),
|
||||
"clipboard unreachable; copy retrievable from backup file"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if clipboard.delivery.reported_success() {
|
||||
tracing::debug!(
|
||||
error = %error,
|
||||
len = text.len(),
|
||||
"copy backup file write failed (clipboard succeeded)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
len = text.len(),
|
||||
"clipboard and copy file fallback both failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve_delivery(clipboard, file)
|
||||
}
|
||||
|
||||
fn log_clipboard_copy_event(
|
||||
text: &str,
|
||||
route: &ClipboardRoute,
|
||||
|
|
@ -1517,7 +1791,9 @@ mod tests {
|
|||
];
|
||||
|
||||
for case in cases {
|
||||
let route = resolve_clipboard_route(&case.ctx);
|
||||
// Pure helper with kill switch off so ambient GROK_CLIPBOARD_NO_OSC52
|
||||
// cannot flake CI (route() itself still reads the real env).
|
||||
let route = resolve_clipboard_route_with(&case.ctx, false);
|
||||
assert_eq!(
|
||||
route.native, case.native,
|
||||
"native mismatch on case '{}'",
|
||||
|
|
@ -1595,9 +1871,9 @@ mod tests {
|
|||
#[test]
|
||||
fn clipboard_route_osc52_always_for_tmux_backed() {
|
||||
// In tmux-backed environments, OSC 52 is always emitted regardless of
|
||||
// remote session status.
|
||||
// remote session status (unless the kill switch is on — tested below).
|
||||
for ctx in [plain_tmux_ctx(), byobu_tmux_ctx()] {
|
||||
let route = resolve_clipboard_route(&ctx);
|
||||
let route = resolve_clipboard_route_with(&ctx, false);
|
||||
assert!(
|
||||
route.osc52,
|
||||
"OSC 52 should always be emitted in tmux-backed env: {:?}",
|
||||
|
|
@ -1606,16 +1882,49 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_route_no_osc52_kill_switch_forces_off() {
|
||||
// GROK_CLIPBOARD_NO_OSC52 must win over Linux/tmux/SSH automatic emit.
|
||||
for ctx in [
|
||||
plain_terminal_ctx(),
|
||||
plain_tmux_ctx(),
|
||||
byobu_tmux_ctx(),
|
||||
byobu_screen_ctx(),
|
||||
zellij_ctx(),
|
||||
plain_screen_ctx(),
|
||||
] {
|
||||
let route = resolve_clipboard_route_with(&ctx, true);
|
||||
assert!(
|
||||
!route.osc52,
|
||||
"OSC 52 must be off under kill switch for {:?}",
|
||||
ctx.multiplexer
|
||||
);
|
||||
assert!(
|
||||
!route.osc52_tmux_passthrough,
|
||||
"tmux passthrough must be off when OSC 52 is killed for {:?}",
|
||||
ctx.multiplexer
|
||||
);
|
||||
// Other legs are unaffected.
|
||||
assert!(route.native);
|
||||
}
|
||||
// tmux buffer still active when in tmux — only OSC 52 is killed.
|
||||
let tmux = resolve_clipboard_route_with(&plain_tmux_ctx(), true);
|
||||
assert!(tmux.tmux_buffer);
|
||||
assert!(!tmux.osc52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_route_osc52_tmux_passthrough_truth_table() {
|
||||
// tmux + no editor: wrap (tmux is the immediate terminal).
|
||||
assert!(resolve_clipboard_route(&plain_tmux_ctx()).osc52_tmux_passthrough);
|
||||
assert!(resolve_clipboard_route_with(&plain_tmux_ctx(), false).osc52_tmux_passthrough);
|
||||
// tmux + embedded editor: don't wrap (libvterm is the immediate terminal).
|
||||
let mut tmux_in_editor = plain_tmux_ctx();
|
||||
tmux_in_editor.embedded_editor = Some(EmbeddedEditor::Neovim);
|
||||
assert!(!resolve_clipboard_route(&tmux_in_editor).osc52_tmux_passthrough);
|
||||
assert!(!resolve_clipboard_route_with(&tmux_in_editor, false).osc52_tmux_passthrough);
|
||||
// non-tmux: never wrap.
|
||||
assert!(!resolve_clipboard_route(&plain_terminal_ctx()).osc52_tmux_passthrough);
|
||||
assert!(!resolve_clipboard_route_with(&plain_terminal_ctx(), false).osc52_tmux_passthrough);
|
||||
// kill switch: never wrap even in plain tmux.
|
||||
assert!(!resolve_clipboard_route_with(&plain_tmux_ctx(), true).osc52_tmux_passthrough);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
|
|
@ -1674,7 +1983,7 @@ mod tests {
|
|||
#[test]
|
||||
fn clipboard_route_tmux_backed_all_three_legs() {
|
||||
for ctx in [plain_tmux_ctx(), byobu_tmux_ctx()] {
|
||||
let route = resolve_clipboard_route(&ctx);
|
||||
let route = resolve_clipboard_route_with(&ctx, false);
|
||||
assert!(route.native, "native should be true");
|
||||
assert!(route.tmux_buffer, "tmux_buffer should be true");
|
||||
assert!(route.osc52, "osc52 should be true for tmux-backed");
|
||||
|
|
@ -1775,6 +2084,266 @@ mod tests {
|
|||
assert_eq!(result.message, message);
|
||||
assert_eq!(result.ticks, ticks);
|
||||
assert_eq!(result.delivery, delivery);
|
||||
// The lead must prefix the full message so the path-bearing
|
||||
// toast never rewords the static copy.
|
||||
assert!(
|
||||
message.starts_with(result.message_lead),
|
||||
"message_lead must prefix message for {feedback:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_text_to_copy_file_creates_parent_and_writes() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("nested").join("copy.txt");
|
||||
let written = write_text_to_copy_file("hello fallback", &path).expect("write");
|
||||
assert_eq!(written, path);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("read"),
|
||||
"hello fallback"
|
||||
);
|
||||
}
|
||||
|
||||
/// Copied text can be sensitive and the fallback path is predictable, so
|
||||
/// the file must be owner-only (`0600`) — including when an older grok
|
||||
/// left a pre-existing `0644` file behind.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn copy_file_is_owner_only_0600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("copy.txt");
|
||||
|
||||
// Fresh file: created 0600.
|
||||
write_text_to_copy_file("secret", &path).expect("write");
|
||||
let mode = std::fs::metadata(&path)
|
||||
.expect("metadata")
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "fresh copy file must be 0600");
|
||||
|
||||
// Pre-existing world-readable file: tightened to 0600 on rewrite.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
|
||||
.expect("loosen for test");
|
||||
write_text_to_copy_file("secret2", &path).expect("rewrite");
|
||||
let mode = std::fs::metadata(&path)
|
||||
.expect("metadata")
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(
|
||||
mode & 0o777,
|
||||
0o600,
|
||||
"pre-existing copy file must be tightened to 0600"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("read"), "secret2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(grok_copy_file)]
|
||||
fn default_copy_fallback_path_respects_grok_copy_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let custom = dir.path().join("custom-copy.txt");
|
||||
// SAFETY: test-only env mutation; serialized on the grok_copy_file key.
|
||||
unsafe {
|
||||
std::env::set_var(GROK_COPY_FILE_ENV, &custom);
|
||||
}
|
||||
let resolved = default_copy_fallback_path();
|
||||
unsafe {
|
||||
std::env::remove_var(GROK_COPY_FILE_ENV);
|
||||
}
|
||||
assert_eq!(resolved, Some(custom));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(grok_copy_file)]
|
||||
fn write_copy_fallback_uses_env_override() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let custom = dir.path().join("last.txt");
|
||||
unsafe {
|
||||
std::env::set_var(GROK_COPY_FILE_ENV, &custom);
|
||||
}
|
||||
let written = write_copy_fallback("payload").expect("fallback write");
|
||||
unsafe {
|
||||
std::env::remove_var(GROK_COPY_FILE_ENV);
|
||||
}
|
||||
assert_eq!(written, custom);
|
||||
assert_eq!(std::fs::read_to_string(&custom).expect("read"), "payload");
|
||||
}
|
||||
|
||||
/// Without `GROK_COPY_FILE`, the default is `~/.grok/last-copy.txt`
|
||||
/// (grok home) — short and toast-friendly, unlike macOS's temp dir.
|
||||
#[test]
|
||||
#[serial_test::serial(grok_copy_file)]
|
||||
fn default_copy_fallback_path_is_grok_home() {
|
||||
unsafe {
|
||||
std::env::remove_var(GROK_COPY_FILE_ENV);
|
||||
}
|
||||
let path = default_copy_fallback_path();
|
||||
// Test envs always resolve a home (or set GROK_HOME).
|
||||
let expected = xai_grok_config::user_grok_home()
|
||||
.expect("home resolves in tests")
|
||||
.join("last-copy.txt");
|
||||
assert_eq!(path, Some(expected));
|
||||
}
|
||||
|
||||
/// Toast paths collapse the home prefix to `~` (grok-home paths go
|
||||
/// through the shared `abbreviate_path` convention, covered further by
|
||||
/// the `GROK_HOME`-override integration test in `xai-grok-pager`).
|
||||
#[test]
|
||||
fn display_copy_path_abbreviates_home() {
|
||||
if std::env::var_os("GROK_HOME").is_none() {
|
||||
let home = dirs::home_dir().expect("home resolves in tests");
|
||||
assert_eq!(
|
||||
display_copy_path(&home.join(".grok").join("last-copy.txt")),
|
||||
"~/.grok/last-copy.txt"
|
||||
);
|
||||
}
|
||||
// Non-home paths pass through untouched — including multi-byte
|
||||
// UTF-8 components (must never slice at a non-char boundary).
|
||||
assert_eq!(
|
||||
display_copy_path(std::path::Path::new("/tmp/grok-0/last-copy.txt")),
|
||||
"/tmp/grok-0/last-copy.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
display_copy_path(std::path::Path::new("/tmp/日本語/コピー.txt")),
|
||||
"/tmp/日本語/コピー.txt"
|
||||
);
|
||||
}
|
||||
|
||||
// -- resolve_delivery: pure clipboard × file composition matrix ----------
|
||||
|
||||
fn copy_result(success: bool) -> CopyResult {
|
||||
CopyResult {
|
||||
message: "test",
|
||||
message_lead: "test",
|
||||
ticks: 30,
|
||||
delivery: if success {
|
||||
ClipboardDelivery::Confirmed
|
||||
} else {
|
||||
ClipboardDelivery::Failed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_clipboard_success_carries_backup_file() {
|
||||
let path = std::path::PathBuf::from("/tmp/grok-1/last-copy.txt");
|
||||
match resolve_delivery(copy_result(true), Ok(path.clone())) {
|
||||
CopyDelivery::Clipboard { result, file } => {
|
||||
assert!(result.delivery.reported_success());
|
||||
assert_eq!(file, Some(path));
|
||||
}
|
||||
other => panic!("expected Clipboard delivery, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed backup write never fails a copy whose clipboard succeeded.
|
||||
#[test]
|
||||
fn delivery_clipboard_success_survives_file_write_failure() {
|
||||
let err = std::io::Error::other("disk full");
|
||||
let delivery = resolve_delivery(copy_result(true), Err(err));
|
||||
assert!(delivery.success());
|
||||
match delivery {
|
||||
CopyDelivery::Clipboard { file, .. } => assert!(file.is_none()),
|
||||
other => panic!("expected Clipboard delivery, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipboard `Failed` still yields `File` delivery (the pre-existing
|
||||
/// fallback contract).
|
||||
#[test]
|
||||
fn delivery_clipboard_failure_yields_file() {
|
||||
let path = std::path::PathBuf::from("/tmp/grok-1/last-copy.txt");
|
||||
let delivery = resolve_delivery(copy_result(false), Ok(path.clone()));
|
||||
assert!(delivery.success());
|
||||
match delivery {
|
||||
CopyDelivery::File { path: p } => assert_eq!(p, path),
|
||||
other => panic!("expected File delivery, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_both_failed_is_failed() {
|
||||
let err = std::io::Error::other("read-only fs");
|
||||
let delivery = resolve_delivery(copy_result(false), Err(err));
|
||||
assert!(!delivery.success());
|
||||
assert!(matches!(delivery, CopyDelivery::Failed { .. }));
|
||||
}
|
||||
|
||||
// -- CopyDelivery toast composition ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn toast_message_always_names_backup_file() {
|
||||
let path = std::path::PathBuf::from("/tmp/grok-1/last-copy.txt");
|
||||
|
||||
// Plain success with a backup: names the path.
|
||||
let plain = CopyDelivery::Clipboard {
|
||||
result: ClipboardFeedback::Copied.to_result(),
|
||||
file: Some(path.clone()),
|
||||
};
|
||||
assert_eq!(
|
||||
plain.toast_message(),
|
||||
"Copied! — saved to /tmp/grok-1/last-copy.txt"
|
||||
);
|
||||
assert_eq!(plain.toast_ticks(), 30);
|
||||
|
||||
// Unverified OSC 52 with a backup: compact lead + path, guidance tail
|
||||
// dropped (the file is the recovery path; the full sentence overflows
|
||||
// narrow terminals).
|
||||
let unverified = CopyDelivery::Clipboard {
|
||||
result: ClipboardFeedback::UnverifiedOscRemote.to_result(),
|
||||
file: Some(path.clone()),
|
||||
};
|
||||
assert_eq!(
|
||||
unverified.toast_message(),
|
||||
"Copy sent — saved to /tmp/grok-1/last-copy.txt"
|
||||
);
|
||||
assert_eq!(unverified.toast_ticks(), 120);
|
||||
|
||||
// No backup file (write failed): falls back to the static message.
|
||||
let no_file = CopyDelivery::Clipboard {
|
||||
result: ClipboardFeedback::UnverifiedOscRemote.to_result(),
|
||||
file: None,
|
||||
};
|
||||
assert_eq!(
|
||||
no_file.toast_message(),
|
||||
ClipboardFeedback::UnverifiedOscRemote.message()
|
||||
);
|
||||
|
||||
// File-only delivery keeps the "unreachable" wording.
|
||||
let file_only = CopyDelivery::File { path };
|
||||
assert_eq!(
|
||||
file_only.toast_message(),
|
||||
"Clipboard unreachable — wrote /tmp/grok-1/last-copy.txt"
|
||||
);
|
||||
assert_eq!(file_only.toast_ticks(), 120);
|
||||
|
||||
// Failed delivery surfaces the clipboard failure message.
|
||||
let failed = CopyDelivery::Failed {
|
||||
clipboard: ClipboardFeedback::Failed.to_result(),
|
||||
file_error: std::io::Error::other("nope"),
|
||||
};
|
||||
assert_eq!(failed.toast_message(), ClipboardFeedback::Failed.message());
|
||||
assert_eq!(failed.toast_ticks(), 120);
|
||||
}
|
||||
|
||||
/// An UNVERIFIED clipboard delivery still counts as a clipboard delivery
|
||||
/// (not a file fallback): the toast hedges but the backup path is named.
|
||||
#[test]
|
||||
fn unverified_clipboard_delivery_composes_as_clipboard() {
|
||||
let path = std::path::PathBuf::from("/tmp/grok-1/last-copy.txt");
|
||||
let delivery = resolve_delivery(
|
||||
ClipboardFeedback::UnverifiedOscRemote.to_result(),
|
||||
Ok(path.clone()),
|
||||
);
|
||||
match delivery {
|
||||
CopyDelivery::Clipboard { result, file } => {
|
||||
assert_eq!(result.delivery, ClipboardDelivery::Unverified);
|
||||
assert_eq!(file, Some(path));
|
||||
}
|
||||
other => panic!("expected Clipboard delivery, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,45 +3,119 @@
|
|||
//! Provides lazily-initialized `Syntect` instances for code highlighting.
|
||||
//! Dark themes (GrokNight, TokyoNight) share `grok-night.tmTheme`;
|
||||
//! GrokDay uses `grok-day.tmTheme` with deepened colors for light backgrounds.
|
||||
//!
|
||||
//! ## Minimal / terminal-native lock
|
||||
//!
|
||||
//! While [`crate::theme::cache::terminal_native_locked`] is set, chrome uses
|
||||
//! [`Theme::terminal_default`](crate::theme::Theme::terminal_default) and
|
||||
//! `current_kind()` is a nominal `GrokNight` (so leftover kind-keyed paths
|
||||
//! still resolve). Syntect therefore loads the night `.tmTheme` whose pastel
|
||||
//! RGB tokens, after naive ANSI-16 quantization, collapse to **White** —
|
||||
//! invisible on light terminal profiles.
|
||||
//!
|
||||
//! Under the lock we do **not** detect light/dark. Instead:
|
||||
//! 1. Near-gray tokens → `Color::Reset` (terminal default fg; always readable).
|
||||
//! 2. Chromatic tokens → base ANSI-16 accents (Red/Green/Yellow/Blue/Magenta/Cyan),
|
||||
//! never White/Black/bright variants.
|
||||
//!
|
||||
//! That matches the "first + second" minimal syntax policy: default-fg baseline
|
||||
//! plus a dual-polarity accent map, with zero polarity detection.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use xai_grok_markdown::Syntect;
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
|
||||
use crate::theme::ThemeKind;
|
||||
|
||||
static SYNTECT_GROKNIGHT: OnceLock<Syntect> = OnceLock::new();
|
||||
static SYNTECT_TOKYONIGHT: OnceLock<Syntect> = OnceLock::new();
|
||||
static SYNTECT_GROKDAY: OnceLock<Syntect> = OnceLock::new();
|
||||
|
||||
/// Convert syntect style to ratatui foreground-only style, quantized for terminal color support.
|
||||
pub fn syntect_to_ratatui_fg(style: syntect::highlighting::Style) -> ratatui::style::Style {
|
||||
let fg = crate::theme::quantize(ratatui::style::Color::Rgb(
|
||||
style.foreground.r,
|
||||
style.foreground.g,
|
||||
style.foreground.b,
|
||||
));
|
||||
let mut out = ratatui::style::Style::default().fg(fg);
|
||||
/// Convert syntect style to ratatui foreground-only style, quantized for
|
||||
/// terminal color support (or polarity-safe under the terminal-native lock).
|
||||
pub fn syntect_to_ratatui_fg(style: syntect::highlighting::Style) -> Style {
|
||||
let fg = syntect_rgb_to_fg(style.foreground.r, style.foreground.g, style.foreground.b);
|
||||
let mut out = Style::default().fg(fg);
|
||||
use syntect::highlighting::FontStyle;
|
||||
if style.font_style.contains(FontStyle::BOLD) {
|
||||
out = out.add_modifier(ratatui::style::Modifier::BOLD);
|
||||
out = out.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if style.font_style.contains(FontStyle::ITALIC) {
|
||||
out = out.add_modifier(ratatui::style::Modifier::ITALIC);
|
||||
out = out.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if style.font_style.contains(FontStyle::UNDERLINE) {
|
||||
out = out.add_modifier(ratatui::style::Modifier::UNDERLINED);
|
||||
out = out.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Map a syntect RGB triplet to a ratatui foreground color.
|
||||
///
|
||||
/// Under the terminal-native lock, uses [`polarity_safe_syntax_fg`]; otherwise
|
||||
/// quantizes via the normal theme color pipeline.
|
||||
pub fn syntect_rgb_to_fg(r: u8, g: u8, b: u8) -> Color {
|
||||
if crate::theme::cache::terminal_native_locked() {
|
||||
polarity_safe_syntax_fg(r, g, b)
|
||||
} else {
|
||||
crate::theme::quantize(Color::Rgb(r, g, b))
|
||||
}
|
||||
}
|
||||
|
||||
/// Dual-polarity-safe ANSI mapping for syntax tokens on a transparent canvas.
|
||||
///
|
||||
/// - Low chroma (gray / near-gray body text) → [`Color::Reset`] so the host
|
||||
/// default fg carries contrast on both light and dark profiles.
|
||||
/// - Saturated hues → base ANSI Red/Green/Yellow/Blue/Magenta/Cyan only.
|
||||
///
|
||||
/// Never returns White, Black, or bright (Light*) variants — those are the
|
||||
/// colors that vanish on the opposite polarity after naive RGB→ANSI16.
|
||||
pub fn polarity_safe_syntax_fg(r: u8, g: u8, b: u8) -> Color {
|
||||
let max = r.max(g).max(b) as i32;
|
||||
let min = r.min(g).min(b) as i32;
|
||||
let chroma = max - min;
|
||||
// Night default body (~#c8c8c8) and dim comments are near-gray.
|
||||
if chroma < 40 {
|
||||
return Color::Reset;
|
||||
}
|
||||
// Integer HSV hue in degrees [0, 360).
|
||||
let (ri, gi, bi) = (r as i32, g as i32, b as i32);
|
||||
let h = if max == ri {
|
||||
let mut h = (gi - bi) * 60 / chroma;
|
||||
if h < 0 {
|
||||
h += 360;
|
||||
}
|
||||
h
|
||||
} else if max == gi {
|
||||
(bi - ri) * 60 / chroma + 120
|
||||
} else {
|
||||
(ri - gi) * 60 / chroma + 240
|
||||
};
|
||||
// Magenta starts at 255° so Tokyo Night purple (#bb9af7, ~261°) lands
|
||||
// Magenta rather than Blue; pure blues (~221°) stay Blue.
|
||||
match h {
|
||||
0..30 | 330..=360 => Color::Red,
|
||||
30..90 => Color::Yellow,
|
||||
90..150 => Color::Green,
|
||||
150..210 => Color::Cyan,
|
||||
210..255 => Color::Blue,
|
||||
_ => Color::Magenta,
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlight a single line of source, falling back to plain text style.
|
||||
///
|
||||
/// Under the terminal-native lock, syntect tokens are remapped via
|
||||
/// [`polarity_safe_syntax_fg`]; if highlighting fails, `fallback` (typically
|
||||
/// [`Theme::primary`](crate::theme::Theme::primary) = Reset) is used.
|
||||
pub fn highlight_line(
|
||||
text: &str,
|
||||
highlighter: &mut Option<syntect::easy::HighlightLines<'_>>,
|
||||
syntect: &Syntect,
|
||||
fallback: ratatui::style::Style,
|
||||
) -> Vec<ratatui::text::Span<'static>> {
|
||||
fallback: Style,
|
||||
) -> Vec<Span<'static>> {
|
||||
if let Some(hl) = highlighter.as_mut()
|
||||
&& let Ok(ranges) = hl.highlight_line(&format!("{text}\n"), &syntect.syntax_set)
|
||||
{
|
||||
|
|
@ -54,16 +128,21 @@ pub fn highlight_line(
|
|||
if s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
spans.push(ratatui::text::Span::styled(s, syntect_to_ratatui_fg(style)));
|
||||
spans.push(Span::styled(s, syntect_to_ratatui_fg(style)));
|
||||
}
|
||||
if !spans.is_empty() {
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
vec![ratatui::text::Span::styled(text.to_string(), fallback)]
|
||||
vec![Span::styled(text.to_string(), fallback)]
|
||||
}
|
||||
|
||||
/// Returns the syntect instance matching the active theme.
|
||||
///
|
||||
/// Note: while the terminal-native lock is engaged, [`Theme::current_kind`]
|
||||
/// reports a nominal `GrokNight`, so this returns the night theme. Token
|
||||
/// colors are remapped in [`syntect_to_ratatui_fg`] — do not load a day
|
||||
/// theme based on OS/terminal polarity detection.
|
||||
pub fn get_syntect() -> &'static Syntect {
|
||||
match crate::theme::Theme::current_kind() {
|
||||
ThemeKind::GrokNight
|
||||
|
|
@ -77,3 +156,114 @@ pub fn get_syntect() -> &'static Syntect {
|
|||
.get_or_init(|| Syntect::new(include_bytes!("../assets/grok-day.tmTheme"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::cache as theme_cache;
|
||||
|
||||
/// Hold the theme test lock so we can flip the terminal-native flag.
|
||||
fn with_native_lock<R>(locked: bool, f: impl FnOnce() -> R) -> R {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
theme_cache::reset_for_test();
|
||||
theme_cache::set_terminal_native_lock(locked);
|
||||
let out = f();
|
||||
theme_cache::set_terminal_native_lock(false);
|
||||
theme_cache::reset_for_test();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polarity_safe_grays_are_reset() {
|
||||
// Night default body / comments.
|
||||
assert_eq!(polarity_safe_syntax_fg(0xc8, 0xc8, 0xc8), Color::Reset);
|
||||
assert_eq!(polarity_safe_syntax_fg(0x6c, 0x6c, 0x6c), Color::Reset);
|
||||
assert_eq!(polarity_safe_syntax_fg(0xb2, 0xb2, 0xb2), Color::Reset);
|
||||
assert_eq!(polarity_safe_syntax_fg(0x44, 0x44, 0x44), Color::Reset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polarity_safe_never_emits_white_or_black() {
|
||||
// Common night-theme pastels that naive ANSI16 maps to White.
|
||||
let samples = [
|
||||
(0xbb, 0x9a, 0xf7), // magenta
|
||||
(0x7d, 0xcf, 0xff), // cyan
|
||||
(0x7a, 0xa2, 0xf7), // blue
|
||||
(0xff, 0x9e, 0x64), // orange
|
||||
(0xf7, 0x76, 0x8e), // red
|
||||
(0xe0, 0xaf, 0x68), // yellow
|
||||
(0x9e, 0xce, 0x6a), // green
|
||||
(0xc8, 0xc8, 0xc8), // gray body
|
||||
];
|
||||
for (r, g, b) in samples {
|
||||
let c = polarity_safe_syntax_fg(r, g, b);
|
||||
assert!(
|
||||
!matches!(
|
||||
c,
|
||||
Color::White
|
||||
| Color::Black
|
||||
| Color::Gray
|
||||
| Color::DarkGray
|
||||
| Color::LightRed
|
||||
| Color::LightGreen
|
||||
| Color::LightYellow
|
||||
| Color::LightBlue
|
||||
| Color::LightMagenta
|
||||
| Color::LightCyan
|
||||
),
|
||||
"polarity-unsafe color for #{r:02x}{g:02x}{b:02x}: {c:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polarity_safe_chromatic_buckets() {
|
||||
assert_eq!(polarity_safe_syntax_fg(0xf7, 0x76, 0x8e), Color::Red);
|
||||
assert_eq!(polarity_safe_syntax_fg(0xe0, 0xaf, 0x68), Color::Yellow);
|
||||
assert_eq!(polarity_safe_syntax_fg(0x9e, 0xce, 0x6a), Color::Yellow); // lime → yellow bucket
|
||||
assert_eq!(polarity_safe_syntax_fg(0x7d, 0xcf, 0xff), Color::Cyan);
|
||||
assert_eq!(polarity_safe_syntax_fg(0x7a, 0xa2, 0xf7), Color::Blue);
|
||||
assert_eq!(polarity_safe_syntax_fg(0xbb, 0x9a, 0xf7), Color::Magenta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntect_rgb_to_fg_uses_polarity_safe_when_locked() {
|
||||
with_native_lock(true, || {
|
||||
// Pastel that naive quantize would turn White.
|
||||
assert_eq!(syntect_rgb_to_fg(0xc8, 0xc8, 0xc8), Color::Reset);
|
||||
assert_eq!(syntect_rgb_to_fg(0xbb, 0x9a, 0xf7), Color::Magenta);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_line_fallback_when_no_highlighter() {
|
||||
let syn = get_syntect();
|
||||
let mut hl = None;
|
||||
let fallback = Style::default().fg(Color::Reset);
|
||||
let spans = highlight_line("fn main() {}", &mut hl, syn, fallback);
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!(spans[0].content.as_ref(), "fn main() {}");
|
||||
assert_eq!(spans[0].style.fg, Some(Color::Reset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_line_under_native_lock_avoids_white_tokens() {
|
||||
with_native_lock(true, || {
|
||||
let syn = get_syntect();
|
||||
let mut hl = syn.highlight_lines_for_token("rust");
|
||||
let fallback = Style::default().fg(Color::Reset);
|
||||
let spans = highlight_line("fn main() { let x = 1; /* c */ }", &mut hl, syn, fallback);
|
||||
assert!(!spans.is_empty());
|
||||
for span in &spans {
|
||||
let fg = span.style.fg;
|
||||
assert!(
|
||||
!matches!(fg, Some(Color::White)),
|
||||
"token {:?} painted White under native lock",
|
||||
span.content
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,11 +113,16 @@ pub fn terminal_native_locked() -> bool {
|
|||
/// Engage or clear the terminal-native theme lock.
|
||||
pub fn set_terminal_native_lock(locked: bool) {
|
||||
TERMINAL_NATIVE_LOCK.store(locked, Ordering::Relaxed);
|
||||
// Cap quantization at ANSI-16 and switch syntax tokens to the dual-
|
||||
// polarity accent map (default-fg grays + base ANSI hues). Without the
|
||||
// polarity-safe remap, night-theme pastels collapse to White and vanish
|
||||
// on light terminal profiles in minimal mode.
|
||||
xai_grok_markdown::set_color_level_cap(if locked {
|
||||
xai_grok_markdown::ColorLevel::Basic
|
||||
} else {
|
||||
xai_grok_markdown::ColorLevel::TrueColor
|
||||
});
|
||||
xai_grok_markdown::set_polarity_safe_syntax(locked);
|
||||
}
|
||||
|
||||
// -- Auto-mode ---------------------------------------------------------------
|
||||
|
|
@ -384,6 +389,20 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_native_lock_enables_polarity_safe_syntax() {
|
||||
with_test_env(|| {
|
||||
assert!(!xai_grok_markdown::polarity_safe_syntax());
|
||||
set_terminal_native_lock(true);
|
||||
assert!(
|
||||
xai_grok_markdown::polarity_safe_syntax(),
|
||||
"minimal must engage polarity-safe syntax remapping"
|
||||
);
|
||||
set_terminal_native_lock(false);
|
||||
assert!(!xai_grok_markdown::polarity_safe_syntax());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_native_lock_caps_quantize_at_ansi16() {
|
||||
use ratatui::style::Color;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@
|
|||
//! `Color::Reset`; [`Theme::muted`] / [`Theme::dim`] apply `Modifier::DIM`
|
||||
//! so de-emphasis tracks the terminal's own fg (polarity-safe), unlike
|
||||
//! hard-coding bright black.
|
||||
//! - **Syntax highlighting** is not themed day/night. Under the native lock,
|
||||
//! syntect tokens are remapped via
|
||||
//! [`crate::syntax::polarity_safe_syntax_fg`] (default-fg grays + base ANSI
|
||||
//! accents). Do not load a light tmTheme based on OS/terminal detection.
|
||||
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue