use std::{collections::HashMap, io, sync::mpsc, time::Duration};
use anyhow::Result;
use crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal};
use crate::{
agent::{self, AgentEvent},
buffer::{Buffer, BufferId},
config::{self, chord_of, KeyBindings, KeyChord},
layout::PaneLayout,
mode::Mode,
palette::{self, Action, BarMode, ItemKind, Palette},
pane::{Pane, PaneContent, PaneId},
project,
tab::{Tab, TabId},
terminal::{self, Term, TermEvent, TermId},
tuning::{self, Tuning},
ui,
};
pub enum InputEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Paste(String),
Resize(u16, u16),
}
pub struct FileTree {
pub root: std::path::PathBuf,
pub expanded: std::collections::HashSet<std::path::PathBuf>,
pub selected: usize,
pub filter: String,
}
pub struct TreeRow {
pub path: std::path::PathBuf,
pub label: String,
pub depth: usize,
pub is_dir: bool,
pub expanded: bool,
pub updir: bool,
}
#[derive(Clone)]
pub struct Prompt {
pub label: String,
pub input: String,
pub kind: PromptKind,
}
#[derive(Clone, PartialEq)]
pub enum PromptKind {
SaveAs,
GotoLine,
RenameTab,
RenamePane,
RenameSession,
Search,
ReplaceFrom,
ReplaceTo,
QueryReplace,
ConfirmQuit,
ConfirmAction(Action),
}
#[derive(Default)]
pub struct WatchState {
pub watched: bool,
pub last_output_tick: u64,
pub triggered: bool,
pub verdict: Option<String>,
pub run_started_tick: u64,
}
#[derive(Clone, Copy)]
pub enum WatchReason { Exit, Quiet }
pub(crate) fn selection_text_from_screen(
screen: &vt100::Screen,
a: (u16, u16),
b: (u16, u16),
last_col: u16,
) -> String {
let mut out = String::new();
for row in a.0..=b.0 {
let c0 = if row == a.0 { a.1 } else { 0 };
let c1 = if row == b.0 { b.1 } else { last_col };
let mut line = String::new();
for col in c0..=c1 {
let ch = screen.cell(row, col).map(|c| c.contents()).unwrap_or_default();
line.push_str(if ch.is_empty() { " " } else { &ch });
}
out.push_str(line.trim_end());
if row < b.0 {
out.push('\n');
}
}
out
}
#[derive(Clone, Copy)]
pub struct TermSel {
pub tid: TermId,
pub ox: u16,
pub oy: u16,
pub vw: u16,
pub vh: u16,
pub anchor: (u16, u16),
pub end: (u16, u16),
}
#[derive(Clone, Copy, PartialEq)]
enum EditRun { None, Insert, Delete }
pub struct Notice {
pub text: String,
pub kind: NoticeKind,
}
#[derive(PartialEq, PartialOrd, Eq, Ord)]
pub enum NoticeKind { Failure, Info }
#[derive(Default)]
pub struct Snapshot {
dirty: std::collections::HashSet<String>,
}
#[derive(Clone)]
pub struct AwayEvent {
pub tick: u64,
pub kind: AwayKind,
pub text: String,
pub dur_ticks: Option<u64>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum AwayKind { NeedsYou, Done, Context }
pub struct App {
pub buffers: HashMap<BufferId, Buffer>,
pub panes: HashMap<PaneId, Pane>,
pub tabs: Vec<Tab>,
pub active_tab: usize,
pub mode: Mode,
pub palette: Option<Palette>,
pub status_msg: Option<String>,
pub should_quit: bool,
pub keys: KeyBindings,
pub frecency: HashMap<String, u32>,
pub pending_prefix: Vec<KeyChord>,
pub prefix_tick: u64,
pub prompt: Option<Prompt>,
pub kill_ring: Vec<String>,
last_yank: Option<(BufferId, usize, usize, usize)>,
pub search_origin: Option<(usize, usize)>,
pub search_hl: Vec<(usize, usize, usize)>,
pub search_labels: Vec<(usize, usize, char)>,
pub search_pick: bool,
pub bar_return: Mode,
pub bar_uses: HashMap<String, u32>,
pub pane_rects: Vec<(PaneId, Rect)>,
pub cursor_screen: Option<(u16, u16)>,
clipboard: Option<arboard::Clipboard>,
pub tuning: Tuning,
pub show_splash: bool,
startup_cwd: Option<std::path::PathBuf>,
run_cwd: Option<std::path::PathBuf>,
project_index: Option<project::Index>,
pub file_frecency: HashMap<String, u32>,
pub file_tree: Option<FileTree>,
pub tree_open: bool,
pub tree_rows: Vec<TreeRow>,
pub session_name: Option<String>,
pub detach_requested: bool,
pub rename_session_to: Option<String>,
pub agent_tx: mpsc::Sender<AgentEvent>,
pub agent_rx: mpsc::Receiver<AgentEvent>,
pub agent_pending: bool,
pub agent_partial: Option<String>,
pub agent_answer: Option<String>,
pub agent_directive: Option<agent::AgentDirective>,
pub refactor_target: Option<(BufferId, usize, usize)>,
pub refactor_replacement: Option<String>,
last_question: String,
need_depth: u8,
pub watches: HashMap<TermId, WatchState>,
pub notices: Vec<Notice>,
pending_watch: Option<(TermId, WatchReason)>,
detach_snapshot: Option<Snapshot>,
pub away_log: Vec<AwayEvent>,
detach_tick: Option<u64>,
pub digest_from_tick: Option<u64>,
pub agent_history: Vec<(String, String)>,
pub ask_scroll: usize,
pub bg_busy: bool,
auto_name_attempted: std::collections::HashSet<TabId>,
pub shell_ready: bool,
translate_call_id: Option<u64>,
translate_request: Option<String>,
session_name_attempted: bool,
edit_run: EditRun,
close_confirmed: bool,
force_close_confirm: bool,
replace_from: String,
replace_to: String,
replace_idx: Option<usize>,
replace_checkpointed: bool,
pub term_sel: Option<TermSel>,
pub frame_tick: u64,
pub needs_redraw: bool,
pub terms: HashMap<TermId, Term>,
pub term_tx: mpsc::Sender<TermEvent>,
pub term_rx: mpsc::Receiver<TermEvent>,
next_buffer_id: usize,
next_pane_id: usize,
next_tab_id: usize,
next_term_id: usize,
}
impl App {
pub fn new(file: Option<String>) -> Result<Self> {
let keys = config::load();
let state = PersistedState::load();
let (agent_tx, agent_rx) = mpsc::channel();
let (term_tx, term_rx) = mpsc::channel();
let mut app = App {
buffers: HashMap::new(),
panes: HashMap::new(),
tabs: vec![],
active_tab: 0,
mode: Mode::Edit,
palette: None,
status_msg: None,
should_quit: false,
keys,
frecency: state.frecency,
pending_prefix: Vec::new(),
prefix_tick: 0,
prompt: None,
kill_ring: Vec::new(),
last_yank: None,
search_origin: None,
search_hl: Vec::new(),
search_labels: Vec::new(),
search_pick: false,
bar_return: Mode::Edit,
bar_uses: state.bar_uses,
pane_rects: Vec::new(),
cursor_screen: None,
clipboard: if std::env::var("MARS_NO_SYSTEM_CLIPBOARD").is_ok()
|| std::env::var("ARES_NO_SYSTEM_CLIPBOARD").is_ok()
{
None
} else {
arboard::Clipboard::new().ok()
},
tuning: tuning::load(),
show_splash: file.is_none(),
startup_cwd: file
.as_ref()
.and_then(|f| std::path::Path::new(f).parent().map(|p| p.to_path_buf()))
.filter(|p| !p.as_os_str().is_empty()),
run_cwd: std::env::current_dir().ok(),
project_index: None,
file_frecency: state.file_frecency,
file_tree: None,
tree_open: false,
tree_rows: Vec::new(),
session_name: None,
detach_requested: false,
rename_session_to: None,
agent_tx,
agent_rx,
agent_pending: false,
agent_partial: None,
agent_answer: None,
agent_directive: None,
refactor_target: None,
refactor_replacement: None,
last_question: String::new(),
need_depth: 0,
watches: HashMap::new(),
notices: Vec::new(),
pending_watch: None,
detach_snapshot: None,
away_log: Vec::new(),
detach_tick: None,
digest_from_tick: None,
agent_history: Vec::new(),
ask_scroll: 0,
bg_busy: false,
auto_name_attempted: std::collections::HashSet::new(),
shell_ready: false,
translate_call_id: None,
translate_request: None,
session_name_attempted: false,
close_confirmed: false,
force_close_confirm: false,
term_sel: None,
needs_redraw: true, edit_run: EditRun::None,
replace_from: String::new(),
replace_to: String::new(),
replace_idx: None,
replace_checkpointed: false,
frame_tick: 0,
terms: HashMap::new(),
term_tx,
term_rx,
next_buffer_id: 0,
next_pane_id: 0,
next_tab_id: 0,
next_term_id: 0,
};
let buf_id = match file {
Some(ref path) => app.open_file(path)?,
None => app.new_scratch(),
};
let pane_id = app.alloc_pane(buf_id);
let tab = Tab::new(app.alloc_tab_id(), "1".into(), pane_id);
app.tabs.push(tab);
Ok(app)
}
fn alloc_buf_id(&mut self) -> BufferId {
let id = self.next_buffer_id;
self.next_buffer_id += 1;
id
}
fn alloc_pane_id(&mut self) -> PaneId {
let id = self.next_pane_id;
self.next_pane_id += 1;
id
}
fn alloc_tab_id(&mut self) -> TabId {
let id = self.next_tab_id;
self.next_tab_id += 1;
id
}
pub fn new_scratch(&mut self) -> BufferId {
let id = self.alloc_buf_id();
self.buffers.insert(id, Buffer::new_scratch(id));
id
}
pub fn open_file(&mut self, path: &str) -> Result<BufferId> {
let id = self.alloc_buf_id();
let buf = Buffer::from_file(id, std::path::PathBuf::from(path))?;
self.buffers.insert(id, buf);
if self.startup_cwd.is_none() {
self.startup_cwd = std::path::Path::new(path)
.parent()
.map(|p| p.to_path_buf())
.filter(|p| !p.as_os_str().is_empty());
}
*self.file_frecency.entry(path.to_string()).or_insert(0) += 1;
Ok(id)
}
pub fn set_project_index_for_test(&mut self, root: std::path::PathBuf, files: Vec<String>) {
self.project_index = Some(project::Index { root, files });
}
fn ensure_project_index(&mut self) -> &project::Index {
if self.project_index.is_none() {
let root = self
.startup_cwd
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let root = project::project_root(&root);
let idx = project::Index::build(
root,
self.tuning.project_index_max,
&self.tuning.project_ignore,
);
self.project_index = Some(idx);
}
self.project_index.as_ref().unwrap()
}
fn alloc_pane(&mut self, buffer_id: BufferId) -> PaneId {
let id = self.alloc_pane_id();
self.panes.insert(id, Pane::new(buffer_id));
id
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.active_tab]
}
pub fn tab_mut(&mut self) -> &mut Tab {
&mut self.tabs[self.active_tab]
}
pub fn focused_pane_id(&self) -> PaneId {
self.tabs[self.active_tab].focused_pane
}
pub fn focused_pane(&self) -> &Pane {
let id = self.focused_pane_id();
self.panes.get(&id).unwrap()
}
pub fn focused_pane_mut(&mut self) -> &mut Pane {
let id = self.focused_pane_id();
self.panes.get_mut(&id).unwrap()
}
pub fn focused_buf_id(&self) -> BufferId {
match self.focused_pane().content {
PaneContent::Editor(buf_id) => buf_id,
PaneContent::Terminal(_) => {
*self.buffers.keys().next().unwrap_or(&0)
}
}
}
pub fn focused_buf(&self) -> &Buffer {
let id = self.focused_buf_id();
self.buffers.get(&id).unwrap()
}
pub fn focused_buf_mut(&mut self) -> &mut Buffer {
let id = self.focused_buf_id();
self.buffers.get_mut(&id).unwrap()
}
pub fn move_up(&mut self) {
let pane = self.focused_pane();
if let PaneContent::Terminal(_) = pane.content { return; }
let (row, affinity, buf_id) = (pane.cursor_row, pane.col_affinity, match pane.content { PaneContent::Editor(id) => id, _ => return });
if row == 0 {
return;
}
let new_row = row - 1;
let len = self.buffers[&buf_id].line_len(new_row);
let p = self.focused_pane_mut();
p.cursor_row = new_row;
p.cursor_col = affinity.min(len);
}
pub fn move_down(&mut self) {
let pane = self.focused_pane();
if let PaneContent::Terminal(_) = pane.content { return; }
let (row, affinity, buf_id) = (pane.cursor_row, pane.col_affinity, match pane.content { PaneContent::Editor(id) => id, _ => return });
let line_count = self.buffers[&buf_id].line_count();
if row + 1 >= line_count {
return;
}
let new_row = row + 1;
let len = self.buffers[&buf_id].line_len(new_row);
let p = self.focused_pane_mut();
p.cursor_row = new_row;
p.cursor_col = affinity.min(len);
}
pub fn move_left(&mut self) {
let (row, col) = { let p = self.focused_pane(); (p.cursor_row, p.cursor_col) };
if col > 0 {
let p = self.focused_pane_mut();
p.cursor_col = col - 1;
p.col_affinity = p.cursor_col;
} else if row > 0 {
let buf_id = match self.focused_pane().content { PaneContent::Editor(id) => id, _ => return };
let prev_len = self.buffers[&buf_id].line_len(row - 1);
let p = self.focused_pane_mut();
p.cursor_row = row - 1;
p.cursor_col = prev_len;
p.col_affinity = prev_len;
}
}
pub fn move_right(&mut self) {
let pane = self.focused_pane();
if let PaneContent::Terminal(_) = pane.content { return; }
let (row, col, buf_id) = (pane.cursor_row, pane.cursor_col, match pane.content { PaneContent::Editor(id) => id, _ => return });
let len = self.buffers[&buf_id].line_len(row);
if col < len {
let p = self.focused_pane_mut();
p.cursor_col = col + 1;
p.col_affinity = p.cursor_col;
} else {
let last = self.buffers[&buf_id].line_count().saturating_sub(1);
if row < last {
let p = self.focused_pane_mut();
p.cursor_row = row + 1;
p.cursor_col = 0;
p.col_affinity = 0;
}
}
}
pub fn move_line_start(&mut self) {
let p = self.focused_pane_mut();
p.cursor_col = 0;
p.col_affinity = 0;
}
pub fn move_line_end(&mut self) {
let pane = self.focused_pane();
if let PaneContent::Terminal(_) = pane.content { return; }
let (row, buf_id) = (pane.cursor_row, match pane.content { PaneContent::Editor(id) => id, _ => return });
let len = self.buffers[&buf_id].line_len(row);
let p = self.focused_pane_mut();
p.cursor_col = len;
p.col_affinity = len;
}
pub fn move_file_start(&mut self) {
let p = self.focused_pane_mut();
p.cursor_row = 0;
p.cursor_col = 0;
p.col_affinity = 0;
}
pub fn move_file_end(&mut self) {
let pane = self.focused_pane();
if let PaneContent::Terminal(_) = pane.content { return; }
let buf_id = match pane.content { PaneContent::Editor(id) => id, _ => return };
let line_count = self.buffers[&buf_id].line_count();
let last = line_count.saturating_sub(1);
let len = self.buffers[&buf_id].line_len(last);
let p = self.focused_pane_mut();
p.cursor_row = last;
p.cursor_col = len;
p.col_affinity = len;
}
fn insert_char_at_cursor(&mut self, c: char) {
let pane = self.focused_pane();
let buf_id = match pane.content { PaneContent::Editor(id) => id, _ => return };
let (row, col) = (pane.cursor_row, pane.cursor_col);
let char_idx = self.buffers[&buf_id].char_at(row, col);
{
let buf = self.buffers.get_mut(&buf_id).unwrap();
buf.rope.insert_char(char_idx, c);
buf.modified = true;
}
let p = self.focused_pane_mut();
if c == '\n' {
p.cursor_row += 1;
p.cursor_col = 0;
} else {
p.cursor_col += 1;
}
p.col_affinity = p.cursor_col;
}
fn auto_indent(&mut self) {
let (row, buf_id) = match self.editor_pos() { Some((r, _, id)) => (r, id), None => return };
if row == 0 {
return;
}
let indent: String = self.buffers[&buf_id]
.rope
.line(row - 1)
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect();
for c in indent.chars() {
self.insert_char_at_cursor(c);
}
}
fn delete_before_cursor(&mut self) {
let pane = self.focused_pane();
let buf_id = match pane.content { PaneContent::Editor(id) => id, _ => return };
let (row, col) = (pane.cursor_row, pane.cursor_col);
if col == 0 && row == 0 {
return;
}
let char_idx = self.buffers[&buf_id].char_at(row, col);
if char_idx == 0 {
return;
}
let new_pos = if col > 0 {
(row, col - 1)
} else {
let prev_len = self.buffers[&buf_id].line_len(row - 1);
(row - 1, prev_len)
};
{
let buf = self.buffers.get_mut(&buf_id).unwrap();
buf.rope.remove(char_idx - 1..char_idx);
buf.modified = true;
}
let p = self.focused_pane_mut();
p.cursor_row = new_pos.0;
p.cursor_col = new_pos.1;
p.col_affinity = new_pos.1;
}
fn editor_pos(&self) -> Option<(usize, usize, BufferId)> {
let p = self.focused_pane();
match p.content {
PaneContent::Editor(id) => Some((p.cursor_row, p.cursor_col, id)),
PaneContent::Terminal(_) => None,
}
}
fn rowcol_of(&self, buf_id: BufferId, idx: usize) -> (usize, usize) {
let rope = &self.buffers[&buf_id].rope;
let idx = idx.min(rope.len_chars());
let line = rope.char_to_line(idx);
(line, idx - rope.line_to_char(line))
}
fn set_cursor(&mut self, row: usize, col: usize) {
let p = self.focused_pane_mut();
p.cursor_row = row;
p.cursor_col = col;
p.col_affinity = col;
}
fn clear_selection(&mut self) {
let id = self.focused_pane_id();
if let Some(p) = self.panes.get_mut(&id) { p.selection_anchor = None; }
}
fn begin_or_keep_selection(&mut self) {
let (r, c) = { let p = self.focused_pane(); (p.cursor_row, p.cursor_col) };
let p = self.focused_pane_mut();
if p.selection_anchor.is_none() { p.selection_anchor = Some((r, c)); }
}
pub fn selection_range(&self) -> Option<(BufferId, usize, usize)> {
let p = self.focused_pane();
let anchor = p.selection_anchor?;
let buf_id = match p.content { PaneContent::Editor(id) => id, _ => return None };
let buf = &self.buffers[&buf_id];
let a = buf.char_at(anchor.0, anchor.1);
let b = buf.char_at(p.cursor_row, p.cursor_col);
let (s, e) = if a <= b { (a, b) } else { (b, a) };
if s == e { None } else { Some((buf_id, s, e)) }
}
fn move_left_sel(&mut self, extend: bool) { if extend { self.begin_or_keep_selection(); } else { self.clear_selection(); } self.move_left(); }
fn move_right_sel(&mut self, extend: bool) { if extend { self.begin_or_keep_selection(); } else { self.clear_selection(); } self.move_right(); }
fn move_up_sel(&mut self, extend: bool) { if extend { self.begin_or_keep_selection(); } else { self.clear_selection(); } self.move_up(); }
fn move_down_sel(&mut self, extend: bool) { if extend { self.begin_or_keep_selection(); } else { self.clear_selection(); } self.move_down(); }
fn move_line_start_sel(&mut self, extend: bool) { if extend { self.begin_or_keep_selection(); } else { self.clear_selection(); } self.move_line_start(); }
fn move_line_end_sel(&mut self, extend: bool) { if extend { self.begin_or_keep_selection(); } else { self.clear_selection(); } self.move_line_end(); }
fn page_len(&self) -> usize {
let h = self.focused_pane().view_h;
if h == 0 { 18 } else { h.saturating_sub(self.tuning.page_overlap).max(1) }
}
fn page_up(&mut self) {
self.clear_selection();
for _ in 0..self.page_len() { self.move_up(); }
}
fn page_down(&mut self) {
self.clear_selection();
for _ in 0..self.page_len() { self.move_down(); }
}
fn move_word_forward(&mut self) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let (len, mut idx) = {
let b = &self.buffers[&buf_id];
(b.rope.len_chars(), b.char_at(row, col))
};
let is_word = |c: char| c.is_alphanumeric() || c == '_';
{
let rope = &self.buffers[&buf_id].rope;
while idx < len && !is_word(rope.char(idx)) { idx += 1; }
while idx < len && is_word(rope.char(idx)) { idx += 1; }
}
let (r, c) = self.rowcol_of(buf_id, idx);
self.set_cursor(r, c);
}
fn move_word_backward(&mut self) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let mut idx = self.buffers[&buf_id].char_at(row, col);
let is_word = |c: char| c.is_alphanumeric() || c == '_';
{
let rope = &self.buffers[&buf_id].rope;
while idx > 0 && !is_word(rope.char(idx - 1)) { idx -= 1; }
while idx > 0 && is_word(rope.char(idx - 1)) { idx -= 1; }
}
let (r, c) = self.rowcol_of(buf_id, idx);
self.set_cursor(r, c);
}
fn token_class(c: char) -> u8 {
if c.is_whitespace() { 0 } else if c.is_alphanumeric() || c == '_' { 1 } else { 2 }
}
pub fn move_token_forward(&mut self) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let (len, mut idx) = {
let b = &self.buffers[&buf_id];
(b.rope.len_chars(), b.char_at(row, col))
};
{
let rope = &self.buffers[&buf_id].rope;
if idx < len {
let c0 = Self::token_class(rope.char(idx));
if c0 != 0 {
while idx < len && Self::token_class(rope.char(idx)) == c0 { idx += 1; }
}
}
while idx < len && Self::token_class(rope.char(idx)) == 0 { idx += 1; }
}
let (r, c) = self.rowcol_of(buf_id, idx);
self.set_cursor(r, c);
}
pub fn move_token_backward(&mut self) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let mut idx = self.buffers[&buf_id].char_at(row, col);
{
let rope = &self.buffers[&buf_id].rope;
while idx > 0 && Self::token_class(rope.char(idx - 1)) == 0 { idx -= 1; }
if idx > 0 {
let c0 = Self::token_class(rope.char(idx - 1));
while idx > 0 && Self::token_class(rope.char(idx - 1)) == c0 { idx -= 1; }
}
}
let (r, c) = self.rowcol_of(buf_id, idx);
self.set_cursor(r, c);
}
fn move_token_sel(&mut self, forward: bool, extend: bool) {
if extend { self.begin_or_keep_selection(); } else { self.clear_selection(); }
if forward { self.move_token_forward(); } else { self.move_token_backward(); }
}
fn line_is_blank(b: &Buffer, r: usize) -> bool {
b.rope.line(r).chars().all(|c| c.is_whitespace())
}
pub fn jump_block(&mut self, forward: bool) {
let (row, _c, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let n = self.buffers[&buf_id].line_count();
let target = {
let b = &self.buffers[&buf_id];
if forward {
let mut r = row + 1;
while r < n && Self::line_is_blank(b, r) { r += 1; }
while r < n && !Self::line_is_blank(b, r) { r += 1; }
r.min(n.saturating_sub(1))
} else {
let mut r = row.saturating_sub(1);
while r > 0 && Self::line_is_blank(b, r) { r -= 1; }
while r > 0 && !Self::line_is_blank(b, r) { r -= 1; }
r
}
};
self.clear_selection();
self.set_cursor(target, 0);
}
pub fn jump_symbol(&mut self, forward: bool) {
let (row, _c, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let n = self.buffers[&buf_id].line_count();
const KWS: &[&str] = &[
"fn ", "pub fn", "pub(", "pub struct", "pub enum", "def ", "class ", "impl",
"struct ", "enum ", "trait ", "mod ", "type ", "func ", "function ",
"interface ", "async fn", "export ", "const fn",
];
let is_def = |b: &Buffer, r: usize| -> bool {
let line: String = b.rope.line(r).chars().collect();
let t = line.trim_start();
KWS.iter().any(|k| t.starts_with(k))
};
let target = {
let b = &self.buffers[&buf_id];
if forward {
let mut r = row + 1;
while r < n && !is_def(b, r) { r += 1; }
(r < n).then_some(r)
} else if row == 0 {
None
} else {
let mut r = row - 1;
loop {
if is_def(b, r) { break Some(r); }
if r == 0 { break None; }
r -= 1;
}
}
};
if let Some(r) = target {
self.clear_selection();
self.set_cursor(r, 0);
}
}
pub fn match_bracket(&mut self) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
const OPENS: [char; 3] = ['(', '[', '{'];
const CLOSES: [char; 3] = [')', ']', '}'];
let target = {
let rope = &self.buffers[&buf_id].rope;
let len = rope.len_chars();
let cur = self.buffers[&buf_id].char_at(row, col);
let mut found = None;
let mut j = cur;
while j < len {
let c = rope.char(j);
if c == '\n' { break; }
if OPENS.contains(&c) || CLOSES.contains(&c) { found = Some((j, c)); break; }
j += 1;
}
if found.is_none() && cur > 0 {
let c = rope.char(cur - 1);
if OPENS.contains(&c) || CLOSES.contains(&c) { found = Some((cur - 1, c)); }
}
found.and_then(|(pos, c)| {
if let Some(oi) = OPENS.iter().position(|&o| o == c) {
let (open, close) = (c, CLOSES[oi]);
let mut depth = 1i32;
let mut k = pos + 1;
while k < len {
let ch = rope.char(k);
if ch == open { depth += 1; }
else if ch == close { depth -= 1; if depth == 0 { return Some(k); } }
k += 1;
}
None
} else if let Some(ci) = CLOSES.iter().position(|&cc| cc == c) {
let (open, close) = (OPENS[ci], c);
let mut depth = 1i32;
let mut k = pos;
while k > 0 {
k -= 1;
let ch = rope.char(k);
if ch == close { depth += 1; }
else if ch == open { depth -= 1; if depth == 0 { return Some(k); } }
}
None
} else {
None
}
})
};
if let Some(idx) = target {
let (r, c) = self.rowcol_of(buf_id, idx);
self.clear_selection();
self.set_cursor(r, c);
}
}
fn push_kill(&mut self, text: String) {
if let Some(cb) = self.clipboard.as_mut() {
let _ = cb.set_text(text.clone());
}
self.kill_ring.push(text);
}
fn insert_text(&mut self, text: &str) {
if self.editor_pos().is_none() {
return;
}
self.focused_buf_mut().checkpoint();
self.delete_selection();
for ch in text.chars() {
self.insert_char_at_cursor(ch);
}
}
fn paste_clipboard(&mut self) {
let text = self
.clipboard
.as_mut()
.and_then(|cb| cb.get_text().ok())
.filter(|t| !t.is_empty())
.or_else(|| self.kill_ring.last().cloned());
match text {
Some(t) => self.insert_text(&t),
None => self.status_msg = Some("Clipboard empty".into()),
}
}
pub fn paste_text(&mut self, s: &str) {
match self.mode {
Mode::Terminal => {
if let PaneContent::Terminal(tid) = self.focused_pane().content {
if let Some(t) = self.terms.get_mut(&tid) {
let wrap = t.screen().bracketed_paste();
if wrap { t.send_bytes(b"\x1b[200~"); }
t.send_bytes(s.as_bytes());
if wrap { t.send_bytes(b"\x1b[201~"); }
}
}
}
Mode::Bar => {
let clean: String = s.chars().map(|c| if c == '\n' || c == '\r' { ' ' } else { c }).collect();
if let Some(p) = self.palette.as_mut() {
p.query.push_str(&clean);
}
}
Mode::Prompt => {
let clean: String = s.chars().map(|c| if c == '\n' || c == '\r' { ' ' } else { c }).collect();
let is_search = if let Some(p) = self.prompt.as_mut() {
p.input.push_str(&clean);
p.kind == PromptKind::Search
} else {
false
};
if is_search {
let q = self.prompt.as_ref().map(|p| p.input.clone()).unwrap_or_default();
self.update_isearch(&q);
}
}
_ => self.insert_text(s),
}
}
fn delete_char_forward(&mut self) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let buf = self.buffers.get_mut(&buf_id).unwrap();
let idx = buf.char_at(row, col);
if idx < buf.rope.len_chars() {
buf.checkpoint();
buf.rope.remove(idx..idx + 1);
buf.modified = true;
}
}
fn kill_line(&mut self) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let killed = {
let buf = self.buffers.get_mut(&buf_id).unwrap();
buf.checkpoint();
let start = buf.char_at(row, col);
let eol = buf.line_len(row);
let end = if col >= eol { (start + 1).min(buf.rope.len_chars()) } else { buf.char_at(row, eol) };
if end > start {
let k = buf.rope.slice(start..end).to_string();
buf.rope.remove(start..end);
buf.modified = true;
k
} else {
String::new()
}
};
if !killed.is_empty() { self.push_kill(killed); }
}
fn kill_region(&mut self) {
if let Some((buf_id, s, e)) = self.selection_range() {
let killed = {
let buf = self.buffers.get_mut(&buf_id).unwrap();
buf.checkpoint();
let k = buf.rope.slice(s..e).to_string();
buf.rope.remove(s..e);
buf.modified = true;
k
};
self.push_kill(killed);
let (r, c) = self.rowcol_of(buf_id, s);
self.set_cursor(r, c);
self.clear_selection();
}
}
fn copy_region(&mut self) {
if let Some((buf_id, s, e)) = self.selection_range() {
let text = self.buffers[&buf_id].rope.slice(s..e).to_string();
self.push_kill(text);
self.status_msg = Some("Copied".into());
} else if let Some((row, _, buf_id)) = self.editor_pos() {
let line = self.buffers[&buf_id].line_str(row);
if !line.is_empty() {
self.push_kill(line);
self.status_msg = Some("Copied line".into());
}
}
self.clear_selection();
}
fn yank(&mut self) {
if let Some(text) = self.kill_ring.last().cloned() {
let start = match self.editor_pos() {
Some((r, c, id)) => (id, self.buffers[&id].char_at(r, c)),
None => return,
};
self.focused_buf_mut().checkpoint();
for ch in text.chars() { self.insert_char_at_cursor(ch); }
self.last_yank =
Some((start.0, start.1, text.chars().count(), self.kill_ring.len() - 1));
}
}
fn yank_pop(&mut self) {
let (buf_id, start, len, ridx) = match self.last_yank {
Some(x) => x,
None => {
self.status_msg = Some("Previous command was not a yank".into());
return;
}
};
let at_end = self
.editor_pos()
.map(|(r, c, id)| id == buf_id && self.buffers[&id].char_at(r, c) == start + len)
.unwrap_or(false);
if !at_end || self.kill_ring.len() < 2 {
self.status_msg = Some("Previous command was not a yank".into());
return;
}
let new_ridx = if ridx == 0 { self.kill_ring.len() - 1 } else { ridx - 1 };
let text = self.kill_ring[new_ridx].clone();
{
let buf = self.buffers.get_mut(&buf_id).unwrap();
buf.rope.remove(start..start + len);
buf.modified = true;
}
let (r, c) = self.rowcol_of(buf_id, start);
self.set_cursor(r, c);
for ch in text.chars() { self.insert_char_at_cursor(ch); }
self.last_yank = Some((buf_id, start, text.chars().count(), new_ridx));
}
fn kill_word(&mut self, forward: bool) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let from = self.buffers[&buf_id].char_at(row, col);
if forward { self.move_word_forward(); } else { self.move_word_backward(); }
let (row2, col2, _) = match self.editor_pos() { Some(x) => x, None => return };
let to = self.buffers[&buf_id].char_at(row2, col2);
let (s, e) = if from <= to { (from, to) } else { (to, from) };
if s == e { return; }
let killed = {
let buf = self.buffers.get_mut(&buf_id).unwrap();
buf.checkpoint();
let k = buf.rope.slice(s..e).to_string();
buf.rope.remove(s..e);
buf.modified = true;
k
};
let (r, c) = self.rowcol_of(buf_id, s);
self.set_cursor(r, c);
self.push_kill(killed);
}
fn recenter(&mut self) {
let p = self.focused_pane_mut();
let half = (p.view_h / 2).max(1);
p.scroll_row = p.cursor_row.saturating_sub(half);
}
fn select_all(&mut self) {
if self.editor_pos().is_none() { return; }
self.focused_pane_mut().selection_anchor = Some((0, 0));
self.move_file_end();
}
fn kill_buffer(&mut self) {
let buf_id = match self.editor_pos() { Some((_, _, id)) => id, None => return };
if self.buffers.len() <= 1 {
self.status_msg = Some("Only buffer".into());
return;
}
let other = self.buffers.keys().copied().find(|&id| id != buf_id);
self.buffers.remove(&buf_id);
if let Some(o) = other {
for pane in self.panes.values_mut() {
if matches!(pane.content, PaneContent::Editor(id) if id == buf_id) {
pane.content = PaneContent::Editor(o);
pane.buffer_id = o;
pane.cursor_row = 0; pane.cursor_col = 0; pane.scroll_row = 0;
pane.selection_anchor = None;
}
}
}
}
fn delete_other_windows(&mut self) {
let force = std::mem::take(&mut self.force_close_confirm);
let focused = self.focused_pane_id();
let victims: Vec<PaneId> =
self.tab().layout.pane_ids().into_iter().filter(|id| *id != focused).collect();
if self.confirm_close(
self.live_terms_in(&victims),
force,
Action::DeleteOtherWindows,
"the other panes",
) {
return;
}
self.reap_panes(&victims);
let tab = self.tab_mut();
tab.layout = PaneLayout::Single(focused);
tab.focused_pane = focused;
}
fn find_matches(&self, buf_id: BufferId, needle: &str) -> Vec<usize> {
let text: Vec<char> = self.buffers[&buf_id].rope.chars().collect();
let pat: Vec<char> = needle.chars().collect();
if pat.is_empty() || pat.len() > text.len() {
return Vec::new();
}
(0..=text.len() - pat.len())
.filter(|&i| text[i..i + pat.len()] == pat[..])
.collect()
}
fn update_isearch(&mut self, needle: &str) {
let buf_id = match self.editor_pos() { Some((_, _, id)) => id, None => return };
let matches = self.find_matches(buf_id, needle);
self.search_hl = matches
.iter()
.map(|&i| {
let (r, c) = self.rowcol_of(buf_id, i);
(r, c, needle.chars().count())
})
.collect();
if needle.is_empty() {
if let Some((r, c)) = self.search_origin {
self.set_cursor(r, c);
}
return;
}
let origin_idx = self
.search_origin
.map(|(r, c)| self.buffers[&buf_id].char_at(r, c))
.unwrap_or(0);
match matches.iter().find(|&&i| i >= origin_idx).or(matches.first()) {
Some(&idx) => {
let (r, c) = self.rowcol_of(buf_id, idx);
self.set_cursor(r, c);
}
None => self.status_msg = Some(format!("Failing I-search: {}", needle)),
}
}
fn isearch_step(&mut self, needle: &str, forward: bool) {
let (row, col, buf_id) = match self.editor_pos() { Some(x) => x, None => return };
let matches = self.find_matches(buf_id, needle);
if matches.is_empty() {
self.status_msg = Some(format!("Failing I-search: {}", needle));
return;
}
let cur = self.buffers[&buf_id].char_at(row, col);
let idx = if forward {
*matches.iter().find(|&&i| i > cur).unwrap_or(&matches[0]) } else {
*matches.iter().rev().find(|&&i| i < cur).unwrap_or(matches.last().unwrap())
};
let (r, c) = self.rowcol_of(buf_id, idx);
self.set_cursor(r, c);
}
fn start_isearch(&mut self) {
let (row, col, _) = match self.editor_pos() {
Some(x) => x,
None => {
self.status_msg = Some("No editor pane here".into());
return;
}
};
self.search_origin = Some((row, col));
self.search_hl.clear();
self.start_prompt(PromptKind::Search, "I-search: ");
}
fn end_isearch(&mut self, restore_origin: bool) {
if restore_origin {
if let Some((r, c)) = self.search_origin {
self.set_cursor(r, c);
}
}
self.search_origin = None;
self.search_hl.clear();
self.search_labels.clear();
self.search_pick = false;
}
fn do_undo(&mut self) {
let did = self.focused_buf_mut().undo();
if did {
self.status_msg = Some("Undo".into());
} else {
self.status_msg = Some("Nothing to undo".into());
}
self.clamp_cursor_after_edit();
}
fn do_redo(&mut self) {
let did = self.focused_buf_mut().redo();
if did {
self.status_msg = Some("Redo".into());
} else {
self.status_msg = Some("Nothing to redo".into());
}
self.clamp_cursor_after_edit();
}
fn enter_undo_mode(&mut self) {
if self.editor_pos().is_none() {
self.status_msg = Some("Undo history works in an editor pane".into());
return;
}
self.edit_run = EditRun::None;
self.mode = Mode::Undo;
self.undo_status();
}
fn undo_status(&mut self) {
let (back, fwd) = self
.editor_pos()
.and_then(|(_, _, id)| self.buffers.get(&id))
.map(|b| b.undo_depth())
.unwrap_or((0, 0));
self.status_msg =
Some(format!("TIME-TRAVEL ◂ {back} back · {fwd} forward ▸ ←/→ step · Home/End all · Esc done"));
}
fn handle_undo_mode(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Left | KeyCode::Up | KeyCode::Char('u') => { self.do_undo(); self.undo_status(); }
KeyCode::Right | KeyCode::Down | KeyCode::Char('r') => { self.do_redo(); self.undo_status(); }
KeyCode::Home => { while self.focused_buf_mut().undo() {} self.clamp_cursor_after_edit(); self.undo_status(); }
KeyCode::End => { while self.focused_buf_mut().redo() {} self.clamp_cursor_after_edit(); self.undo_status(); }
_ => { self.mode = Mode::Edit; self.status_msg = Some("Undo history closed".into()); }
}
}
fn clamp_cursor_after_edit(&mut self) {
let pane = self.focused_pane();
let buf_id = match pane.content { PaneContent::Editor(id) => id, _ => return };
let line_count = self.buffers[&buf_id].line_count();
let (row, col) = (pane.cursor_row, pane.cursor_col);
let new_row = row.min(line_count.saturating_sub(1));
let new_col = col.min(self.buffers[&buf_id].line_len(new_row));
let p = self.focused_pane_mut();
p.cursor_row = new_row;
p.cursor_col = new_col;
p.col_affinity = new_col;
}
fn do_save(&mut self) {
if self.focused_buf().path.is_none() {
self.start_prompt(PromptKind::SaveAs, "Save as: ");
return;
}
let name = self.focused_buf().name.clone();
match self.focused_buf_mut().save() {
Ok(_) => self.status_msg = Some(format!("Saved {}", name)),
Err(e) => self.status_msg = Some(format!("Save error: {}", e)),
}
}
fn request_quit(&mut self) {
let dirty = self.buffers.values().filter(|b| b.modified).count();
if dirty == 0 {
self.should_quit = true;
} else {
self.start_prompt(
PromptKind::ConfirmQuit,
&format!("{} modified buffer(s): s save all & quit · q quit anyway · C-g cancel ", dirty),
);
}
}
pub fn autosave(&mut self) {
let mut failed: Vec<String> = Vec::new();
for buf in self.buffers.values_mut() {
if buf.modified && buf.path.is_some() {
if buf.save().is_err() {
failed.push(buf.name.clone());
}
}
}
if !failed.is_empty() {
let text = format!("⚠ autosave FAILED: {} — check disk/permissions", failed.join(", "));
if !self.notices.iter().any(|n| n.text == text) {
self.notices.push(Notice { text, kind: NoticeKind::Failure });
self.notices.sort_by(|a, b| a.kind.cmp(&b.kind));
}
}
}
fn save_all(&mut self) -> Vec<String> {
let mut unsaved = Vec::new();
for buf in self.buffers.values_mut() {
if buf.modified {
if buf.path.is_some() {
if buf.save().is_err() {
unsaved.push(buf.name.clone());
}
} else {
unsaved.push(buf.name.clone());
}
}
}
unsaved
}
pub fn split_horizontal(&mut self) {
if self.tab().layout.count() >= self.tuning.max_panes {
self.status_msg = Some(format!("Max {} panes", self.tuning.max_panes));
return;
}
let focused = self.focused_pane_id();
let buf_id = match self.focused_pane().content { PaneContent::Editor(id) => id, _ => self.new_scratch() };
let new_id = self.alloc_pane(buf_id);
let (r, c, s) = {
let p = &self.panes[&focused];
(p.cursor_row, p.cursor_col, p.scroll_row)
};
{
let p = self.panes.get_mut(&new_id).unwrap();
p.cursor_row = r;
p.cursor_col = c;
p.scroll_row = s;
}
let tab = self.tab_mut();
tab.layout.hsplit(focused, new_id);
tab.focused_pane = new_id;
self.status_msg = Some("Split ─".into());
}
pub fn split_vertical(&mut self) {
if self.tab().layout.count() >= self.tuning.max_panes {
self.status_msg = Some(format!("Max {} panes", self.tuning.max_panes));
return;
}
let focused = self.focused_pane_id();
let buf_id = match self.focused_pane().content { PaneContent::Editor(id) => id, _ => self.new_scratch() };
let new_id = self.alloc_pane(buf_id);
let (r, c, s) = {
let p = &self.panes[&focused];
(p.cursor_row, p.cursor_col, p.scroll_row)
};
{
let p = self.panes.get_mut(&new_id).unwrap();
p.cursor_row = r;
p.cursor_col = c;
p.scroll_row = s;
}
let tab = self.tab_mut();
tab.layout.vsplit(focused, new_id);
tab.focused_pane = new_id;
self.status_msg = Some("Split │".into());
}
fn live_terms_in(&self, pane_ids: &[PaneId]) -> usize {
pane_ids
.iter()
.filter_map(|pid| self.panes.get(pid))
.filter_map(|p| match p.content {
PaneContent::Terminal(tid) => self.terms.get(&tid),
_ => None,
})
.filter(|t| !t.exited)
.count()
}
fn reap_panes(&mut self, pane_ids: &[PaneId]) {
for pid in pane_ids {
if let Some(p) = self.panes.remove(pid) {
if let PaneContent::Terminal(tid) = p.content {
self.terms.remove(&tid);
self.watches.remove(&tid);
}
}
}
}
fn confirm_close(&mut self, live: usize, force: bool, action: Action, what: &str) -> bool {
if std::mem::take(&mut self.close_confirmed) || (live == 0 && !force) {
return false;
}
let msg = if live > 0 {
let plural = if live == 1 { "" } else { "s" };
format!("Close {what} — {live} running terminal{plural} will be killed. y/n ")
} else {
format!("Close {what}? y/n ")
};
self.start_prompt(PromptKind::ConfirmAction(action), &msg);
true
}
pub fn close_pane(&mut self) {
let force = std::mem::take(&mut self.force_close_confirm);
if self.tab().layout.count() <= 1 {
return;
}
let focused = self.focused_pane_id();
if self.confirm_close(self.live_terms_in(&[focused]), force, Action::ClosePane, "this pane") {
return;
}
let next = self.tab().layout.next_pane(focused);
let tab = self.tab_mut();
tab.layout.remove(focused);
tab.focused_pane = next;
self.reap_panes(&[focused]);
}
pub fn focus_next_pane(&mut self) {
let focused = self.focused_pane_id();
let next = self.tab().layout.next_pane(focused);
self.tab_mut().focused_pane = next;
}
fn focus_direction(&mut self, dx: i32, dy: i32) {
let cur = self.focused_pane_id();
let cur_rect = match self.pane_rects.iter().find(|(id, _)| *id == cur) {
Some((_, r)) => *r,
None => { self.focus_next_pane(); return; } };
let (cx, cy) = (
cur_rect.x as i32 + cur_rect.width as i32 / 2,
cur_rect.y as i32 + cur_rect.height as i32 / 2,
);
let mut best: Option<(i32, PaneId)> = None;
for (id, r) in &self.pane_rects {
if *id == cur { continue; }
let px = r.x as i32 + r.width as i32 / 2;
let py = r.y as i32 + r.height as i32 / 2;
let (ddx, ddy) = (px - cx, py - cy);
let aligned = if dx != 0 {
ddx.signum() == dx && ddx.abs() >= ddy.abs()
} else {
ddy.signum() == dy && ddy.abs() >= ddx.abs()
};
if aligned {
let dist = ddx.abs() + ddy.abs();
if best.map(|(d, _)| dist < d).unwrap_or(true) {
best = Some((dist, *id));
}
}
}
if let Some((_, id)) = best {
self.tab_mut().focused_pane = id;
}
}
fn resize_pane(&mut self, delta: i16) {
let focused = self.focused_pane_id();
self.tab_mut().layout.resize(focused, delta);
}
fn toggle_zoom(&mut self) {
let focused = self.focused_pane_id();
let tab = self.tab_mut();
tab.zoomed = if tab.zoomed == Some(focused) { None } else { Some(focused) };
}
fn swap_pane(&mut self) {
let a = self.focused_pane_id();
let b = self.tab().layout.next_pane(a);
if a == b { return; }
let snap_a = self.panes.get(&a).unwrap().clone();
let snap_b = self.panes.get(&b).unwrap().clone();
for (dst, src) in [(a, &snap_b), (b, &snap_a)] {
let p = self.panes.get_mut(&dst).unwrap();
p.content = src.content.clone();
p.buffer_id = src.buffer_id;
p.cursor_row = src.cursor_row;
p.cursor_col = src.cursor_col;
p.col_affinity = src.col_affinity;
p.scroll_row = src.scroll_row;
p.selection_anchor = src.selection_anchor;
}
self.tab_mut().focused_pane = b;
self.status_msg = Some("Pane moved".into());
}
pub fn focus_prev_pane(&mut self) {
let focused = self.focused_pane_id();
let prev = self.tab().layout.prev_pane(focused);
self.tab_mut().focused_pane = prev;
}
pub fn new_tab(&mut self) {
let buf_id = self.new_scratch();
let pane_id = self.alloc_pane(buf_id);
let n = self.tabs.len() + 1;
let id = self.alloc_tab_id();
let tab = Tab::new(id, n.to_string(), pane_id);
self.tabs.push(tab);
self.active_tab = self.tabs.len() - 1;
}
pub fn open_file_in_new_tab(&mut self, path: &str) {
match self.open_file(path) {
Ok(buf_id) => {
let pane_id = self.alloc_pane(buf_id);
let id = self.alloc_tab_id();
let name = std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.map(str::to_string)
.unwrap_or_else(|| (self.tabs.len() + 1).to_string());
let tab = Tab::new(id, name.clone(), pane_id);
self.tabs.push(tab);
self.active_tab = self.tabs.len() - 1;
self.mode = Mode::Edit;
self.status_msg = Some(format!("Opened {name}"));
}
Err(e) => self.status_msg = Some(format!("Open failed: {e}")),
}
}
pub fn close_tab(&mut self) {
let force = std::mem::take(&mut self.force_close_confirm);
if self.tabs.len() == 1 {
self.request_quit(); return;
}
let pane_ids = self.tab().layout.pane_ids();
if self.confirm_close(self.live_terms_in(&pane_ids), force, Action::CloseTab, "this tab") {
return;
}
self.reap_panes(&pane_ids);
self.tabs.remove(self.active_tab);
if self.active_tab >= self.tabs.len() {
self.active_tab = self.tabs.len() - 1;
}
}
pub fn next_tab(&mut self) {
if !self.tabs.is_empty() {
self.active_tab = (self.active_tab + 1) % self.tabs.len();
}
}
pub fn prev_tab(&mut self) {
if !self.tabs.is_empty() {
self.active_tab = if self.active_tab == 0 {
self.tabs.len() - 1
} else {
self.active_tab - 1
};
}
}
pub fn move_tab(&mut self, delta: i32) {
let i = self.active_tab as i32;
let j = i + delta;
if j < 0 || j >= self.tabs.len() as i32 {
return;
}
self.tabs.swap(i as usize, j as usize);
self.active_tab = j as usize;
}
fn goto_tab(&mut self, n: usize) {
if n >= 1 && n <= self.tabs.len() {
self.active_tab = n - 1;
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
self.show_splash = false; let chord = chord_of(&key);
let bar_open = self.pending_prefix.is_empty()
&& (self.keys.bar_open.contains(&chord) || matches!(key.code, KeyCode::Null));
if bar_open && matches!(self.mode, Mode::Tab | Mode::Tree | Mode::Undo) {
self.open_bar(BarMode::Command);
return Ok(());
}
match self.mode.clone() {
Mode::Edit => self.handle_edit(key),
Mode::Bar => self.handle_bar(key),
Mode::Prompt => self.handle_prompt(key),
Mode::Tab => self.handle_tab(key),
Mode::Terminal => self.handle_terminal(key),
Mode::Tree => self.handle_tree(key),
Mode::Undo => self.handle_undo_mode(key),
}
Ok(())
}
fn handle_edit(&mut self, key: KeyEvent) {
self.status_msg = None;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let chord = chord_of(&key);
if self.pending_prefix.is_empty()
&& (self.keys.bar_open.contains(&chord) || matches!(key.code, KeyCode::Null))
{
self.open_bar(BarMode::Command);
return;
}
if self.pending_prefix.is_empty() {
if let PaneContent::Terminal(tid) = self.focused_pane().content {
if matches!(key.code, KeyCode::Enter) && key.modifiers == KeyModifiers::NONE {
if self.terms.get(&tid).map(|t| t.exited).unwrap_or(true) {
self.close_terminal_pane(tid);
} else {
self.mode = Mode::Terminal;
}
return;
}
}
}
if (ctrl && matches!(key.code, KeyCode::Char('g')))
|| (matches!(key.code, KeyCode::Esc) && key.modifiers == KeyModifiers::NONE)
{
if self.pending_prefix.is_empty()
&& self.focused_pane().selection_anchor.is_none()
&& self.dismiss_notice()
{
return;
}
let had_state = !self.pending_prefix.is_empty()
|| self.focused_pane().selection_anchor.is_some();
self.pending_prefix.clear();
self.clear_selection();
if had_state {
self.status_msg = Some("Quit".into());
}
return;
}
let mut seq = self.pending_prefix.clone();
seq.push(chord.clone());
if let Some(action) = self.keys.lookup(&seq) {
self.pending_prefix.clear();
self.run_action(action);
return;
}
let extends = self.keys.edit.keys().any(|k| k.len() > seq.len() && k.starts_with(&seq));
if extends {
self.pending_prefix = seq;
self.prefix_tick = self.frame_tick;
return;
}
if !self.pending_prefix.is_empty() {
let shown = crate::config::render_chords(&seq);
self.pending_prefix.clear();
self.status_msg = Some(format!("{} is undefined", shown));
return;
}
self.handle_edit_primitive(key);
}
fn handle_edit_primitive(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let cmd = key.modifiers.contains(KeyModifiers::SUPER);
self.last_yank = None; let prev_run = self.edit_run;
self.edit_run = EditRun::None;
match key.code {
KeyCode::Char('f') if ctrl => self.move_right_sel(false),
KeyCode::Char('b') if ctrl => self.move_left_sel(false),
KeyCode::Char('n') if ctrl => self.move_down_sel(false),
KeyCode::Char('p') if ctrl => self.move_up_sel(false),
KeyCode::Char('a') if ctrl => self.move_line_start_sel(false),
KeyCode::Char('e') if ctrl => self.move_line_end_sel(false),
KeyCode::Char('d') if ctrl => self.delete_char_forward(),
KeyCode::Char('f') if alt => self.move_word_forward(),
KeyCode::Char('b') if alt => self.move_word_backward(),
KeyCode::Char('v') if alt => self.page_up(),
KeyCode::Char(c) if alt && c.is_ascii_digit() => {
self.goto_tab((c as u8 - b'0') as usize);
}
KeyCode::Left if cmd || alt => self.move_token_sel(false, shift),
KeyCode::Right if cmd || alt => self.move_token_sel(true, shift),
KeyCode::Up if cmd || alt => self.page_up(),
KeyCode::Down if cmd || alt => self.page_down(),
KeyCode::Left if ctrl => self.focus_direction(-1, 0),
KeyCode::Right if ctrl => self.focus_direction(1, 0),
KeyCode::Up if ctrl => self.focus_direction(0, -1),
KeyCode::Down if ctrl => self.focus_direction(0, 1),
KeyCode::Left => self.move_left_sel(shift),
KeyCode::Right => self.move_right_sel(shift),
KeyCode::Up => self.move_up_sel(shift),
KeyCode::Down => self.move_down_sel(shift),
KeyCode::Home => self.move_line_start_sel(shift),
KeyCode::End => self.move_line_end_sel(shift),
KeyCode::PageUp => self.page_up(),
KeyCode::PageDown => self.page_down(),
KeyCode::Backspace => {
if !self.delete_selection() {
if prev_run != EditRun::Delete { self.focused_buf_mut().checkpoint(); }
self.delete_before_cursor();
self.edit_run = EditRun::Delete;
}
}
KeyCode::Delete => {
if !self.delete_selection() { self.delete_char_forward(); }
}
KeyCode::Enter => {
self.focused_buf_mut().checkpoint();
self.delete_selection();
self.insert_char_at_cursor('\n');
self.auto_indent(); }
KeyCode::Tab => {
if self.focused_pane().selection_anchor.is_some() {
self.indent_region(false); } else {
if prev_run != EditRun::Insert { self.focused_buf_mut().checkpoint(); }
for _ in 0..4 { self.insert_char_at_cursor(' '); }
self.edit_run = EditRun::Insert;
}
}
KeyCode::BackTab => self.indent_region(true), KeyCode::Char(c) if !ctrl && !alt => {
if self.delete_selection() {
} else if prev_run != EditRun::Insert {
self.focused_buf_mut().checkpoint();
}
self.insert_char_at_cursor(c);
self.edit_run = EditRun::Insert;
}
_ => {}
}
}
fn indent_region(&mut self, dedent: bool) {
let sel = self.selection_range();
let (buf_id, start_row, end_row) = match sel {
Some((id, s, e)) => {
let (sr, _) = self.rowcol_of(id, s);
let (er, ec) = self.rowcol_of(id, e);
let er = if ec == 0 && er > sr { er - 1 } else { er };
(id, sr, er)
}
None => match self.editor_pos() {
Some((row, _, id)) => (id, row, row),
None => return,
},
};
self.focused_buf_mut().checkpoint();
self.edit_run = EditRun::None;
for row in start_row..=end_row {
let line_start = self.buffers[&buf_id].char_at(row, 0);
if dedent {
let head: String = self.buffers[&buf_id].rope.line(row).chars().take(4).collect();
let n = if head.starts_with('\t') { 1 } else { head.chars().take_while(|c| *c == ' ').count() };
if n > 0 {
self.buffers.get_mut(&buf_id).unwrap().rope.remove(line_start..line_start + n);
}
} else {
self.buffers.get_mut(&buf_id).unwrap().rope.insert(line_start, " ");
}
}
self.buffers.get_mut(&buf_id).unwrap().modified = true;
if sel.is_some() {
let end_len = self.buffers[&buf_id].line_len(end_row);
self.focused_pane_mut().selection_anchor = Some((start_row, 0));
let p = self.focused_pane_mut();
p.cursor_row = end_row;
p.cursor_col = end_len;
p.col_affinity = end_len;
} else {
self.clamp_cursor_after_edit();
}
}
fn begin_query_replace(&mut self) {
self.replace_checkpointed = false;
self.replace_idx = None;
if self.qr_show_next(0) {
let label = format!("Replace '{}' → '{}'? y / n / ! (all) / q ", self.replace_from, self.replace_to);
self.start_prompt(PromptKind::QueryReplace, &label);
} else {
self.status_msg = Some(format!("No matches for '{}'", self.replace_from));
}
}
fn qr_show_next(&mut self, from_idx: usize) -> bool {
let from = self.replace_from.clone();
let buf_id = match self.editor_pos() { Some((_, _, id)) => id, None => return false };
match self.find_matches(buf_id, &from).into_iter().find(|&m| m >= from_idx) {
Some(idx) => {
self.replace_idx = Some(idx);
let (r, c) = self.rowcol_of(buf_id, idx);
self.set_cursor(r, c);
self.search_hl = vec![(r, c, from.chars().count())];
true
}
None => false,
}
}
fn qr_replace_at(&mut self, idx: usize, flen: usize, to: &str) {
let buf_id = match self.editor_pos() { Some((_, _, id)) => id, None => return };
if !self.replace_checkpointed {
self.focused_buf_mut().checkpoint(); self.replace_checkpointed = true;
}
let buf = self.buffers.get_mut(&buf_id).unwrap();
buf.rope.remove(idx..idx + flen);
buf.rope.insert(idx, to);
buf.modified = true;
}
fn qr_finish(&mut self) {
self.replace_idx = None;
self.search_hl.clear();
self.close_prompt();
}
fn handle_query_replace_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let to = self.replace_to.clone();
let flen = self.replace_from.chars().count();
let tlen = to.chars().count();
let Some(idx) = self.replace_idx else { self.qr_finish(); return };
match key.code {
KeyCode::Char('y') | KeyCode::Char(' ') => {
self.qr_replace_at(idx, flen, &to);
if !self.qr_show_next(idx + tlen) {
self.qr_finish();
self.status_msg = Some("Replaced".into());
}
}
KeyCode::Char('n') => {
if !self.qr_show_next(idx + flen) { self.qr_finish(); }
}
KeyCode::Char('!') => {
let mut count = 0;
loop {
let m = self.replace_idx.unwrap();
self.qr_replace_at(m, flen, &to);
count += 1;
if !self.qr_show_next(m + tlen) { break; }
}
self.qr_finish();
self.status_msg = Some(format!("Replaced {count}"));
}
KeyCode::Char('q') | KeyCode::Esc => self.qr_finish(),
KeyCode::Char('g') if ctrl => self.qr_finish(),
_ => {}
}
}
fn delete_selection(&mut self) -> bool {
if let Some((buf_id, s, e)) = self.selection_range() {
{
let buf = self.buffers.get_mut(&buf_id).unwrap();
buf.checkpoint();
buf.rope.remove(s..e);
buf.modified = true;
}
let (r, c) = self.rowcol_of(buf_id, s);
self.set_cursor(r, c);
self.clear_selection();
true
} else {
self.clear_selection();
false
}
}
fn open_bar(&mut self, bar_mode: BarMode) {
self.bar_return = if self.mode == Mode::Terminal { Mode::Terminal } else { Mode::Edit };
let mut p = Palette::root();
p.bar_mode = bar_mode;
p.navigated = self.bar_return != Mode::Terminal;
self.palette = Some(p);
self.mode = Mode::Bar;
self.shell_ready = false;
}
fn start_prompt(&mut self, kind: PromptKind, label: &str) {
self.start_prompt_with(kind, label, "");
}
fn start_prompt_with(&mut self, kind: PromptKind, label: &str, initial: &str) {
self.prompt = Some(Prompt {
label: label.to_string(),
input: initial.to_string(),
kind,
});
self.mode = Mode::Prompt;
}
fn handle_prompt(&mut self, key: KeyEvent) {
let kind = match self.prompt.as_ref() {
Some(p) => p.kind.clone(),
None => { self.mode = Mode::Edit; return; }
};
match kind {
PromptKind::Search => self.handle_isearch_key(key),
PromptKind::ConfirmQuit => self.handle_confirm_quit_key(key),
PromptKind::ConfirmAction(action) => self.handle_confirm_action_key(key, action),
PromptKind::QueryReplace => self.handle_query_replace_key(key),
_ => self.handle_line_prompt_key(key),
}
}
fn close_prompt(&mut self) {
self.prompt = None;
self.mode = Mode::Edit;
}
fn handle_line_prompt_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && matches!(key.code, KeyCode::Char('g')) {
self.close_prompt();
return;
}
match key.code {
KeyCode::Esc => self.close_prompt(),
KeyCode::Enter => {
if let Some(p) = self.prompt.take() {
self.mode = Mode::Edit;
self.finish_prompt(p);
}
}
KeyCode::Backspace => { if let Some(p) = self.prompt.as_mut() { p.input.pop(); } }
KeyCode::Char(c) if !ctrl => { if let Some(p) = self.prompt.as_mut() { p.input.push(c); } }
_ => {}
}
}
fn handle_isearch_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let query = self.prompt.as_ref().map(|p| p.input.clone()).unwrap_or_default();
if self.search_pick {
self.search_pick = false;
if let KeyCode::Char(c) = key.code {
if let Some(&(r, col, _)) = self.search_labels.iter().find(|(_, _, l)| *l == c) {
self.set_cursor(r, col);
self.end_isearch(false);
self.close_prompt();
return;
}
}
self.search_labels.clear(); }
if ctrl && matches!(key.code, KeyCode::Char('g')) {
self.end_isearch(true);
self.close_prompt();
return;
}
match key.code {
KeyCode::Esc => { self.end_isearch(true); self.close_prompt(); }
KeyCode::Enter => { self.end_isearch(false); self.close_prompt(); }
KeyCode::Char('s') if ctrl => self.isearch_step(&query, true),
KeyCode::Char('r') if ctrl => self.isearch_step(&query, false),
KeyCode::Tab => {
if self.search_hl.len() >= 2 {
self.build_search_labels();
self.search_pick = true;
}
}
KeyCode::Backspace => {
if let Some(p) = self.prompt.as_mut() { p.input.pop(); }
let q = self.prompt.as_ref().map(|p| p.input.clone()).unwrap_or_default();
self.update_isearch(&q);
}
KeyCode::Char(c) if !ctrl => {
if let Some(p) = self.prompt.as_mut() { p.input.push(c); }
let q = self.prompt.as_ref().map(|p| p.input.clone()).unwrap_or_default();
self.update_isearch(&q);
}
_ => {
self.end_isearch(false);
self.close_prompt();
let _ = self.handle_key(key);
}
}
}
fn build_search_labels(&mut self) {
const ALPHA: &[u8] = b"asdfghjklqwertyuiopvbnm";
self.search_labels = self
.search_hl
.iter()
.take(ALPHA.len())
.enumerate()
.map(|(i, &(r, c, _))| (r, c, ALPHA[i] as char))
.collect();
}
pub fn isearch_status(&self) -> Option<(usize, usize)> {
let total = self.search_hl.len();
if total == 0 {
return None;
}
let pane = self.focused_pane();
let (cr, cc) = (pane.cursor_row, pane.cursor_col);
let cur = self
.search_hl
.iter()
.position(|&(r, c, _)| r == cr && c == cc)
.map(|i| i + 1)
.unwrap_or(0);
Some((cur, total))
}
fn handle_confirm_quit_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && matches!(key.code, KeyCode::Char('g')) {
self.close_prompt();
return;
}
match key.code {
KeyCode::Char('s') => {
let unsaved = self.save_all();
self.close_prompt();
if unsaved.is_empty() {
self.should_quit = true;
} else {
self.status_msg = Some(format!(
"No file for: {} — save it first (C-x C-s)",
unsaved.join(", ")
));
}
}
KeyCode::Char('q') | KeyCode::Char('!') => {
self.close_prompt();
self.should_quit = true;
}
KeyCode::Esc | KeyCode::Char('n') => self.close_prompt(),
_ => {}
}
}
fn handle_confirm_action_key(&mut self, key: KeyEvent, action: Action) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
self.close_prompt();
self.close_confirmed = true; self.run_action(action);
self.close_confirmed = false;
}
_ if ctrl || matches!(key.code, KeyCode::Esc | KeyCode::Char('n')) => {
self.close_prompt();
self.status_msg = Some("Cancelled".into());
}
_ => {}
}
}
fn finish_prompt(&mut self, p: Prompt) {
match p.kind {
PromptKind::ReplaceFrom => {
self.replace_from = p.input;
if self.replace_from.is_empty() {
return;
}
self.start_prompt(PromptKind::ReplaceTo, "Replace with: ");
}
PromptKind::ReplaceTo => {
self.replace_to = p.input;
self.begin_query_replace();
}
PromptKind::GotoLine => {
match p.input.trim().parse::<usize>() {
Ok(n) if n >= 1 => {
if let Some((_, _, buf_id)) = self.editor_pos() {
let last = self.buffers[&buf_id].line_count().saturating_sub(1);
let row = (n - 1).min(last);
self.set_cursor(row, 0);
self.recenter();
}
}
_ => self.status_msg = Some("Not a line number".into()),
}
}
PromptKind::SaveAs => {
let path = p.input.trim().to_string();
if path.is_empty() { return; }
let result = self
.focused_buf_mut()
.save_as(std::path::PathBuf::from(&path));
self.status_msg = Some(match result {
Ok(_) => format!("Saved {}", path),
Err(e) => format!("Save error: {}", e),
});
}
PromptKind::RenameTab => {
let name = p.input.trim().to_string();
if !name.is_empty() {
let id = self.tab().id;
self.auto_name_attempted.insert(id); self.tab_mut().name = name;
}
}
PromptKind::RenamePane => {
let title = p.input.trim().to_string();
let pid = self.focused_pane_id();
if let Some(pane) = self.panes.get_mut(&pid) {
pane.title = if title.is_empty() { None } else { Some(title) };
}
}
PromptKind::RenameSession => {
let name = p.input.trim().to_string();
if !name.is_empty() && !name.contains('/') {
self.rename_session_to = Some(name);
} else if !name.is_empty() {
self.status_msg = Some("Session names cannot contain '/'".into());
}
}
PromptKind::Search | PromptKind::ConfirmQuit
| PromptKind::ConfirmAction(_) | PromptKind::QueryReplace => {}
}
}
fn handle_tab(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers == KeyModifiers::SHIFT;
if matches!(key.code, KeyCode::Esc | KeyCode::Enter)
|| (ctrl && matches!(key.code, KeyCode::Char('g') | KeyCode::Char('t')))
{
self.mode = self.mode_for_focused_pane();
return;
}
match key.code {
KeyCode::Char('t') | KeyCode::Char('n') => {
self.new_tab();
self.mode = Mode::Edit; }
KeyCode::Char('d') => {
self.force_close_confirm = true;
self.close_tab();
}
KeyCode::Char('T') => {
self.new_tab();
self.open_terminal(); }
KeyCode::Char('r') => self.run_action(Action::RenameTab), KeyCode::Char('?') => self.run_action(Action::ExplainFailure), KeyCode::Char('w') => self.toggle_watch_pane(), KeyCode::Char('h') | KeyCode::Left if !shift => self.prev_tab(),
KeyCode::Char('l') | KeyCode::Right if !shift => self.next_tab(),
KeyCode::Char('H') => self.move_tab(-1),
KeyCode::Char('L') => self.move_tab(1),
KeyCode::Left if shift => self.move_tab(-1),
KeyCode::Right if shift => self.move_tab(1),
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
self.goto_tab((c as u8 - b'0') as usize);
self.mode = self.mode_for_focused_pane(); }
KeyCode::Char('o') | KeyCode::Tab => self.focus_next_pane(),
KeyCode::Char('x') => self.swap_pane(),
KeyCode::Char('z') => self.toggle_zoom(),
KeyCode::Char('>') | KeyCode::Char('+') | KeyCode::Char('=') => self.resize_pane(6),
KeyCode::Char('<') => self.resize_pane(-6),
KeyCode::Char('|') | KeyCode::Char('\\') | KeyCode::Char('v') => {
self.split_vertical();
self.mode = Mode::Edit;
}
KeyCode::Char('-') | KeyCode::Char('s') => {
self.split_horizontal();
self.mode = Mode::Edit;
}
KeyCode::Char('q') | KeyCode::Char('0') => {
self.force_close_confirm = true; self.close_pane();
}
KeyCode::Char('D') => {
self.run_action(Action::Detach);
if !self.detach_requested {
self.mode = self.mode_for_focused_pane(); }
}
_ => {}
}
}
fn handle_bar(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let none = key.modifiers == KeyModifiers::NONE;
let shift = key.modifiers == KeyModifiers::SHIFT;
if ctrl && matches!(key.code, KeyCode::Char('g')) {
self.close_bar();
return;
}
let chord = chord_of(&key);
if self.keys.bar_open.contains(&chord) || matches!(key.code, KeyCode::Null) {
if let Some(p) = self.palette.as_mut() {
if p.bar_mode == BarMode::Shell {
p.bar_mode = BarMode::Command;
p.query.clear();
p.selected = 0;
self.shell_ready = false;
}
}
return;
}
if let KeyCode::Tab = key.code {
let mode = self.palette.as_ref().map(|p| p.bar_mode.clone());
match mode {
Some(BarMode::Shell) => { self.translate_shell_query(); return; }
_ => {
if let Some(p) = self.palette.as_mut() {
p.bar_mode = match p.bar_mode {
BarMode::Command => BarMode::Ask,
_ => BarMode::Command,
};
}
return;
}
}
}
let empty_query = self.palette.as_ref().map(|p| p.query.is_empty()).unwrap_or(false);
if (none || shift) && empty_query {
match key.code {
KeyCode::Char('!') => {
if let Some(p) = self.palette.as_mut() { p.bar_mode = BarMode::Shell; }
return;
}
KeyCode::Char('?') => {
if let Some(p) = self.palette.as_mut() { p.bar_mode = BarMode::Ask; }
return;
}
KeyCode::Char('@') => {
self.close_bar();
self.toggle_file_tree(); return;
}
_ => {}
}
}
let bar_mode = self
.palette
.as_ref()
.map(|p| p.bar_mode.clone())
.unwrap_or(BarMode::Command);
match bar_mode {
BarMode::Command => self.handle_bar_command(key, ctrl, none, shift),
BarMode::Ask => self.handle_bar_ask(key, none, shift),
BarMode::Shell => self.handle_bar_shell(key, none, shift),
}
}
fn submit_terminal_shell(&mut self) {
let cmd = self.palette.as_ref().map(|p| p.query.clone()).unwrap_or_default();
if cmd.trim().is_empty() {
return;
}
if self.shell_ready || !agent::AgentConfig::from_env().is_configured() {
self.close_bar();
self.run_shell_command(&cmd);
} else {
if let Some(p) = self.palette.as_mut() { p.bar_mode = BarMode::Shell; }
self.translate_shell_query();
}
}
fn close_bar(&mut self) {
if let Some(id) = self.translate_call_id.take() {
crate::llm_log::record_outcome(id, None, false, true);
}
self.translate_request = None;
self.palette = None;
self.mode = self.bar_return.clone();
self.agent_answer = None;
self.agent_partial = None;
self.agent_directive = None;
self.refactor_target = None;
self.refactor_replacement = None;
self.ask_scroll = 0;
}
fn handle_bar_command(&mut self, key: KeyEvent, ctrl: bool, none: bool, shift: bool) {
let items_len = {
let frec = &self.frecency;
self.palette.as_ref().map(|p| p.visible_items(frec).len()).unwrap_or(0)
};
match key.code {
KeyCode::Esc => {
let close = if let Some(p) = self.palette.as_mut() { !p.pop() } else { true };
if close { self.close_bar(); }
}
KeyCode::Up | KeyCode::BackTab => {
if let Some(p) = self.palette.as_mut() { p.select_up(items_len); }
}
KeyCode::Down => {
if let Some(p) = self.palette.as_mut() { p.select_down(items_len); }
}
KeyCode::Char('p') if ctrl => {
if let Some(p) = self.palette.as_mut() { p.select_up(items_len); }
}
KeyCode::Char('n') if ctrl => {
if let Some(p) = self.palette.as_mut() { p.select_down(items_len); }
}
KeyCode::Enter => {
let frec = &self.frecency;
let (kind, navigated) = self
.palette
.as_ref()
.map(|p| {
(p.visible_items(frec).into_iter().nth(p.selected).map(|r| r.kind), p.navigated)
})
.unwrap_or((None, false));
let has_query = self.palette.as_ref().map(|p| !p.query.trim().is_empty()).unwrap_or(false);
if kind.is_some() && (navigated || has_query) {
self.activate_kind(kind);
} else if has_query {
if self.bar_return == Mode::Terminal {
self.submit_terminal_shell();
} else {
if let Some(p) = self.palette.as_mut() { p.bar_mode = BarMode::Ask; }
self.submit_agent_query();
}
}
}
KeyCode::Backspace => {
let close = if let Some(p) = self.palette.as_mut() {
if p.query.is_empty() {
!p.pop()
} else {
p.query.pop();
p.selected = 0;
p.navigated = !p.query.is_empty();
false
}
} else { false };
if close { self.close_bar(); }
}
KeyCode::Char(c) if none || shift => {
if let Some(p) = self.palette.as_mut() {
p.query.push(c);
p.selected = 0;
p.navigated = true;
}
}
_ => {}
}
}
fn handle_bar_ask(&mut self, key: KeyEvent, none: bool, shift: bool) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && matches!(key.code, KeyCode::Char('l')) {
self.agent_history.clear();
self.agent_answer = None;
self.agent_partial = None;
self.agent_directive = None;
self.refactor_target = None;
self.refactor_replacement = None;
self.ask_scroll = 0;
return;
}
match key.code {
KeyCode::Esc => self.close_bar(),
KeyCode::Up => self.ask_scroll = self.ask_scroll.saturating_add(1),
KeyCode::Down => self.ask_scroll = self.ask_scroll.saturating_sub(1),
KeyCode::Enter => {
let has_query = self.palette.as_ref().map(|p| !p.query.trim().is_empty()).unwrap_or(false);
if self.refactor_replacement.is_some() && !has_query {
self.apply_refactor();
return;
}
match self.agent_directive.clone() {
Some(agent::AgentDirective::Run(name)) => {
self.agent_directive = None;
let Some(action) = Action::from_name(&name) else {
self.agent_answer = Some(format!("⚠ unknown action: {name}"));
return;
};
self.close_bar();
if action.is_destructive() {
self.start_prompt(
PromptKind::ConfirmAction(action.clone()),
&format!("Agent wants to run “{}” — y run · n cancel ", action.label()),
);
} else {
self.run_action(action);
}
}
Some(agent::AgentDirective::Type(cmd)) => {
self.agent_directive = None;
self.close_bar();
self.run_shell_command(&cmd);
}
Some(agent::AgentDirective::Open(loc)) => {
self.agent_directive = None;
self.close_bar();
self.open_at(&loc);
}
Some(agent::AgentDirective::Need(_)) => { self.agent_directive = None; }
None => self.submit_agent_query(),
}
}
KeyCode::Backspace => {
let close = if let Some(p) = self.palette.as_mut() {
if p.query.is_empty() {
true
} else {
p.query.pop();
false
}
} else { false };
if close {
self.close_bar();
} else {
self.agent_directive = None; }
}
KeyCode::Char(c) if none || shift => {
if let Some(p) = self.palette.as_mut() { p.query.push(c); }
self.agent_directive = None;
}
_ => {}
}
}
fn handle_bar_shell(&mut self, key: KeyEvent, none: bool, shift: bool) {
match key.code {
KeyCode::Esc => self.close_bar(),
KeyCode::Enter => {
let cmd = self.palette.as_ref().map(|p| p.query.clone()).unwrap_or_default();
if cmd.trim().is_empty() {
return;
}
if self.shell_ready || !agent::AgentConfig::from_env().is_configured() {
if let Some(id) = self.translate_call_id.take() {
if let Some(req) = self.translate_request.take() {
crate::retrieval::remember_command(&req, &cmd);
}
crate::llm_log::record_outcome(id, Some(&cmd), false, false);
}
self.close_bar();
self.run_shell_command(&cmd);
} else {
self.translate_shell_query();
}
}
KeyCode::Backspace => {
self.on_translation_edited();
self.shell_ready = false; self.agent_answer = None; if let Some(p) = self.palette.as_mut() {
if p.query.is_empty() {
p.bar_mode = BarMode::Command;
} else {
p.query.pop();
}
}
}
KeyCode::Char(c) if none || shift => {
self.on_translation_edited();
self.shell_ready = false;
self.agent_answer = None;
if let Some(p) = self.palette.as_mut() { p.query.push(c); }
}
_ => {}
}
}
fn open_at(&mut self, loc: &str) {
let (path, line) = match loc.rsplit_once(':') {
Some((p, n)) if n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty() => {
(p.to_string(), n.parse::<usize>().unwrap_or(1))
}
_ => (loc.to_string(), 1),
};
let path = path.trim();
if path.is_empty() {
return;
}
if matches!(self.focused_pane().content, PaneContent::Terminal(_))
&& self.tab().layout.count() < self.tuning.max_panes
{
self.split_vertical();
}
match self.open_file(path) {
Ok(buf_id) => {
let pid = self.focused_pane_id();
if let Some(pane) = self.panes.get_mut(&pid) {
pane.content = PaneContent::Editor(buf_id);
}
let last = self.buffers[&buf_id].line_count().saturating_sub(1);
let row = line.saturating_sub(1).min(last);
self.set_cursor(row, 0);
self.recenter();
self.mode = Mode::Edit;
self.status_msg = Some(format!("Opened {}:{}", path, line));
}
Err(e) => self.status_msg = Some(format!("Can't open {}: {}", path, e)),
}
}
pub fn toggle_file_tree(&mut self) {
if !self.tree_open {
self.ensure_project_index();
if self.file_tree.is_none() {
let root = self
.project_index
.as_ref()
.map(|i| i.root.clone())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let root = std::fs::canonicalize(&root).unwrap_or(root);
self.file_tree = Some(FileTree {
root,
expanded: std::collections::HashSet::new(),
selected: 0,
filter: String::new(),
});
}
self.tree_open = true;
self.mode = Mode::Tree;
self.refresh_tree_rows();
} else if self.mode == Mode::Tree {
self.close_tree();
} else {
self.mode = Mode::Tree;
self.refresh_tree_rows();
}
}
fn close_tree(&mut self) {
self.tree_open = false;
self.mode = Mode::Edit;
self.file_tree = None;
self.tree_rows.clear();
}
fn refresh_tree_rows(&mut self) {
let rows = self.compute_tree_rows();
let n = rows.len();
self.tree_rows = rows;
if let Some(t) = self.file_tree.as_mut() {
if t.selected >= n {
t.selected = n.saturating_sub(1);
}
}
}
fn compute_tree_rows(&self) -> Vec<TreeRow> {
let Some(tree) = self.file_tree.as_ref() else { return Vec::new() };
if !tree.filter.is_empty() {
let mut scored: Vec<(i64, u32, String)> = self
.project_index
.as_ref()
.map(|i| {
i.files
.iter()
.filter_map(|f| {
palette::fuzzy_score(&tree.filter, f)
.map(|s| (s, *self.file_frecency.get(f).unwrap_or(&0), f.clone()))
})
.collect()
})
.unwrap_or_default();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
return scored
.into_iter()
.take(300)
.map(|(_, _, rel)| TreeRow {
path: tree.root.join(&rel),
label: rel,
depth: 0,
is_dir: false,
expanded: false,
updir: false,
})
.collect();
}
let mut rows = Vec::new();
if tree.root.parent().is_some() {
rows.push(TreeRow {
path: tree.root.clone(),
label: "../".into(),
depth: 0,
is_dir: true,
expanded: false,
updir: true,
});
}
self.push_dir_rows(&tree.root, 0, &tree.expanded, &mut rows);
rows
}
fn push_dir_rows(
&self,
dir: &std::path::Path,
depth: usize,
expanded: &std::collections::HashSet<std::path::PathBuf>,
rows: &mut Vec<TreeRow>,
) {
for (name, is_dir) in self.read_dir_entries(dir) {
let path = dir.join(&name);
let is_expanded = is_dir && expanded.contains(&path);
rows.push(TreeRow {
path: path.clone(),
label: name,
depth,
is_dir,
expanded: is_expanded,
updir: false,
});
if is_expanded {
self.push_dir_rows(&path, depth + 1, expanded, rows);
}
}
}
fn read_dir_entries(&self, dir: &std::path::Path) -> Vec<(String, bool)> {
let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() };
let mut entries: Vec<(String, bool)> = rd
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') || self.tuning.project_ignore.iter().any(|i| i == &name) {
return None;
}
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
Some((name, is_dir))
})
.collect();
entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.to_lowercase().cmp(&b.0.to_lowercase())));
entries
}
fn handle_tree(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let none = key.modifiers.is_empty();
let shift = key.modifiers == KeyModifiers::SHIFT;
let len = self.tree_rows.len();
match key.code {
KeyCode::Esc | KeyCode::Char('g') if key.code == KeyCode::Esc || ctrl => {
let cleared = self.file_tree.as_mut().map(|t| {
if t.filter.is_empty() { false } else { t.filter.clear(); t.selected = 0; true }
}).unwrap_or(false);
if cleared { self.refresh_tree_rows(); }
else { self.close_tree(); }
}
KeyCode::Up | KeyCode::BackTab => {
if let Some(t) = self.file_tree.as_mut() { t.selected = t.selected.saturating_sub(1); }
}
KeyCode::Down => {
if let Some(t) = self.file_tree.as_mut() {
if t.selected + 1 < len { t.selected += 1; }
}
}
KeyCode::Char('p') if ctrl => {
if let Some(t) = self.file_tree.as_mut() { t.selected = t.selected.saturating_sub(1); }
}
KeyCode::Char('n') if ctrl => {
if let Some(t) = self.file_tree.as_mut() {
if t.selected + 1 < len { t.selected += 1; }
}
}
KeyCode::Enter => self.tree_activate(true), KeyCode::Right => self.tree_activate(false), KeyCode::Left => self.tree_collapse(),
KeyCode::Backspace => {
let changed = self.file_tree.as_mut().map(|t| {
if t.filter.is_empty() { false } else { t.filter.pop(); t.selected = 0; true }
}).unwrap_or(false);
if changed { self.refresh_tree_rows(); }
}
KeyCode::Char(c) if none || shift => {
if let Some(t) = self.file_tree.as_mut() { t.filter.push(c); t.selected = 0; }
self.refresh_tree_rows();
}
_ => {}
}
}
fn tree_activate(&mut self, commit: bool) {
let Some(row) = self.tree_rows.get(self.file_tree.as_ref().map(|t| t.selected).unwrap_or(0)) else { return };
let (path, is_dir, updir) = (row.path.clone(), row.is_dir, row.updir);
if updir {
if let Some(parent) = path.parent().map(|p| p.to_path_buf()) {
if let Some(t) = self.file_tree.as_mut() { t.root = parent; t.selected = 0; }
}
self.refresh_tree_rows();
} else if is_dir {
if let Some(t) = self.file_tree.as_mut() {
if !t.expanded.remove(&path) { t.expanded.insert(path); }
}
self.refresh_tree_rows();
} else {
self.show_file_in_pane(&path, commit);
}
}
fn tree_collapse(&mut self) {
let sel = self.file_tree.as_ref().map(|t| t.selected).unwrap_or(0);
let Some(row) = self.tree_rows.get(sel) else { return };
if row.is_dir && row.expanded {
let path = row.path.clone();
if let Some(t) = self.file_tree.as_mut() { t.expanded.remove(&path); }
self.refresh_tree_rows();
} else if row.depth > 0 {
let target_depth = row.depth - 1;
let parent = self.tree_rows[..sel].iter().rposition(|r| r.depth == target_depth);
if let (Some(idx), Some(t)) = (parent, self.file_tree.as_mut()) { t.selected = idx; }
}
}
fn show_file_in_pane(&mut self, path: &std::path::Path, commit: bool) {
let existing = self
.buffers
.values()
.find(|b| b.path.as_deref() == Some(path))
.map(|b| b.id);
if matches!(self.focused_pane().content, PaneContent::Terminal(_))
&& self.tab().layout.count() < self.tuning.max_panes
{
self.split_vertical();
}
let buf = match existing {
Some(id) => Ok(id),
None => self.open_file(&path.to_string_lossy()),
};
match buf {
Ok(buf_id) => {
let pid = self.focused_pane_id();
if let Some(pane) = self.panes.get_mut(&pid) {
pane.content = PaneContent::Editor(buf_id);
pane.cursor_row = 0; pane.cursor_col = 0; pane.scroll_row = 0;
pane.selection_anchor = None;
}
if commit {
self.mode = Mode::Edit; let name = path.file_name().map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
self.status_msg = Some(format!("Opened {name}"));
}
}
Err(e) => self.status_msg = Some(format!("Can't open {}: {e}", path.display())),
}
}
fn translate_shell_query(&mut self) {
let text = self.palette.as_ref().map(|p| p.query.clone()).unwrap_or_default();
if text.trim().is_empty() {
return;
}
let cfg = agent::AgentConfig::from_env();
if !cfg.is_configured() {
self.status_msg = Some("No API key — set GEMINI_API_KEY to translate".into());
return;
}
self.agent_pending = true;
self.translate_request = Some(text.clone()); agent::translate_shell(cfg, text, self.screen_context(), self.agent_tx.clone());
}
fn on_translation_edited(&mut self) {
if self.shell_ready {
if let Some(id) = self.translate_call_id.take() {
crate::llm_log::record_outcome(id, None, true, false);
}
}
}
fn run_shell_command(&mut self, cmd: &str) {
let term_pane = self
.tab()
.layout
.pane_ids()
.into_iter()
.find(|id| matches!(self.panes.get(id).map(|p| &p.content), Some(PaneContent::Terminal(_))));
if let Some(pid) = term_pane {
self.tab_mut().focused_pane = pid;
self.mode = Mode::Terminal;
} else {
self.open_terminal();
}
if let PaneContent::Terminal(tid) = self.focused_pane().content {
if let Some(t) = self.terms.get_mut(&tid) {
t.send_bytes(cmd.as_bytes());
t.send_bytes(b"\n");
}
}
}
fn ask_prefilled(&mut self, question: &str) {
self.open_bar(BarMode::Ask);
if let Some(p) = self.palette.as_mut() {
p.query = question.to_string();
}
self.submit_agent_query();
}
fn submit_agent_query(&mut self) {
let question = self.palette.as_ref().map(|p| p.query.clone()).unwrap_or_default();
if question.trim().is_empty() {
return;
}
let mut cfg = agent::AgentConfig::from_env();
cfg.max_tokens = self.tuning.agent_max_tokens;
cfg.temperature = self.tuning.agent_temperature;
if !cfg.is_configured() {
self.agent_answer = Some(
"⚠ No API key. Export GROQ_API_KEY, GEMINI_API_KEY, or MARS_LLM_KEY and retry."
.into(),
);
self.agent_directive = None;
return;
}
self.agent_pending = true;
self.agent_answer = None;
self.agent_directive = None;
self.refactor_replacement = None;
self.last_question = question.clone();
self.need_depth = 0;
self.ask_scroll = 0; let history = self.agent_history.clone();
self.agent_history.push(("user".into(), question.clone()));
if let Some(p) = self.palette.as_mut() {
p.query.clear();
}
let mut context = self.screen_context();
self.refactor_target = self.selection_range();
if let Some(sel) = self.selected_text() {
context.push_str(&sel);
} else if let PaneContent::Editor(buf_id) = self.focused_pane().content {
let (row, col) = {
let p = self.focused_pane();
(p.cursor_row, p.cursor_col)
};
let at = self.buffers[&buf_id].char_at(row, col);
self.refactor_target = Some((buf_id, at, at));
context.push_str(
&crate::prompts::CURSOR_INSERT
.replace("{line}", &(row + 1).to_string())
.replace("{file}", &self.buffers[&buf_id].name),
);
}
agent::ask(
cfg,
question,
palette::registry_context(),
context,
history,
self.agent_tx.clone(),
);
}
pub fn apply_refactor(&mut self) {
let (Some((buf_id, s, e)), Some(code)) =
(self.refactor_target, self.refactor_replacement.take())
else {
return;
};
self.refactor_target = None;
let len = self.buffers.get(&buf_id).map(|b| b.rope.len_chars()).unwrap_or(0);
let (s, e) = (s.min(len), e.min(len));
if let Some(buf) = self.buffers.get_mut(&buf_id) {
buf.checkpoint(); buf.rope.remove(s..e);
buf.rope.insert(s, &code);
buf.modified = true;
}
let (r, c) = self.rowcol_of(buf_id, s + code.chars().count());
self.close_bar();
self.clear_selection();
self.set_cursor(r, c);
self.mode = Mode::Edit;
self.status_msg = Some("Refactor applied — C-/ to undo".into());
}
fn reask_with_need(&mut self, kind: agent::NeedKind) {
let mut cfg = agent::AgentConfig::from_env();
cfg.max_tokens = self.tuning.agent_max_tokens;
cfg.temperature = self.tuning.agent_temperature;
if !cfg.is_configured() {
self.agent_pending = false;
return;
}
let extra = self.expand_context(&kind);
let context = format!("{}\n\n### expanded ###\n{}", self.screen_context(), extra);
let history = self.agent_history.clone();
let q = self.last_question.clone();
self.agent_pending = true; agent::ask(cfg, q, palette::registry_context(), context, history, self.agent_tx.clone());
}
fn expand_context(&self, kind: &agent::NeedKind) -> String {
match kind {
agent::NeedKind::Scrollback => {
if let PaneContent::Terminal(id) = self.focused_pane().content {
if let Some(t) = self.terms.get(&id) {
let cap = self.tuning.terminal_scrollback_lines.min(2000);
return format!("FULL TERMINAL SCROLLBACK:\n{}", t.history_tail(cap));
}
}
String::new()
}
agent::NeedKind::Tab(name) => {
let low = name.to_lowercase();
let Some(tab) = self.tabs.iter().find(|t| t.name.to_lowercase().contains(&low)) else {
return format!("(no tab matching '{name}')");
};
let mut out = format!("TAB {}:\n", tab.name);
for pid in tab.layout.pane_ids() {
let Some(p) = self.panes.get(&pid) else { continue };
match p.content {
PaneContent::Terminal(tid) => {
if let Some(t) = self.terms.get(&tid) {
out.push_str(t.screen().contents().trim_end());
out.push('\n');
}
}
PaneContent::Editor(bid) => {
if let Some(b) = self.buffers.get(&bid) {
out.push_str(&format!("[{}]\n", b.name));
for line in b.rope.to_string().lines().take(120) {
out.push_str(line);
out.push('\n');
}
}
}
}
}
out
}
}
}
fn selected_text(&self) -> Option<String> {
let (buf_id, s, e) = self.selection_range()?;
let buf = self.buffers.get(&buf_id)?;
let text = buf.rope.slice(s..e).to_string();
let (sr, _) = self.rowcol_of(buf_id, s);
let (er, _) = self.rowcol_of(buf_id, e);
Some(format!(
"\n\nSELECTED CODE — {} lines {}-{} (the user has this highlighted). If they ask \
to refactor/rewrite/fix/simplify it, reply with ONLY the replacement inside one \
``` code block and no prose:\n```\n{}\n```\n",
buf.name,
sr + 1,
er + 1,
text
))
}
fn screen_context(&self) -> String {
const CAP: usize = 6 * 1024;
let mut out = String::new();
if let Some(s) = &self.session_name {
out.push_str(&format!("session: {s}\n"));
}
let tab_names: Vec<String> = self
.tabs
.iter()
.enumerate()
.map(|(i, t)| {
if i == self.active_tab { format!("[{}]", t.name) } else { t.name.clone() }
})
.collect();
out.push_str(&format!("tabs: {}\n", tab_names.join(" ")));
let focused = self.focused_pane_id();
for pid in self.tab().layout.pane_ids() {
let Some(pane) = self.panes.get(&pid) else { continue };
let marker = if pid == focused { " (focused)" } else { "" };
match pane.content {
PaneContent::Editor(buf_id) => {
if let Some(buf) = self.buffers.get(&buf_id) {
out.push_str(&format!(
"\n--- editor pane: {}{marker}, cursor at line {} ---\n",
buf.name,
pane.cursor_row + 1
));
let from = pane.scroll_row;
let to = (from + pane.view_h.max(20) + 10).min(buf.line_count());
for row in from..to {
out.push_str(&buf.line_str(row));
out.push('\n');
}
}
}
PaneContent::Terminal(tid) => {
if let Some(t) = self.terms.get(&tid) {
out.push_str(&format!("\n--- terminal pane{marker} ---\n"));
out.push_str(t.screen().contents().trim_end());
out.push('\n');
}
}
}
if out.len() > CAP {
break;
}
}
if out.len() > CAP {
let head: String = out.chars().take(CAP / 3).collect();
let tail: String = out
.chars()
.rev()
.take(2 * CAP / 3)
.collect::<String>()
.chars()
.rev()
.collect();
out = format!("{head}\n…(truncated)…\n{tail}");
}
out
}
fn activate_kind(&mut self, kind: Option<ItemKind>) {
match kind {
Some(ItemKind::Submenu(name)) => {
if let Some(p) = self.palette.as_mut() {
p.push(name);
}
}
Some(ItemKind::Run(action)) => {
self.palette = None;
self.mode = self.bar_return.clone();
self.run_action_from_bar(action);
}
None => {}
}
}
fn run_action_from_bar(&mut self, action: Action) {
let key = format!("{:?}", action);
let uses = self.bar_uses.entry(key).or_insert(0);
*uses += 1;
let nudge = if *uses >= self.tuning.nudge_threshold {
self.keys
.binding_for(&action)
.map(|b| format!("💡 next time: {} ({})", b, action.label()))
} else {
None
};
self.run_action(action);
if let Some(n) = nudge {
self.status_msg = Some(n);
}
}
pub fn run_action(&mut self, action: Action) {
self.edit_run = EditRun::None; let key = format!("{:?}", action);
*self.frecency.entry(key).or_insert(0) += 1;
if !matches!(action, Action::Yank | Action::YankPop) {
self.last_yank = None;
}
match action {
Action::SplitHorizontal => self.split_horizontal(),
Action::SplitVertical => self.split_vertical(),
Action::ClosePane => self.close_pane(),
Action::DeleteOtherWindows => self.delete_other_windows(),
Action::NextPane => self.focus_next_pane(),
Action::PrevPane => self.focus_prev_pane(),
Action::SwapPane => self.swap_pane(),
Action::ZoomPane => self.toggle_zoom(),
Action::NewTab => self.new_tab(),
Action::CloseTab => self.close_tab(),
Action::NextTab => self.next_tab(),
Action::PrevTab => self.prev_tab(),
Action::MoveTabLeft => self.move_tab(-1),
Action::MoveTabRight => self.move_tab(1),
Action::RenameTab => {
let current = self.tab().name.clone();
self.start_prompt_with(PromptKind::RenameTab, "Rename tab: ", ¤t);
}
Action::RenamePane => {
let current = self.focused_pane().title.clone().unwrap_or_default();
self.start_prompt_with(PromptKind::RenamePane, "Rename pane: ", ¤t);
}
Action::RenameSession => {
if self.session_name.is_some() {
let current = self.session_name.clone().unwrap_or_default();
self.start_prompt_with(PromptKind::RenameSession, "Rename session: ", ¤t);
} else {
self.status_msg =
Some("Not in a session — start one with: mars new <name>".into());
}
}
Action::TabMode => self.mode = Mode::Tab,
Action::Save => self.do_save(),
Action::ToggleFileTree => self.toggle_file_tree(),
Action::RefreshIndex => {
self.project_index = None;
self.ensure_project_index();
if self.tree_open { self.refresh_tree_rows(); }
self.status_msg = Some("File index refreshed".into());
}
Action::RestoreKeybindings => {
match config::reset_keys() {
Ok(_) => {
self.keys = config::load(); self.status_msg = Some("Default keybindings restored (old file → keys.json.bak)".into());
}
Err(e) => self.status_msg = Some(format!("Reset failed: {e}")),
}
}
Action::KillBuffer => self.kill_buffer(),
Action::Undo => self.do_undo(),
Action::Redo => self.do_redo(),
Action::UndoMode => self.enter_undo_mode(),
Action::KillLine => self.kill_line(),
Action::KillRegion => self.kill_region(),
Action::CopyRegion => self.copy_region(),
Action::Yank => self.yank(),
Action::YankPop => self.yank_pop(),
Action::Paste => self.paste_clipboard(),
Action::KillWordForward => self.kill_word(true),
Action::KillWordBackward => self.kill_word(false),
Action::SelectAll => self.select_all(),
Action::GoTop => self.move_file_start(),
Action::GoBottom => self.move_file_end(),
Action::GotoLine => self.start_prompt(PromptKind::GotoLine, "Go to line: "),
Action::JumpBlockPrev => self.jump_block(false),
Action::JumpBlockNext => self.jump_block(true),
Action::JumpSymbolPrev => self.jump_symbol(false),
Action::JumpSymbolNext => self.jump_symbol(true),
Action::MatchBracket => self.match_bracket(),
Action::Recenter => self.recenter(),
Action::Search => self.start_isearch(),
Action::QueryReplace => self.start_prompt(PromptKind::ReplaceFrom, "Query replace: "),
Action::OpenTerminal => self.open_terminal(),
Action::AskAgent => self.open_bar(BarMode::Ask),
Action::ExplainThis => self.ask_prefilled(crate::prompts::EXPLAIN_THIS.trim_end()),
Action::ExplainFailure => self.ask_prefilled(crate::prompts::EXPLAIN_FAILURE.trim_end()),
Action::WatchPane => self.toggle_watch_pane(),
Action::ExpandNotices => self.expand_notices(),
Action::AwayDigest => self.show_away_digest(),
Action::Detach => {
if self.session_name.is_some() {
self.detach_requested = true;
} else {
self.status_msg =
Some("Not in a session — start one with: mars --session <name>".into());
}
}
Action::OpenCommandMemory => self.open_command_memory(),
Action::ClearCommandMemory => self.clear_command_memory(),
Action::OpenDenylist => self.open_denylist(),
Action::Quit => {
if self.session_name.is_some() {
self.detach_requested = true;
} else {
self.request_quit();
}
}
Action::KillSession => self.request_quit(),
}
}
fn open_command_memory(&mut self) {
match crate::retrieval::command_memory_path() {
Some(p) if p.exists() => {
let path = p.to_string_lossy().into_owned();
if let Err(e) = self.open_file(&path) {
self.status_msg = Some(format!("couldn't open {path}: {e}"));
}
}
_ => {
self.status_msg =
Some("no command memory yet — accept a translated command first".into());
}
}
}
fn clear_command_memory(&mut self) {
let n = crate::retrieval::load_command_records().len();
if n == 0 {
self.status_msg = Some("command memory is already empty".into());
return;
}
if !self.close_confirmed {
self.start_prompt(
PromptKind::ConfirmAction(Action::ClearCommandMemory),
&format!("Forget all {n} remembered command(s)? y forget · n cancel "),
);
return;
}
if let Some(p) = crate::retrieval::command_memory_path() {
let _ = std::fs::write(&p, "");
}
self.status_msg = Some(format!("forgot {n} remembered command(s)"));
}
fn open_denylist(&mut self) {
let Some(p) = crate::retrieval::denylist_path() else {
self.status_msg = Some("no HOME — can't locate ~/.mars/denylist".into());
return;
};
if !p.exists() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(
&p,
"# One entry per line. Any line's text appearing in command memory or\n\
# shell history is replaced with [REDACTED] before entering an LLM prompt.\n",
);
}
let path = p.to_string_lossy().into_owned();
if let Err(e) = self.open_file(&path) {
self.status_msg = Some(format!("couldn't open {path}: {e}"));
}
}
pub fn open_terminal(&mut self) {
if let PaneContent::Terminal(_) = self.focused_pane().content {
self.mode = Mode::Terminal;
return;
}
let id = self.next_term_id;
self.next_term_id += 1;
let (rows, cols) = (self.tuning.terminal_default_rows, self.tuning.terminal_default_cols);
let scrollback = self.tuning.terminal_scrollback_lines;
let cwd = self.startup_cwd.clone().or_else(|| self.run_cwd.clone());
match terminal::spawn(id, rows, cols, scrollback, cwd, self.session_name.as_deref(), self.term_tx.clone()) {
Ok(term) => {
self.terms.insert(id, term);
let pid = self.focused_pane_id();
if let Some(p) = self.panes.get_mut(&pid) {
p.content = PaneContent::Terminal(id);
}
self.mode = Mode::Terminal;
self.status_msg = Some("Terminal — Ctrl+g back to editor".into());
}
Err(e) => {
self.status_msg = Some(format!("Terminal failed: {}", e));
}
}
}
fn mode_for_focused_pane(&self) -> Mode {
match self.focused_pane().content {
PaneContent::Terminal(_) => Mode::Terminal,
PaneContent::Editor(_) => Mode::Edit,
}
}
fn is_chrome_action(a: &Action) -> bool {
matches!(
a,
Action::NextPane | Action::PrevPane | Action::SwapPane
| Action::NextTab | Action::PrevTab | Action::MoveTabLeft
| Action::MoveTabRight | Action::NewTab | Action::TabMode
| Action::SplitHorizontal | Action::SplitVertical
)
}
fn handle_terminal(&mut self, key: KeyEvent) {
let chord = chord_of(&key);
if self.keys.bar_open.contains(&chord) || matches!(key.code, KeyCode::Null) {
self.open_bar(BarMode::Command);
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
if let KeyCode::Char('g') = key.code {
self.mode = Mode::Edit;
return;
}
}
if matches!(key.code, KeyCode::Esc) && !self.notices.is_empty() {
self.dismiss_notice();
return;
}
if let Some(action) = self.keys.lookup(std::slice::from_ref(&chord)) {
if Self::is_chrome_action(&action) {
self.run_action(action);
if !matches!(self.mode, Mode::Tab | Mode::Bar) {
self.mode = self.mode_for_focused_pane();
}
return;
}
}
let alt = key.modifiers.contains(KeyModifiers::ALT);
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char(c) if alt && c.is_ascii_digit() => {
self.goto_tab((c as u8 - b'0') as usize);
self.mode = self.mode_for_focused_pane();
return;
}
KeyCode::Left if alt || ctrl => {
self.focus_direction(-1, 0);
self.mode = self.mode_for_focused_pane();
return;
}
KeyCode::Right if alt || ctrl => {
self.focus_direction(1, 0);
self.mode = self.mode_for_focused_pane();
return;
}
KeyCode::Up if alt || ctrl => {
self.focus_direction(0, -1);
self.mode = self.mode_for_focused_pane();
return;
}
KeyCode::Down if alt || ctrl => {
self.focus_direction(0, 1);
self.mode = self.mode_for_focused_pane();
return;
}
_ => {}
}
let term_id = match self.focused_pane().content {
PaneContent::Terminal(id) => id,
_ => {
self.mode = Mode::Edit;
return;
}
};
if self.terms.get(&term_id).map(|t| t.exited).unwrap_or(false) {
if matches!(key.code, KeyCode::Enter | KeyCode::Char('q')) {
self.close_terminal_pane(term_id);
}
return;
}
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
if shift && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) {
let page = self.focused_pane().view_h.max(2) as i64 - 1;
let delta = if key.code == KeyCode::PageUp { page } else { -page };
if let Some(t) = self.terms.get_mut(&term_id) {
t.scroll_view(delta);
}
return;
}
let bytes = key_to_bytes(&key);
if !bytes.is_empty() {
if let Some(t) = self.terms.get_mut(&term_id) {
t.scroll_to_live(); t.send_bytes(&bytes);
}
}
}
fn close_terminal_pane(&mut self, term_id: TermId) {
self.terms.remove(&term_id);
if self.tab().layout.count() > 1 {
self.close_pane();
} else {
let buf = match self.buffers.keys().next().copied() {
Some(b) => b,
None => self.new_scratch(),
};
let pid = self.focused_pane_id();
if let Some(p) = self.panes.get_mut(&pid) {
p.content = PaneContent::Editor(buf);
p.cursor_row = 0;
p.cursor_col = 0;
p.scroll_row = 0;
}
}
self.mode = self.mode_for_focused_pane();
}
pub fn tick(&mut self) {
self.frame_tick = self.frame_tick.wrapping_add(1);
let now = self.frame_tick;
while let Ok(ev) = self.term_rx.try_recv() {
self.needs_redraw = true; match ev {
TermEvent::Output(id) => {
if let Some(w) = self.watches.get_mut(&id) {
if w.triggered || w.run_started_tick == 0 {
w.run_started_tick = now;
}
w.last_output_tick = now;
w.triggered = false;
}
}
TermEvent::Exited(id) => {
if let Some(t) = self.terms.get_mut(&id) {
t.exited = true;
}
let watched = self.watches.get(&id).map(|w| w.watched && !w.triggered).unwrap_or(false);
if watched {
self.pending_watch = Some((id, WatchReason::Exit)); } else {
self.push_away(AwayKind::Done, "shell exited".into(), None);
}
}
}
}
let secs = self.tuning.autosave_secs;
if secs > 0 {
let ticks_per_save = (secs * 1000 / self.tuning.poll_interval_ms.max(1)).max(1);
if self.frame_tick % ticks_per_save == 0 {
self.autosave();
}
}
let mut events = Vec::new();
while let Ok(ev) = self.agent_rx.try_recv() {
events.push(ev);
}
if !events.is_empty() {
self.needs_redraw = true; }
for ev in events {
match ev {
AgentEvent::Answer { text, directive } => {
if let Some(agent::AgentDirective::Need(kind)) = &directive {
if self.need_depth < 1 {
self.need_depth += 1;
self.reask_with_need(kind.clone());
continue;
}
}
self.agent_pending = false;
self.agent_partial = None;
if self.refactor_target.is_some() {
self.refactor_replacement = extract_code_block(&text);
}
self.agent_history.push(("assistant".into(), text));
self.agent_directive = directive;
self.ask_scroll = 0; }
AgentEvent::AnswerStart => {
self.agent_partial = Some(String::new());
}
AgentEvent::AnswerDelta { text } => {
self.agent_partial.get_or_insert_with(String::new).push_str(&text);
}
AgentEvent::AutoName { tab_id, name } => {
self.bg_busy = false;
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
if tab.name.chars().all(|c| c.is_ascii_digit()) {
tab.name = name;
}
}
}
AgentEvent::SessionName { name } => {
self.bg_busy = false;
let numeric = self
.session_name
.as_ref()
.map(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(false);
if numeric {
self.rename_session_to = Some(name);
}
}
AgentEvent::ShellTranslation { command, call_id } => {
self.agent_pending = false;
let is_shell = self
.palette
.as_ref()
.map(|p| p.bar_mode == BarMode::Shell)
.unwrap_or(false);
if is_shell {
if let Some(p) = self.palette.as_mut() {
p.query = command;
}
self.shell_ready = true; self.translate_call_id = Some(call_id); self.agent_answer = None; }
}
AgentEvent::Error(e) => {
self.agent_pending = false;
self.agent_partial = None;
self.bg_busy = false;
self.agent_answer = Some(format!("⚠ {}", e));
self.agent_directive = None;
}
AgentEvent::BgDone => {
self.bg_busy = false;
}
AgentEvent::WatchSummary { term_id, verdict } => {
self.bg_busy = false;
let failed = verdict.to_lowercase().contains("fail")
|| verdict.to_lowercase().contains("error");
let tab = self.tab_label_of_term(term_id);
let dur = self.watches.get(&term_id).and_then(|w| {
(w.run_started_tick > 0).then(|| now.saturating_sub(w.run_started_tick))
});
if let Some(w) = self.watches.get_mut(&term_id) {
w.verdict = Some(verdict.clone());
}
self.notices.push(Notice {
text: format!("{verdict}{tab}"),
kind: if failed { NoticeKind::Failure } else { NoticeKind::Info },
});
self.notices.sort_by(|a, b| a.kind.cmp(&b.kind));
self.push_away(
if failed { AwayKind::NeedsYou } else { AwayKind::Done },
format!("{verdict}{tab}"),
dur,
);
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
crate::worklog::record(&crate::worklog::WorkEntry {
ts,
session: self.session_label(),
tab: tab.trim_start_matches(" · ").trim().to_string(),
verdict: verdict.clone(),
failed,
dur_secs: dur.map(|t| t * self.tuning.poll_interval_ms / 1000),
});
self.maybe_infer_mission(ts);
}
AgentEvent::Mission { text } => {
self.bg_busy = false;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
crate::worklog::save_mission(&self.session_label(), &text, ts);
}
}
}
self.maybe_auto_name();
self.maybe_auto_name_session();
self.maybe_fire_watches();
if self.agent_pending || !self.pending_prefix.is_empty() {
self.needs_redraw = true;
}
}
fn push_away(&mut self, kind: AwayKind, text: String, dur_ticks: Option<u64>) {
self.away_log.push(AwayEvent {
tick: self.frame_tick,
kind,
text,
dur_ticks,
});
const CAP: usize = 200;
if self.away_log.len() > CAP {
let drop = self.away_log.len() - CAP;
self.away_log.drain(0..drop);
}
}
fn fmt_dur(&self, ticks: u64) -> String {
let secs = ticks * self.tuning.poll_interval_ms.max(1) / 1000;
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
fn toggle_watch_pane(&mut self) {
let PaneContent::Terminal(id) = self.focused_pane().content else {
self.status_msg = Some("Watch works on a terminal pane".into());
return;
};
let now = self.frame_tick;
let w = self.watches.entry(id).or_default();
w.watched = !w.watched;
w.last_output_tick = now;
w.triggered = false;
let watching = w.watched;
self.status_msg = Some(if watching {
if agent::AgentConfig::from_env().is_configured() {
"Watching this pane — I'll summarize it when it quiets (~20s) or exits".into()
} else {
"Watching — but set GROQ_API_KEY/GEMINI_API_KEY for the AI summary".into()
}
} else {
"Stopped watching this pane".into()
});
}
pub fn on_detach(&mut self) {
self.detach_tick = Some(self.frame_tick);
self.detach_snapshot = Some(Snapshot {
dirty: self.buffers.values().filter(|b| b.modified).map(|b| b.name.clone()).collect(),
});
}
pub fn on_attach(&mut self) {
let Some(snap) = self.detach_snapshot.take() else { return };
let from = self.detach_tick.take().unwrap_or(0);
let dirty: Vec<String> = self
.buffers
.values()
.filter(|b| b.modified && !snap.dirty.contains(&b.name))
.map(|b| b.name.clone())
.collect();
if !dirty.is_empty() {
let text = format!(
"{} file{} modified ({})",
dirty.len(),
if dirty.len() == 1 { "" } else { "s" },
dirty.join(", ")
);
self.push_away(AwayKind::Context, text, None);
}
let session = self.session_label();
let mission = crate::worklog::load_mission(&session);
let recent = crate::worklog::recent(&session, 5);
if mission.is_some() || !recent.is_empty() {
let mut brief = String::from("Where you left off\n");
if let Some((m, _)) = &mission {
brief.push_str(&format!(" mission: {m}\n"));
}
for e in &recent {
let mark = if e.failed { "✗" } else { "✓" };
brief.push_str(&format!(
" {mark} {} [{}] {}\n",
crate::broker::ago(e.ts),
e.tab,
e.verdict
));
}
self.agent_history.push(("assistant".into(), brief));
}
let events: Vec<&AwayEvent> = self.away_log.iter().filter(|e| e.tick >= from).collect();
let mut items: Vec<String> = Vec::new();
let mut failed = false;
for e in events.iter().filter(|e| e.kind == AwayKind::NeedsYou) {
items.push(format!("✗ {}", e.text));
failed = true;
}
let done = events.iter().filter(|e| e.kind == AwayKind::Done).count();
for e in events.iter().filter(|e| e.kind == AwayKind::Done).take(2) {
items.push(format!("✓ {}", e.text));
}
if done > 2 {
items.push(format!("+{} more done", done - 2));
}
for e in events.iter().filter(|e| e.kind == AwayKind::Context) {
items.push(e.text.clone());
}
if items.is_empty() {
return; }
self.notices.retain(|n| !events.iter().any(|e| n.text == e.text));
self.digest_from_tick = Some(from); let away = self.fmt_dur(self.frame_tick.saturating_sub(from));
let in_term = matches!(self.focused_pane().content, PaneContent::Terminal(_));
let hint = match self.keys.binding_for(&Action::AwayDigest) {
Some(b) if in_term => format!(" · C-g then {b} · digest"),
Some(b) => format!(" · {b} digest"),
None => String::new(),
};
self.notices.push(Notice {
text: format!("while away {away} — {}{hint}", items.join(" · ")),
kind: if failed { NoticeKind::Failure } else { NoticeKind::Info },
});
self.notices.sort_by(|a, b| a.kind.cmp(&b.kind));
}
fn show_away_digest(&mut self) {
let from = self.digest_from_tick.unwrap_or(0);
let events: Vec<AwayEvent> =
self.away_log.iter().filter(|e| e.tick >= from).cloned().collect();
if events.is_empty() {
self.status_msg = Some("Away digest: nothing notable recorded".into());
return;
}
let now = self.frame_tick;
let mut out = String::from("While you were away\n");
for (kind, title) in [
(AwayKind::NeedsYou, "✗ needs you"),
(AwayKind::Done, "✓ done"),
(AwayKind::Context, "· context"),
] {
let section: Vec<&AwayEvent> = events.iter().filter(|e| e.kind == kind).collect();
if section.is_empty() {
continue;
}
out.push_str(&format!("\n{title}\n"));
for e in section {
let ago = self.fmt_dur(now.saturating_sub(e.tick));
let dur = e
.dur_ticks
.map(|d| format!(", ran {}", self.fmt_dur(d)))
.unwrap_or_default();
out.push_str(&format!(" {ago} ago — {}{dur}\n", e.text));
}
}
self.agent_history.push(("assistant".into(), out));
self.ask_scroll = 0;
self.open_bar(BarMode::Ask);
}
fn session_label(&self) -> String {
self.session_name.clone().unwrap_or_else(|| "standalone".to_string())
}
fn maybe_infer_mission(&mut self, now: u64) {
let refresh = self.tuning.mission_refresh_secs;
if refresh == 0 || self.bg_busy {
return;
}
let session = self.session_label();
if let Some((_, as_of)) = crate::worklog::load_mission(&session) {
if now.saturating_sub(as_of) < refresh {
return;
}
}
let entries = crate::worklog::recent(&session, 15);
if entries.len() < 2 {
return;
}
let cfg = agent::AgentConfig::from_env();
if !cfg.is_configured() {
return;
}
let lines: Vec<String> = entries
.iter()
.map(|e| {
let mark = if e.failed { "✗" } else { "✓" };
format!("{} {} [{}] {}", mark, crate::broker::ago(e.ts), e.tab, e.verdict)
})
.collect();
self.bg_busy = true;
agent::infer_mission(cfg, lines, self.agent_tx.clone());
}
fn expand_notices(&mut self) {
if self.notices.is_empty() {
self.status_msg = Some("no pending notices".into());
return;
}
let mut out = String::from("Pending notices\n");
for n in &self.notices {
let mark = if n.kind == NoticeKind::Failure { "✗" } else { "·" };
out.push_str(&format!(" {mark} {}\n", n.text));
}
self.notices.clear();
self.agent_history.push(("assistant".into(), out));
self.ask_scroll = 0;
self.open_bar(BarMode::Ask);
}
pub fn dismiss_notice(&mut self) -> bool {
if self.notices.is_empty() {
false
} else {
self.notices.remove(0);
true
}
}
fn maybe_fire_watches(&mut self) {
if self.bg_busy || self.agent_pending {
return;
}
let quiet_ticks =
self.tuning.watch_quiet_secs * 1000 / self.tuning.poll_interval_ms.max(1);
let now = self.frame_tick;
let candidate = self.pending_watch.is_some()
|| self.watches.values().any(|w| {
w.watched && !w.triggered && now.saturating_sub(w.last_output_tick) > quiet_ticks
});
if !candidate {
return;
}
let cfg = agent::AgentConfig::from_env();
if cfg.provider == "broker" && !cfg.is_configured() {
return;
}
let fire = self.pending_watch.take().or_else(|| {
self.watches
.iter()
.find(|(_, w)| {
w.watched && !w.triggered && now.saturating_sub(w.last_output_tick) > quiet_ticks
})
.map(|(id, _)| (*id, WatchReason::Quiet))
});
let Some((id, reason)) = fire else { return };
if let Some(w) = self.watches.get_mut(&id) {
w.triggered = true;
}
if !cfg.is_configured() {
return; }
let tail = self.terminal_tail(id, self.tuning.agent_scrollback_context);
self.bg_busy = true;
agent::watch_summary(cfg, id, reason, tail, self.agent_tx.clone());
}
fn terminal_tail(&self, id: TermId, lines: usize) -> String {
let Some(t) = self.terms.get(&id) else { return String::new() };
let contents = t.screen().contents();
let rows: Vec<&str> = contents.lines().collect();
let start = rows.len().saturating_sub(lines);
rows[start..].join("\n")
}
fn tab_label_of_term(&self, id: TermId) -> String {
for tab in &self.tabs {
for pid in tab.layout.pane_ids() {
if let Some(p) = self.panes.get(&pid) {
if matches!(p.content, PaneContent::Terminal(tid) if tid == id) {
return format!(" · {}", tab.name);
}
}
}
}
String::new()
}
fn maybe_auto_name_session(&mut self) {
if self.session_name_attempted || self.tuning.auto_name_secs == 0 {
return;
}
let numeric = self
.session_name
.as_ref()
.map(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(false);
if !numeric {
self.session_name_attempted = true; return;
}
let ticks = (self.tuning.auto_name_secs * 2 * 1000 / self.tuning.poll_interval_ms.max(1)).max(1);
if self.frame_tick % ticks != 0 || self.bg_busy {
return;
}
let cfg = agent::AgentConfig::from_env();
if !cfg.is_configured() {
return;
}
self.session_name_attempted = true;
self.bg_busy = true;
agent::name_session(cfg, self.screen_context(), self.agent_tx.clone());
}
fn maybe_auto_name(&mut self) {
let secs = self.tuning.auto_name_secs;
if secs == 0 || self.bg_busy {
return;
}
let ticks = (secs * 1000 / self.tuning.poll_interval_ms.max(1)).max(1);
if self.frame_tick % ticks != 0 {
return;
}
let tab = self.tab();
let (tab_id, default_named) =
(tab.id, tab.name.chars().all(|c| c.is_ascii_digit()));
if !default_named || self.auto_name_attempted.contains(&tab_id) {
return;
}
let has_content = self.tab().layout.pane_ids().iter().any(|pid| {
match self.panes.get(pid).map(|p| &p.content) {
Some(PaneContent::Editor(b)) => {
self.buffers.get(b).map(|b| b.rope.len_chars() > 40).unwrap_or(false)
}
Some(PaneContent::Terminal(_)) => true,
None => false,
}
});
if !has_content {
return;
}
let cfg = agent::AgentConfig::from_env();
if !cfg.is_configured() {
return;
}
self.auto_name_attempted.insert(tab_id);
self.bg_busy = true;
agent::auto_name(cfg, tab_id, self.screen_context(), self.agent_tx.clone());
}
pub fn apply_input(&mut self, ev: InputEvent) -> Result<()> {
match ev {
InputEvent::Key(key) => self.handle_key(key)?,
InputEvent::Mouse(m) => self.handle_mouse(m),
InputEvent::Paste(s) => self.paste_text(&s),
InputEvent::Resize(_, _) => {} }
Ok(())
}
pub fn run<W: io::Write>(
&mut self,
terminal: &mut Terminal<CrosstermBackend<W>>,
events: &mpsc::Receiver<InputEvent>,
) -> Result<()> {
loop {
self.tick();
if self.needs_redraw {
terminal.draw(|f| ui::render(f, self))?;
self.needs_redraw = false;
}
match events.recv_timeout(Duration::from_millis(self.tuning.poll_interval_ms)) {
Ok(first) => {
self.apply_input(first)?;
while let Ok(ev) = events.try_recv() {
self.apply_input(ev)?;
}
self.needs_redraw = true; }
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break, }
if self.detach_requested {
self.detach_requested = false;
self.status_msg =
Some("Not in a session — start one with: mars --session <name>".into());
}
if self.should_quit {
break;
}
}
self.save_state();
Ok(())
}
pub fn save_state_now(&self) {
self.save_state();
}
pub fn handle_mouse(&mut self, m: MouseEvent) {
if matches!(self.mode, Mode::Bar)
&& matches!(self.palette.as_ref().map(|p| &p.bar_mode), Some(BarMode::Ask))
{
match m.kind {
MouseEventKind::ScrollUp => {
self.ask_scroll = self.ask_scroll.saturating_add(self.tuning.wheel_scroll_lines);
}
MouseEventKind::ScrollDown => {
self.ask_scroll = self.ask_scroll.saturating_sub(self.tuning.wheel_scroll_lines);
}
_ => {}
}
return;
}
if !matches!(self.mode, Mode::Edit | Mode::Terminal) {
return;
}
match m.kind {
MouseEventKind::Down(MouseButton::Left) => {
let hit = self
.pane_rects
.iter()
.find(|(_, r)| {
m.column >= r.x && m.column < r.x + r.width
&& m.row >= r.y && m.row < r.y + r.height
})
.map(|(id, r)| (*id, *r));
let (pane_id, rect) = match hit { Some(h) => h, None => return };
self.tab_mut().focused_pane = pane_id;
match self.panes.get(&pane_id).map(|p| p.content.clone()) {
Some(PaneContent::Terminal(tid)) => {
self.mode = Mode::Terminal;
let (rows, cols) = self
.terms
.get(&tid)
.map(|t| t.screen().size())
.unwrap_or((0, 0));
let vh = rows.min(rect.height.saturating_sub(2));
let vw = cols.min(rect.width.saturating_sub(2));
let (ox, oy) = (rect.x + 1, rect.y + 1);
let cell = (
m.row.saturating_sub(oy).min(vh.saturating_sub(1)),
m.column.saturating_sub(ox).min(vw.saturating_sub(1)),
);
self.term_sel = Some(TermSel { tid, ox, oy, vw, vh, anchor: cell, end: cell });
}
Some(PaneContent::Editor(buf_id)) => {
self.mode = Mode::Edit;
let inner_x = rect.x + 1 + crate::ui::gutter_width(&self.tuning);
let inner_y = rect.y + 1;
if m.row >= inner_y && m.column >= rect.x + 1 {
let scroll = self.panes[&pane_id].scroll_row;
let row = scroll + (m.row - inner_y) as usize;
let row = row.min(self.buffers[&buf_id].line_count().saturating_sub(1));
let col = (m.column.saturating_sub(inner_x)) as usize;
let col = col.min(self.buffers[&buf_id].line_len(row));
self.clear_selection();
let p = self.panes.get_mut(&pane_id).unwrap();
p.cursor_row = row;
p.cursor_col = col;
p.col_affinity = col;
}
}
None => {}
}
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
let up = matches!(m.kind, MouseEventKind::ScrollUp);
let n = self.tuning.wheel_scroll_lines;
let fid = self.focused_pane_id();
let rect = self.pane_rects.iter().find(|(id, _)| *id == fid).map(|(_, r)| *r);
match self.focused_pane().content {
PaneContent::Terminal(tid) => {
let Some(t) = self.terms.get_mut(&tid) else { return };
let screen = t.screen();
let delta = if up { n as i64 } else { -(n as i64) };
if t.view_offset() > 0 {
t.scroll_view(delta);
} else if screen.mouse_protocol_mode() != vt100::MouseProtocolMode::None {
let (mut x, mut y) = (1u16, 1u16);
if let Some(r) = rect {
if m.column > r.x && m.row > r.y {
x = (m.column - r.x).min(r.width.saturating_sub(2).max(1));
y = (m.row - r.y).min(r.height.saturating_sub(2).max(1));
}
}
let bytes = encode_wheel(&screen, up, x, y);
t.send_bytes(&bytes);
} else if screen.alternate_screen() {
let seq: &[u8] = match (up, screen.application_cursor()) {
(true, false) => b"\x1b[A",
(true, true) => b"\x1bOA",
(false, false) => b"\x1b[B",
(false, true) => b"\x1bOB",
};
for _ in 0..n {
t.send_bytes(seq);
}
} else {
t.scroll_view(delta);
}
}
PaneContent::Editor(_) if self.mode == Mode::Edit => {
for _ in 0..n {
if up { self.move_up() } else { self.move_down() }
}
}
_ => {}
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(sel) = self.term_sel.as_mut() {
sel.end = (
m.row.saturating_sub(sel.oy).min(sel.vh.saturating_sub(1)),
m.column.saturating_sub(sel.ox).min(sel.vw.saturating_sub(1)),
);
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(sel) = self.term_sel.take() {
let text = if sel.anchor == sel.end {
String::new()
} else {
self.term_selection_text(&sel)
};
if !text.is_empty() {
if let Some(cb) = self.clipboard.as_mut() {
let _ = cb.set_text(text.clone());
}
self.kill_ring.push(text.clone());
self.status_msg = Some(format!("Copied {} chars", text.chars().count()));
}
}
}
_ => {}
}
}
fn term_selection_text(&self, sel: &TermSel) -> String {
let Some(t) = self.terms.get(&sel.tid) else { return String::new() };
let (mut a, mut b) = (sel.anchor, sel.end);
if b < a {
std::mem::swap(&mut a, &mut b);
}
selection_text_from_screen(&t.screen(), a, b, sel.vw.saturating_sub(1))
}
fn save_state(&self) {
let state = PersistedState {
frecency: self.frecency.clone(),
bar_uses: self.bar_uses.clone(),
file_frecency: self.file_frecency.clone(),
};
if let Some(path) = config::state_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(&state) {
let _ = std::fs::write(path, json);
}
}
}
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct PersistedState {
#[serde(default)]
frecency: HashMap<String, u32>,
#[serde(default)]
bar_uses: HashMap<String, u32>,
#[serde(default)]
file_frecency: HashMap<String, u32>,
}
impl PersistedState {
fn load() -> Self {
config::state_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
}
pub fn extract_code_block(text: &str) -> Option<String> {
let start = text.find("```")?;
let after = &text[start + 3..];
let body_start = after.find('\n').map(|i| i + 1).unwrap_or(after.len());
let body = &after[body_start..];
let end = body.find("```")?;
Some(body[..end].trim_end_matches('\n').to_string())
}
fn encode_wheel(screen: &vt100::Screen, up: bool, x: u16, y: u16) -> Vec<u8> {
let button: u32 = if up { 64 } else { 65 };
match screen.mouse_protocol_encoding() {
vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{x};{y}M").into_bytes(),
vt100::MouseProtocolEncoding::Utf8 => {
let mut out = vec![0x1b, b'[', b'M'];
for v in [32 + button, 32 + x as u32, 32 + y as u32] {
let mut buf = [0u8; 4];
out.extend_from_slice(
char::from_u32(v).unwrap_or(' ').encode_utf8(&mut buf).as_bytes(),
);
}
out
}
vt100::MouseProtocolEncoding::Default => {
let b = |v: u32| (32 + v.min(223)) as u8;
vec![0x1b, b'[', b'M', b(button), b(x as u32), b(y as u32)]
}
}
}
fn key_to_bytes(key: &KeyEvent) -> Vec<u8> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char(c) => {
if ctrl && c.is_ascii_alphabetic() {
vec![(c.to_ascii_lowercase() as u8 - b'a') + 1]
} else {
let mut b = [0u8; 4];
c.encode_utf8(&mut b).as_bytes().to_vec()
}
}
KeyCode::Enter => vec![b'\r'],
KeyCode::Backspace => vec![0x7f],
KeyCode::Tab => vec![b'\t'],
KeyCode::BackTab => vec![0x1b, b'[', b'Z'],
KeyCode::Esc => vec![0x1b],
KeyCode::Left => vec![0x1b, b'[', b'D'],
KeyCode::Right => vec![0x1b, b'[', b'C'],
KeyCode::Up => vec![0x1b, b'[', b'A'],
KeyCode::Down => vec![0x1b, b'[', b'B'],
KeyCode::Home => vec![0x1b, b'[', b'H'],
KeyCode::End => vec![0x1b, b'[', b'F'],
KeyCode::PageUp => vec![0x1b, b'[', b'5', b'~'],
KeyCode::PageDown => vec![0x1b, b'[', b'6', b'~'],
KeyCode::Delete => vec![0x1b, b'[', b'3', b'~'],
_ => vec![],
}
}