Synced from monorepo
Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
260
crates/codegen/xai-grok-pager/src/doctor_cmd/human.rs
Normal file
260
crates/codegen/xai-grok-pager/src/doctor_cmd/human.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
use crate::clipboard::{ClipboardDelivery, NativeClipboardPreflight};
|
||||
use crate::diagnostics::{
|
||||
DataControlFact, DiagnosticFinding, DiagnosticReport, FindingDisposition, NewlineFact,
|
||||
ProbeStatus, RuntimeFact, VoiceFacts,
|
||||
};
|
||||
use crate::host::{DisplayServer, HostOs};
|
||||
|
||||
const LIVE_TUI_PROBE_CTA: &str = "Run /doctor inside Grok.";
|
||||
|
||||
pub(super) fn format(report: &DiagnosticReport) -> String {
|
||||
let facts = &report.facts;
|
||||
let mut out = String::from("Grok Doctor\n\nTerminal\n");
|
||||
|
||||
fact(&mut out, "terminal", &facts.terminal.to_string());
|
||||
match &facts.xtversion {
|
||||
RuntimeFact::Available(value) => fact(&mut out, "xtversion", value),
|
||||
RuntimeFact::NoReply => unavailable(&mut out, "xtversion", "no reply"),
|
||||
RuntimeFact::Unavailable => unavailable(&mut out, "xtversion", "unavailable"),
|
||||
}
|
||||
fact(&mut out, "multiplexer", &facts.multiplexer.to_string());
|
||||
if let Some(byobu) = facts.byobu {
|
||||
fact(&mut out, "byobu", &byobu.to_string());
|
||||
}
|
||||
fact(&mut out, "ssh", if facts.ssh { "yes" } else { "no" });
|
||||
match &facts.color.level {
|
||||
RuntimeFact::Available(level) => {
|
||||
fact(&mut out, "color", level.as_str());
|
||||
let themes = if facts.color.available_themes.len() == facts.color.total_themes {
|
||||
"all".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{}/{}: {}",
|
||||
facts.color.available_themes.len(),
|
||||
facts.color.total_themes,
|
||||
facts
|
||||
.color
|
||||
.available_themes
|
||||
.iter()
|
||||
.map(|theme| theme.display_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
fact(&mut out, "themes", &themes);
|
||||
}
|
||||
RuntimeFact::NoReply | RuntimeFact::Unavailable => {
|
||||
unavailable(&mut out, "color", "unavailable");
|
||||
unavailable(&mut out, "themes", "unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(keyboard) = &facts.keyboard {
|
||||
let rescue = if keyboard.os == HostOs::Macos {
|
||||
"OS rescue active"
|
||||
} else {
|
||||
"OS rescue unavailable on this platform"
|
||||
};
|
||||
fact(
|
||||
&mut out,
|
||||
"keyboard",
|
||||
&format!("{} ({rescue})", keyboard.modifier_delivery.label()),
|
||||
);
|
||||
}
|
||||
if let Some(newline) = &facts.newline {
|
||||
fact(&mut out, "newline", &format_newline(newline));
|
||||
}
|
||||
|
||||
let clipboard = &facts.clipboard;
|
||||
let native = match clipboard.native_preflight {
|
||||
NativeClipboardPreflight::LocalAvailable => {
|
||||
format!("local ({})", clipboard.native_tool)
|
||||
}
|
||||
NativeClipboardPreflight::RemoteOnly if clipboard.container_no_display => {
|
||||
format!("container ({})", clipboard.native_tool)
|
||||
}
|
||||
NativeClipboardPreflight::RemoteOnly => format!("remote ({})", clipboard.native_tool),
|
||||
NativeClipboardPreflight::Unavailable => "unavailable".to_owned(),
|
||||
NativeClipboardPreflight::Disabled => "off".to_owned(),
|
||||
};
|
||||
out.push_str("\nClipboard\n");
|
||||
fact(&mut out, "native", &native);
|
||||
fact(
|
||||
&mut out,
|
||||
"tmux",
|
||||
if clipboard.tmux_route { "on" } else { "off" },
|
||||
);
|
||||
fact(
|
||||
&mut out,
|
||||
"osc 52",
|
||||
if clipboard.osc52_route {
|
||||
clipboard.osc52_capability.label()
|
||||
} else {
|
||||
"off"
|
||||
},
|
||||
);
|
||||
fact(
|
||||
&mut out,
|
||||
"wrap",
|
||||
if clipboard.wrap_sink { "on" } else { "off" },
|
||||
);
|
||||
if clipboard.display_server == DisplayServer::Wayland {
|
||||
match clipboard.data_control {
|
||||
DataControlFact::Available => fact(&mut out, "data-control", "on"),
|
||||
DataControlFact::Missing => fact(&mut out, "data-control", "off"),
|
||||
DataControlFact::Unavailable => unavailable(&mut out, "data-control", "unavailable"),
|
||||
DataControlFact::Error => {
|
||||
let detail = report
|
||||
.probe_notes
|
||||
.iter()
|
||||
.find(|note| note.probe == "wayland.data-control")
|
||||
.and_then(|note| note.message.as_deref());
|
||||
match detail {
|
||||
Some(message) => {
|
||||
unavailable(&mut out, "data-control", &format!("error: {message}"))
|
||||
}
|
||||
None => unavailable(&mut out, "data-control", "error"),
|
||||
}
|
||||
}
|
||||
DataControlFact::NotApplicable => {}
|
||||
}
|
||||
}
|
||||
let status = match clipboard.delivery {
|
||||
ClipboardDelivery::Confirmed => "confirmed",
|
||||
ClipboardDelivery::Unverified => "unverified",
|
||||
ClipboardDelivery::Failed => "unavailable",
|
||||
};
|
||||
fact(&mut out, "status", status);
|
||||
if let Some(fix) = &clipboard.fix {
|
||||
fact(&mut out, "fix", fix);
|
||||
}
|
||||
|
||||
if let Some(voice) = &facts.voice {
|
||||
out.push_str("\nVoice\n");
|
||||
match voice {
|
||||
VoiceFacts::Device { name, detail } => {
|
||||
fact(&mut out, "microphone", &format!("{name} ({detail})"));
|
||||
}
|
||||
VoiceFacts::Missing { error } => {
|
||||
fact(&mut out, "microphone", &format!("none detected ({error})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !report.findings.is_empty() {
|
||||
out.push_str("\nFindings\n");
|
||||
for finding in &report.findings {
|
||||
format_finding(&mut out, finding);
|
||||
}
|
||||
}
|
||||
|
||||
let visible_notes = report
|
||||
.probe_notes
|
||||
.iter()
|
||||
.filter(|note| !fact_already_shows_probe(note.probe));
|
||||
let mut notes = visible_notes.peekable();
|
||||
if notes.peek().is_some() {
|
||||
out.push_str("\nProbe notes\n");
|
||||
for note in notes {
|
||||
let message = match ¬e.message {
|
||||
Some(message) => format!("{}: {message}", probe_status(note.status)),
|
||||
None => probe_status(note.status).to_owned(),
|
||||
};
|
||||
row(&mut out, "?", note.probe, &message);
|
||||
}
|
||||
}
|
||||
|
||||
if report
|
||||
.probe_notes
|
||||
.iter()
|
||||
.any(crate::diagnostics::probe_requires_live_tui)
|
||||
{
|
||||
out.push_str("\nLive TUI evidence\n");
|
||||
out.push_str(&format!(" {LIVE_TUI_PROBE_CTA}\n"));
|
||||
}
|
||||
|
||||
let issues = report.issue_count();
|
||||
let recommendations = report.recommendation_count();
|
||||
out.push('\n');
|
||||
out.push_str(&format!(
|
||||
"{} {}, {} {}\n",
|
||||
issues,
|
||||
plural(issues, "issue", "issues"),
|
||||
recommendations,
|
||||
plural(recommendations, "recommendation", "recommendations")
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn fact_already_shows_probe(probe: &str) -> bool {
|
||||
matches!(
|
||||
probe,
|
||||
"runtime.xtversion" | "terminal.color" | "wayland.data-control"
|
||||
)
|
||||
}
|
||||
|
||||
fn fact(out: &mut String, label: &str, value: &str) {
|
||||
row(out, "·", label, value);
|
||||
}
|
||||
|
||||
fn unavailable(out: &mut String, label: &str, value: &str) {
|
||||
row(out, "?", label, value);
|
||||
}
|
||||
|
||||
fn row(out: &mut String, marker: &str, label: &str, value: &str) {
|
||||
out.push_str(&format!(" {marker} {label:<28} {value}\n"));
|
||||
}
|
||||
|
||||
fn format_finding(out: &mut String, finding: &DiagnosticFinding) {
|
||||
let marker = match finding.disposition {
|
||||
FindingDisposition::Issue => "!",
|
||||
FindingDisposition::Recommendation => "i",
|
||||
};
|
||||
row(out, marker, &finding.id.to_string(), &finding.message);
|
||||
if let Some(automatic) = finding.automatic_remediation {
|
||||
let command = crate::diagnostics::human_fix_command(automatic.fix_id)
|
||||
.unwrap_or_else(|| automatic.command.to_owned());
|
||||
out.push_str(&format!(" → Automatic setup: `{command}`\n"));
|
||||
}
|
||||
if let Some(remediation) = &finding.remediation {
|
||||
let instruction = match (&remediation.config_path, &finding.automatic_remediation) {
|
||||
(Some(path), _) => format!("Add `{}` to {path}", remediation.fix),
|
||||
(None, Some(_)) => format!("One-off: `{}`", remediation.fix),
|
||||
(None, None) => format!("Run `{}`", remediation.fix),
|
||||
};
|
||||
out.push_str(&format!(" → {instruction}\n"));
|
||||
}
|
||||
if let Some(note) = &finding.note {
|
||||
out.push_str(&format!(" {note}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
fn format_newline(newline: &NewlineFact) -> String {
|
||||
let detail = match newline {
|
||||
NewlineFact::Vte {
|
||||
version: Some(version),
|
||||
} => format!("VTE {version}; need >= 8200 for Shift+Enter"),
|
||||
NewlineFact::Vte { version: None } => {
|
||||
"legacy VTE; need VTE >= 0.82 for Shift+Enter".to_owned()
|
||||
}
|
||||
NewlineFact::XtermJs { terminal } => {
|
||||
format!("{terminal}: xterm.js cannot distinguish Shift+Enter")
|
||||
}
|
||||
NewlineFact::NoKittyKeyboardProtocol => {
|
||||
"no Kitty keyboard protocol; Shift+Enter equals Enter".to_owned()
|
||||
}
|
||||
};
|
||||
format!("Alt+Enter ({detail})")
|
||||
}
|
||||
|
||||
fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
|
||||
if count == 1 { singular } else { plural }
|
||||
}
|
||||
|
||||
fn probe_status(status: ProbeStatus) -> &'static str {
|
||||
match status {
|
||||
ProbeStatus::Unsupported => "unsupported",
|
||||
ProbeStatus::Unavailable => "unavailable",
|
||||
ProbeStatus::Error => "error",
|
||||
}
|
||||
}
|
||||
466
crates/codegen/xai-grok-pager/src/doctor_cmd/json.rs
Normal file
466
crates/codegen/xai-grok-pager/src/doctor_cmd/json.rs
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use crate::clipboard::{ClipboardDelivery, NativeClipboardPreflight, Osc52Capability};
|
||||
use crate::diagnostics::{
|
||||
DataControlFact, DiagnosticFinding, DiagnosticReport, FindingDisposition, NewlineFact,
|
||||
ProbeNote, ProbeStatus, RuntimeFact, VoiceFacts,
|
||||
};
|
||||
use crate::host::HostOs;
|
||||
use crate::terminal::{ByobuBackend, ModifierFate, MultiplexerKind, TerminalName};
|
||||
use crate::theme::color_support::ColorLevel;
|
||||
|
||||
use super::SCHEMA_VERSION;
|
||||
|
||||
pub(super) fn write(
|
||||
report: &DiagnosticReport,
|
||||
writer: &mut impl std::io::Write,
|
||||
) -> anyhow::Result<()> {
|
||||
serde_json::to_writer_pretty(&mut *writer, &JsonReport::from(report))?;
|
||||
writeln!(writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonReport<'a> {
|
||||
schema_version: &'static str,
|
||||
facts: JsonFacts<'a>,
|
||||
findings: Vec<JsonFinding<'a>>,
|
||||
probe_notes: Vec<JsonProbeNote<'a>>,
|
||||
counts: JsonCounts,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a DiagnosticReport> for JsonReport<'a> {
|
||||
fn from(report: &'a DiagnosticReport) -> Self {
|
||||
Self {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
facts: JsonFacts::from(report),
|
||||
findings: report.findings.iter().map(JsonFinding::from).collect(),
|
||||
probe_notes: report.probe_notes.iter().map(JsonProbeNote::from).collect(),
|
||||
counts: JsonCounts {
|
||||
issues: report.issue_count(),
|
||||
recommendations: report.recommendation_count(),
|
||||
probe_notes: report.probe_notes.len(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonFacts<'a> {
|
||||
terminal: JsonTerminalFact<'a>,
|
||||
multiplexer: JsonMultiplexerFact,
|
||||
ssh: bool,
|
||||
color: JsonColorFacts,
|
||||
keyboard: Option<JsonKeyboardFact>,
|
||||
newline: Option<JsonNewlineFact<'a>>,
|
||||
clipboard: JsonClipboardFacts<'a>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
voice: Option<JsonVoiceFacts<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a DiagnosticReport> for JsonFacts<'a> {
|
||||
fn from(report: &'a DiagnosticReport) -> Self {
|
||||
let facts = &report.facts;
|
||||
Self {
|
||||
terminal: JsonTerminalFact {
|
||||
name: terminal_name(facts.terminal),
|
||||
xtversion: JsonRuntimeFact::from(&facts.xtversion),
|
||||
},
|
||||
multiplexer: JsonMultiplexerFact {
|
||||
kind: multiplexer(facts.multiplexer),
|
||||
byobu: facts.byobu.map(byobu_backend),
|
||||
},
|
||||
ssh: facts.ssh,
|
||||
color: JsonColorFacts {
|
||||
level: JsonColorLevel::from(&facts.color.level),
|
||||
available_themes: facts
|
||||
.color
|
||||
.available_themes
|
||||
.iter()
|
||||
.map(|theme| theme.display_name())
|
||||
.collect(),
|
||||
total_themes: facts.color.total_themes,
|
||||
},
|
||||
keyboard: facts.keyboard.as_ref().map(|keyboard| JsonKeyboardFact {
|
||||
cmd: modifier_fate(keyboard.modifier_delivery.cmd),
|
||||
opt: modifier_fate(keyboard.modifier_delivery.opt),
|
||||
os: host_os(keyboard.os),
|
||||
}),
|
||||
newline: facts.newline.as_ref().map(JsonNewlineFact::from),
|
||||
clipboard: JsonClipboardFacts::from(&facts.clipboard),
|
||||
voice: facts.voice.as_ref().map(JsonVoiceFacts::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonVoiceFacts<'a> {
|
||||
status: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a VoiceFacts> for JsonVoiceFacts<'a> {
|
||||
fn from(facts: &'a VoiceFacts) -> Self {
|
||||
match facts {
|
||||
VoiceFacts::Device { name, detail } => Self {
|
||||
status: "available",
|
||||
name: Some(name),
|
||||
detail: Some(detail),
|
||||
error: None,
|
||||
},
|
||||
VoiceFacts::Missing { error } => Self {
|
||||
status: "missing",
|
||||
name: None,
|
||||
detail: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonTerminalFact<'a> {
|
||||
name: &'static str,
|
||||
xtversion: JsonRuntimeFact<'a>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonMultiplexerFact {
|
||||
kind: &'static str,
|
||||
byobu: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonRuntimeFact<'a> {
|
||||
status: &'static str,
|
||||
value: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a RuntimeFact<String>> for JsonRuntimeFact<'a> {
|
||||
fn from(fact: &'a RuntimeFact<String>) -> Self {
|
||||
match fact {
|
||||
RuntimeFact::Available(value) => Self {
|
||||
status: "available",
|
||||
value: Some(value),
|
||||
},
|
||||
RuntimeFact::NoReply => Self {
|
||||
status: "no_reply",
|
||||
value: None,
|
||||
},
|
||||
RuntimeFact::Unavailable => Self {
|
||||
status: "unavailable",
|
||||
value: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonColorFacts {
|
||||
level: JsonColorLevel,
|
||||
available_themes: Vec<&'static str>,
|
||||
total_themes: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonColorLevel {
|
||||
status: &'static str,
|
||||
value: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl From<&RuntimeFact<ColorLevel>> for JsonColorLevel {
|
||||
fn from(fact: &RuntimeFact<ColorLevel>) -> Self {
|
||||
match fact {
|
||||
RuntimeFact::Available(level) => Self {
|
||||
status: "available",
|
||||
value: Some(level.as_str()),
|
||||
},
|
||||
RuntimeFact::NoReply => Self {
|
||||
status: "no_reply",
|
||||
value: None,
|
||||
},
|
||||
RuntimeFact::Unavailable => Self {
|
||||
status: "unavailable",
|
||||
value: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonKeyboardFact {
|
||||
cmd: &'static str,
|
||||
opt: &'static str,
|
||||
os: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
rename_all = "snake_case",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
enum JsonNewlineFact<'a> {
|
||||
Vte { version: Option<&'a str> },
|
||||
XtermJs { terminal_name: &'static str },
|
||||
NoKittyKeyboardProtocol,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a NewlineFact> for JsonNewlineFact<'a> {
|
||||
fn from(newline: &'a NewlineFact) -> Self {
|
||||
match newline {
|
||||
NewlineFact::Vte { version } => Self::Vte {
|
||||
version: version.as_deref(),
|
||||
},
|
||||
NewlineFact::XtermJs { terminal } => Self::XtermJs {
|
||||
terminal_name: terminal_name(*terminal),
|
||||
},
|
||||
NewlineFact::NoKittyKeyboardProtocol => Self::NoKittyKeyboardProtocol,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonClipboardFacts<'a> {
|
||||
native_route: bool,
|
||||
native_tool: &'a str,
|
||||
native_preflight: &'static str,
|
||||
tmux_route: bool,
|
||||
osc52_route: bool,
|
||||
osc52_capability: &'static str,
|
||||
wrap_sink: bool,
|
||||
display_server: &'static str,
|
||||
container_no_display: bool,
|
||||
data_control: &'static str,
|
||||
delivery: &'static str,
|
||||
fix: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a crate::diagnostics::ClipboardFacts> for JsonClipboardFacts<'a> {
|
||||
fn from(facts: &'a crate::diagnostics::ClipboardFacts) -> Self {
|
||||
Self {
|
||||
native_route: facts.native_route,
|
||||
native_tool: &facts.native_tool,
|
||||
native_preflight: native_preflight(facts.native_preflight),
|
||||
tmux_route: facts.tmux_route,
|
||||
osc52_route: facts.osc52_route,
|
||||
osc52_capability: osc52_capability(facts.osc52_capability),
|
||||
wrap_sink: facts.wrap_sink,
|
||||
display_server: display_server(facts.display_server),
|
||||
container_no_display: facts.container_no_display,
|
||||
data_control: data_control(facts.data_control),
|
||||
delivery: clipboard_delivery(facts.delivery),
|
||||
fix: facts.fix.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonFinding<'a> {
|
||||
id: String,
|
||||
disposition: &'static str,
|
||||
message: &'a str,
|
||||
remediation: Option<JsonRemediation<'a>>,
|
||||
automatic_remediation: Option<JsonAutomaticRemediation>,
|
||||
note: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a DiagnosticFinding> for JsonFinding<'a> {
|
||||
fn from(finding: &'a DiagnosticFinding) -> Self {
|
||||
Self {
|
||||
id: finding.id.to_string(),
|
||||
disposition: match finding.disposition {
|
||||
FindingDisposition::Issue => "issue",
|
||||
FindingDisposition::Recommendation => "recommendation",
|
||||
},
|
||||
message: &finding.message,
|
||||
remediation: finding
|
||||
.remediation
|
||||
.as_ref()
|
||||
.map(|remediation| JsonRemediation {
|
||||
fix: &remediation.fix,
|
||||
config_path: remediation.config_path.as_deref(),
|
||||
}),
|
||||
automatic_remediation: finding.automatic_remediation.map(|automatic| {
|
||||
JsonAutomaticRemediation {
|
||||
fix_id: automatic.fix_id.to_string(),
|
||||
command: automatic.command,
|
||||
}
|
||||
}),
|
||||
note: finding.note.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonRemediation<'a> {
|
||||
fix: &'a str,
|
||||
config_path: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonAutomaticRemediation {
|
||||
fix_id: String,
|
||||
command: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonProbeNote<'a> {
|
||||
probe: &'static str,
|
||||
status: &'static str,
|
||||
message: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a ProbeNote> for JsonProbeNote<'a> {
|
||||
fn from(note: &'a ProbeNote) -> Self {
|
||||
Self {
|
||||
probe: note.probe,
|
||||
status: probe_status(note.status),
|
||||
message: note.message.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonCounts {
|
||||
issues: usize,
|
||||
recommendations: usize,
|
||||
probe_notes: usize,
|
||||
}
|
||||
|
||||
pub(super) fn terminal_name(name: TerminalName) -> &'static str {
|
||||
match name {
|
||||
TerminalName::AppleTerminal => "apple_terminal",
|
||||
TerminalName::Ghostty => "ghostty",
|
||||
TerminalName::Iterm2 => "iterm2",
|
||||
TerminalName::WarpTerminal => "warp",
|
||||
TerminalName::VsCode => "vs_code",
|
||||
TerminalName::Cursor => "cursor",
|
||||
TerminalName::Windsurf => "windsurf",
|
||||
TerminalName::Zed => "zed",
|
||||
TerminalName::WezTerm => "wezterm",
|
||||
TerminalName::Kitty => "kitty",
|
||||
TerminalName::Alacritty => "alacritty",
|
||||
TerminalName::Rio => "rio",
|
||||
TerminalName::Foot => "foot",
|
||||
TerminalName::JetBrains => "jetbrains",
|
||||
TerminalName::GrokDesktop => "grok_desktop",
|
||||
TerminalName::Vte => "vte",
|
||||
TerminalName::Terminator => "terminator",
|
||||
TerminalName::WindowsTerminal => "windows_terminal",
|
||||
TerminalName::Otty => "otty",
|
||||
TerminalName::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn multiplexer(kind: MultiplexerKind) -> &'static str {
|
||||
match kind {
|
||||
MultiplexerKind::Tmux => "tmux",
|
||||
MultiplexerKind::Screen => "screen",
|
||||
MultiplexerKind::Zellij => "zellij",
|
||||
MultiplexerKind::Cmux => "cmux",
|
||||
MultiplexerKind::Undetected => "undetected",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn byobu_backend(backend: ByobuBackend) -> &'static str {
|
||||
match backend {
|
||||
ByobuBackend::Unknown => "unknown",
|
||||
ByobuBackend::Tmux => "tmux",
|
||||
ByobuBackend::Screen => "screen",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn modifier_fate(fate: ModifierFate) -> &'static str {
|
||||
match fate {
|
||||
ModifierFate::Native => "native",
|
||||
ModifierFate::Dropped => "dropped",
|
||||
ModifierFate::Unrecoverable => "unrecoverable",
|
||||
ModifierFate::Unknown => "unknown",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn host_os(os: HostOs) -> &'static str {
|
||||
match os {
|
||||
HostOs::Macos => "macos",
|
||||
HostOs::Linux => "linux",
|
||||
HostOs::Windows => "windows",
|
||||
HostOs::Other => "other",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn native_preflight(fact: NativeClipboardPreflight) -> &'static str {
|
||||
match fact {
|
||||
NativeClipboardPreflight::Disabled => "disabled",
|
||||
NativeClipboardPreflight::LocalAvailable => "local_available",
|
||||
NativeClipboardPreflight::RemoteOnly => "remote_only",
|
||||
NativeClipboardPreflight::Unavailable => "unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn osc52_capability(capability: Osc52Capability) -> &'static str {
|
||||
match capability {
|
||||
Osc52Capability::Supported => "supported",
|
||||
Osc52Capability::Unsupported => "unsupported",
|
||||
Osc52Capability::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn display_server(server: crate::host::DisplayServer) -> &'static str {
|
||||
match server {
|
||||
crate::host::DisplayServer::Quartz => "quartz",
|
||||
crate::host::DisplayServer::Wayland => "wayland",
|
||||
crate::host::DisplayServer::X11 => "x11",
|
||||
crate::host::DisplayServer::Win32 => "win32",
|
||||
crate::host::DisplayServer::Unknown => "unknown",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clipboard_delivery(delivery: ClipboardDelivery) -> &'static str {
|
||||
match delivery {
|
||||
ClipboardDelivery::Confirmed => "confirmed",
|
||||
ClipboardDelivery::Unverified => "unverified",
|
||||
ClipboardDelivery::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn data_control(fact: DataControlFact) -> &'static str {
|
||||
match fact {
|
||||
DataControlFact::Available => "available",
|
||||
DataControlFact::Missing => "missing",
|
||||
DataControlFact::Unavailable => "unavailable",
|
||||
DataControlFact::Error => "error",
|
||||
DataControlFact::NotApplicable => "not_applicable",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn probe_status(status: ProbeStatus) -> &'static str {
|
||||
match status {
|
||||
ProbeStatus::Unsupported => "unsupported",
|
||||
ProbeStatus::Unavailable => "unavailable",
|
||||
ProbeStatus::Error => "error",
|
||||
}
|
||||
}
|
||||
225
crates/codegen/xai-grok-pager/src/doctor_cmd/mod.rs
Normal file
225
crates/codegen/xai-grok-pager/src/doctor_cmd/mod.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
use std::io::{IsTerminal as _, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::diagnostics::{DiagnosticReport, FixPlan, FixStatus, ShellKind};
|
||||
|
||||
mod human;
|
||||
mod json;
|
||||
|
||||
pub const SCHEMA_VERSION: &str = "1";
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, clap::Args)]
|
||||
#[command(args_conflicts_with_subcommands = true)]
|
||||
pub struct DoctorArgs {
|
||||
/// Emit machine-readable JSON output.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
#[command(subcommand)]
|
||||
pub command: Option<DoctorCommand>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, clap::Subcommand)]
|
||||
pub enum DoctorCommand {
|
||||
/// Apply a named automatic remediation.
|
||||
Fix(FixArgs),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, clap::Args)]
|
||||
pub struct FixArgs {
|
||||
/// Short fix handle (`ssh-wrap`); canonical `terminal.ssh-wrap` is also accepted.
|
||||
pub id: String,
|
||||
/// Apply without prompting after printing the exact plan.
|
||||
#[arg(long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
pub fn run(args: DoctorArgs) -> Result<()> {
|
||||
match args.command {
|
||||
None => run_report(args.json, &mut std::io::stdout().lock()),
|
||||
Some(DoctorCommand::Fix(fix)) => run_fix(
|
||||
fix,
|
||||
std::io::stdin().is_terminal(),
|
||||
&mut std::io::stdin().lock(),
|
||||
&mut std::io::stdout().lock(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_with_writer(args: DoctorArgs, writer: &mut impl Write) -> Result<()> {
|
||||
match args.command {
|
||||
None => run_report(args.json, writer),
|
||||
Some(_) => anyhow::bail!("doctor fixes require interactive input/output"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_report(json_output: bool, writer: &mut impl Write) -> Result<()> {
|
||||
let report = collect_report();
|
||||
write_report(&report, json_output, writer)
|
||||
}
|
||||
|
||||
pub fn collect_report() -> DiagnosticReport {
|
||||
let terminal = crate::terminal::standalone_terminal_context();
|
||||
let report = collect_report_with(crate::diagnostics::probes::collect_standalone(&terminal));
|
||||
configured_report_for_terminal(report, &terminal)
|
||||
}
|
||||
|
||||
fn configured_report_for_terminal(
|
||||
report: DiagnosticReport,
|
||||
terminal: &crate::terminal::TerminalContext,
|
||||
) -> DiagnosticReport {
|
||||
let configured = shell_home_and_kind()
|
||||
.map(|(home, shell)| {
|
||||
crate::diagnostics::managed_alias_configured(&shell.config_path(&home), shell)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if terminal.is_ssh || terminal.is_official_vscode_remote {
|
||||
report
|
||||
} else {
|
||||
crate::diagnostics::configured_report(report, configured)
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_report_with(
|
||||
snapshot: crate::diagnostics::probes::StandaloneDiagnosticSnapshot<'_>,
|
||||
) -> DiagnosticReport {
|
||||
let mut report = crate::diagnostics::view(snapshot.into());
|
||||
// Passive mic fact when audio is compiled in. No issue finding — headless
|
||||
// hosts often have no input device; the Voice fact row is enough.
|
||||
crate::diagnostics::apply_voice_probe(&mut report, false);
|
||||
report
|
||||
}
|
||||
|
||||
fn write_report(
|
||||
report: &DiagnosticReport,
|
||||
json_output: bool,
|
||||
writer: &mut impl Write,
|
||||
) -> Result<()> {
|
||||
if json_output {
|
||||
json::write(report, writer)
|
||||
} else {
|
||||
write!(writer, "{}", human::format(report))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn run_fix(
|
||||
args: FixArgs,
|
||||
stdin_is_terminal: bool,
|
||||
input: &mut impl std::io::BufRead,
|
||||
writer: &mut impl Write,
|
||||
) -> Result<()> {
|
||||
let id = crate::diagnostics::resolve_fix_id(&args.id)?;
|
||||
let terminal = crate::terminal::standalone_terminal_context();
|
||||
let report = configured_report_for_terminal(
|
||||
collect_report_with(crate::diagnostics::probes::collect_standalone(&terminal)),
|
||||
&terminal,
|
||||
);
|
||||
let request = crate::diagnostics::FixRequest::from_environment(id)?;
|
||||
let plan = crate::diagnostics::plan_fix(request, &report, &terminal)?;
|
||||
apply_fix_plan(args, stdin_is_terminal, input, writer, &terminal, plan)
|
||||
}
|
||||
|
||||
fn apply_fix_plan(
|
||||
args: FixArgs,
|
||||
stdin_is_terminal: bool,
|
||||
input: &mut impl std::io::BufRead,
|
||||
writer: &mut impl Write,
|
||||
terminal: &crate::terminal::TerminalContext,
|
||||
plan: FixPlan,
|
||||
) -> Result<()> {
|
||||
let id = plan.id;
|
||||
write_fix_preview(&plan, writer)?;
|
||||
|
||||
if !args.yes {
|
||||
if !stdin_is_terminal {
|
||||
anyhow::bail!(
|
||||
"refusing to apply a doctor fix from non-interactive stdin without --yes"
|
||||
);
|
||||
}
|
||||
write!(writer, "\nApply this change? [y/N] ")?;
|
||||
writer.flush()?;
|
||||
let mut answer = String::new();
|
||||
input.read_line(&mut answer)?;
|
||||
if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
|
||||
writeln!(writer, "Cancelled.")?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let shell = plan.shell;
|
||||
let outcome = crate::diagnostics::apply_fix(plan)?;
|
||||
let post_report = crate::diagnostics::configured_report(
|
||||
collect_report_with(crate::diagnostics::probes::collect_standalone(terminal)),
|
||||
crate::diagnostics::managed_alias_configured(&outcome.changed_path, shell),
|
||||
);
|
||||
if post_report.findings.iter().any(|finding| finding.id == id) {
|
||||
anyhow::bail!("fix applied, but `{id}` is still reported");
|
||||
}
|
||||
|
||||
match outcome.status {
|
||||
FixStatus::Applied => writeln!(
|
||||
writer,
|
||||
"\nConfigured {id} in {}.",
|
||||
outcome.changed_path.display()
|
||||
)?,
|
||||
FixStatus::AlreadyConfigured => writeln!(
|
||||
writer,
|
||||
"\n{id} is already configured in {}.",
|
||||
outcome.changed_path.display()
|
||||
)?,
|
||||
}
|
||||
if let Some(backup) = outcome.backup_path {
|
||||
writeln!(writer, "Backup: {}", backup.display())?;
|
||||
}
|
||||
writeln!(writer, "Open a new interactive shell to use the alias.")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_fix_preview(plan: &FixPlan, writer: &mut impl Write) -> std::io::Result<()> {
|
||||
writeln!(writer, "Doctor fix: {}", plan.id)?;
|
||||
writeln!(writer, "Shell: {}", plan.shell.name())?;
|
||||
for change in &plan.changes {
|
||||
writeln!(writer, "File: {}", change.requested_path.display())?;
|
||||
if change.target_path != change.requested_path {
|
||||
writeln!(writer, "Physical target: {}", change.target_path.display())?;
|
||||
}
|
||||
writeln!(writer, "\nManaged block:")?;
|
||||
writeln!(writer, "{}", change.block)?;
|
||||
match &change.backup_path_hint {
|
||||
Some(path) => writeln!(
|
||||
writer,
|
||||
"\nProposed backup: {} (apply retries a nearby unique name on collision)",
|
||||
path.display()
|
||||
)?,
|
||||
None => writeln!(writer, "\nBackup: none (new file or exact no-op)")?,
|
||||
}
|
||||
}
|
||||
writeln!(writer, "\nBehavior:")?;
|
||||
writeln!(
|
||||
writer,
|
||||
" New interactive shells run typed `ssh ...` as `grok wrap ssh ...`."
|
||||
)?;
|
||||
writeln!(
|
||||
writer,
|
||||
" One-off alternative without changing config: `{}`.",
|
||||
crate::diagnostics::SSH_WRAP_ONE_OFF
|
||||
)?;
|
||||
writeln!(writer, "Caveats:")?;
|
||||
for caveat in &plan.caveats {
|
||||
writeln!(writer, " - {caveat}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shell_home_and_kind() -> Option<(std::path::PathBuf, ShellKind)> {
|
||||
#[allow(deprecated)]
|
||||
let home = std::env::home_dir()?;
|
||||
let shell = std::env::var_os("SHELL")?;
|
||||
let kind = ShellKind::from_shell_path(Path::new(&shell))?;
|
||||
Some((home, kind))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
969
crates/codegen/xai-grok-pager/src/doctor_cmd/tests.rs
Normal file
969
crates/codegen/xai-grok-pager/src/doctor_cmd/tests.rs
Normal file
|
|
@ -0,0 +1,969 @@
|
|||
use super::*;
|
||||
use crate::clipboard::{
|
||||
ClipboardDelivery, ClipboardRoute, NativeClipboardPreflight, Osc52Capability,
|
||||
};
|
||||
use crate::diagnostics::probes::{
|
||||
RuntimeEvidence, TmuxProbeFacts, TmuxProbeResult, WaylandProbeFacts,
|
||||
};
|
||||
use crate::diagnostics::{
|
||||
ClipboardFacts, ColorFacts, DataControlFact, DiagnosticFacts, DiagnosticFinding, DiagnosticId,
|
||||
DiagnosticReport, FindingDisposition, KeyboardFact, ManualRemediation, NewlineFact, ProbeNote,
|
||||
ProbeStatus, RuntimeFact,
|
||||
};
|
||||
use crate::host::{DisplayServer, HostOs};
|
||||
use crate::terminal::{
|
||||
ByobuBackend, ModifierDelivery, ModifierFate, MultiplexerKind, TerminalContext, TerminalName,
|
||||
};
|
||||
use crate::theme::{ThemeKind, color_support::ColorLevel};
|
||||
|
||||
fn ssh_wrap_report() -> DiagnosticReport {
|
||||
let mut report = healthy_report();
|
||||
report.findings.push(DiagnosticFinding {
|
||||
id: crate::diagnostics::SSH_WRAP_ID,
|
||||
disposition: FindingDisposition::Recommendation,
|
||||
message: "Use local SSH wrapping".to_owned(),
|
||||
remediation: Some(ManualRemediation {
|
||||
fix: crate::diagnostics::SSH_WRAP_ONE_OFF.to_owned(),
|
||||
config_path: None,
|
||||
}),
|
||||
automatic_remediation: Some(crate::diagnostics::ssh_wrap_automatic_remediation()),
|
||||
note: None,
|
||||
});
|
||||
report
|
||||
}
|
||||
|
||||
fn local_terminal() -> TerminalContext {
|
||||
TerminalContext {
|
||||
brand: TerminalName::Ghostty,
|
||||
env_brand: TerminalName::Ghostty,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ssh_wrap_fix_request(home: &std::path::Path) -> crate::diagnostics::FixRequest {
|
||||
crate::diagnostics::FixRequest {
|
||||
id: crate::diagnostics::SSH_WRAP_ID,
|
||||
home: home.to_path_buf(),
|
||||
shell: Some(std::path::PathBuf::from("/bin/bash")),
|
||||
validator: None,
|
||||
}
|
||||
}
|
||||
|
||||
static TMUX_ROUTE: ClipboardRoute = ClipboardRoute {
|
||||
native: true,
|
||||
tmux_buffer: true,
|
||||
osc52: true,
|
||||
osc52_tmux_passthrough: true,
|
||||
};
|
||||
|
||||
static LOCAL_ROUTE: ClipboardRoute = ClipboardRoute {
|
||||
native: true,
|
||||
tmux_buffer: false,
|
||||
osc52: false,
|
||||
osc52_tmux_passthrough: false,
|
||||
};
|
||||
|
||||
fn tmux_facts(
|
||||
set_clipboard: TmuxProbeResult<String>,
|
||||
control_mode: TmuxProbeResult<bool>,
|
||||
) -> TmuxProbeFacts {
|
||||
TmuxProbeFacts {
|
||||
version: TmuxProbeResult::Unavailable,
|
||||
extended_keys: TmuxProbeResult::Unavailable,
|
||||
set_clipboard,
|
||||
allow_passthrough_support: TmuxProbeResult::Available(()),
|
||||
allow_passthrough: TmuxProbeResult::Available("on".to_owned()),
|
||||
control_mode,
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable_tmux_facts() -> TmuxProbeFacts {
|
||||
TmuxProbeFacts {
|
||||
version: TmuxProbeResult::Unavailable,
|
||||
extended_keys: TmuxProbeResult::Unavailable,
|
||||
set_clipboard: TmuxProbeResult::Unavailable,
|
||||
allow_passthrough_support: TmuxProbeResult::Unavailable,
|
||||
allow_passthrough: TmuxProbeResult::Unavailable,
|
||||
control_mode: TmuxProbeResult::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn healthy_report() -> DiagnosticReport {
|
||||
DiagnosticReport {
|
||||
facts: DiagnosticFacts {
|
||||
terminal: TerminalName::Ghostty,
|
||||
xtversion: RuntimeFact::NoReply,
|
||||
multiplexer: MultiplexerKind::Undetected,
|
||||
byobu: None,
|
||||
ssh: false,
|
||||
color: ColorFacts {
|
||||
level: RuntimeFact::Available(ColorLevel::TrueColor),
|
||||
available_themes: ThemeKind::ALL.to_vec(),
|
||||
total_themes: ThemeKind::ALL.len(),
|
||||
},
|
||||
keyboard: None,
|
||||
newline: None,
|
||||
clipboard: ClipboardFacts {
|
||||
native_route: true,
|
||||
native_tool: "pbcopy".to_owned(),
|
||||
native_preflight: NativeClipboardPreflight::LocalAvailable,
|
||||
tmux_route: false,
|
||||
osc52_route: false,
|
||||
osc52_capability: Osc52Capability::Supported,
|
||||
wrap_sink: false,
|
||||
display_server: DisplayServer::Unknown,
|
||||
container_no_display: false,
|
||||
data_control: DataControlFact::NotApplicable,
|
||||
delivery: ClipboardDelivery::Confirmed,
|
||||
fix: None,
|
||||
},
|
||||
voice: None,
|
||||
},
|
||||
findings: Vec::new(),
|
||||
probe_notes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mixed_report() -> DiagnosticReport {
|
||||
let mut report = healthy_report();
|
||||
report.facts.xtversion = RuntimeFact::Available("Ghostty 1.2.3".to_owned());
|
||||
report.facts.multiplexer = MultiplexerKind::Tmux;
|
||||
report.facts.byobu = Some(ByobuBackend::Tmux);
|
||||
report.facts.ssh = true;
|
||||
report.facts.color = ColorFacts {
|
||||
level: RuntimeFact::Available(ColorLevel::Ansi256),
|
||||
available_themes: vec![ThemeKind::GrokNight, ThemeKind::GrokDay],
|
||||
total_themes: ThemeKind::ALL.len(),
|
||||
};
|
||||
report.facts.keyboard = Some(KeyboardFact {
|
||||
modifier_delivery: ModifierDelivery::new_for_test(
|
||||
ModifierFate::Dropped,
|
||||
ModifierFate::Native,
|
||||
),
|
||||
os: HostOs::Macos,
|
||||
});
|
||||
report.facts.newline = Some(NewlineFact::XtermJs {
|
||||
terminal: TerminalName::Cursor,
|
||||
});
|
||||
report.facts.clipboard.tmux_route = true;
|
||||
report.facts.clipboard.osc52_route = true;
|
||||
report.findings = vec![
|
||||
DiagnosticFinding {
|
||||
id: DiagnosticId::new("terminal", "tmux-clipboard"),
|
||||
disposition: FindingDisposition::Issue,
|
||||
message: "OSC 52 clipboard passthrough is disabled".to_owned(),
|
||||
remediation: Some(ManualRemediation {
|
||||
fix: "set -g set-clipboard on".to_owned(),
|
||||
config_path: Some("~/.tmux.conf".to_owned()),
|
||||
}),
|
||||
automatic_remediation: None,
|
||||
note: Some("Reload tmux after editing.".to_owned()),
|
||||
},
|
||||
DiagnosticFinding {
|
||||
id: DiagnosticId::new("terminal", "ssh-wrap"),
|
||||
disposition: FindingDisposition::Recommendation,
|
||||
message: "Use local SSH wrapping".to_owned(),
|
||||
remediation: Some(ManualRemediation {
|
||||
fix: "grok wrap ssh <host>".to_owned(),
|
||||
config_path: None,
|
||||
}),
|
||||
automatic_remediation: Some(crate::diagnostics::ssh_wrap_automatic_remediation()),
|
||||
note: None,
|
||||
},
|
||||
];
|
||||
report.probe_notes = vec![
|
||||
ProbeNote {
|
||||
probe: "tmux.version",
|
||||
status: ProbeStatus::Unavailable,
|
||||
message: None,
|
||||
},
|
||||
ProbeNote {
|
||||
probe: "tmux.extended-keys",
|
||||
status: ProbeStatus::Unavailable,
|
||||
message: None,
|
||||
},
|
||||
ProbeNote {
|
||||
probe: "tmux.allow-passthrough-support",
|
||||
status: ProbeStatus::Unsupported,
|
||||
message: None,
|
||||
},
|
||||
ProbeNote {
|
||||
probe: "runtime.fullscreen-active",
|
||||
status: ProbeStatus::Unavailable,
|
||||
message: None,
|
||||
},
|
||||
ProbeNote {
|
||||
probe: "tmux.control-mode",
|
||||
status: ProbeStatus::Error,
|
||||
message: Some("server unavailable".to_owned()),
|
||||
},
|
||||
];
|
||||
report
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_standalone_facts_compose_through_shared_view() {
|
||||
let terminal = TerminalContext {
|
||||
brand: TerminalName::Iterm2,
|
||||
env_brand: TerminalName::Iterm2,
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
..Default::default()
|
||||
};
|
||||
let snapshot = crate::diagnostics::probes::collect_standalone_from(
|
||||
&terminal,
|
||||
tmux_facts(
|
||||
TmuxProbeResult::Available("off".to_owned()),
|
||||
TmuxProbeResult::Available(false),
|
||||
),
|
||||
WaylandProbeFacts {
|
||||
is_wayland: false,
|
||||
data_control: TmuxProbeResult::Unavailable,
|
||||
wl_copy_available: false,
|
||||
},
|
||||
"pbcopy",
|
||||
TMUX_ROUTE.clone(),
|
||||
true,
|
||||
HostOs::Macos,
|
||||
DisplayServer::Unknown,
|
||||
false,
|
||||
RuntimeEvidence::Available(ColorLevel::TrueColor),
|
||||
);
|
||||
let report = collect_report_with(snapshot);
|
||||
|
||||
assert_eq!(report.issue_count(), 1);
|
||||
assert!(
|
||||
report
|
||||
.findings
|
||||
.iter()
|
||||
.all(|finding| { finding.id != DiagnosticId::new("terminal", "control-mode") })
|
||||
);
|
||||
assert_eq!(
|
||||
report.findings[0].id,
|
||||
DiagnosticId::new("terminal", "tmux-clipboard")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_wayland_missing_is_issue_but_no_seats_or_errors_are_not() {
|
||||
let terminal = TerminalContext::default();
|
||||
for data_control in [
|
||||
TmuxProbeResult::Available(false),
|
||||
TmuxProbeResult::Unavailable,
|
||||
TmuxProbeResult::Error("probe worker died".to_owned()),
|
||||
] {
|
||||
let snapshot = crate::diagnostics::probes::collect_standalone_from(
|
||||
&terminal,
|
||||
unavailable_tmux_facts(),
|
||||
WaylandProbeFacts {
|
||||
is_wayland: true,
|
||||
data_control,
|
||||
wl_copy_available: false,
|
||||
},
|
||||
"arboard",
|
||||
LOCAL_ROUTE.clone(),
|
||||
false,
|
||||
HostOs::Macos,
|
||||
DisplayServer::Wayland,
|
||||
false,
|
||||
RuntimeEvidence::Available(ColorLevel::TrueColor),
|
||||
);
|
||||
let report = collect_report_with(snapshot);
|
||||
let has_issue = report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.id == DiagnosticId::new("terminal", "wayland-data-control"));
|
||||
match report.facts.clipboard.data_control {
|
||||
DataControlFact::Missing => assert!(has_issue),
|
||||
DataControlFact::Unavailable => {
|
||||
assert!(!has_issue);
|
||||
assert_eq!(
|
||||
report
|
||||
.probe_notes
|
||||
.iter()
|
||||
.find(|note| note.probe == "wayland.data-control")
|
||||
.and_then(|note| note.message.as_deref()),
|
||||
None
|
||||
);
|
||||
}
|
||||
DataControlFact::Error => {
|
||||
assert!(!has_issue);
|
||||
assert_eq!(
|
||||
report
|
||||
.probe_notes
|
||||
.iter()
|
||||
.find(|note| note.probe == "wayland.data-control")
|
||||
.and_then(|note| note.message.as_deref()),
|
||||
Some("probe worker died")
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected data-control fact: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_wayland_error_includes_detail_once() {
|
||||
let mut report = healthy_report();
|
||||
report.facts.clipboard.native_preflight = NativeClipboardPreflight::Unavailable;
|
||||
report.facts.clipboard.display_server = DisplayServer::Wayland;
|
||||
report.facts.clipboard.data_control = DataControlFact::Error;
|
||||
report.facts.clipboard.delivery = ClipboardDelivery::Failed;
|
||||
report.facts.clipboard.fix = Some("/minimal".to_owned());
|
||||
report.probe_notes = vec![ProbeNote {
|
||||
probe: "wayland.data-control",
|
||||
status: ProbeStatus::Error,
|
||||
message: Some("probe worker died".to_owned()),
|
||||
}];
|
||||
assert_eq!(
|
||||
human::format(&report),
|
||||
concat!(
|
||||
"Grok Doctor\n",
|
||||
"\n",
|
||||
"Terminal\n",
|
||||
" · terminal Ghostty\n",
|
||||
" ? xtversion no reply\n",
|
||||
" · multiplexer None detected\n",
|
||||
" · ssh no\n",
|
||||
" · color truecolor\n",
|
||||
" · themes all\n",
|
||||
"\n",
|
||||
"Clipboard\n",
|
||||
" · native unavailable\n",
|
||||
" · tmux off\n",
|
||||
" · osc 52 off\n",
|
||||
" · wrap off\n",
|
||||
" ? data-control error: probe worker died\n",
|
||||
" · status unavailable\n",
|
||||
" · fix /minimal\n",
|
||||
"\n",
|
||||
"1 issue, 0 recommendations\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_runtime_and_tmux_are_unavailable_without_false_wezterm_finding() {
|
||||
let terminal = TerminalContext {
|
||||
brand: TerminalName::WezTerm,
|
||||
env_brand: TerminalName::WezTerm,
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
..Default::default()
|
||||
};
|
||||
let snapshot = crate::diagnostics::probes::collect_standalone_from(
|
||||
&terminal,
|
||||
unavailable_tmux_facts(),
|
||||
WaylandProbeFacts {
|
||||
is_wayland: false,
|
||||
data_control: TmuxProbeResult::Unavailable,
|
||||
wl_copy_available: false,
|
||||
},
|
||||
"pbcopy",
|
||||
LOCAL_ROUTE.clone(),
|
||||
true,
|
||||
HostOs::Macos,
|
||||
DisplayServer::Unknown,
|
||||
false,
|
||||
RuntimeEvidence::Available(ColorLevel::TrueColor),
|
||||
);
|
||||
let report = collect_report_with(snapshot);
|
||||
|
||||
assert!(report.findings.iter().all(|finding| {
|
||||
finding.id != DiagnosticId::new("terminal", "wezterm-kitty")
|
||||
&& finding.id != DiagnosticId::new("terminal", "control-mode")
|
||||
}));
|
||||
assert_eq!(report.facts.xtversion, RuntimeFact::Unavailable);
|
||||
assert_eq!(
|
||||
report
|
||||
.probe_notes
|
||||
.iter()
|
||||
.filter(|note| note.probe.starts_with("tmux."))
|
||||
.map(|note| note.probe)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"tmux.version",
|
||||
"tmux.extended-keys",
|
||||
"tmux.set-clipboard",
|
||||
"tmux.allow-passthrough-support",
|
||||
"tmux.control-mode",
|
||||
]
|
||||
);
|
||||
let runtime_notes = report
|
||||
.probe_notes
|
||||
.iter()
|
||||
.filter(|note| note.probe.starts_with("runtime."))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
runtime_notes
|
||||
.iter()
|
||||
.map(|note| (note.probe, note.status))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
("runtime.fullscreen-active", ProbeStatus::Unavailable),
|
||||
("runtime.kitty-flags-pushed", ProbeStatus::Unavailable),
|
||||
("runtime.xtversion", ProbeStatus::Unavailable),
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
runtime_notes
|
||||
.iter()
|
||||
.all(|note| crate::diagnostics::probe_requires_live_tui(note))
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.probe_notes
|
||||
.iter()
|
||||
.filter(|note| note.probe.starts_with("tmux."))
|
||||
.all(|note| !crate::diagnostics::probe_requires_live_tui(note))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_healthy_fixture_is_exact() {
|
||||
assert_eq!(
|
||||
human::format(&healthy_report()),
|
||||
concat!(
|
||||
"Grok Doctor\n",
|
||||
"\n",
|
||||
"Terminal\n",
|
||||
" · terminal Ghostty\n",
|
||||
" ? xtversion no reply\n",
|
||||
" · multiplexer None detected\n",
|
||||
" · ssh no\n",
|
||||
" · color truecolor\n",
|
||||
" · themes all\n",
|
||||
"\n",
|
||||
"Clipboard\n",
|
||||
" · native local (pbcopy)\n",
|
||||
" · tmux off\n",
|
||||
" · osc 52 off\n",
|
||||
" · wrap off\n",
|
||||
" · status confirmed\n",
|
||||
"\n",
|
||||
"0 issues, 0 recommendations\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_mixed_fixture_is_exact() {
|
||||
assert_eq!(
|
||||
human::format(&mixed_report()),
|
||||
concat!(
|
||||
"Grok Doctor\n",
|
||||
"\n",
|
||||
"Terminal\n",
|
||||
" · terminal Ghostty\n",
|
||||
" · xtversion Ghostty 1.2.3\n",
|
||||
" · multiplexer tmux\n",
|
||||
" · byobu tmux\n",
|
||||
" · ssh yes\n",
|
||||
" · color 256\n",
|
||||
" · themes 2/5: groknight, grokday\n",
|
||||
" · keyboard cmd=dropped, opt=native (OS rescue active)\n",
|
||||
" · newline Alt+Enter (Cursor: xterm.js cannot distinguish Shift+Enter)\n",
|
||||
"\n",
|
||||
"Clipboard\n",
|
||||
" · native local (pbcopy)\n",
|
||||
" · tmux on\n",
|
||||
" · osc 52 supported\n",
|
||||
" · wrap off\n",
|
||||
" · status confirmed\n",
|
||||
"\n",
|
||||
"Findings\n",
|
||||
" ! terminal.tmux-clipboard OSC 52 clipboard passthrough is disabled\n",
|
||||
" → Add `set -g set-clipboard on` to ~/.tmux.conf\n",
|
||||
" Reload tmux after editing.\n",
|
||||
" i terminal.ssh-wrap Use local SSH wrapping\n",
|
||||
" → Automatic setup: `grok doctor fix ssh-wrap`\n",
|
||||
" → One-off: `grok wrap ssh <host>`\n",
|
||||
"\n",
|
||||
"Probe notes\n",
|
||||
" ? tmux.version unavailable\n",
|
||||
" ? tmux.extended-keys unavailable\n",
|
||||
" ? tmux.allow-passthrough-support unsupported\n",
|
||||
" ? runtime.fullscreen-active unavailable\n",
|
||||
" ? tmux.control-mode error: server unavailable\n",
|
||||
"\n",
|
||||
"Live TUI evidence\n",
|
||||
" Run /doctor inside Grok.\n",
|
||||
"\n",
|
||||
"1 issue, 1 recommendation\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_preview_contains_exact_change_and_caveats() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let terminal = local_terminal();
|
||||
let plan = crate::diagnostics::plan_fix(
|
||||
ssh_wrap_fix_request(temp.path()),
|
||||
&ssh_wrap_report(),
|
||||
&terminal,
|
||||
)
|
||||
.unwrap();
|
||||
let mut preview = Vec::new();
|
||||
write_fix_preview(&plan, &mut preview).unwrap();
|
||||
let preview = String::from_utf8(preview).unwrap();
|
||||
assert!(preview.contains("File: "));
|
||||
assert!(
|
||||
preview.contains(
|
||||
"# >>> grok doctor >>>\n# >>> terminal.ssh-wrap >>>\nalias ssh='grok wrap ssh'"
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
preview.contains("One-off alternative without changing config: `grok wrap ssh <host>`")
|
||||
);
|
||||
assert!(preview.contains("Use `command ssh ...` to bypass the alias."));
|
||||
assert!(preview.contains("ssh -f"));
|
||||
assert!(preview.contains("ControlPersist"));
|
||||
assert!(preview.contains("~^Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decline_is_success_and_does_not_write() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let terminal = local_terminal();
|
||||
let plan = crate::diagnostics::plan_fix(
|
||||
ssh_wrap_fix_request(temp.path()),
|
||||
&ssh_wrap_report(),
|
||||
&terminal,
|
||||
)
|
||||
.unwrap();
|
||||
let mut input = std::io::Cursor::new(b"n\n");
|
||||
let mut output = Vec::new();
|
||||
apply_fix_plan(
|
||||
FixArgs {
|
||||
id: "ssh-wrap".to_owned(),
|
||||
yes: false,
|
||||
},
|
||||
true,
|
||||
&mut input,
|
||||
&mut output,
|
||||
&terminal,
|
||||
plan,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(String::from_utf8(output).unwrap().ends_with("Cancelled.\n"));
|
||||
assert!(!temp.path().join(".bashrc").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_tty_without_yes_fails_safely_before_write() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let terminal = local_terminal();
|
||||
let plan = crate::diagnostics::plan_fix(
|
||||
ssh_wrap_fix_request(temp.path()),
|
||||
&ssh_wrap_report(),
|
||||
&terminal,
|
||||
)
|
||||
.unwrap();
|
||||
let error = apply_fix_plan(
|
||||
FixArgs {
|
||||
id: "terminal.ssh-wrap".to_owned(),
|
||||
yes: false,
|
||||
},
|
||||
false,
|
||||
&mut std::io::Cursor::new(Vec::<u8>::new()),
|
||||
&mut Vec::new(),
|
||||
&terminal,
|
||||
plan,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("non-interactive stdin without --yes")
|
||||
);
|
||||
assert!(!temp.path().join(".bashrc").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_incomplete_fixture_is_exact_without_duplicate_probe_rows() {
|
||||
let mut report = healthy_report();
|
||||
report.facts.xtversion = RuntimeFact::Unavailable;
|
||||
report.facts.color.level = RuntimeFact::Unavailable;
|
||||
report.facts.color.available_themes.clear();
|
||||
report.facts.clipboard.data_control = DataControlFact::Unavailable;
|
||||
report.probe_notes = vec![
|
||||
ProbeNote {
|
||||
probe: "runtime.xtversion",
|
||||
status: ProbeStatus::Unavailable,
|
||||
message: None,
|
||||
},
|
||||
ProbeNote {
|
||||
probe: "terminal.color",
|
||||
status: ProbeStatus::Unavailable,
|
||||
message: None,
|
||||
},
|
||||
ProbeNote {
|
||||
probe: "wayland.data-control",
|
||||
status: ProbeStatus::Unavailable,
|
||||
message: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
human::format(&report),
|
||||
concat!(
|
||||
"Grok Doctor\n",
|
||||
"\n",
|
||||
"Terminal\n",
|
||||
" · terminal Ghostty\n",
|
||||
" ? xtversion unavailable\n",
|
||||
" · multiplexer None detected\n",
|
||||
" · ssh no\n",
|
||||
" ? color unavailable\n",
|
||||
" ? themes unavailable\n",
|
||||
"\n",
|
||||
"Clipboard\n",
|
||||
" · native local (pbcopy)\n",
|
||||
" · tmux off\n",
|
||||
" · osc 52 off\n",
|
||||
" · wrap off\n",
|
||||
" · status confirmed\n",
|
||||
"\n",
|
||||
"Live TUI evidence\n",
|
||||
" Run /doctor inside Grok.\n",
|
||||
"\n",
|
||||
"0 issues, 0 recommendations\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_empty_fixture_pins_null_policy() {
|
||||
let mut report = healthy_report();
|
||||
report.facts.xtversion = RuntimeFact::Unavailable;
|
||||
report.facts.color.level = RuntimeFact::Unavailable;
|
||||
report.facts.color.available_themes.clear();
|
||||
report.facts.clipboard.data_control = DataControlFact::Unavailable;
|
||||
let mut output = Vec::new();
|
||||
write_report(&report, true, &mut output).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"schemaVersion": "1",
|
||||
"facts": {
|
||||
"terminal": {
|
||||
"name": "ghostty",
|
||||
"xtversion": {"status": "unavailable", "value": null}
|
||||
},
|
||||
"multiplexer": {"kind": "undetected", "byobu": null},
|
||||
"ssh": false,
|
||||
"color": {
|
||||
"level": {"status": "unavailable", "value": null},
|
||||
"availableThemes": [],
|
||||
"totalThemes": 5
|
||||
},
|
||||
"keyboard": null,
|
||||
"newline": null,
|
||||
"clipboard": {
|
||||
"nativeRoute": true,
|
||||
"nativeTool": "pbcopy",
|
||||
"nativePreflight": "local_available",
|
||||
"tmuxRoute": false,
|
||||
"osc52Route": false,
|
||||
"osc52Capability": "supported",
|
||||
"wrapSink": false,
|
||||
"displayServer": "unknown",
|
||||
"containerNoDisplay": false,
|
||||
"dataControl": "unavailable",
|
||||
"delivery": "confirmed",
|
||||
"fix": null
|
||||
}
|
||||
},
|
||||
"findings": [],
|
||||
"probeNotes": [],
|
||||
"counts": {"issues": 0, "recommendations": 0, "probeNotes": 0}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_contract_is_structural_stable_ordered_and_ansi_free() {
|
||||
let report = mixed_report();
|
||||
let mut output = Vec::new();
|
||||
write_report(&report, true, &mut output).expect("serialize doctor report");
|
||||
let text = String::from_utf8(output).expect("JSON is UTF-8");
|
||||
let json: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
|
||||
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"schemaVersion": "1",
|
||||
"facts": {
|
||||
"terminal": {
|
||||
"name": "ghostty",
|
||||
"xtversion": {"status": "available", "value": "Ghostty 1.2.3"}
|
||||
},
|
||||
"multiplexer": {"kind": "tmux", "byobu": "tmux"},
|
||||
"ssh": true,
|
||||
"color": {
|
||||
"level": {"status": "available", "value": "256"},
|
||||
"availableThemes": ["groknight", "grokday"],
|
||||
"totalThemes": 5
|
||||
},
|
||||
"keyboard": {"cmd": "dropped", "opt": "native", "os": "macos"},
|
||||
"newline": {"kind": "xterm_js", "terminalName": "cursor"},
|
||||
"clipboard": {
|
||||
"nativeRoute": true,
|
||||
"nativeTool": "pbcopy",
|
||||
"nativePreflight": "local_available",
|
||||
"tmuxRoute": true,
|
||||
"osc52Route": true,
|
||||
"osc52Capability": "supported",
|
||||
"wrapSink": false,
|
||||
"displayServer": "unknown",
|
||||
"containerNoDisplay": false,
|
||||
"dataControl": "not_applicable",
|
||||
"delivery": "confirmed",
|
||||
"fix": null
|
||||
}
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"id": "terminal.tmux-clipboard",
|
||||
"disposition": "issue",
|
||||
"message": "OSC 52 clipboard passthrough is disabled",
|
||||
"remediation": {
|
||||
"fix": "set -g set-clipboard on",
|
||||
"configPath": "~/.tmux.conf"
|
||||
},
|
||||
"automaticRemediation": null,
|
||||
"note": "Reload tmux after editing."
|
||||
},
|
||||
{
|
||||
"id": "terminal.ssh-wrap",
|
||||
"disposition": "recommendation",
|
||||
"message": "Use local SSH wrapping",
|
||||
"remediation": {"fix": "grok wrap ssh <host>", "configPath": null},
|
||||
"automaticRemediation": {
|
||||
"fixId": "terminal.ssh-wrap",
|
||||
"command": "grok doctor fix terminal.ssh-wrap"
|
||||
},
|
||||
"note": null
|
||||
}
|
||||
],
|
||||
"probeNotes": [
|
||||
{"probe": "tmux.version", "status": "unavailable", "message": null},
|
||||
{"probe": "tmux.extended-keys", "status": "unavailable", "message": null},
|
||||
{"probe": "tmux.allow-passthrough-support", "status": "unsupported", "message": null},
|
||||
{"probe": "runtime.fullscreen-active", "status": "unavailable", "message": null},
|
||||
{"probe": "tmux.control-mode", "status": "error", "message": "server unavailable"}
|
||||
],
|
||||
"counts": {"issues": 1, "recommendations": 1, "probeNotes": 5}
|
||||
})
|
||||
);
|
||||
let issue = text.find("terminal.tmux-clipboard").expect("issue ID");
|
||||
let recommendation = text.find("terminal.ssh-wrap").expect("recommendation ID");
|
||||
let version = text.find("tmux.version").expect("version probe");
|
||||
let extended = text.find("tmux.extended-keys").expect("extended-key probe");
|
||||
let unsupported = text
|
||||
.find("tmux.allow-passthrough-support")
|
||||
.expect("unsupported probe");
|
||||
let unavailable = text
|
||||
.find("runtime.fullscreen-active")
|
||||
.expect("unavailable probe");
|
||||
assert!(issue < recommendation);
|
||||
assert!(version < extended && extended < unsupported && unsupported < unavailable);
|
||||
assert!(!text.contains("\u{1b}"));
|
||||
assert!(!text.contains("Grok Doctor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_mapping_tables_are_complete() {
|
||||
use super::json::{
|
||||
byobu_backend, clipboard_delivery, data_control, display_server, host_os, modifier_fate,
|
||||
multiplexer, native_preflight, osc52_capability, terminal_name,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
[
|
||||
TerminalName::AppleTerminal,
|
||||
TerminalName::Ghostty,
|
||||
TerminalName::Iterm2,
|
||||
TerminalName::WarpTerminal,
|
||||
TerminalName::VsCode,
|
||||
TerminalName::Cursor,
|
||||
TerminalName::Windsurf,
|
||||
TerminalName::Zed,
|
||||
TerminalName::WezTerm,
|
||||
TerminalName::Kitty,
|
||||
TerminalName::Alacritty,
|
||||
TerminalName::Rio,
|
||||
TerminalName::Foot,
|
||||
TerminalName::JetBrains,
|
||||
TerminalName::GrokDesktop,
|
||||
TerminalName::Vte,
|
||||
TerminalName::Terminator,
|
||||
TerminalName::WindowsTerminal,
|
||||
TerminalName::Otty,
|
||||
TerminalName::Unknown,
|
||||
]
|
||||
.map(terminal_name),
|
||||
[
|
||||
"apple_terminal",
|
||||
"ghostty",
|
||||
"iterm2",
|
||||
"warp",
|
||||
"vs_code",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"zed",
|
||||
"wezterm",
|
||||
"kitty",
|
||||
"alacritty",
|
||||
"rio",
|
||||
"foot",
|
||||
"jetbrains",
|
||||
"grok_desktop",
|
||||
"vte",
|
||||
"terminator",
|
||||
"windows_terminal",
|
||||
"otty",
|
||||
"unknown",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
[
|
||||
MultiplexerKind::Tmux,
|
||||
MultiplexerKind::Screen,
|
||||
MultiplexerKind::Zellij,
|
||||
MultiplexerKind::Cmux,
|
||||
MultiplexerKind::Undetected,
|
||||
]
|
||||
.map(multiplexer),
|
||||
["tmux", "screen", "zellij", "cmux", "undetected"]
|
||||
);
|
||||
assert_eq!(
|
||||
[
|
||||
ByobuBackend::Unknown,
|
||||
ByobuBackend::Tmux,
|
||||
ByobuBackend::Screen
|
||||
]
|
||||
.map(byobu_backend),
|
||||
["unknown", "tmux", "screen"]
|
||||
);
|
||||
assert_eq!(
|
||||
[
|
||||
DataControlFact::Available,
|
||||
DataControlFact::Missing,
|
||||
DataControlFact::Unavailable,
|
||||
DataControlFact::Error,
|
||||
DataControlFact::NotApplicable,
|
||||
]
|
||||
.map(data_control),
|
||||
[
|
||||
"available",
|
||||
"missing",
|
||||
"unavailable",
|
||||
"error",
|
||||
"not_applicable"
|
||||
]
|
||||
);
|
||||
assert_eq!(modifier_fate(ModifierFate::Native), "native");
|
||||
assert_eq!(modifier_fate(ModifierFate::Dropped), "dropped");
|
||||
assert_eq!(modifier_fate(ModifierFate::Unrecoverable), "unrecoverable");
|
||||
assert_eq!(modifier_fate(ModifierFate::Unknown), "unknown");
|
||||
assert_eq!(host_os(HostOs::Macos), "macos");
|
||||
assert_eq!(host_os(HostOs::Linux), "linux");
|
||||
assert_eq!(host_os(HostOs::Windows), "windows");
|
||||
assert_eq!(host_os(HostOs::Other), "other");
|
||||
assert_eq!(
|
||||
[
|
||||
NativeClipboardPreflight::Disabled,
|
||||
NativeClipboardPreflight::LocalAvailable,
|
||||
NativeClipboardPreflight::RemoteOnly,
|
||||
NativeClipboardPreflight::Unavailable,
|
||||
]
|
||||
.map(native_preflight),
|
||||
["disabled", "local_available", "remote_only", "unavailable"]
|
||||
);
|
||||
assert_eq!(
|
||||
[
|
||||
Osc52Capability::Supported,
|
||||
Osc52Capability::Unsupported,
|
||||
Osc52Capability::Unknown,
|
||||
]
|
||||
.map(osc52_capability),
|
||||
["supported", "unsupported", "unknown"]
|
||||
);
|
||||
assert_eq!(
|
||||
[
|
||||
ClipboardDelivery::Confirmed,
|
||||
ClipboardDelivery::Unverified,
|
||||
ClipboardDelivery::Failed,
|
||||
]
|
||||
.map(clipboard_delivery),
|
||||
["confirmed", "unverified", "failed"]
|
||||
);
|
||||
assert_eq!(
|
||||
[
|
||||
DisplayServer::Quartz,
|
||||
DisplayServer::Wayland,
|
||||
DisplayServer::X11,
|
||||
DisplayServer::Win32,
|
||||
DisplayServer::Unknown,
|
||||
]
|
||||
.map(display_server),
|
||||
["quartz", "wayland", "x11", "win32", "unknown"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_variant_and_field_mappings_are_stable() {
|
||||
for (fact, kind, field, value) in [
|
||||
(
|
||||
NewlineFact::Vte {
|
||||
version: Some("8200".to_owned()),
|
||||
},
|
||||
"vte",
|
||||
"version",
|
||||
"8200",
|
||||
),
|
||||
(
|
||||
NewlineFact::XtermJs {
|
||||
terminal: TerminalName::Cursor,
|
||||
},
|
||||
"xterm_js",
|
||||
"terminalName",
|
||||
"cursor",
|
||||
),
|
||||
] {
|
||||
let mut report = healthy_report();
|
||||
report.facts.newline = Some(fact);
|
||||
let mut output = Vec::new();
|
||||
write_report(&report, true, &mut output).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
|
||||
assert_eq!(json["facts"]["newline"]["kind"], kind);
|
||||
assert_eq!(json["facts"]["newline"][field], value);
|
||||
}
|
||||
let mut report = healthy_report();
|
||||
report.facts.newline = Some(NewlineFact::NoKittyKeyboardProtocol);
|
||||
let mut output = Vec::new();
|
||||
write_report(&report, true, &mut output).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
|
||||
assert_eq!(
|
||||
json["facts"]["newline"],
|
||||
serde_json::json!({"kind": "no_kitty_keyboard_protocol"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_writer_errors_propagate() {
|
||||
struct BrokenWriter;
|
||||
|
||||
impl std::io::Write for BrokenWriter {
|
||||
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::Error::other("closed"))
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
assert!(write_report(&healthy_report(), false, &mut BrokenWriter).is_err());
|
||||
assert!(write_report(&healthy_report(), true, &mut BrokenWriter).is_err());
|
||||
}
|
||||
Loading…
Reference in a new issue