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
106 changes: 90 additions & 16 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1152,9 +1152,11 @@ pub struct LayoutSnapshot {
/// last render so click-to-row mapping stays correct when the
/// list overflows its visible area.
pub list_scroll_offset: usize,
/// Clickable segments in the minibuffer hint line. Empty when a
/// minibuffer prompt (palette / send-input / etc.) is open.
pub minibuffer_hints: Vec<HintZone>,
/// Clickable shortcut labels in the last frame (minibuffer hints,
/// empty-state onboarding shortcuts). Empty when a minibuffer prompt
/// (palette / send-input / etc.) is open for minibuffer hints, but may
/// still contain main-view shortcut affordances.
pub shortcut_hints: Vec<HintZone>,
/// Clickable harness names in the new-session picker prompt
/// (`MinibufferIntent::NewSessionHarness`). Click → submit the
/// matching name as if the user typed it and hit Enter.
Expand Down Expand Up @@ -4096,20 +4098,18 @@ impl App {
}
return;
}
// Clickable shortcut affordances (minibuffer hints, empty-state
// onboarding shortcuts) dispatch their bound key action before
// pane-level click handling.
for hint in &self.layout.shortcut_hints {
if row == hint.y && col >= hint.x_start && col < hint.x_end {
let action = hint.action;
self.run_action(action).await;
return;
}
}
if let Some(mb_area) = self.layout.minibuffer_area {
if contains(mb_area, col, row) {
// First check the inline-hint zones ("C-x z unzoom" /
// "? help" / etc.). They sit on the same row as the
// minibuffer area when no prompt is open and dispatch
// their bound action directly instead of opening the
// palette.
for hint in &self.layout.minibuffer_hints {
if row == hint.y && col >= hint.x_start && col < hint.x_end {
let action = hint.action;
self.run_action(action).await;
return;
}
}
// Orchestrator panel: click on a tool block toggles
// its expand state. The orchestrator's render area
// is the minibuffer rect minus the 1-row top border.
Expand Down Expand Up @@ -6962,7 +6962,7 @@ mod tests {
list_row_count: 0,
list_items_area: None,
list_scroll_offset: 0,
minibuffer_hints: Vec::new(),
shortcut_hints: Vec::new(),
minibuffer_harness_hits: Vec::new(),
modal_area: None,
browser_preview_area: None,
Expand Down Expand Up @@ -7712,6 +7712,80 @@ mod tests {
screen.contains("new: C-x C-f help: ? palette: C-x x"),
"missing modeline hint:\n{screen}"
);
assert!(
!screen.contains("CLI examples:"),
"empty state should not include CLI examples:\n{screen}"
);
assert!(
app.layout.shortcut_hints.len() >= 3,
"expected clickable shortcuts, got {:?}",
app.layout.shortcut_hints
);
assert!(app
.layout
.shortcut_hints
.iter()
.any(|h| h.action == KeyAction::OpenNewSession));
assert!(app
.layout
.shortcut_hints
.iter()
.any(|h| h.action == KeyAction::OpenCommandPalette));
assert!(app
.layout
.shortcut_hints
.iter()
.any(|h| h.action == KeyAction::ToggleHelp));
server.abort();
}

#[tokio::test]
async fn empty_state_shortcut_clicks_dispatch_actions() {
let (mut app, _dir, server) = empty_app().await;
app.harnesses = vec![agentd_protocol::HarnessInfo {
name: "shell".to_string(),
available: true,
binary: None,
description: None,
capabilities: Default::default(),
}];
let backend = ratatui::backend::TestBackend::new(120, 36);
let mut terminal = ratatui::Terminal::new(backend).expect("terminal");

terminal
.draw(|f| crate::ui::render(f, &mut app))
.expect("draw");

let click = |app: &App, action: KeyAction| {
let h = app
.layout
.shortcut_hints
.iter()
.find(|h| h.action == action)
.expect("shortcut hit")
.clone();
(h.x_start, h.y)
};

let (x, y) = click(&app, KeyAction::OpenCommandPalette);
app.handle_left_click(x, y).await;
assert!(matches!(
app.minibuffer.as_ref().map(|m| &m.intent),
Some(MinibufferIntent::CommandPalette)
));
app.minibuffer = None;

let (x, y) = click(&app, KeyAction::ToggleHelp);
app.handle_left_click(x, y).await;
assert!(app.help_visible);
app.help_visible = false;

let (x, y) = click(&app, KeyAction::OpenNewSession);
app.handle_left_click(x, y).await;
assert!(matches!(
app.minibuffer.as_ref().map(|m| &m.intent),
Some(MinibufferIntent::NewSessionHarness)
));
server.abort();
}

Expand Down
62 changes: 40 additions & 22 deletions crates/cli/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
app.layout.dynamic_ui_inline_hit = None;
app.layout.dynamic_ui_trigger = None;
app.layout.dynamic_ui_triggers.clear();
app.layout.shortcut_hints.clear();
app.layout.main_window_areas.clear();
app.layout.main_window_dividers.clear();
app.window_pane_sizes.clear();
Expand Down Expand Up @@ -2396,7 +2397,33 @@ fn render_detail(f: &mut Frame, area: Rect, app: &mut App, window_id: Option<u64
render_main_transition(f, inner, app, window_id);
}

fn render_empty_session_state(f: &mut Frame, area: Rect, app: &App) {
fn render_empty_session_state(f: &mut Frame, area: Rect, app: &mut App) {
let card = centered_rect(area, 72, 9);
let label_style = Style::default().fg(app.theme.accent);
let hover_style = label_style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
let mouse = app.mouse_pos;
let shortcut_rows = [
(4_u16, 2_u16, "C-x C-f", KeyAction::OpenNewSession),
(5_u16, 2_u16, "C-x x", KeyAction::OpenCommandPalette),
(6_u16, 2_u16, "?", KeyAction::ToggleHelp),
];
let mut hovered = [false; 3];
for (i, (row, col, label, action)) in shortcut_rows.iter().enumerate() {
let x_start = card.x + *col;
let y = card.y + *row;
let w = UnicodeWidthStr::width(*label) as u16;
let x_end = x_start + w;
hovered[i] = mouse
.map(|(mx, my)| my == y && mx >= x_start && mx < x_end)
.unwrap_or(false);
app.layout.shortcut_hints.push(HintZone {
x_start,
x_end,
y,
action: *action,
});
}

let lines = vec![
Line::from(Span::styled(
"Welcome to agentd",
Expand All @@ -2411,33 +2438,26 @@ fn render_empty_session_state(f: &mut Frame, area: Rect, app: &App) {
)),
Line::raw(""),
Line::from(vec![
Span::styled(" C-x C-f", Style::default().fg(app.theme.accent)),
Span::raw(" "),
Span::styled(
"C-x C-f",
if hovered[0] { hover_style } else { label_style },
),
Span::raw(" create a session"),
]),
Line::from(vec![
Span::styled(" C-x x", Style::default().fg(app.theme.accent)),
Span::raw(" open the command palette"),
Span::raw(" "),
Span::styled("C-x x", if hovered[1] { hover_style } else { label_style }),
Span::raw(" open the Operator"),
]),
Line::from(vec![
Span::styled(" ?", Style::default().fg(app.theme.accent)),
Span::raw(" "),
Span::styled("?", if hovered[2] { hover_style } else { label_style }),
Span::raw(" show shortcuts and concepts"),
]),
Line::raw(""),
Line::from(Span::styled(
"CLI examples:",
Style::default().fg(app.theme.text),
)),
Line::from(Span::styled(
" agent new zarvis \"inspect this repo\"",
Style::default().fg(app.theme.dim),
)),
Line::from(Span::styled(
" agent new shell",
Style::default().fg(app.theme.dim),
)),
];
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(para, centered_rect(area, 72, 12));
f.render_widget(para, card);
}

fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
Expand Down Expand Up @@ -4597,8 +4617,6 @@ fn minibuffer_panel_height(preferred: Option<u16>, total_h: u16) -> u16 {
}

fn render_minibuffer(f: &mut Frame, area: Rect, app: &mut App) {
// Hint zones from the previous frame are stale once we re-render.
app.layout.minibuffer_hints.clear();
app.layout.minibuffer_harness_hits.clear();

// Orchestrator panel: events above, input row at the bottom.
Expand Down Expand Up @@ -4694,7 +4712,7 @@ fn render_minibuffer(f: &mut Frame, area: Rect, app: &mut App) {
};
let style = if hovered { hover_style } else { base_style };
spans.push(Span::styled(label.to_string(), style));
app.layout.minibuffer_hints.push(HintZone {
app.layout.shortcut_hints.push(HintZone {
x_start,
x_end,
y: area.y,
Expand Down
Loading