Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Repository-managed agent integrations:
- `Comment` - typing a comment (Ctrl-S saves, Ctrl-C cancels)
- `Search` - after pressing `/`, search pattern entry
- `Help` - showing help popup
- `MessageDetails` - showing the complete current error in a scrollable full-screen view
- `Confirm` - Y/N confirmation dialog
- `CommitSelect` - selecting commits to review
- `VisualSelect` - visual mode for range comments
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ A first-session cheatsheet. Press `?` inside tuicr for the full reference.
| `[` / `]` | Previous / next hunk |
| `m` / `M` | Next / previous comment |
| `/` | Search the diff, or search help while help is open (case-insensitive) |
| `:messages` | Open full details for the current error |
| `c` / `C` | Add line / file comment |
| `v` / `V` | Visual mode (range comment) |
| `r` | Toggle file reviewed |
Expand Down
2 changes: 2 additions & 0 deletions src/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,9 +458,11 @@ impl App {
help_state: HelpState::default(),
command_buffer: String::new(),
command_completion: None,
command_return_mode: InputMode::Normal,
search_buffer: String::new(),
last_search_pattern: None,
search_return_mode: InputMode::Normal,
overlay_return_mode: InputMode::Normal,
comment_buffer: String::new(),
comment_cursor: 0,
comment_vim_enabled: false,
Expand Down
4 changes: 4 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,8 @@ pub enum InputMode {
Command,
Search,
Help,
/// Scrollable full-screen view for the complete current error message.
MessageDetails,
Confirm,
CommitSelect,
VisualSelect,
Expand Down Expand Up @@ -1028,9 +1030,11 @@ pub struct App {
pub help_state: HelpState,
pub command_buffer: String,
pub(crate) command_completion: Option<CommandCompletionState>,
pub(crate) command_return_mode: InputMode,
pub search_buffer: String,
pub last_search_pattern: Option<String>,
pub(crate) search_return_mode: InputMode,
pub(crate) overlay_return_mode: InputMode,
pub comment_buffer: String,
pub comment_cursor: usize,
/// Config `comment_vim`: vim modal editing in the comment box.
Expand Down
38 changes: 32 additions & 6 deletions src/app/modes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ impl App {
message_type: MessageType,
ttl: Option<Duration>,
) {
if self.input_mode == InputMode::MessageDetails {
if message_type == MessageType::Error {
self.help_state.scroll_offset = 0;
} else {
self.input_mode = self.overlay_return_mode;
}
}
self.message = Some(Message {
content: msg.into(),
message_type,
Expand All @@ -47,13 +54,14 @@ impl App {
}

pub fn enter_command_mode(&mut self) {
self.command_return_mode = self.input_mode;
self.input_mode = InputMode::Command;
self.command_buffer.clear();
self.command_completion = None;
}

pub fn exit_command_mode(&mut self) {
self.input_mode = InputMode::Normal;
self.input_mode = self.command_return_mode;
self.command_buffer.clear();
self.command_completion = None;
}
Expand All @@ -73,12 +81,30 @@ impl App {
self.input_mode == InputMode::Search && self.search_return_mode == InputMode::Help
}

pub fn toggle_help(&mut self) {
if self.input_mode == InputMode::Help {
self.input_mode = InputMode::Normal;
} else {
self.input_mode = InputMode::Help;
pub fn open_message_details(&mut self) {
if self
.message
.as_ref()
.is_some_and(|message| message.message_type == MessageType::Error)
{
self.overlay_return_mode = self.input_mode;
self.input_mode = InputMode::MessageDetails;
self.help_state.scroll_offset = 0;
} else {
self.set_message("No current error");
}
}

pub fn toggle_help(&mut self) {
match self.input_mode {
InputMode::Help | InputMode::MessageDetails => {
self.input_mode = self.overlay_return_mode;
}
_ => {
self.overlay_return_mode = self.input_mode;
self.input_mode = InputMode::Help;
self.help_state.scroll_offset = 0;
}
}
}

Expand Down
12 changes: 11 additions & 1 deletion src/app/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,9 +750,19 @@ impl App {
self.pr_tab.apply_canonical(canonical.clone());
self.forge_repository = Some(canonical);
self.canonical_resolved = true;
let error = result.as_ref().err().cloned();
self.pr_tab.apply_initial_load(result);
if let Some(error) = error {
self.set_error(error);
}
}
PrLoadEvent::LoadMore(result) => {
let error = result.as_ref().err().cloned();
self.pr_tab.apply_load_more(result);
if let Some(error) = error {
self.set_error(error);
}
}
PrLoadEvent::LoadMore(result) => self.pr_tab.apply_load_more(result),
}
}
self.pr_tab.clamp_cursor();
Expand Down
31 changes: 31 additions & 0 deletions src/app/tests/target_selector_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1976,6 +1976,29 @@ fn should_apply_initial_load_event_to_pr_tab() {
assert_eq!(app.pr_tab.view().rows[0].summary.number, 7);
}

#[test]
fn should_surface_initial_pr_list_error_to_message_bar() {
let mut app = build_app();
let repository = ForgeRepository::github("github.com", "agavra", "tuicr");
app.forge_repository = Some(repository.clone());
app.pr_tab = PullRequestsTab::new(Some(repository.clone()));
app.pr_tab.start_initial_load();
let (tx, rx) = std::sync::mpsc::channel();
app.pr_load_rx = Some(rx);
tx.send(PrLoadEvent::Initial {
canonical: repository,
result: Err("forge API response body".to_string()),
})
.unwrap();
drop(tx);

app.poll_pr_load_events();

let message = app.message.as_ref().expect("expected PR-list error");
assert_eq!(message.message_type, MessageType::Error);
assert_eq!(message.content, "forge API response body");
}

#[test]
fn should_promote_app_forge_repository_to_canonical_on_initial_load() {
// given — origin is a fork; canonical from the background thread is upstream
Expand Down Expand Up @@ -2275,6 +2298,7 @@ fn should_return_false_when_cancelling_with_no_in_flight_open() {
fn should_surface_pr_open_error_to_message_bar_when_done_event_carries_error() {
// given an app waiting on a synthetic open
let mut app = build_app();
app.input_mode = InputMode::CommitSelect;
app.forge_repository = Some(ForgeRepository::github("github.com", "agavra", "tuicr"));
app.pr_tab = loaded_pr_tab(vec![sample_pr(42, "boom")]);
app.target_tab = TargetTab::PullRequests;
Expand Down Expand Up @@ -2304,6 +2328,13 @@ fn should_surface_pr_open_error_to_message_bar_when_done_event_carries_error() {
.expect("expected an error message on the bar");
assert!(matches!(msg.message_type, MessageType::Error));
assert!(msg.content.contains("auth failed"), "got {msg:?}");

app.enter_command_mode();
app.command_buffer = "messages".to_string();
crate::handler::handle_command_action(&mut app, crate::input::Action::SubmitInput);
assert_eq!(app.input_mode, InputMode::MessageDetails);
app.toggle_help();
assert_eq!(app.input_mode, InputMode::CommitSelect);
}

#[test]
Expand Down
10 changes: 9 additions & 1 deletion src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const COMMAND_SPECS: &[CommandSpec] = &[
),
CommandSpec::new(&["clearc"], CommandKind::Clear(ClearScope::CommentsOnly)),
CommandSpec::new(&["help", "h"], CommandKind::Help),
CommandSpec::new(&["messages"], CommandKind::MessageDetails),
CommandSpec::new(&["version"], CommandKind::Version),
CommandSpec::new(&["update"], CommandKind::Update),
CommandSpec::new(&["set wrap"], CommandKind::SetWrap),
Expand Down Expand Up @@ -109,6 +110,7 @@ enum CommandKind {
Export,
Clear(ClearScope),
Help,
MessageDetails,
Version,
Update,
SetWrap,
Expand Down Expand Up @@ -152,7 +154,7 @@ pub fn handle_mouse_event(app: &mut App, event: MouseEvent) {
let over_diff = app.diff_area.is_some_and(|r| r.contains(pos));
let over_commit_list = app.commit_list_inner_area.is_some_and(|r| r.contains(pos));
match app.input_mode {
InputMode::Help => handle_help_action(app, action),
InputMode::Help | InputMode::MessageDetails => handle_help_action(app, action),
InputMode::CommitSelect | InputMode::Normal if over_commit_list => {
wheel_commit_list(app, scroll_up);
}
Expand Down Expand Up @@ -778,6 +780,11 @@ fn dispatch_command(app: &mut App, kind: CommandKind) -> CommandAfterDispatch {
app.toggle_help();
CommandAfterDispatch::KeepMode
}
CommandKind::MessageDetails => {
app.exit_command_mode();
app.open_message_details();
CommandAfterDispatch::KeepMode
}
CommandKind::Version => {
app.set_message(format!("tuicr v{}", env!("CARGO_PKG_VERSION")));
CommandAfterDispatch::ExitCommandMode
Expand Down Expand Up @@ -1119,6 +1126,7 @@ pub fn handle_commit_select_action(app: &mut App, action: Action) {
match action {
Action::TargetSelectorTabNext => app.cycle_target_tab(true),
Action::TargetSelectorTabPrev => app.cycle_target_tab(false),
Action::EnterCommandMode => app.enter_command_mode(),
Action::Quit => app.should_quit = true,
Action::ExitMode => {
// Esc during an in-flight PR open aborts the load and stays
Expand Down
33 changes: 33 additions & 0 deletions src/input/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ pub fn map_key_to_action(key: KeyEvent, mode: InputMode, leader_key: char) -> Ac
InputMode::Search => map_search_mode(key),
InputMode::Comment => map_comment_mode(key),
InputMode::Help => map_help_mode(key),
InputMode::MessageDetails => match map_help_mode(key) {
Action::EnterSearchMode | Action::SearchNext | Action::SearchPrev => Action::None,
action => action,
},
InputMode::Confirm => map_confirm_mode(key),
InputMode::CommitSelect => map_commit_select_mode(key),
InputMode::VisualSelect => map_visual_mode(key),
Expand Down Expand Up @@ -400,6 +404,7 @@ fn map_commit_select_mode(key: KeyEvent) -> Action {
(KeyCode::Enter, KeyModifiers::NONE) => Action::ConfirmCommitSelect,
(KeyCode::Esc, KeyModifiers::NONE) => Action::ExitMode,
(KeyCode::Char('q'), KeyModifiers::NONE) => Action::Quit,
(KeyCode::Char(':'), _) => Action::EnterCommandMode,
(KeyCode::Tab, KeyModifiers::NONE) => Action::TargetSelectorTabNext,
(KeyCode::BackTab, _) => Action::TargetSelectorTabPrev,
(KeyCode::Char('/'), _) => Action::BeginTargetFilter,
Expand Down Expand Up @@ -494,6 +499,28 @@ mod tests {
assert_eq!(map_help_mode(key_shift('N')), Action::SearchPrev);
}

#[test]
fn should_reuse_help_navigation_without_search_for_message_details() {
assert_eq!(
map_key_to_action(
key(KeyCode::Char('j')),
InputMode::MessageDetails,
DEFAULT_LEADER_KEY,
),
Action::CursorDown(1)
);
for key in [
key(KeyCode::Char('/')),
key(KeyCode::Char('n')),
key_shift('N'),
] {
assert_eq!(
map_key_to_action(key, InputMode::MessageDetails, DEFAULT_LEADER_KEY),
Action::None
);
}
}

#[test]
fn should_map_lowercase_g_to_go_to_top_in_normal_mode() {
let action = map_normal_mode(key(KeyCode::Char('g')), DEFAULT_LEADER_KEY);
Expand Down Expand Up @@ -669,6 +696,12 @@ mod tests {
assert_eq!(action, Action::BeginTargetFilter);
}

#[test]
fn should_map_colon_to_command_mode_in_commit_select_mode() {
let action = map_commit_select_mode(key(KeyCode::Char(':')));
assert_eq!(action, Action::EnterCommandMode);
}

#[test]
fn should_map_r_to_review_requested_filter_in_commit_select_mode() {
// given / when
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ fn main() -> anyhow::Result<()> {

fn dispatch_action(app: &mut App, action: Action) {
match app.input_mode {
InputMode::Help => handle_help_action(app, action),
InputMode::Help | InputMode::MessageDetails => handle_help_action(app, action),
InputMode::Command => handle_command_action(app, action),
InputMode::Search => handle_search_action(app, action),
InputMode::Comment => handle_comment_action(app, action),
Expand Down
28 changes: 26 additions & 2 deletions src/ui/app_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,33 @@ pub fn render(frame: &mut Frame, app: &mut App) {
frame.area(),
);

// Special handling for commit selection mode
if app.input_mode == InputMode::CommitSelect {
if app.input_mode == InputMode::MessageDetails {
help_popup::render_message_details(frame, app);
return;
}

let selector_background = app.input_mode == InputMode::CommitSelect
|| (app.input_mode == InputMode::Command
&& app.command_return_mode == InputMode::CommitSelect)
|| (app.input_mode == InputMode::Help
&& app.overlay_return_mode == InputMode::CommitSelect)
|| (app.searching_help() && app.overlay_return_mode == InputMode::CommitSelect);
if selector_background {
render_commit_select(frame, app);
let area = frame.area();
let footer = Rect::new(
area.x,
area.bottom().saturating_sub(1),
area.width,
area.height.min(1),
);
if matches!(app.input_mode, InputMode::Command | InputMode::Search) {
status_bar::render_status_bar(frame, app, footer);
status_bar::render_command_completion_popup(frame, app, footer);
}
if app.input_mode == InputMode::Help || app.searching_help() {
help_popup::render_help(frame, app);
}
return;
}

Expand Down
Loading
Loading