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
16 changes: 12 additions & 4 deletions crates/jcode-base/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
pub use jcode_config_types::{
AgentsConfig, AmbientConfig, AuthConfig, AutoJudgeConfig, AutoReviewConfig, CompactionConfig,
CompactionMode, CrossProviderFailoverMode, DiagramDisplayMode, DiagramPanePosition,
DiffDisplayMode, DisplayConfig, FeatureConfig, GatewayConfig, KeybindingsConfig,
MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig, NamedProviderModelConfig,
NamedProviderType, NativeScrollbarConfig, ProviderConfig, SafetyConfig,
SessionPickerResumeAction, SwarmSpawnMode, UpdateChannel, WebSearchConfig, WebSearchEngine,
DiffDisplayMode, DisplayConfig, FeatureConfig, GatewayConfig, InputHistoryConfig,
KeybindingsConfig, MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig,
NamedProviderModelConfig, NamedProviderType, NativeScrollbarConfig, ProviderConfig,
SafetyConfig, SessionPickerResumeAction, SwarmSpawnMode, UpdateChannel, WebSearchConfig,
WebSearchEngine,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashSet};
Expand All @@ -26,6 +27,9 @@ const CONFIG_CACHE_CHECK_INTERVAL: Duration = if cfg!(test) {

const CONFIG_ENV_KEYS: &[&str] = &[
"HOME",
"JCODE_ACP_ENABLED",
"JCODE_ACP_PROFILE",
"JCODE_ACP_TOOL_PROFILE",
"JCODE_AMBIENT_ENABLED",
"JCODE_AMBIENT_MAX_INTERVAL",
"JCODE_AMBIENT_MIN_INTERVAL",
Expand Down Expand Up @@ -72,6 +76,7 @@ const CONFIG_ENV_KEYS: &[&str] = &[
"JCODE_HOME",
"JCODE_IDLE_ANIMATION",
"JCODE_IMAP_HOST",
"JCODE_INPUT_HISTORY_MAX",
"JCODE_MARKDOWN_SPACING",
"JCODE_MEMORY_ENABLED",
"JCODE_PERSIST_MEMORY_INJECTIONS",
Expand Down Expand Up @@ -410,6 +415,9 @@ pub struct Config {

/// Auto-judge configuration
pub autojudge: AutoJudgeConfig,

/// Input history configuration
pub input_history: InputHistoryConfig,
}

/// Agent Client Protocol adapter configuration.
Expand Down
5 changes: 5 additions & 0 deletions crates/jcode-base/src/config/default_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ desktop_notifications = true
# discord_channel_id = "" # Channel ID to post in
# discord_bot_user_id = "" # Bot's user ID (for filtering own messages)
# discord_reply_enabled = false # Messages in channel become agent directives

[input_history]
# Maximum number of entries kept in input history (default: 100)
# Can also be set via JCODE_INPUT_HISTORY_MAX env var.
# max_entries = 100
"#;

std::fs::write(&path, default_content)?;
Expand Down
7 changes: 7 additions & 0 deletions crates/jcode-base/src/config/env_overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,13 @@ impl Config {
crate::env::set_var("JCODE_COPILOT_PREMIUM", env_val);
}
}

// Input history
if let Ok(v) = std::env::var("JCODE_INPUT_HISTORY_MAX") {
if let Ok(n) = v.parse::<usize>() {
self.input_history.max_entries = n.clamp(1, 10_000);
}
}
}
}

Expand Down
47 changes: 47 additions & 0 deletions crates/jcode-base/src/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,50 @@ impl Config {
.any(|value| value.trim().eq_ignore_ascii_case(&entry))
}
}

#[test]
fn input_history_config_defaults_to_100() {
let cfg: Config = toml::from_str("").expect("empty config should parse");
assert_eq!(cfg.input_history.max_entries, 100);
}

#[test]
fn input_history_config_toml_overrides_default() {
let cfg: Config = toml::from_str("[input_history]\nmax_entries = 250\n")
.expect("input_history config should parse");
assert_eq!(cfg.input_history.max_entries, 250);
}

#[test]
fn input_history_config_section_without_max_entries_defaults_100() {
let cfg: Config = toml::from_str("[input_history]\n")
.expect("input_history section without fields should parse");
assert_eq!(cfg.input_history.max_entries, 100);
}

#[test]
fn input_history_config_clamps_zero_to_one() {
let cfg: Config = toml::from_str("[input_history]\nmax_entries = 0\n")
.expect("input_history config should parse");
assert_eq!(cfg.input_history.max_entries, 1);
}

#[test]
fn input_history_config_clamps_excessive_value() {
let cfg: Config = toml::from_str("[input_history]\nmax_entries = 999999\n")
.expect("input_history config should parse");
assert_eq!(cfg.input_history.max_entries, 10_000);
}

#[test]
fn input_history_env_override_clamps_value() {
let mut cfg = Config::default();
assert_eq!(cfg.input_history.max_entries, 100);
// Simulate env override with clamping
cfg.input_history.max_entries = 0usize.clamp(1, 10_000);
assert_eq!(cfg.input_history.max_entries, 1);
cfg.input_history.max_entries = 999_999usize.clamp(1, 10_000);
assert_eq!(cfg.input_history.max_entries, 10_000);
cfg.input_history.max_entries = 500usize.clamp(1, 10_000);
assert_eq!(cfg.input_history.max_entries, 500);
}
30 changes: 30 additions & 0 deletions crates/jcode-config-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -907,3 +907,33 @@ impl Default for GatewayConfig {
}
}
}

/// Input history configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct InputHistoryConfig {
/// Maximum number of entries kept in input history (default: 100, clamped to 1..=10000).
#[serde(
default = "default_max_entries",
deserialize_with = "deserialize_clamped_usize"
)]
pub max_entries: usize,
}

impl Default for InputHistoryConfig {
fn default() -> Self {
Self { max_entries: 100 }
}
}

fn default_max_entries() -> usize {
100
}

fn deserialize_clamped_usize<'de, D>(deserializer: D) -> Result<usize, D::Error>
where
D: serde::Deserializer<'de>,
{
let n = usize::deserialize(deserializer)?;
Ok(n.clamp(1, 10_000))
}
22 changes: 22 additions & 0 deletions crates/jcode-tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,20 @@ struct CommandCandidatesCache {
candidates: Vec<(String, &'static str)>,
}

/// State for Ctrl+R reverse incremental input history search.
pub(super) struct HistorySearchState {
/// The search query typed by the user.
pub(super) query: String,
/// All matching history indices (sorted newest-first), recomputed on query change.
pub(super) matches: Vec<usize>,
/// Index into `matches` of the currently highlighted result.
pub(super) selected: usize,
/// The original input before the search started (restored on Esc with no match).
pub(super) saved_input: String,
/// The original cursor position before the search started.
pub(super) saved_cursor: usize,
}

/// State for an in-progress OAuth/API-key login flow triggered by `/login`.
/// TUI Application state
pub struct App {
Expand Down Expand Up @@ -944,6 +958,14 @@ 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>,
// Saved input before Up-arrow history browsing started (restored on Down-past-end)
input_history_pre_browse: Option<(String, usize)>,
// Ctrl+R reverse incremental search state; None when not searching
input_history_search: Option<HistorySearchState>,
// 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 crates/jcode-tui/src/tui/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1894,6 +1894,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
Loading