Render host escalation cards with Ratatui

This commit is contained in:
ms2k 2026-08-04 00:42:39 +09:00
commit dfe1a387a6

View file

@ -1,8 +1,9 @@
use std::collections::VecDeque;
use std::time::Instant;
use lazaret_core::records::{DeveloperDecisionKind, TerminalResize};
use lazaret_core::records::{DeveloperDecisionKind, TerminalPrompt, TerminalResize};
use super::{PROMPT_ESCAPE_INTERVAL, PromptOutcome};
use super::{PROMPT_ESCAPE_INTERVAL, PromptOutcome, hex};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum PromptChoice {
@ -15,10 +16,275 @@ pub(super) enum PromptInput {
Decide(PromptChoice),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum PromptReview {
Up,
Down,
}
#[derive(Default)]
pub(super) struct PromptView {
detail_offset: usize,
review_delta: i8,
}
impl PromptView {
pub(super) fn apply_review(&mut self, review: PromptReview) {
self.review_delta = match review {
PromptReview::Up => self.review_delta.saturating_sub(1),
PromptReview::Down => self.review_delta.saturating_add(1),
};
}
fn apply_offset(&mut self, maximum: usize) {
let delta = usize::from(self.review_delta.unsigned_abs());
self.detail_offset = if self.review_delta.is_negative() {
self.detail_offset.saturating_sub(delta)
} else {
self.detail_offset.saturating_add(delta).min(maximum)
};
self.review_delta = 0;
self.detail_offset = self.detail_offset.min(maximum);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct PromptGeometry {
pub(super) column: u16,
pub(super) row: u16,
pub(super) width: u16,
pub(super) height: u16,
pub(super) fullscreen: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum PromptLineKind {
Section,
TrustedValue,
RequesterValue,
Argument,
Continuation,
}
pub(super) struct PromptLine {
pub(super) kind: PromptLineKind,
pub(super) text: String,
}
pub(super) struct PromptLayout {
pub(super) geometry: PromptGeometry,
pub(super) heading: String,
details: Vec<PromptLine>,
}
impl PromptLayout {
pub(super) fn new(prompt: &TerminalPrompt, columns: u16, rows: u16) -> Self {
let geometry = prompt_geometry(columns, rows);
let inner_width = usize::from(geometry.width.saturating_sub(2)).max(1);
let details = detail_lines(prompt, inner_width);
Self {
geometry,
heading: if geometry.fullscreen {
"LAZARET HOST ESCALATION | FULL-SCREEN TRUSTED DECISION".into()
} else {
"LAZARET HOST ESCALATION | TRUSTED DECISION SURFACE".into()
},
details,
}
}
pub(super) fn detail_window(
&self,
view: &mut PromptView,
capacity: u16,
) -> (PromptLine, Vec<PromptLine>) {
let visible_details = usize::from(capacity);
let maximum = self.details.len().saturating_sub(visible_details);
view.apply_offset(maximum);
let end = view
.detail_offset
.saturating_add(visible_details)
.min(self.details.len());
(
PromptLine {
kind: PromptLineKind::Section,
text: format!(
"REQUEST DETAILS {}-{} OF {} | UP/DOWN TO REVIEW",
view.detail_offset.saturating_add(1),
end,
self.details.len()
),
},
self.details[view.detail_offset..end]
.iter()
.map(|line| PromptLine {
kind: line.kind,
text: line.text.clone(),
})
.collect(),
)
}
}
const MIN_MODAL_COLUMNS: u16 = 60;
const MIN_MODAL_ROWS: u16 = 18;
fn prompt_geometry(columns: u16, rows: u16) -> PromptGeometry {
let width = columns / 2;
let height = rows / 2;
let fullscreen = width < MIN_MODAL_COLUMNS || height < MIN_MODAL_ROWS;
if fullscreen {
return PromptGeometry {
column: 1,
row: 1,
width: columns.max(1),
height: rows.max(1),
fullscreen: true,
};
}
PromptGeometry {
column: (columns - width) / 2 + 1,
row: (rows - height) / 2 + 1,
width,
height,
fullscreen: false,
}
}
fn detail_lines(prompt: &TerminalPrompt, width: usize) -> Vec<PromptLine> {
let mut lines = Vec::new();
lines.push(PromptLine {
kind: PromptLineKind::TrustedValue,
text: format!(
"Profile (Lazaret): {}",
display_bytes(prompt.agent_profile.as_bytes())
),
});
lines.push(PromptLine {
kind: PromptLineKind::TrustedValue,
text: "Session (Lazaret):".into(),
});
lines.extend(wrap_with_indent(
&hex(prompt.session_id.as_bytes()),
width,
2,
PromptLineKind::Continuation,
));
lines.push(PromptLine {
kind: PromptLineKind::Section,
text: "REQUESTER-PROVIDED JUSTIFICATION".into(),
});
lines.extend(wrap_words(
&display_bytes(prompt.justification.as_bytes()),
width,
PromptLineKind::RequesterValue,
));
lines.push(PromptLine {
kind: PromptLineKind::Section,
text: "REQUESTER-PROVIDED ARGUMENTS".into(),
});
for (index, argument) in prompt.command_argv.iter().enumerate() {
let prefix = format!("[{index}] ");
lines.extend(wrap_with_prefix(
&display_bytes(argument),
width,
&prefix,
PromptLineKind::Argument,
));
}
lines
}
fn wrap_words(text: &str, width: usize, kind: PromptLineKind) -> Vec<PromptLine> {
let width = width.max(1);
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.len() + 1 + word.len() <= width {
line.push(' ');
line.push_str(word);
} else {
lines.push(PromptLine {
kind,
text: std::mem::take(&mut line),
});
line.push_str(word);
}
while line.len() > width {
let remainder = line.split_off(width);
lines.push(PromptLine {
kind,
text: std::mem::replace(&mut line, remainder),
});
}
}
if line.is_empty() {
lines.push(PromptLine {
kind,
text: "(empty)".into(),
});
} else {
lines.push(PromptLine { kind, text: line });
}
lines
}
fn wrap_with_indent(
text: &str,
width: usize,
indent: usize,
kind: PromptLineKind,
) -> Vec<PromptLine> {
wrap_with_prefix(text, width, &" ".repeat(indent), kind)
}
fn wrap_with_prefix(
text: &str,
width: usize,
prefix: &str,
kind: PromptLineKind,
) -> Vec<PromptLine> {
let width = width.max(1);
let prefix = if prefix.len() >= width {
&prefix[..width]
} else {
prefix
};
let continuation = " ".repeat(prefix.len());
let available = width.saturating_sub(prefix.len()).max(1);
let mut lines = Vec::new();
let mut remaining = text;
let mut current_prefix = prefix;
loop {
let (segment, rest) = if remaining.len() <= available {
(remaining, None)
} else {
let mut split = available;
while !remaining.is_char_boundary(split) {
split -= 1;
}
(&remaining[..split], Some(&remaining[split..]))
};
lines.push(PromptLine {
kind,
text: format!("{current_prefix}{segment}"),
});
let Some(rest) = rest else {
break;
};
remaining = rest;
current_prefix = &continuation;
}
lines
}
pub(super) struct PromptInputDecoder {
pub(super) choice: PromptChoice,
escape: Vec<u8>,
escape_deadline: Option<Instant>,
reviews: VecDeque<PromptReview>,
}
impl PromptInputDecoder {
@ -27,6 +293,7 @@ impl PromptInputDecoder {
choice: PromptChoice::Deny,
escape: Vec::new(),
escape_deadline: None,
reviews: VecDeque::new(),
}
}
@ -48,6 +315,14 @@ impl PromptInputDecoder {
self.choice = PromptChoice::Deny;
self.clear_escape();
}
b"\x1b[A" | b"\x1bOA" => {
self.reviews.push_back(PromptReview::Up);
self.clear_escape();
}
b"\x1b[B" | b"\x1bOB" => {
self.reviews.push_back(PromptReview::Down);
self.clear_escape();
}
sequence if supported_prompt_key_prefix(sequence) => {}
_ => return PromptInput::Decide(PromptChoice::Deny),
}
@ -74,6 +349,10 @@ impl PromptInputDecoder {
}
}
pub(super) fn take_review(&mut self) -> Option<PromptReview> {
self.reviews.pop_front()
}
fn clear_escape(&mut self) {
self.escape.clear();
self.escape_deadline = None;
@ -81,9 +360,18 @@ impl PromptInputDecoder {
}
fn supported_prompt_key_prefix(bytes: &[u8]) -> bool {
[b"\x1b[D".as_slice(), b"\x1b[C", b"\x1bOD", b"\x1bOC"]
.iter()
.any(|sequence| sequence.starts_with(bytes))
[
b"\x1b[A".as_slice(),
b"\x1b[B",
b"\x1b[D",
b"\x1b[C",
b"\x1bOA",
b"\x1bOB",
b"\x1bOD",
b"\x1bOC",
]
.iter()
.any(|sequence| sequence.starts_with(bytes))
}
pub(super) fn prompt_outcome(
@ -99,6 +387,7 @@ pub(super) fn prompt_outcome(
}
}
#[cfg(test)]
pub(super) fn display_argv(argv: &[Vec<u8>]) -> String {
let arguments = argv
.iter()
@ -121,3 +410,108 @@ pub(super) fn display_bytes(bytes: &[u8]) -> String {
output
})
}
#[cfg(test)]
mod tests {
use lazaret_core::identity::{RequestDigest, RequestId, SessionId};
use super::{PromptLayout, PromptView};
fn prompt(justification: &str) -> lazaret_core::records::TerminalPrompt {
lazaret_core::records::TerminalPrompt {
session_id: SessionId::new([1; 32]),
request_id: RequestId::new([2; 32]),
request_sha256: RequestDigest::new([3; 32]),
agent_profile: "trusted-agent".into(),
command_argv: vec![
b"/usr/bin/privileged-tool".to_vec(),
b"--replace-host-state".to_vec(),
],
justification: justification.into(),
}
}
#[test]
fn modal_geometry_uses_the_qualified_centered_thresholds() {
let prompt = prompt("needed");
for (columns, rows, expected) in [
(
140,
40,
super::PromptGeometry {
column: 36,
row: 11,
width: 70,
height: 20,
fullscreen: false,
},
),
(
120,
38,
super::PromptGeometry {
column: 31,
row: 10,
width: 60,
height: 19,
fullscreen: false,
},
),
(
100,
24,
super::PromptGeometry {
column: 1,
row: 1,
width: 100,
height: 24,
fullscreen: true,
},
),
] {
let layout = PromptLayout::new(&prompt, columns, rows);
assert_eq!(layout.geometry, expected);
}
}
#[test]
fn requester_details_scroll_without_displacing_the_decision_controls() {
let prompt = prompt(
"one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty",
);
let mut view = PromptView::default();
let initial = PromptLayout::new(&prompt, 120, 38);
let (status, _) = initial.detail_window(&mut view, 10);
assert_eq!(
status.text,
"REQUEST DETAILS 1-10 OF 11 | UP/DOWN TO REVIEW"
);
view.apply_review(super::PromptReview::Down);
let (status, details) = initial.detail_window(&mut view, 10);
assert_eq!(
status.text,
"REQUEST DETAILS 2-11 OF 11 | UP/DOWN TO REVIEW"
);
assert_eq!(details.len(), 10);
}
#[test]
fn requester_text_is_escaped_and_arguments_are_separate_rows() {
let mut prompt = prompt("review \u{1b}[2J this");
prompt.command_argv = vec![b"one two".to_vec(), b"three\x1b[H".to_vec()];
let layout = PromptLayout::new(&prompt, 100, 24);
assert!(
layout
.details
.iter()
.any(|line| line.text == "review \\x1b[2J this")
);
assert!(layout.details.iter().any(|line| line.text == "[0] one two"));
assert!(
layout
.details
.iter()
.any(|line| line.text == "[1] three\\x1b[H")
);
}
}