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
4 changes: 4 additions & 0 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,10 @@ pub struct App {
scroll_bookmark: Option<usize>,
// Stashed input: saved via Ctrl+S for later retrieval
stashed_input: Option<(String, usize)>,
// Input history for recall (ring buffer, newest at the end)
input_history: Vec<String>,
// Index into `input_history` while browsing; None when not browsing
input_history_index: Option<usize>,
// Undo history for in-progress input editing (Ctrl+Z)
input_undo_stack: Vec<(String, usize)>,
// Short-lived notice for status feedback (model switch, cycle diff mode, etc.)
Expand Down
125 changes: 125 additions & 0 deletions src/tui/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2062,6 +2062,131 @@ pub(super) fn handle_session_command(app: &mut App, trimmed: &str) -> bool {
return true;
}

if trimmed == "/history input" || trimmed == "/history" {
if app.input_history.is_empty() {
app.push_display_message(DisplayMessage::system("No input history yet.".to_string()));
return true;
}
let mut listing = String::from("**Input history:**\n\n");
for (i, entry) in app.input_history.iter().enumerate() {
let preview = crate::util::truncate_str(entry, 80);
listing.push_str(&format!(" `{}` {}\n", i + 1, preview));
}
listing.push_str("\nUse `/history input N` to load entry N into the input box.");
listing.push_str("\nUse `/history search <term>` to search.");
listing.push_str("\nUse `/history delete N` to remove entry N.");
listing.push_str("\nUse `/history clear` to remove all entries.");
app.push_display_message(DisplayMessage::system(listing));
return true;
}

if trimmed == "/history clear" {
let count = app.input_history.len();
app.clear_input_history();
app.set_status_notice(format!("🗑 Cleared {} input history entries", count));
return true;
}

if let Some(term) = trimmed.strip_prefix("/history search ") {
let term = term.trim();
if term.is_empty() {
app.push_display_message(DisplayMessage::system(
"Usage: `/history search <term>`".to_string(),
));
return true;
}
let term_lower = term.to_lowercase();
let matches: Vec<(usize, &str)> = app
.input_history
.iter()
.enumerate()
.filter(|(_, entry)| entry.to_lowercase().contains(&term_lower))
.map(|(i, e)| (i + 1, e.as_str()))
.collect();
if matches.is_empty() {
app.push_display_message(DisplayMessage::system(format!(
"No history entries match \"{}\".",
term
)));
} else {
let mut listing = format!("**History matches for \"{}\":**\n\n", term);
for (i, entry) in &matches {
let preview = crate::util::truncate_str(entry, 80);
listing.push_str(&format!(" `{}` {}\n", i, preview));
}
listing.push_str(&format!(
"\n{} match{} found.",
matches.len(),
if matches.len() == 1 { "" } else { "es" }
));
app.push_display_message(DisplayMessage::system(listing));
}
return true;
}

if let Some(num_str) = trimmed.strip_prefix("/history delete ") {
let num_str = num_str.trim();
if app.input_history.is_empty() {
app.push_display_message(DisplayMessage::system(
"No input history to delete.".to_string(),
));
return true;
}
match num_str.parse::<usize>() {
Ok(n) if n >= 1 && n <= app.input_history.len() => {
let entry = app.input_history[n - 1].clone();
let preview = crate::util::truncate_str(&entry, 40).to_string();
app.delete_input_history_entry(n - 1);
app.set_status_notice(format!("🗑 Deleted history #{}: {}", n, preview));
}
_ => {
app.push_display_message(DisplayMessage::system(format!(
"Invalid index. Use `/history delete N` where N is 1..{}.",
app.input_history.len()
)));
}
}
return true;
}

if let Some(num_str) = trimmed.strip_prefix("/history input ") {
let num_str = num_str.trim();
match num_str.parse::<usize>() {
Ok(n) if n >= 1 && n <= app.input_history.len() => {
let entry = app.input_history[n - 1].clone();
if !app.input.is_empty() {
app.remember_input_undo_state();
}
app.input = entry;
app.cursor_pos = app.input.len();
app.reset_tab_completion();
app.reset_input_history_browse();
app.sync_model_picker_preview_from_input();
app.set_status_notice(format!("📋 Loaded input #{}", n));
}
_ => {
if app.input_history.is_empty() {
app.push_display_message(DisplayMessage::system(
"No input history yet.".to_string(),
));
} else {
app.push_display_message(DisplayMessage::system(format!(
"Invalid index. Use `/history input N` where N is 1..{}.",
app.input_history.len()
)));
}
}
}
return true;
}

if trimmed.starts_with("/history ") {
app.push_display_message(DisplayMessage::system(
"Unknown /history subcommand. Use: input N, search <term>, delete N, clear".to_string(),
));
return true;
}

if trimmed == "/rewind" {
let visible_messages = app.session.visible_conversation_messages();
if visible_messages.is_empty() {
Expand Down
21 changes: 21 additions & 0 deletions src/tui/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,7 @@ pub(super) fn insert_input_text(app: &mut App, text: &str) {
app.input.insert_str(app.cursor_pos, text);
app.cursor_pos += text.len();
app.reset_tab_completion();
app.reset_input_history_browse();
app.sync_model_picker_preview_from_input();
}

Expand Down Expand Up @@ -2240,11 +2241,23 @@ pub(super) fn handle_basic_key(app: &mut App, code: KeyCode) -> bool {
true
}
KeyCode::Up | KeyCode::PageUp => {
if code == KeyCode::Up
&& (app.input.is_empty() || app.input_history_index.is_some())
&& app.input_history_up()
{
return true;
}
let inc = if code == KeyCode::PageUp { 10 } else { 1 };
app.scroll_up(inc);
true
}
KeyCode::Down | KeyCode::PageDown => {
if code == KeyCode::Down
&& app.input_history_index.is_some()
&& app.input_history_down()
{
return true;
}
let dec = if code == KeyCode::PageDown { 10 } else { 1 };
app.scroll_down(dec);
true
Expand Down Expand Up @@ -2302,6 +2315,8 @@ pub(super) fn take_prepared_input(app: &mut App) -> PreparedInput {
let images = std::mem::take(&mut app.pending_images);
app.cursor_pos = 0;
app.clear_input_undo_history();
app.reset_input_history_browse();
app.push_input_history(expanded.clone());
PreparedInput {
raw_input,
expanded,
Expand Down Expand Up @@ -2746,6 +2761,7 @@ impl App {
self.pasted_contents.clear();
self.cursor_pos = 0;
self.clear_input_undo_history();
self.reset_input_history_browse();
self.follow_chat_bottom(); // Reset to bottom and resume auto-scroll on new input

// If the previous assistant turn still has visible streamed text that has not yet been
Expand Down Expand Up @@ -2777,6 +2793,11 @@ impl App {
// templates.
let mut input = expand_prompt_template_invocation(&input).unwrap_or(input);

// Issue #265 (input history): record submitted input for Up/Down recall.
// Done after template expansion so the recall menu shows the
// user-typed string, not the expanded body.
self.push_input_history(input.clone());

let trimmed = input.trim();
let handled = commands::handle_help_command(self, trimmed)
|| commands::handle_ssh_command(self, trimmed)
Expand Down
22 changes: 18 additions & 4 deletions src/tui/app/remote/key_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2260,12 +2260,26 @@ async fn handle_remote_key_internal(
}
}
KeyCode::Up | KeyCode::PageUp => {
let inc = if code == KeyCode::PageUp { 10 } else { 1 };
app.scroll_up(inc);
if code == KeyCode::Up
&& (app.input.is_empty() || app.input_history_index.is_some())
&& app.input_history_up()
{
// Input restored from history
} else {
let inc = if code == KeyCode::PageUp { 10 } else { 1 };
app.scroll_up(inc);
}
}
KeyCode::Down | KeyCode::PageDown => {
let dec = if code == KeyCode::PageDown { 10 } else { 1 };
app.scroll_down(dec);
if code == KeyCode::Down
&& app.input_history_index.is_some()
&& app.input_history_down()
{
// Navigated down in history
} else {
let dec = if code == KeyCode::PageDown { 10 } else { 1 };
app.scroll_down(dec);
}
}
KeyCode::Esc => {
if app
Expand Down
8 changes: 8 additions & 0 deletions src/tui/app/state_ui_input_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ pub(super) const REGISTERED_COMMANDS: &[RegisteredCommand] = &[
RegisteredCommand::public("/alignment", "Show/change default text alignment"),
RegisteredCommand::public("/clear", "Clear conversation history"),
RegisteredCommand::public("/rewind", "Rewind conversation to previous message"),
RegisteredCommand::public(
"/history",
"Input history: list, load N, search, delete N, clear",
),
RegisteredCommand::public("/poke", "Poke model to resume with incomplete todos"),
RegisteredCommand::public("/improve", "Autonomously improve the repository"),
RegisteredCommand::public("/refactor", "Run a safe refactor loop"),
Expand Down Expand Up @@ -1710,6 +1714,8 @@ impl App {
self.input_undo_stack.remove(0);
}
self.input_undo_stack.push(snapshot);
// Any manual edit cancels history browsing
self.reset_input_history_browse();
}

pub(super) fn clear_input_undo_history(&mut self) {
Expand All @@ -1721,6 +1727,7 @@ impl App {
self.input = input;
self.cursor_pos = cursor_pos.min(self.input.len());
self.reset_tab_completion();
self.reset_input_history_browse();
self.sync_model_picker_preview_from_input();
self.set_status_notice("↶ Input restored");
} else {
Expand Down Expand Up @@ -1768,6 +1775,7 @@ impl App {
| "/improve"
| "/refactor"
| "/rewind"
| "/history"
| "/compact"
| "/compact mode"
| "/alignment"
Expand Down
Loading