Render host escalation cards with Ratatui
This commit is contained in:
parent
f468592d95
commit
dfe1a387a6
2 changed files with 1105 additions and 79 deletions
|
|
@ -12,6 +12,13 @@ use lazaret_core::records::{
|
|||
};
|
||||
use nix::sys::signal::{SigSet, SigmaskHow, Signal};
|
||||
use nix::sys::signalfd::{SfdFlags, SignalFd};
|
||||
use ratatui::backend::{Backend, CrosstermBackend};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::crossterm::style::Colored;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
|
||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||
use rustix::termios::{
|
||||
OptionalActions, QueueSelector, SpecialCodeIndex, Termios, tcflush, tcgetattr, tcgetwinsize,
|
||||
|
|
@ -23,7 +30,8 @@ use crate::{ClientError, ClientSessionIdentity};
|
|||
mod prompt;
|
||||
|
||||
use prompt::{
|
||||
PromptChoice, PromptInput, PromptInputDecoder, display_argv, display_bytes, prompt_outcome,
|
||||
PromptChoice, PromptInput, PromptInputDecoder, PromptLayout, PromptLineKind, PromptView,
|
||||
prompt_outcome,
|
||||
};
|
||||
|
||||
pub trait InteractiveSessionTransport {
|
||||
|
|
@ -287,6 +295,8 @@ impl HostTerminal for SystemTerminal {
|
|||
if !self.raw {
|
||||
return Ok(());
|
||||
}
|
||||
self.terminal.write_all(b"\x1b[?7h\x1b[?25h")?;
|
||||
self.terminal.flush()?;
|
||||
fcntl_setfl(&self.terminal, self.original_flags)?;
|
||||
tcsetattr(&self.terminal, OptionalActions::Flush, &self.original)?;
|
||||
self.raw = false;
|
||||
|
|
@ -347,32 +357,42 @@ impl HostTerminal for SystemTerminal {
|
|||
self.key_translator.clear();
|
||||
tcflush(&self.terminal, QueueSelector::IFlush)?;
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
let mut view = PromptView::default();
|
||||
self.render_prompt(prompt, decoder.choice, &mut view, false)?;
|
||||
let mut resumed_size = None;
|
||||
loop {
|
||||
match self.next_event()? {
|
||||
HostTerminalEvent::Input(bytes) => {
|
||||
let previous = decoder.choice;
|
||||
if let PromptInput::Decide(choice) = decoder.feed(&bytes, Instant::now()) {
|
||||
return Ok(prompt_outcome(choice, resumed_size));
|
||||
let input = decoder.feed(&bytes, Instant::now());
|
||||
let mut reviewed = false;
|
||||
while let Some(review) = decoder.take_review() {
|
||||
view.apply_review(review);
|
||||
reviewed = true;
|
||||
}
|
||||
if decoder.choice != previous {
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
if let PromptInput::Decide(choice) = input {
|
||||
return self.complete_prompt(prompt_outcome(choice, resumed_size));
|
||||
}
|
||||
if decoder.choice != previous || reviewed {
|
||||
self.render_prompt(prompt, decoder.choice, &mut view, false)?;
|
||||
}
|
||||
}
|
||||
HostTerminalEvent::LocalSuspend => {
|
||||
self.suspend_and_resume()?;
|
||||
resumed_size = Some(self.size(prompt.session_id)?);
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
self.render_prompt(prompt, decoder.choice, &mut view, true)?;
|
||||
}
|
||||
HostTerminalEvent::Terminate => {
|
||||
self.write_output(b"\x1b[?7h\x1b[?25h")?;
|
||||
return Err(io::ErrorKind::Interrupted.into());
|
||||
}
|
||||
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
|
||||
HostTerminalEvent::Resize => {
|
||||
resumed_size = Some(self.size(prompt.session_id)?);
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
self.render_prompt(prompt, decoder.choice, &mut view, true)?;
|
||||
}
|
||||
HostTerminalEvent::Idle => {
|
||||
if let PromptInput::Decide(choice) = decoder.expire(Instant::now()) {
|
||||
return Ok(prompt_outcome(choice, resumed_size));
|
||||
return self.complete_prompt(prompt_outcome(choice, resumed_size));
|
||||
}
|
||||
}
|
||||
HostTerminalEvent::Break => {}
|
||||
|
|
@ -388,30 +408,159 @@ impl HostTerminal for SystemTerminal {
|
|||
}
|
||||
|
||||
impl SystemTerminal {
|
||||
fn render_prompt(&mut self, prompt: &TerminalPrompt, choice: PromptChoice) -> io::Result<()> {
|
||||
let approve = if choice == PromptChoice::Approve {
|
||||
"[ Approve ]"
|
||||
} else {
|
||||
" Approve "
|
||||
};
|
||||
let deny = if choice == PromptChoice::Deny {
|
||||
"[ Deny ]"
|
||||
} else {
|
||||
" Deny "
|
||||
};
|
||||
self.write_output(
|
||||
format!(
|
||||
"\x1b[?2026h\x1b[2J\x1b[HLazaret host request\r\nrequester: {} / {}\r\ncommand: {}\r\njustification: {}\r\n\r\n{} {}\r\n\x1b[?2026l",
|
||||
display_bytes(prompt.agent_profile.as_bytes()),
|
||||
hex(prompt.session_id.as_bytes()),
|
||||
display_argv(&prompt.command_argv),
|
||||
display_bytes(prompt.justification.as_bytes()),
|
||||
approve,
|
||||
deny,
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
fn complete_prompt(&mut self, outcome: PromptOutcome) -> io::Result<PromptOutcome> {
|
||||
self.write_output(b"\x1b[?7h\x1b[?25h")?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn render_prompt(
|
||||
&mut self,
|
||||
prompt: &TerminalPrompt,
|
||||
choice: PromptChoice,
|
||||
view: &mut PromptView,
|
||||
clear_before: bool,
|
||||
) -> io::Result<()> {
|
||||
let size = self.size(prompt.session_id)?;
|
||||
let layout = PromptLayout::new(prompt, size.columns.get(), size.rows.get());
|
||||
self.terminal.write_all(b"\x1b[?2026h\x1b[?25l\x1b[?7l")?;
|
||||
if clear_before {
|
||||
self.terminal.write_all(b"\x1b[2J\x1b[H")?;
|
||||
}
|
||||
render_prompt_card(&mut self.terminal, &layout, choice, view)?;
|
||||
self.terminal.write_all(b"\x1b[?7h\x1b[?2026l")?;
|
||||
self.terminal.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_prompt_card(
|
||||
terminal: &mut File,
|
||||
layout: &PromptLayout,
|
||||
choice: PromptChoice,
|
||||
view: &mut PromptView,
|
||||
) -> io::Result<()> {
|
||||
let buffer = prompt_buffer(layout, choice, view);
|
||||
let area = *buffer.area();
|
||||
if area.width == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
Colored::set_ansi_color_disabled(false);
|
||||
let mut backend = CrosstermBackend::new(terminal);
|
||||
backend.draw(buffer.content().iter().enumerate().map(|(index, cell)| {
|
||||
let index = u16::try_from(index).expect("prompt buffer dimensions fit u16");
|
||||
(
|
||||
area.x + index % area.width,
|
||||
area.y + index / area.width,
|
||||
cell,
|
||||
)
|
||||
}))?;
|
||||
Backend::flush(&mut backend)
|
||||
}
|
||||
|
||||
fn prompt_buffer(layout: &PromptLayout, choice: PromptChoice, view: &mut PromptView) -> Buffer {
|
||||
let geometry = layout.geometry;
|
||||
let area = Rect::new(
|
||||
geometry.column - 1,
|
||||
geometry.row - 1,
|
||||
geometry.width,
|
||||
geometry.height,
|
||||
);
|
||||
let surface = Style::default().bg(Color::Rgb(35, 37, 41)).fg(Color::White);
|
||||
let border = Style::default().fg(Color::Cyan).bg(Color::Rgb(35, 37, 41));
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
layout.heading.as_str(),
|
||||
Style::default()
|
||||
.fg(Color::LightCyan)
|
||||
.bg(Color::Rgb(35, 37, 41))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.style(surface)
|
||||
.border_style(border);
|
||||
let inner = block.inner(area);
|
||||
let [
|
||||
status_area,
|
||||
detail_area,
|
||||
rule_area,
|
||||
selection_area,
|
||||
action_area,
|
||||
legend_area,
|
||||
] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(inner);
|
||||
let (status, details) = layout.detail_window(view, detail_area.height);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
block.render(area, &mut buffer);
|
||||
Paragraph::new(prompt_line(status))
|
||||
.style(surface)
|
||||
.render(status_area, &mut buffer);
|
||||
Paragraph::new(details.into_iter().map(prompt_line).collect::<Vec<_>>())
|
||||
.style(surface)
|
||||
.render(detail_area, &mut buffer);
|
||||
Block::new()
|
||||
.borders(Borders::TOP)
|
||||
.style(surface)
|
||||
.border_style(border)
|
||||
.render(rule_area, &mut buffer);
|
||||
Paragraph::new(selection_text(choice))
|
||||
.style(surface.fg(Color::Yellow).add_modifier(Modifier::BOLD))
|
||||
.render(selection_area, &mut buffer);
|
||||
Paragraph::new(action_text(choice))
|
||||
.alignment(Alignment::Center)
|
||||
.style(action_style(choice))
|
||||
.render(action_area, &mut buffer);
|
||||
Paragraph::new("Up/Down: review | Left/Right: select | Enter: decide")
|
||||
.alignment(Alignment::Center)
|
||||
.style(surface.fg(Color::Gray))
|
||||
.render(legend_area, &mut buffer);
|
||||
buffer
|
||||
}
|
||||
|
||||
fn prompt_line(line: prompt::PromptLine) -> Line<'static> {
|
||||
Line::styled(line.text, prompt_line_style(line.kind))
|
||||
}
|
||||
|
||||
fn prompt_line_style(kind: PromptLineKind) -> Style {
|
||||
match kind {
|
||||
PromptLineKind::Section => Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
PromptLineKind::TrustedValue | PromptLineKind::Continuation => {
|
||||
Style::default().fg(Color::LightCyan)
|
||||
}
|
||||
PromptLineKind::RequesterValue => Style::default().fg(Color::White),
|
||||
PromptLineKind::Argument => Style::default().fg(Color::Gray),
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_text(choice: PromptChoice) -> &'static str {
|
||||
match choice {
|
||||
PromptChoice::Approve => "Selection: APPROVE | Esc: deny",
|
||||
PromptChoice::Deny => "Selection: DENY (default) | Esc: deny",
|
||||
}
|
||||
}
|
||||
|
||||
fn action_text(choice: PromptChoice) -> &'static str {
|
||||
match choice {
|
||||
PromptChoice::Approve => "< APPROVE > [ Deny ]",
|
||||
PromptChoice::Deny => "[ Approve ] > DENY (default) <",
|
||||
}
|
||||
}
|
||||
|
||||
fn action_style(choice: PromptChoice) -> Style {
|
||||
let color = match choice {
|
||||
PromptChoice::Approve => Color::LightGreen,
|
||||
PromptChoice::Deny => Color::LightRed,
|
||||
};
|
||||
Style::default()
|
||||
.bg(Color::Rgb(35, 37, 41))
|
||||
.fg(color)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
impl Drop for SystemTerminal {
|
||||
|
|
@ -732,15 +881,19 @@ mod tests {
|
|||
use std::io::Write as _;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lazaret_core::records::TerminalPrompt;
|
||||
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use rustix::fs::fcntl_getfl;
|
||||
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
|
||||
use rustix::termios::{LocalModes, tcgetattr};
|
||||
|
||||
use super::prompt::{display_argv, display_bytes};
|
||||
use super::{
|
||||
HostTerminal, HostTerminalEvent, PromptChoice, PromptInput, PromptInputDecoder,
|
||||
SignalQueue, SystemTerminal, display_argv, display_bytes,
|
||||
SignalQueue, SystemTerminal,
|
||||
};
|
||||
|
||||
fn terminal() -> (SystemTerminal, File) {
|
||||
|
|
@ -861,6 +1014,22 @@ mod tests {
|
|||
PromptInputDecoder::new().feed(b"y", start),
|
||||
PromptInput::Decide(PromptChoice::Approve)
|
||||
));
|
||||
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\x1b[A\x1b[B", start),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert_eq!(decoder.take_review(), Some(super::prompt::PromptReview::Up));
|
||||
assert_eq!(
|
||||
decoder.take_review(),
|
||||
Some(super::prompt::PromptReview::Down)
|
||||
);
|
||||
assert_eq!(decoder.choice, PromptChoice::Deny);
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\r", start),
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -872,6 +1041,40 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratatui_prompt_buffer_is_scoped_to_the_centered_opaque_card() {
|
||||
use lazaret_core::identity::{RequestDigest, RequestId, SessionId};
|
||||
|
||||
let prompt = 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/host-tool".to_vec()],
|
||||
justification: "request host operation".into(),
|
||||
};
|
||||
let layout = super::PromptLayout::new(&prompt, 140, 40);
|
||||
let buffer = super::prompt_buffer(
|
||||
&layout,
|
||||
PromptChoice::Deny,
|
||||
&mut super::PromptView::default(),
|
||||
);
|
||||
assert_eq!(*buffer.area(), Rect::new(35, 10, 70, 20));
|
||||
assert!(
|
||||
buffer
|
||||
.content()
|
||||
.iter()
|
||||
.all(|cell| cell.bg == Color::Rgb(35, 37, 41))
|
||||
);
|
||||
let rendered = buffer
|
||||
.content()
|
||||
.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<String>();
|
||||
assert!(rendered.contains("DENY (default)"));
|
||||
assert!(rendered.contains("Up/Down: review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_host_keys_translate_to_the_controlled_guest_profile() {
|
||||
let start = Instant::now();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue