Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions rustcode/engine/src/remote_gateway/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ pub enum QrStyle {
pub fn format_offer(offer: &OfferDetails, style: QrStyle) -> String {
let panel = style == QrStyle::Panel;
let mut blocks = Vec::new();
if offer.loopback_only {
if offer.loopback_only && !panel {
blocks.push(loopback_guidance(panel));
}
let code = match style {
Expand All @@ -147,6 +147,11 @@ pub fn format_offer(offer: &OfferDetails, style: QrStyle) -> String {
offer.code.expose()
);
match code {
Some(code) if panel => {
blocks.push(format!("{lifetime}\n{by_hand}"));
blocks.push(code);
blocks.push("Scan the QR in the app, or enter the address and code above.".to_owned());
}
Some(code) => {
blocks.push(format!("{lifetime} Scan this code in the app:"));
blocks.push(code);
Expand All @@ -157,6 +162,9 @@ pub fn format_offer(offer: &OfferDetails, style: QrStyle) -> String {
offer.qr_payload.expose()
)),
}
if offer.loopback_only && panel {
blocks.push(loopback_guidance(true));
}
blocks.push("Asking for pairing details again replaces this challenge.".to_owned());
blocks.join("\n\n")
}
Expand All @@ -182,7 +190,7 @@ pub async fn show_pairing_details(
state.lock().await.update_command_panel_if_current(
title,
generation,
format!("{header}\n\n{details}"),
format!("{details}\n\n{header}"),
);
}

Expand Down Expand Up @@ -337,6 +345,13 @@ mod tests {
// The drawing replaces the raw payload, which carries the credential.
assert!(!lan.contains("credential-value"));

let local_panel = format_offer(&offer("127.0.0.1"), QrStyle::Panel);
let qr_start = local_panel.find(qr::PANEL_ROW_MARK).unwrap();
assert!(local_panel.find("address:").unwrap() < qr_start);
assert!(local_panel.find("code:").unwrap() < qr_start);
assert!(local_panel[..qr_start].lines().count() <= 5);
assert!(local_panel.find("The gateway listens").unwrap() > qr_start);

let local = format_offer(&offer("127.0.0.1"), QrStyle::Text);
assert!(local.starts_with("The gateway listens on this machine only"));
assert!(local.contains("rustcode remote serve --bind <address>"));
Expand Down
83 changes: 83 additions & 0 deletions rustcode/tui/src/runtime/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,89 @@ mod tests {
.unwrap_or_default()
}

#[test]
fn remote_panel_paints_the_whole_qr_black_on_white_in_light_and_dark_themes() {
use crate::inline_terminal::InlineTerminal as Terminal;
use crate::ui::render;
use crate::ui::tests::THEME_TEST_LOCK;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
use rustcode::controller::RenderState;
use rustcode::remote_gateway::address::AdvertisedAddress;
use rustcode::remote_gateway::command::{QrStyle, format_offer};
use rustcode::remote_gateway::control::OfferDetails;
use rustcode::remote_gateway::handshake::Secret;
use rustcode::remote_gateway::pairing::PairingOffer;
use rustcode::remote_gateway::qr;

let _theme_guard = THEME_TEST_LOCK.lock().expect("theme test lock");
for (theme, host) in [("default", "192.168.1.20"), ("light", "127.0.0.1")] {
let offer = OfferDetails::new(
&AdvertisedAddress::new(host, 17879).unwrap(),
"0123456789abcdef0123456789abcdef",
Some("a-host-with-a-longer-name"),
PairingOffer {
credential: Secret::new("q0lYc1o3bXJ3T2d5d0l2Rk5kU2tqeTZqZ0Z2ZUo0V2s"),
code: Secret::new("1234-5678"),
lifetime: std::time::Duration::from_secs(120),
},
);
let rows = qr::rows(offer.qr_payload.expose()).unwrap();
let mut state = RenderState::new();
state.config.theme = theme.into();
state.command_panel = Some(rustcode::controller::CommandPanel {
title: "Remote",
content: format!(
"{}\n\nThis session is shared with the remote gateway.\nSession test-session\nAttached devices none",
format_offer(&offer, QrStyle::Panel)
),
});
let mut terminal = Terminal::new(TestBackend::new(80, 40)).unwrap();
terminal
.draw(|frame| {
render(frame, &state);
})
.unwrap();
let buffer = terminal.backend().buffer();
let qr_y = (0..40)
.find(|&y| buffer[(2, y)].bg == Color::Indexed(231))
.expect("QR is visible");
assert!(
qr_y + rows.len() as u16 <= 38,
"the entire QR fits above the footer"
);
let text = (0..7)
.map(|y| (0..80).map(|x| buffer[(x, y)].symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(
text.contains(&offer.advertised_address) && text.contains("1234-5678"),
"{text}"
);
for (y, row) in rows.iter().enumerate() {
for (x, symbol) in row.chars().enumerate() {
let cell = &buffer[(2 + x as u16, qr_y + y as u16)];
assert_eq!(
cell.symbol(),
symbol.to_string(),
"QR cell ({x}, {y}) in {theme}"
);
assert_eq!(
cell.fg,
Color::Indexed(16),
"QR foreground ({x}, {y}) in {theme}"
);
assert_eq!(
cell.bg,
Color::Indexed(231),
"QR background ({x}, {y}) in {theme}"
);
}
}
}
crate::ui::theme::set_active_theme("default");
}

#[tokio::test]
async fn remote_without_a_gateway_reports_it_and_shares_nothing() {
let mut runtime = AppRuntime::for_test(AppState::new());
Expand Down
32 changes: 32 additions & 0 deletions rustcode/tui/src/ui/history_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,38 @@ mod tests {
}
use rustcode::controller::{ChatMessage, History, RenderState};

#[test]
fn remote_panel_clears_a_hidden_indicator_hold() {
let _theme_guard = super::super::tests::THEME_TEST_LOCK
.lock()
.expect("theme test lock");
let mut transcript = TranscriptState::default();
let now = std::time::Instant::now();
let hold = std::time::Duration::from_secs(1);
transcript.settle_indicator_at(
Some(ratatui::text::Line::from("Executing")),
true,
now,
hold,
);
transcript.settle_indicator_at(None, true, now, hold);
assert!(transcript.indicator_hold_remaining().is_some());
let mut state = RenderState::new();
state.command_panel = Some(rustcode::controller::CommandPanel {
title: "Remote",
content: "Pair a device".into(),
});
let mut terminal =
crate::inline_terminal::InlineTerminal::new(ratatui::backend::TestBackend::new(80, 40))
.unwrap();
terminal
.draw(|frame| {
crate::ui::render_with_transcript(frame, &state, &mut transcript);
})
.unwrap();
assert!(transcript.indicator_hold_remaining().is_none());
}

#[test]
fn indicator_survives_a_short_gap_but_not_a_long_or_deliberate_one() {
let hold = std::time::Duration::from_millis(350);
Expand Down
2 changes: 1 addition & 1 deletion rustcode/tui/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,4 @@ thread_local! {
}

#[cfg(test)]
mod tests;
pub(crate) mod tests;
2 changes: 1 addition & 1 deletion rustcode/tui/src/ui/modals/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub(in crate::ui) fn panel_selection_surface(
} else if state.show_session_modal() {
SESSION_MODAL_HEIGHT
} else if state.command_panel().is_some() {
let area = input_anchor_rect(f, input_area, super::panel::COMMAND_PANEL_HEIGHT);
let area = super::panel::command_panel_area(f, state, input_area);
let inner = area
.inner(Margin {
vertical: 1,
Expand Down
30 changes: 27 additions & 3 deletions rustcode/tui/src/ui/modals/panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,23 @@ const MIN_TABLE_ROWS: usize = 2;
/// layer reserves, so a panel never paints over the transcript (#1588).
pub(in crate::ui) const COMMAND_PANEL_HEIGHT: u16 = 18;

/// Remote pairing needs the entire viewport for its QR code and quiet zone.
/// Other command output keeps its bounded position above the composer.
pub(in crate::ui) fn command_panel_area(
f: &Frame,
state: &RenderSnapshot,
input_area: ratatui::layout::Rect,
) -> ratatui::layout::Rect {
if state
.command_panel()
.is_some_and(|panel| panel.title == "Remote")
{
f.area()
} else {
input_anchor_rect(f, input_area, COMMAND_PANEL_HEIGHT)
}
}

/// One `label<gap>value` row recovered from a raw panel line.
struct PanelRow<'a> {
/// Leading whitespace, kept so an indented table keeps its indent.
Expand Down Expand Up @@ -816,7 +833,7 @@ fn percentage_prefix(value: &str) -> Option<f64> {
.ok()
}

/// Scrollable command output uses the same bounded surface as the other panels.
/// Scrollable command output, with a full-height surface for remote pairing.
///
/// The panel is an output surface, not a picker: it has no rows to activate, so
/// it carries no selection marker and no selection state, and the footer below
Expand All @@ -829,7 +846,7 @@ pub(in crate::ui) fn render_command_panel(
let Some(panel) = state.command_panel() else {
return;
};
let area = input_anchor_rect(f, input_area, COMMAND_PANEL_HEIGHT);
let area = command_panel_area(f, state, input_area);
let inner = render_padded_panel(f, area).inner(Margin {
vertical: 0,
horizontal: 2,
Expand All @@ -851,7 +868,14 @@ pub(in crate::ui) fn render_command_panel(
chunks[0],
);
let mut lines = render_panel_content(&panel.content, inner.width as usize);
paint_panel_line_backgrounds(&mut lines, COLOR_PANEL());
// Use the panel as the fallback; QR spans keep their explicit white ground.
let background = Style::default().bg(COLOR_PANEL());
for line in &mut lines {
line.style = background.patch(line.style);
for span in &mut line.spans {
span.style = background.patch(span.style);
}
}
f.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
Expand Down
35 changes: 32 additions & 3 deletions rustcode/tui/src/ui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,26 @@ pub(crate) fn render_with_transcript_snapshot(
) -> (u16, ratatui::layout::Rect) {
theme::set_active_theme(&state.config().theme);

if state
.command_panel()
.is_some_and(|panel| panel.title == "Remote")
{
let area = f.area();
render_command_panel(f, state, area);
transcript.settle_indicator(None, false);
transcript.follow_control.clear();
transcript.tasks_chip = None;
transcript.tasks_chip_hovered = false;
transcript.set_tool_rows(ratatui::layout::Rect::default(), Vec::new());
transcript.set_panel_targets(f.buffer(), area, false);
transcript.refresh_hover();
refresh_panel_selection(f, state, transcript, area);
return (
0,
ratatui::layout::Rect::new(area.x, area.bottom(), area.width, 0),
);
}

let completion_dismissed =
state.dismissed_completion() == state.completion_identity().as_deref();
let filtered_cmds: Vec<&CommandInfo> = if completion_dismissed || state.modal_open() {
Expand Down Expand Up @@ -676,7 +696,18 @@ pub(crate) fn render_with_transcript_snapshot(
);
transcript.selection.highlight(f.buffer_mut());

let panel_selection = panel_selection_surface(f, state, input_box_area);
refresh_panel_selection(f, state, transcript, input_box_area);

(conversation_content_height, input_box_area)
}

fn refresh_panel_selection(
f: &mut Frame,
state: &RenderSnapshot,
transcript: &mut TranscriptState,
input_area: ratatui::layout::Rect,
) {
let panel_selection = panel_selection_surface(f, state, input_area);
transcript.panel_selection_area = panel_selection.as_ref().map(|(area, _)| *area);
transcript.panel_selection_scrollable = state.command_panel().is_some();
if let Some((area, soft_wrap_before)) = panel_selection {
Expand All @@ -689,8 +720,6 @@ pub(crate) fn render_with_transcript_snapshot(
// conversation on the next frame.
transcript.panel_selection.clear();
}

(conversation_content_height, input_box_area)
}

/// Test-only frame entry point that keeps a caller-owned transcript.
Expand Down
47 changes: 47 additions & 0 deletions rustcode/tui/src/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,53 @@ fn context_and_settings_panels_survive_controller_tool_events_and_completion() {
}
}

#[test]
fn remote_panel_uses_the_full_viewport_and_restores_the_composer_on_close() {
let _theme_guard = THEME_TEST_LOCK.lock().expect("theme test lock");
let mut state = RenderState::new();
state.input_buffer = "saved draft".into();
state.command_panel = Some(rustcode::controller::CommandPanel {
title: "Remote",
content: (0..60)
.map(|row| format!("Remote row {row:02} content"))
.collect::<Vec<_>>()
.join("\n"),
});
let mut transcript = TranscriptState::default();
for height in [24, 48] {
let rendered =
render_state_to_text_with_transcript(&mut state, &mut transcript, 80, height);
assert_eq!(
rendered.lines().nth(1).unwrap().trim(),
"Remote",
"{rendered}"
);
assert!(
rendered.contains(&format!("Remote row {:02}", height - 6)),
"{rendered}"
);
assert_eq!(
transcript.panel_selection_area.unwrap(),
ratatui::layout::Rect::new(2, 3, 76, height - 5)
);
assert!(transcript.tasks_chip.is_none());
assert!(!rendered.contains("saved draft"));
}
state.modal_scroll_row = 12;
let scrolled = render_state_to_text_with_transcript(&mut state, &mut transcript, 80, 24);
assert!(
scrolled.contains("Remote row 12") && !scrolled.contains("Remote row 00"),
"{scrolled}"
);

state.command_panel = None;
state.modal_scroll_row = 0;
let closed = render_state_to_text_with_transcript(&mut state, &mut transcript, 80, 24);
assert!(closed.contains("saved draft"), "{closed}");
assert!(transcript.panel_selection_area.is_none());
assert_eq!(state.input_buffer, "saved draft");
}

#[test]
fn command_output_panel_scrolls_wrapped_content_on_short_terminals() {
let _theme_guard = THEME_TEST_LOCK.lock().expect("theme test lock");
Expand Down
Loading