diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..843c34a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: "/" + schedule: + interval: daily + time: "08:00" + open-pull-requests-limit: 2 diff --git a/README.md b/README.md index e97fbe1..d968d28 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,6 @@ - [x] Lock screen: application lock screen and automatic lock on idle timeout - [x] Static suggestions: preconfigured static command suggestions with wildcard support -### Roadmap 🏁 - -- [ ] Support Lua scripting for more customizable scenarios -- [ ] Support workflows -- [ ] ... - ### Quick Start #### Play cast files diff --git a/README.zh-CN.md b/README.zh-CN.md index b925acb..b17b27f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,12 +35,6 @@ - [x] 锁屏:应用锁屏与超时自动锁定 - [x] 静态提示:基于预配置的静态命令提示,支持通配符 -### 路线 🏁 - -- [ ] 支持 Lua 脚本,支持更多场景的可定制化 -- [ ] 支持工作流 -- [ ] ... - ### 快速上手 #### 回放 cast diff --git a/assets/shell/termua-osc133.bash b/assets/shell/termua-osc133.bash deleted file mode 100644 index 03b841c..0000000 --- a/assets/shell/termua-osc133.bash +++ /dev/null @@ -1,54 +0,0 @@ -# Termua OSC 133 shell integration (bash). -# -# Emits a minimal subset of OSC 133 markers: -# - A: prompt start -# - B: prompt end -# - C: command start -# - D;: command end -# -# This is intentionally best-effort and designed to be injected only for -# Termua-spawned terminals (not by editing user dotfiles). - -[[ $- == *i* ]] || return 0 - -__termua_osc133_print() { - # BEL-terminated OSC: ESC ] ... BEL - printf '\e]133;%s\a' "$1" -} - -# Set once we're about to show a prompt; the first DEBUG trap after that is -# treated as the user's next command starting. -TERMUA_OSC133_PROMPT_READY= -TERMUA_OSC133_COMMAND_ACTIVE= - -__termua_osc133_preexec() { - if [[ -n "${TERMUA_OSC133_PROMPT_READY-}" ]]; then - TERMUA_OSC133_PROMPT_READY= - TERMUA_OSC133_COMMAND_ACTIVE=1 - __termua_osc133_print "B" - __termua_osc133_print "C" - fi -} - -__termua_osc133_precmd() { - local status=$? - - if [[ -n "${TERMUA_OSC133_COMMAND_ACTIVE-}" ]]; then - __termua_osc133_print "D;$status" - TERMUA_OSC133_COMMAND_ACTIVE= - fi - - __termua_osc133_print "A" - - if [[ -n "${__termua_osc133_orig_prompt_command-}" ]]; then - eval "$__termua_osc133_orig_prompt_command" - fi - - TERMUA_OSC133_PROMPT_READY=1 -} - -trap '__termua_osc133_preexec' DEBUG - -# Preserve existing PROMPT_COMMAND best-effort by running it from within our wrapper. -__termua_osc133_orig_prompt_command="${PROMPT_COMMAND-}" -PROMPT_COMMAND='__termua_osc133_precmd' diff --git a/assets/shell/termua-osc133.ps1 b/assets/shell/termua-osc133.ps1 deleted file mode 100644 index c15afa8..0000000 --- a/assets/shell/termua-osc133.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -# Termua OSC 133 shell integration (PowerShell). -# -# Emits a minimal subset of OSC 133 markers: -# - A: prompt start -# - B: prompt end -# - C: command start -# - D;: command end - -if ($global:TERMUA_OSC133_PWSH_INSTALLED) { - return -} - -$global:TERMUA_OSC133_PWSH_INSTALLED = $true -$global:TERMUA_OSC133_COMMAND_ACTIVE = $false -$global:TERMUA_OSC133_ORIG_PROMPT = ${function:prompt} - -function global:__termua_osc133_print { - param([string]$Payload) - - $esc = [char]27 - $bel = [char]7 - [Console]::Out.Write("$esc]133;$Payload$bel") -} - -function global:prompt { - if ($global:TERMUA_OSC133_COMMAND_ACTIVE) { - $exitCode = if ($global:LASTEXITCODE -is [int]) { - $global:LASTEXITCODE - } elseif ($?) { - 0 - } else { - 1 - } - __termua_osc133_print "D;$exitCode" - $global:TERMUA_OSC133_COMMAND_ACTIVE = $false - } - - __termua_osc133_print "A" - - if ($global:TERMUA_OSC133_ORIG_PROMPT) { - & $global:TERMUA_OSC133_ORIG_PROMPT - } else { - "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " - } -} - -if (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue) { - Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock { - param($key, $arg) - - __termua_osc133_print "B" - __termua_osc133_print "C" - $global:TERMUA_OSC133_COMMAND_ACTIVE = $true - [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() - } -} diff --git a/assets/shell/termua-osc133.zsh b/assets/shell/termua-osc133.zsh deleted file mode 100644 index 9565116..0000000 --- a/assets/shell/termua-osc133.zsh +++ /dev/null @@ -1,42 +0,0 @@ -# Termua OSC 133 shell integration (zsh). -# -# Emits a minimal subset of OSC 133 markers: -# - A: prompt start -# - B: prompt end -# - C: command start -# - D;: command end -# -# This is intentionally best-effort and designed to be injected only for -# Termua-spawned terminals (not by editing user dotfiles). - -[[ -o interactive ]] || return 0 - -if [[ -n "${TERMUA_OSC133_ZSH_INSTALLED-}" ]]; then - return 0 -fi -TERMUA_OSC133_ZSH_INSTALLED=1 -TERMUA_OSC133_COMMAND_ACTIVE= - -__termua_osc133_print() { - # BEL-terminated OSC: ESC ] ... BEL - printf '\e]133;%s\a' "$1" -} - -__termua_osc133_preexec() { - TERMUA_OSC133_COMMAND_ACTIVE=1 - __termua_osc133_print "B" - __termua_osc133_print "C" -} - -__termua_osc133_precmd() { - local exit_status=$? - if [[ -n "${TERMUA_OSC133_COMMAND_ACTIVE-}" ]]; then - __termua_osc133_print "D;$exit_status" - TERMUA_OSC133_COMMAND_ACTIVE= - fi - __termua_osc133_print "A" -} - -autoload -Uz add-zsh-hook -add-zsh-hook preexec __termua_osc133_preexec -add-zsh-hook precmd __termua_osc133_precmd diff --git a/crates/alacritty_terminal/src/event.rs b/crates/alacritty_terminal/src/event.rs index d8cf40b..9ad7d54 100644 --- a/crates/alacritty_terminal/src/event.rs +++ b/crates/alacritty_terminal/src/event.rs @@ -60,15 +60,6 @@ pub enum Event { /// Child process exited. ChildExit(ExitStatus), - - /// OSC 133 shell integration event. - /// - /// This is used by downstream UIs to build "command blocks". - Osc133 { - payload: String, - stable_row: i64, - cursor_col: usize, - }, } impl Debug for Event { @@ -87,14 +78,6 @@ impl Debug for Event { Event::Bell => write!(f, "Bell"), Event::Exit => write!(f, "Exit"), Event::ChildExit(status) => write!(f, "ChildExit({status:?})"), - Event::Osc133 { - payload, - stable_row, - cursor_col, - } => write!( - f, - "Osc133(payload={payload:?}, stable_row={stable_row}, cursor_col={cursor_col})" - ), } } } diff --git a/crates/alacritty_terminal/src/event_loop.rs b/crates/alacritty_terminal/src/event_loop.rs index 27aee19..e6345c3 100644 --- a/crates/alacritty_terminal/src/event_loop.rs +++ b/crates/alacritty_terminal/src/event_loop.rs @@ -32,160 +32,6 @@ pub(crate) const READ_BUFFER_SIZE: usize = 0x10_0000; /// Max bytes to read from the PTY while the terminal is locked. const MAX_LOCKED_READ: usize = u16::MAX as usize; -#[derive(Debug, Default)] -struct Osc133StreamParser { - pending_start_esc: bool, - in_osc: bool, - osc_id: u32, - osc_id_digits: bool, - osc_seen_semicolon: bool, - osc_payload: Vec, - osc_pending_st_esc: bool, - osc_invalid: bool, -} - -/// Offset is the exclusive end position in the pushed `bytes` slice. -type Osc133Completion = (usize, String); - -impl Osc133StreamParser { - #[cfg(test)] - fn new() -> Self { - Self::default() - } - - fn push_with_offsets(&mut self, bytes: &[u8]) -> Vec { - let mut out: Vec = Vec::new(); - - let mut i = 0usize; - while i < bytes.len() { - let b = bytes[i]; - - if !self.in_osc { - if self.pending_start_esc { - self.pending_start_esc = false; - if b == b']' { - self.begin_osc(); - i += 1; - continue; - } - } - - if b == 0x1b { - self.pending_start_esc = i + 1 == bytes.len(); - if !self.pending_start_esc && bytes[i + 1] == b']' { - self.begin_osc(); - i += 2; - continue; - } - } - - i += 1; - continue; - } - - // In OSC body. - if self.osc_pending_st_esc { - self.osc_pending_st_esc = false; - if b == b'\\' { - self.finish_osc(&mut out, i + 1); - i += 1; - continue; - } - self.push_osc_byte(0x1b); - self.push_osc_byte(b); - i += 1; - continue; - } - - match b { - 0x07 => { - self.finish_osc(&mut out, i + 1); - i += 1; - } - 0x1b => { - if i + 1 == bytes.len() { - self.osc_pending_st_esc = true; - i += 1; - } else if bytes[i + 1] == b'\\' { - self.finish_osc(&mut out, i + 2); - i += 2; - } else { - self.push_osc_byte(b); - i += 1; - } - } - _ => { - self.push_osc_byte(b); - i += 1; - } - } - } - - out - } - - fn begin_osc(&mut self) { - self.in_osc = true; - self.osc_id = 0; - self.osc_id_digits = false; - self.osc_seen_semicolon = false; - self.osc_payload.clear(); - self.osc_pending_st_esc = false; - self.osc_invalid = false; - } - - fn push_osc_byte(&mut self, b: u8) { - if self.osc_invalid { - return; - } - - if !self.osc_seen_semicolon { - if b.is_ascii_digit() { - self.osc_id_digits = true; - self.osc_id = match self - .osc_id - .checked_mul(10) - .and_then(|v| v.checked_add(u32::from(b - b'0'))) - { - Some(v) => v, - None => { - self.osc_invalid = true; - return; - } - }; - return; - } - - if b == b';' && self.osc_id_digits { - self.osc_seen_semicolon = true; - return; - } - - self.osc_invalid = true; - return; - } - - if self.osc_id == 133 { - self.osc_payload.push(b); - } - } - - fn finish_osc(&mut self, out: &mut Vec, end_offset: usize) { - if !self.osc_invalid && self.osc_seen_semicolon && self.osc_id == 133 { - let payload = String::from_utf8_lossy(&self.osc_payload).to_string(); - out.push((end_offset, payload)); - } - - self.in_osc = false; - self.osc_pending_st_esc = false; - self.osc_payload.clear(); - self.osc_invalid = false; - self.osc_id_digits = false; - self.osc_seen_semicolon = false; - self.osc_id = 0; - } -} - /// Messages that may be sent to the `EventLoop`. #[derive(Debug)] pub enum Msg { @@ -313,30 +159,7 @@ where writer.write_all(&buf[..unprocessed]).unwrap(); } - // Parse the incoming bytes, interleaving OSC 133 completions so we can report cursor - // position at the completion boundary. - let completions = state.osc.push_with_offsets(&buf[..unprocessed]); - let mut prev = 0usize; - for (end, payload) in completions { - let end = end.min(unprocessed); - if end > prev { - state.parser.advance(&mut **terminal, &buf[prev..end]); - } - - let cursor = terminal.grid().cursor.point; - let stable_row = terminal.grid().stable_row_id_for_line(cursor.line); - self.event_proxy.send_event(Event::Osc133 { - payload, - stable_row, - cursor_col: cursor.column.0, - }); - prev = end; - } - if prev < unprocessed { - state - .parser - .advance(&mut **terminal, &buf[prev..unprocessed]); - } + state.parser.advance(&mut **terminal, &buf[..unprocessed]); processed += unprocessed; unprocessed = 0; @@ -590,7 +413,6 @@ pub struct State { write_list: VecDeque>, writing: Option, parser: ansi::Processor, - osc: Osc133StreamParser, } impl State { @@ -676,34 +498,3 @@ impl PeekableReceiver { } } } - -#[cfg(test)] -mod osc133_stream_parser_tests { - use super::*; - - #[test] - fn osc133_stream_parser_reads_bel_terminated_sequences_with_offsets() { - let mut p = Osc133StreamParser::new(); - let evs = p.push_with_offsets(b"\x1b]133;C\x07"); - assert_eq!(evs.len(), 1); - assert_eq!(evs[0].0, b"\x1b]133;C\x07".len()); - assert_eq!(evs[0].1, "C".to_string()); - } - - #[test] - fn osc133_stream_parser_reads_st_terminated_sequences_with_offsets() { - let mut p = Osc133StreamParser::new(); - let evs = p.push_with_offsets(b"\x1b]133;D;0\x1b\\"); - assert_eq!(evs.len(), 1); - assert_eq!(evs[0].0, b"\x1b]133;D;0\x1b\\".len()); - assert_eq!(evs[0].1, "D;0".to_string()); - } - - #[test] - fn osc133_stream_parser_handles_split_reads() { - let mut p = Osc133StreamParser::new(); - assert!(p.push_with_offsets(b"\x1b]133;C").is_empty()); - let evs = p.push_with_offsets(b"\x07"); - assert_eq!(evs, vec![(1, "C".to_string())]); - } -} diff --git a/crates/gpui_term/src/backends/alacritty/mod.rs b/crates/gpui_term/src/backends/alacritty/mod.rs index cacd076..9e2912a 100644 --- a/crates/gpui_term/src/backends/alacritty/mod.rs +++ b/crates/gpui_term/src/backends/alacritty/mod.rs @@ -4,7 +4,7 @@ use std::{ collections::{BTreeMap, HashMap, VecDeque}, ops::RangeInclusive, sync::Arc, - time::{Duration, Instant, SystemTime}, + time::{Duration, SystemTime}, }; use alacritty_terminal::{ @@ -50,7 +50,6 @@ use crate::{ TerminalBackend, TerminalBounds, TerminalContent, TerminalMode, TerminalShutdownPolicy, TerminalType, backends, cast::{CastHeader, CastRecorderSender, CastRecorderState, start_cast_recorder}, - command_blocks::CommandBlockTracker, settings::{CursorShape, TerminalSettings}, }; @@ -66,11 +65,9 @@ fn local_pty_options_for_program_exists( program_exists: impl FnOnce(&str) -> bool, ) -> Options { let shell_program = crate::shell::pick_shell_program_from_env(&env); - let shell = shell_program.filter(|p| program_exists(p)).map(|p| { - let args = crate::shell::shell_integration_args_for_env(p, &env); - - tty::Shell::new(p.to_string(), args) - }); + let shell = shell_program + .filter(|p| program_exists(p)) + .map(|p| tty::Shell::new(p.to_string(), Vec::new())); Options { shell, @@ -79,87 +76,6 @@ fn local_pty_options_for_program_exists( } } -fn prev_stable_row(stable: i64) -> i64 { - if stable > 0 { stable - 1 } else { 0 } -} - -fn adjust_osc133_boundary(payload: &str, stable: i64, cursor_x: usize) -> i64 { - // Keep consistent with the WezTerm backend heuristic. - // - // When the cursor is at column 0, it often means: - // - For `C`: the user hit enter and the cursor moved to the next line, so the command line - // itself is the previous row. - // - For `D`: the command finished and the cursor is on the next (prompt) line, so the output - // ends on the previous row. - let kind = payload.trim_start().as_bytes().first().copied(); - if cursor_x == 0 && matches!(kind, Some(b'A') | Some(b'C') | Some(b'D')) { - prev_stable_row(stable) - } else { - stable - } -} - -fn stable_row_texts(term: &Term) -> Vec<(i64, String)> { - let grid = term.grid(); - let last_col = grid.last_column(); - - (grid.topmost_line().0..=grid.bottommost_line().0) - .map(|line| { - let stable = grid.stable_row_id_for_line(Line(line)); - let text = term - .bounds_to_string( - AlacPoint::new(Line(line), Column(0)), - AlacPoint::new(Line(line), last_col), - ) - .trim_end() - .to_string(); - (stable, text) - }) - .collect() -} - -fn wrapped_command_span_for_stable_row( - term: &Term, - stable_row: i64, -) -> Option<(i64, String)> { - let grid = term.grid(); - let mut line = grid.line_for_stable_row_id(stable_row)?; - let last_col = grid.last_column(); - - while line.0 > grid.topmost_line().0 { - let prev = Line(line.0 - 1); - if !grid[prev][last_col].flags.contains(Flags::WRAPLINE) { - break; - } - line = prev; - } - - let mut command = String::new(); - let end = grid.line_for_stable_row_id(stable_row)?; - for row in line.0..=end.0 { - command.push_str( - term.bounds_to_string( - AlacPoint::new(Line(row), Column(0)), - AlacPoint::new(Line(row), last_col), - ) - .trim_end_matches(|c: char| c.is_whitespace()), - ); - } - - let command = command.trim_end().to_string(); - (!command.trim().is_empty()).then(|| (grid.stable_row_id_for_line(line), command)) -} - -fn stable_row_for_grid_line(term: &Term, line: i32) -> Option { - let grid = term.grid(); - let top = grid.topmost_line().0; - let bottom = grid.bottommost_line().0; - if line < top || line > bottom { - return None; - } - Some(grid.stable_row_id_for_line(Line(line))) -} - fn selection_from_lines(start_line: i32, end_line: i32, last_col: usize) -> Selection { let mut start = AlacPoint::new(Line(start_line), Column(0)); let mut end = AlacPoint::new(Line(end_line), Column(last_col)); @@ -380,7 +296,6 @@ impl TerminalBuilder { content: TerminalContent::default(), sftp, record: RecordState::new(cast_slot), - blocks: CommandBlockTracker::new(200), }, events_rx, }) @@ -464,7 +379,6 @@ impl TerminalBuilder { struct SelectionState { head: Option, phase: SelectionPhase, - command_block_id: Option, } impl Default for SelectionState { @@ -472,7 +386,6 @@ impl Default for SelectionState { Self { head: None, phase: SelectionPhase::Ended, - command_block_id: None, } } } @@ -522,8 +435,6 @@ pub struct AlacrittyBackend { // Asciinema cast recording. record: RecordState, - - blocks: CommandBlockTracker, } impl AlacrittyBackend { @@ -627,37 +538,6 @@ impl AlacrittyBackend { AlacTermEvent::Wakeup => { self.search.dirty = true; } - AlacTermEvent::Osc133 { - payload, - stable_row, - cursor_col, - } => { - let mut line = adjust_osc133_boundary(&payload, stable_row, cursor_col); - let command = if payload.trim_start().starts_with('C') { - let term = self.term.lock_unfair(); - match wrapped_command_span_for_stable_row(&term, line) { - Some((start_line, command)) => { - line = start_line; - Some(command) - } - None => { - let grid = term.grid(); - grid.line_for_stable_row_id(line).map(|row| { - term.bounds_to_string( - AlacPoint::new(row, Column(0)), - AlacPoint::new(row, grid.last_column()), - ) - .trim_end() - .to_string() - }) - } - } - } else { - None - }; - self.blocks - .apply_osc133(&payload, Instant::now(), line, command); - } AlacTermEvent::Bell => cx.emit(Event::Bell), AlacTermEvent::Exit => { self.exited = true; @@ -779,21 +659,6 @@ impl AlacrittyBackend { } self.pty_tx.0.send(Msg::Resize(new_bounds.into())).ok(); term.resize(new_bounds); - let cursor_stable = term - .grid() - .stable_row_id_for_line(term.grid().cursor.point.line); - let lines = stable_row_texts(term); - self.blocks.remap_after_rewrap(&lines, cursor_stable); - if let Some(block_id) = self.selection.command_block_id - && let Some((start_stable, end_stable)) = self.blocks.range_for_block_id(block_id) - && let Some(start_line) = term.grid().line_for_stable_row_id(start_stable) - && let Some(end_line) = term.grid().line_for_stable_row_id(end_stable) - { - let last_col = new_bounds.num_columns().saturating_sub(1); - term.selection = Some(selection_from_lines(start_line.0, end_line.0, last_col)); - self.selection.head = None; - self.selection.phase = SelectionPhase::Ended; - } } fn apply_clear_op(term: &mut Term, cx: &mut Context) { @@ -838,7 +703,6 @@ impl AlacrittyBackend { selection.update(point, side); term.selection = Some(selection); self.selection.head = Some(point); - self.selection.command_block_id = None; cx.emit(Event::SelectionsChanged); } } @@ -973,7 +837,6 @@ impl TerminalBackend for AlacrittyBackend { fn clear_selection(&mut self) { self.selection.head = None; self.selection.phase = SelectionPhase::Ended; - self.selection.command_block_id = None; self.pending_ops.push_back(TermOp::SetSelection(None)); } @@ -1201,36 +1064,9 @@ impl TerminalBackend for AlacrittyBackend { Some(term.bounds_to_string(start, end).trim_end().to_string()) } - fn command_blocks(&self) -> Option> { - Some(self.blocks.blocks()) - } - - fn stable_row_for_grid_line(&self, line: i32) -> Option { - let term = self.term.lock_unfair(); - let grid = term.grid(); - let top = grid.topmost_line().0; - let bottom = grid.bottommost_line().0; - if line < top || line > bottom { - return None; - } - Some(grid.stable_row_id_for_line(Line(line))) - } - - fn grid_line_for_stable_row(&self, stable_row: i64) -> Option { - let term = self.term.lock_unfair(); - let grid = term.grid(); - grid.line_for_stable_row_id(stable_row).map(|l| l.0) - } - fn set_selection_range(&mut self, range: Option) { self.selection.head = None; self.selection.phase = SelectionPhase::Ended; - self.selection.command_block_id = range.as_ref().and_then(|range| { - let term = self.term.lock_unfair(); - let start = stable_row_for_grid_line(&term, range.start.line)?; - let end = stable_row_for_grid_line(&term, range.end.line)?; - self.blocks.block_id_for_range(start, end) - }); let Some(range) = range else { self.pending_ops.push_back(TermOp::SetSelection(None)); @@ -1375,7 +1211,6 @@ impl TerminalBackend for AlacrittyBackend { SelectionType::Semantic }; let selection = Selection::new(ty, point, side); - self.selection.command_block_id = None; self.pending_ops .push_back(TermOp::SetSelection(Some((selection, point)))); } @@ -1389,7 +1224,6 @@ impl TerminalBackend for AlacrittyBackend { let position = e.position - self.content.terminal_bounds.bounds.origin; if !self.mouse_mode(e.modifiers.shift) { self.selection.phase = SelectionPhase::Selecting; - self.selection.command_block_id = None; self.pending_ops .push_back(TermOp::UpdateSelection(position)); @@ -1450,7 +1284,6 @@ impl TerminalBackend for AlacrittyBackend { self.content.display_offset, ); let selection = Selection::new(SelectionType::Simple, point, side); - self.selection.command_block_id = None; self.pending_ops .push_back(TermOp::SetSelection(Some((selection, point)))); cx.notify(); @@ -1867,24 +1700,6 @@ mod shell_tests { assert!(opts.shell.is_some()); } - #[test] - fn local_pty_options_uses_shell_integration_args() { - let mut env = std::collections::HashMap::new(); - env.insert("TERMUA_SHELL".to_string(), "pwsh".to_string()); - env.insert( - "TERMUA_PWSH_INIT".to_string(), - "/tmp/termua-test.ps1".to_string(), - ); - - let opts = local_pty_options_for_program_exists(env, |p| p == "pwsh"); - let shell = opts.shell.expect("expected shell"); - let shell_debug = format!("{shell:?}"); - assert!(shell_debug.contains("pwsh")); - assert!(shell_debug.contains("-NoLogo")); - assert!(shell_debug.contains("-NoExit")); - assert!(shell_debug.contains(". \\\"$env:TERMUA_PWSH_INIT\\\"")); - } - #[test] fn local_pty_options_falls_back_when_shell_not_found() { let mut env = HashMap::new(); @@ -1895,22 +1710,6 @@ mod shell_tests { } } -#[cfg(test)] -mod command_block_tests { - #[test] - fn adjust_osc133_boundary_matches_wezterm_heuristic() { - assert_eq!(super::adjust_osc133_boundary("C", 10, 0), 9); - assert_eq!(super::adjust_osc133_boundary("D;0", 10, 0), 9); - assert_eq!(super::adjust_osc133_boundary("A", 10, 0), 9); - assert_eq!(super::adjust_osc133_boundary("C", 0, 0), 0); - - assert_eq!(super::adjust_osc133_boundary("C", 10, 5), 10); - assert_eq!(super::adjust_osc133_boundary("D;0", 10, 5), 10); - assert_eq!(super::adjust_osc133_boundary("A", 10, 5), 10); - assert_eq!(super::adjust_osc133_boundary("B", 10, 0), 10); - } -} - #[cfg(test)] mod selection_tests { use std::{collections::VecDeque, io, sync::Arc}; @@ -1930,9 +1729,7 @@ mod selection_tests { AlacTermEvent, AlacrittyBackend, EventProxy, RecordState, SearchState, SelectionState, TermOp, }; - use crate::{ - TerminalBackend, TerminalBounds, TerminalContent, command_blocks::CommandBlockTracker, - }; + use crate::{TerminalBackend, TerminalBounds, TerminalContent}; #[derive(Default)] struct DummyPty { @@ -2020,7 +1817,6 @@ mod selection_tests { content: TerminalContent::default(), sftp: None, record: RecordState::new(cast_slot), - blocks: CommandBlockTracker::new(200), } } @@ -2096,7 +1892,6 @@ mod selection_tests { content: TerminalContent::default(), sftp: None, record: RecordState::new(cast_slot), - blocks: CommandBlockTracker::new(200), }; let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(30.0))); diff --git a/crates/gpui_term/src/backends/wezterm/mod.rs b/crates/gpui_term/src/backends/wezterm/mod.rs index 9746930..ce1ac62 100644 --- a/crates/gpui_term/src/backends/wezterm/mod.rs +++ b/crates/gpui_term/src/backends/wezterm/mod.rs @@ -19,7 +19,7 @@ use smol::channel::{Receiver, Sender}; use wezterm_surface::{CursorShape as WezCursorShape, CursorVisibility}; use wezterm_term::{ Alert, AlertHandler, Cell as WezCell, CellAttributes, Intensity, PhysRowIndex, Screen, - StableRowIndex, Terminal, TerminalConfiguration, TerminalSize, Underline, VisibleRowIndex, + StableRowIndex, Terminal, TerminalConfiguration, TerminalSize, Underline, color::{ColorAttribute, ColorPalette}, input::{ KeyCode, KeyModifiers, MouseButton as WezMouseButton, MouseEvent as WezMouseEvent, @@ -33,7 +33,6 @@ use crate::{ TerminalType, backends::{self, ssh}, cast::{CastHeader, CastRecorderSender, CastRecorderState, start_cast_recorder}, - command_blocks::{CommandBlockTracker, OscEvent, OscStreamParser}, serial::{serial2_char_size, serial2_flow_control, serial2_parity, serial2_stop_bits}, settings::{CursorShape, TerminalSettings}, terminal::Terminal as WidgetTerminal, @@ -297,8 +296,6 @@ fn build_backend( scroll_px: px(0.0), sftp, record: RecordState::new(cast_slot), - osc: OscStreamParser::new(), - blocks: CommandBlockTracker::new(200), }, events_rx, )) @@ -314,88 +311,6 @@ fn current_dir_path_from_term(term: &Terminal) -> Option { urlencoding::decode(url.path()).ok().map(|s| s.into_owned()) } -fn cursor_stable_line(term: &Terminal) -> (i64, usize) { - let screen = term.screen(); - let cursor = term.cursor_pos(); - let max_y = screen.physical_rows.saturating_sub(1) as VisibleRowIndex; - let y = cursor.y.clamp(0, max_y); - let phys = screen.phys_row(y); - (screen.phys_to_stable_row_index(phys) as i64, cursor.x) -} - -fn prev_stable_row(stable: i64) -> i64 { - if stable > 0 { stable - 1 } else { 0 } -} - -fn adjust_osc133_boundary(payload: &str, stable: i64, cursor_x: usize) -> i64 { - // Heuristic: when the cursor is at column 0, it often means: - // - For `C`: the user hit enter and the cursor moved to the next line, so the command line - // itself is the previous row. - // - For `D`: the command finished and the cursor is on the next (prompt) line, so the output - // ends on the previous row. - // - // This approximates Warp-style "command line + output" blocks without requiring prompt - // markers (OSC 133 A/B). - let kind = payload.trim_start().as_bytes().first().copied(); - if cursor_x == 0 && matches!(kind, Some(b'A') | Some(b'C') | Some(b'D')) { - prev_stable_row(stable) - } else { - stable - } -} - -fn stable_row_text(screen: &wezterm_term::Screen, stable_row: i64) -> Option { - let stable_row = StableRowIndex::try_from(stable_row).ok()?; - let phys_range = screen.stable_range(&(stable_row..stable_row.saturating_add(1))); - let line = screen.lines_in_phys_range(phys_range).into_iter().next()?; - Some(line.as_str().trim_end().to_string()) -} - -fn wrapped_command_span_for_stable_row( - screen: &wezterm_term::Screen, - stable_row: i64, -) -> Option<(i64, String)> { - let stable_row = StableRowIndex::try_from(stable_row).ok()?; - let phys_range = screen.stable_range(&(stable_row..stable_row.saturating_add(1))); - let mut start = phys_range.start; - let end = phys_range.end.checked_sub(1)?; - - while start > 0 { - let prev = screen - .lines_in_phys_range(start - 1..start) - .into_iter() - .next()?; - if !prev.last_cell_was_wrapped() { - break; - } - start -= 1; - } - - let lines = screen.lines_in_phys_range(start..end.saturating_add(1)); - let mut command = String::new(); - for line in lines { - command.push_str(line.as_str().trim_end_matches(|c: char| c.is_whitespace())); - } - - let command = command.trim_end().to_string(); - (!command.trim().is_empty()).then(|| (screen.phys_to_stable_row_index(start) as i64, command)) -} - -fn stable_row_texts(screen: &wezterm_term::Screen) -> Vec<(i64, String)> { - let total = screen.scrollback_rows(); - screen - .lines_in_phys_range(0..total) - .into_iter() - .enumerate() - .map(|(phys, line)| { - ( - screen.phys_to_stable_row_index(phys) as i64, - line.as_str().trim_end().to_string(), - ) - }) - .collect() -} - #[derive(Clone)] enum TermOp { Resize(TerminalBounds), @@ -409,7 +324,6 @@ struct SelectionState { range: Option, selecting: bool, anchor: Option, - command_block_id: Option, } #[derive(Default)] @@ -493,9 +407,6 @@ pub struct WezTermBackend { // Asciinema cast recording. record: RecordState, - - osc: OscStreamParser, - blocks: CommandBlockTracker, } impl WezTermBackend { @@ -664,74 +575,6 @@ impl WezTermBackend { } } - fn stable_row_for_grid_line_on_screen(screen: &wezterm_term::Screen, line: i32) -> Option { - let total = screen.scrollback_rows(); - if total == 0 { - return None; - } - - let rows = screen.physical_rows.max(1); - let base_start = total.saturating_sub(rows); - let base_stable = screen.phys_to_stable_row_index(base_start); - - let stable = if line >= 0 { - base_stable.saturating_add(line as StableRowIndex) - } else { - base_stable.saturating_sub((-line) as StableRowIndex) - }; - - Some(stable as i64) - } - - fn grid_line_for_stable_row_on_screen( - screen: &wezterm_term::Screen, - stable_row: i64, - ) -> Option { - let total = screen.scrollback_rows(); - if total == 0 { - return None; - } - - let rows = screen.physical_rows.max(1); - let base_start = total.saturating_sub(rows); - let base_stable = screen.phys_to_stable_row_index(base_start) as i64; - - let line = stable_row.saturating_sub(base_stable); - Some(line.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32) - } - - fn command_block_id_for_selection_range( - &self, - selection: &crate::SelectionRange, - ) -> Option { - let term = self.term.lock(); - let screen = term.screen(); - let start = Self::stable_row_for_grid_line_on_screen(screen, selection.start.line)?; - let end = Self::stable_row_for_grid_line_on_screen(screen, selection.end.line)?; - self.blocks.block_id_for_range(start, end) - } - - fn remapped_selected_command_block_selection_range( - &self, - screen: &wezterm_term::Screen, - cols: usize, - ) -> Option { - let block_id = self.selection.command_block_id?; - let (start_stable, end_stable) = self.blocks.range_for_block_id(block_id)?; - let start_line = Self::grid_line_for_stable_row_on_screen(screen, start_stable); - let end_line = Self::grid_line_for_stable_row_on_screen(screen, end_stable); - - let (Some(start_line), Some(end_line)) = (start_line, end_line) else { - return None; - }; - - let last_col = cols.saturating_sub(1); - Some(crate::SelectionRange { - start: GridPoint::new(start_line, 0), - end: GridPoint::new(end_line, last_col), - }) - } - fn select_line_at_event_position(&mut self, e: &MouseDownEvent) { if self.mouse_mode(e.modifiers.shift) || e.modifiers.secondary() { return; @@ -781,7 +624,6 @@ impl WezTermBackend { }); self.selection.selecting = false; self.selection.anchor = None; - self.selection.command_block_id = None; } fn selection_to_string(&self, selection: &crate::SelectionRange) -> String { @@ -1354,14 +1196,7 @@ fn shell_command_candidates_for_local_env( let mut candidates = Vec::new(); if let Some(shell) = crate::shell::pick_shell_program_from_env(env) { - let args = crate::shell::shell_integration_args_for_env(shell, env); - if args.is_empty() { - candidates.push(CommandBuilder::new(shell)); - } else { - let mut cmd = CommandBuilder::new(shell); - cmd.args(args); - candidates.push(cmd); - } + candidates.push(CommandBuilder::new(shell)); } for cmd in default_shell_command_candidates() { @@ -1407,37 +1242,7 @@ impl TerminalBackend for WezTermBackend { } let mut term = self.term.lock(); - let completions = self.osc.push_with_offsets(&bytes); - - let mut prev = 0usize; - for (end, ev) in completions { - let end = end.min(bytes.len()); - if end > prev { - term.advance_bytes(&bytes[prev..end]); - } - - let (cursor_line, cursor_x) = cursor_stable_line(&term); - let OscEvent::Osc133(payload) = ev; - let mut line = adjust_osc133_boundary(&payload, cursor_line, cursor_x); - let command = if payload.trim_start().starts_with('C') { - match wrapped_command_span_for_stable_row(term.screen(), line) { - Some((start_line, command)) => { - line = start_line; - Some(command) - } - None => stable_row_text(term.screen(), line), - } - } else { - None - }; - self.blocks - .apply_osc133(&payload, Instant::now(), line, command); - prev = end; - } - - if prev < bytes.len() { - term.advance_bytes(&bytes[prev..]); - } + term.advance_bytes(&bytes); self.search.dirty = true; cx.emit(Event::Wakeup); } @@ -1462,24 +1267,7 @@ impl TerminalBackend for WezTermBackend { } } - fn command_blocks(&self) -> Option> { - Some(self.blocks.blocks()) - } - - fn stable_row_for_grid_line(&self, line: i32) -> Option { - let term = self.term.lock(); - Self::stable_row_for_grid_line_on_screen(term.screen(), line) - } - - fn grid_line_for_stable_row(&self, stable_row: i64) -> Option { - let term = self.term.lock(); - Self::grid_line_for_stable_row_on_screen(term.screen(), stable_row) - } - fn set_selection_range(&mut self, range: Option) { - self.selection.command_block_id = range - .as_ref() - .and_then(|range| self.command_block_id_for_selection_range(range)); self.selection.range = range; self.selection.selecting = false; self.selection.anchor = None; @@ -1561,17 +1349,6 @@ impl TerminalBackend for WezTermBackend { pixel_height: usize::from(bounds.height().ceil()), dpi: 0, }); - let cursor_stable = cursor_stable_line(&term).0; - let lines = stable_row_texts(term.screen()); - self.blocks.remap_after_rewrap(&lines, cursor_stable); - let remapped_selection = - self.remapped_selected_command_block_selection_range(term.screen(), cols); - drop(term); - if let Some(range) = remapped_selection { - self.selection.range = Some(range); - self.selection.selecting = false; - self.selection.anchor = None; - } self.search.dirty = true; } TermOp::Clear => { @@ -1655,7 +1432,6 @@ impl TerminalBackend for WezTermBackend { self.selection.selecting = false; self.selection.anchor = None; self.selection.range = None; - self.selection.command_block_id = None; } fn set_cursor_shape(&mut self, cursor_shape: CursorShape) { @@ -1746,7 +1522,6 @@ impl TerminalBackend for WezTermBackend { }); self.selection.selecting = false; self.selection.anchor = None; - self.selection.command_block_id = None; } fn copy(&mut self, keep_selection: Option, _cx: &mut Context) { @@ -2040,7 +1815,6 @@ impl TerminalBackend for WezTermBackend { }); self.selection.selecting = false; self.selection.anchor = None; - self.selection.command_block_id = None; } fn mouse_drag( @@ -2105,7 +1879,6 @@ impl TerminalBackend for WezTermBackend { let p = GridPoint::new(line, col); self.selection.range = Some(Self::normalize_selection(anchor, p)); - self.selection.command_block_id = None; cx.emit(Event::SelectionsChanged); } return; @@ -2148,7 +1921,6 @@ impl TerminalBackend for WezTermBackend { self.selection.selecting = true; self.selection.anchor = Some(p); self.selection.range = Some(crate::SelectionRange { start: p, end: p }); - self.selection.command_block_id = None; cx.emit(Event::SelectionsChanged); } return; @@ -2569,9 +2341,8 @@ mod tests { use wezterm_term::{Terminal, TerminalConfiguration, TerminalSize}; use super::{ - Instant, Mutex, SharedWriter, WezEvent, WezTermBackend, compute_viewport_plan, - cursor_stable_line, default_shell_command_candidates, stable_row_texts, - wrapped_command_span_for_stable_row, + Mutex, SharedWriter, WezEvent, WezTermBackend, compute_viewport_plan, + default_shell_command_candidates, }; use crate::{ CastRecordingOptions, TerminalContent, TerminalType, @@ -2707,8 +2478,6 @@ mod tests { scroll_px: px(0.0), sftp: None, record: super::RecordState::new(cast_slot), - osc: crate::command_blocks::OscStreamParser::new(), - blocks: crate::command_blocks::CommandBlockTracker::new(200), }; let term = backend.term.lock(); @@ -2732,172 +2501,6 @@ mod tests { ); } - fn test_backend(rows: usize, cols: usize, scrollback: usize) -> WezTermBackend { - let cast_slot = Arc::new(Mutex::new(None)); - let writer = SharedWriter { - inner: Arc::new(Mutex::new( - Box::new(std::io::sink()) as Box - )), - cast: Arc::clone(&cast_slot), - }; - let cfg = Arc::new(TestConfig { scrollback }); - let wezterm_term = Terminal::new( - TerminalSize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - dpi: 0, - }, - cfg, - "termua", - "0", - Box::new(writer.clone()), - ); - - WezTermBackend { - master: Box::new(DummyMasterPty), - writer, - term: Arc::new(Mutex::new(wezterm_term)), - child_killer: Box::new(DummyChildKiller), - shutdown: super::ShutdownState::default(), - pending_ops: VecDeque::new(), - viewport_top_stable: None, - last_clicked_line: None, - search: super::SearchState::default(), - content: TerminalContent::default(), - exited: false, - last_mouse_pos: None, - selection: super::SelectionState::default(), - default_cursor_shape: crate::CursorShape::default(), - scroll_px: px(0.0), - sftp: None, - record: super::RecordState::new(cast_slot), - osc: crate::command_blocks::OscStreamParser::new(), - blocks: crate::command_blocks::CommandBlockTracker::new(200), - } - } - - #[test] - fn selected_command_block_selection_tracks_resize_rewrap() { - let mut backend = test_backend(5, 20, 100); - let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(200.0), px(50.0))); - backend.content.terminal_bounds = crate::TerminalBounds::new(px(10.0), px(10.0), bounds); - - { - let mut term = backend.term.lock(); - term.advance_bytes(b"$ echo 123456\r\nout\r\n% "); - } - - let (start_stable, end_stable, start_line, end_line) = { - let term = backend.term.lock(); - let screen = term.screen(); - let start_stable = screen.phys_to_stable_row_index(0) as i64; - let end_stable = screen.phys_to_stable_row_index(1) as i64; - ( - start_stable, - end_stable, - WezTermBackend::grid_line_for_stable_row_on_screen(screen, start_stable) - .expect("start line"), - WezTermBackend::grid_line_for_stable_row_on_screen(screen, end_stable) - .expect("end line"), - ) - }; - - backend.blocks.apply_osc133( - "C", - Instant::now(), - start_stable, - Some("$ echo 123456".to_string()), - ); - backend - .blocks - .apply_osc133("D;0", Instant::now(), end_stable, None); - - ::set_selection_range( - &mut backend, - Some(crate::SelectionRange { - start: crate::GridPoint::new(start_line, 0), - end: crate::GridPoint::new(end_line, 19), - }), - ); - let block_id = backend - .selection - .command_block_id - .expect("selection should remember selected command block"); - - { - let mut term = backend.term.lock(); - term.resize(TerminalSize { - rows: 5, - cols: 8, - pixel_width: 0, - pixel_height: 0, - dpi: 0, - }); - let cursor_stable = cursor_stable_line(&term).0; - let lines = stable_row_texts(term.screen()); - backend.blocks.remap_after_rewrap(&lines, cursor_stable); - let remapped_selection = - backend.remapped_selected_command_block_selection_range(term.screen(), 8); - drop(term); - if let Some(range) = remapped_selection { - backend.selection.range = Some(range); - backend.selection.selecting = false; - backend.selection.anchor = None; - } - - let (expected_start, expected_end) = backend - .blocks - .range_for_block_id(block_id) - .expect("block should still exist after resize"); - let selection = backend - .selection - .range - .clone() - .expect("selection should remain active"); - let term = backend.term.lock(); - let selected_start = WezTermBackend::stable_row_for_grid_line_on_screen( - term.screen(), - selection.start.line, - ) - .expect("selected start stable row"); - let selected_end = WezTermBackend::stable_row_for_grid_line_on_screen( - term.screen(), - selection.end.line, - ) - .expect("selected end stable row"); - - assert_eq!( - (selected_start, selected_end), - (expected_start, expected_end) - ); - assert_eq!(selection.start.column, 0); - assert_eq!(selection.end.column, 7); - } - } - - #[test] - fn wrapped_command_span_starts_at_first_visual_row() { - let backend = test_backend(5, 8, 100); - { - let mut term = backend.term.lock(); - term.advance_bytes(b"$ 111111111111"); - } - - let term = backend.term.lock(); - let screen = term.screen(); - assert_eq!(line_text_at_phys(screen, 0), "$ 111111"); - assert_eq!(line_text_at_phys(screen, 1), "111111"); - - let last_command_row = screen.phys_to_stable_row_index(1) as i64; - let (start_stable, command) = - wrapped_command_span_for_stable_row(screen, last_command_row).expect("wrapped span"); - - assert_eq!(start_stable, screen.phys_to_stable_row_index(0) as i64); - assert_eq!(command, "$ 111111111111"); - } - #[test] fn stable_viewport_survives_scrollback_eviction() { let rows = 3usize; @@ -3057,8 +2660,6 @@ mod tests { scroll_px: px(0.0), sftp: None, record: super::RecordState::new(cast_slot), - osc: crate::command_blocks::OscStreamParser::new(), - blocks: crate::command_blocks::CommandBlockTracker::new(200), }; // 3 rows x 10 cols. @@ -3143,8 +2744,6 @@ mod tests { scroll_px: px(0.0), sftp: None, record: super::RecordState::new(cast_slot), - osc: crate::command_blocks::OscStreamParser::new(), - blocks: crate::command_blocks::CommandBlockTracker::new(200), }; let terminal = cx.new(|_| WidgetTerminal::new(TerminalType::WezTerm, Box::new(backend))); @@ -3256,65 +2855,6 @@ mod tests { ); } - #[test] - fn local_shell_candidates_uses_bash_rcfile_when_configured() { - let mut env = std::collections::HashMap::new(); - env.insert("TERMUA_SHELL".to_string(), "/bin/bash".to_string()); - env.insert( - "TERMUA_BASH_RCFILE".to_string(), - "/tmp/termua-test.bashrc".to_string(), - ); - - let candidates = super::shell_command_candidates_for_local_env(&env); - let argv: Vec = candidates[0] - .get_argv() - .iter() - .map(|s| s.to_string_lossy().to_string()) - .collect(); - - assert_eq!( - argv, - vec![ - "/bin/bash", - "--noprofile", - "--rcfile", - "/tmp/termua-test.bashrc", - "-i" - ] - ); - } - - #[test] - fn local_shell_candidates_uses_powershell_init_when_configured() { - let mut env = std::collections::HashMap::new(); - env.insert("TERMUA_SHELL".to_string(), "pwsh".to_string()); - env.insert( - "TERMUA_PWSH_INIT".to_string(), - "/tmp/termua-init.ps1".to_string(), - ); - - let candidates = super::shell_command_candidates_for_local_env(&env); - let argv: Vec = candidates[0] - .get_argv() - .iter() - .map(|s| s.to_string_lossy().to_string()) - .collect(); - - let mut expected = vec!["pwsh", "-NoLogo", "-NoExit"]; - if cfg!(windows) { - expected.extend(["-ExecutionPolicy", "Bypass"]); - } - expected.extend(["-Command", ". \"$env:TERMUA_PWSH_INIT\""]); - - assert_eq!( - argv, - expected - .into_iter() - .map(ToString::to_string) - .collect::>() - ); - } - #[cfg(windows)] #[test] fn default_shell_candidates_includes_cmd_fallback_on_windows() { @@ -3361,17 +2901,4 @@ mod tests { Some("/home/user name") ); } - - #[test] - fn osc133_boundary_adjusts_for_column_zero() { - assert_eq!(super::adjust_osc133_boundary("C", 10, 0), 9); - assert_eq!(super::adjust_osc133_boundary("D;0", 10, 0), 9); - assert_eq!(super::adjust_osc133_boundary("A", 10, 0), 9); - assert_eq!(super::adjust_osc133_boundary("C", 0, 0), 0); - - assert_eq!(super::adjust_osc133_boundary("C", 10, 5), 10); - assert_eq!(super::adjust_osc133_boundary("D;0", 10, 5), 10); - assert_eq!(super::adjust_osc133_boundary("A", 10, 5), 10); - assert_eq!(super::adjust_osc133_boundary("B", 10, 0), 10); - } } diff --git a/crates/gpui_term/src/command_blocks/mod.rs b/crates/gpui_term/src/command_blocks/mod.rs deleted file mode 100644 index e6cd720..0000000 --- a/crates/gpui_term/src/command_blocks/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod osc; -mod query; -mod store; -mod tracker; - -pub use osc::*; -pub use query::*; -pub use store::*; -pub use tracker::*; diff --git a/crates/gpui_term/src/command_blocks/osc.rs b/crates/gpui_term/src/command_blocks/osc.rs deleted file mode 100644 index 95e492b..0000000 --- a/crates/gpui_term/src/command_blocks/osc.rs +++ /dev/null @@ -1,214 +0,0 @@ -#[derive(Debug, Default)] -pub struct OscStreamParser { - pending_start_esc: bool, - in_osc: bool, - osc_id: u32, - osc_id_digits: bool, - osc_seen_semicolon: bool, - osc_payload: Vec, - osc_pending_st_esc: bool, - osc_invalid: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum OscEvent { - Osc133(String), -} - -/// Offset is the exclusive end position in the pushed `bytes` slice. -pub type OscCompletion = (usize, OscEvent); - -impl OscStreamParser { - pub fn new() -> Self { - Self::default() - } - - pub fn push(&mut self, bytes: &[u8]) -> Vec { - self.push_with_offsets(bytes) - .into_iter() - .map(|(_, ev)| ev) - .collect() - } - - pub fn push_with_offsets(&mut self, bytes: &[u8]) -> Vec { - let mut out: Vec = Vec::new(); - - let mut i = 0usize; - while i < bytes.len() { - let b = bytes[i]; - - if !self.in_osc { - if self.pending_start_esc { - self.pending_start_esc = false; - if b == b']' { - self.begin_osc(); - i += 1; - continue; - } - } - - if b == 0x1b { - self.pending_start_esc = i + 1 == bytes.len(); - if !self.pending_start_esc && bytes[i + 1] == b']' { - self.begin_osc(); - i += 2; - continue; - } - } - - i += 1; - continue; - } - - // In OSC body. - if self.osc_pending_st_esc { - self.osc_pending_st_esc = false; - if b == b'\\' { - self.finish_osc(&mut out, i + 1); - i += 1; - continue; - } - self.push_osc_byte(0x1b); - self.push_osc_byte(b); - i += 1; - continue; - } - - match b { - 0x07 => { - self.finish_osc(&mut out, i + 1); - i += 1; - } - 0x1b => { - if i + 1 == bytes.len() { - self.osc_pending_st_esc = true; - i += 1; - } else if bytes[i + 1] == b'\\' { - self.finish_osc(&mut out, i + 2); - i += 2; - } else { - self.push_osc_byte(b); - i += 1; - } - } - _ => { - self.push_osc_byte(b); - i += 1; - } - } - } - - out - } - - fn begin_osc(&mut self) { - self.in_osc = true; - self.osc_id = 0; - self.osc_id_digits = false; - self.osc_seen_semicolon = false; - self.osc_payload.clear(); - self.osc_pending_st_esc = false; - self.osc_invalid = false; - } - - fn push_osc_byte(&mut self, b: u8) { - if self.osc_invalid { - return; - } - - if !self.osc_seen_semicolon { - if b.is_ascii_digit() { - self.osc_id_digits = true; - self.osc_id = match self - .osc_id - .checked_mul(10) - .and_then(|v| v.checked_add(u32::from(b - b'0'))) - { - Some(v) => v, - None => { - self.osc_invalid = true; - return; - } - }; - return; - } - - if b == b';' && self.osc_id_digits { - self.osc_seen_semicolon = true; - return; - } - - self.osc_invalid = true; - return; - } - - if self.osc_id == 133 { - self.osc_payload.push(b); - } - } - - fn finish_osc(&mut self, out: &mut Vec, end_offset: usize) { - if !self.osc_invalid && self.osc_seen_semicolon && self.osc_id == 133 { - let payload = String::from_utf8_lossy(&self.osc_payload).to_string(); - out.push((end_offset, OscEvent::Osc133(payload))); - } - - self.in_osc = false; - self.osc_pending_st_esc = false; - self.osc_payload.clear(); - self.osc_invalid = false; - self.osc_id_digits = false; - self.osc_seen_semicolon = false; - self.osc_id = 0; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn osc_stream_parser_reads_bel_terminated_sequences() { - let mut p = OscStreamParser::new(); - let events = p.push(b"\x1b]133;C\x07"); - assert_eq!(events, vec![OscEvent::Osc133("C".to_string())]); - } - - #[test] - fn osc_stream_parser_reads_st_terminated_sequences() { - let mut p = OscStreamParser::new(); - let events = p.push(b"\x1b]133;D\x1b\\"); - assert_eq!(events, vec![OscEvent::Osc133("D".to_string())]); - } - - #[test] - fn osc_stream_parser_handles_split_reads() { - let mut p = OscStreamParser::new(); - assert_eq!(p.push(b"\x1b]133;C"), Vec::::new()); - assert_eq!(p.push(b"\x07"), vec![OscEvent::Osc133("C".to_string())]); - } - - #[test] - fn osc_stream_parser_ignores_other_osc_ids() { - let mut p = OscStreamParser::new(); - let events = p.push(b"\x1b]7;file://localhost/tmp\x07"); - assert_eq!(events, Vec::::new()); - } - - #[test] - fn osc_stream_parser_ignores_malformed_sequences() { - let mut p = OscStreamParser::new(); - let events = p.push(b"\x1b]133C\x07"); - assert_eq!(events, Vec::::new()); - } - - #[test] - fn osc_stream_parser_keeps_trailing_esc() { - let mut p = OscStreamParser::new(); - assert_eq!(p.push(b"\x1b"), Vec::::new()); - assert_eq!( - p.push(b"]133;A\x07"), - vec![OscEvent::Osc133("A".to_string())] - ); - } -} diff --git a/crates/gpui_term/src/command_blocks/query.rs b/crates/gpui_term/src/command_blocks/query.rs deleted file mode 100644 index 5ff322c..0000000 --- a/crates/gpui_term/src/command_blocks/query.rs +++ /dev/null @@ -1,57 +0,0 @@ -use super::CommandBlock; - -pub fn block_at_stable_row(blocks: &[CommandBlock], stable_row: i64) -> Option<&CommandBlock> { - blocks.iter().rev().find(|b| match b.output_end_line { - Some(end) => stable_row >= b.output_start_line && stable_row <= end, - None => stable_row >= b.output_start_line, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn block(id: u64, start: i64, end: Option) -> CommandBlock { - CommandBlock { - id, - started_at: std::time::Instant::now(), - ended_at: None, - exit_code: None, - command: None, - output_start_line: start, - output_end_line: end, - } - } - - #[test] - fn picks_latest_finished_block_containing_stable_row() { - let blocks = vec![block(1, 10, Some(20)), block(2, 30, Some(40))]; - - assert!(block_at_stable_row(&blocks, 9).is_none()); - assert_eq!(block_at_stable_row(&blocks, 10).unwrap().id, 1); - assert_eq!(block_at_stable_row(&blocks, 15).unwrap().id, 1); - assert_eq!(block_at_stable_row(&blocks, 20).unwrap().id, 1); - assert!(block_at_stable_row(&blocks, 21).is_none()); - - assert!(block_at_stable_row(&blocks, 29).is_none()); - assert_eq!(block_at_stable_row(&blocks, 30).unwrap().id, 2); - assert_eq!(block_at_stable_row(&blocks, 39).unwrap().id, 2); - assert_eq!(block_at_stable_row(&blocks, 40).unwrap().id, 2); - assert!(block_at_stable_row(&blocks, 41).is_none()); - } - - #[test] - fn running_block_matches_everything_after_start() { - let blocks = vec![block(1, 10, Some(20)), block(2, 30, None)]; - - assert!(block_at_stable_row(&blocks, 29).is_none()); - assert_eq!(block_at_stable_row(&blocks, 30).unwrap().id, 2); - assert_eq!(block_at_stable_row(&blocks, 10_000).unwrap().id, 2); - } - - #[test] - fn prefers_latest_block_when_ranges_overlap() { - let blocks = vec![block(1, 10, Some(50)), block(2, 40, Some(60))]; - assert_eq!(block_at_stable_row(&blocks, 45).unwrap().id, 2); - } -} diff --git a/crates/gpui_term/src/command_blocks/store.rs b/crates/gpui_term/src/command_blocks/store.rs deleted file mode 100644 index 4bb9767..0000000 --- a/crates/gpui_term/src/command_blocks/store.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::collections::VecDeque; - -#[derive(Clone, Debug)] -pub struct CommandBlock { - pub id: u64, - pub started_at: std::time::Instant, - pub ended_at: Option, - pub exit_code: Option, - pub command: Option, - pub output_start_line: i64, - pub output_end_line: Option, -} - -#[derive(Debug)] -pub struct CommandBlockStore { - blocks: VecDeque, - next_id: u64, - capacity: usize, -} - -impl CommandBlockStore { - pub fn new(capacity: usize) -> Self { - Self { - blocks: VecDeque::with_capacity(capacity.max(1)), - next_id: 1, - capacity: capacity.max(1), - } - } - - pub fn push(&mut self, block: CommandBlock) { - while self.blocks.len() >= self.capacity { - self.blocks.pop_front(); - } - self.blocks.push_back(block); - } - - pub fn blocks(&self) -> Vec { - self.blocks.iter().cloned().collect() - } - - pub fn next_id(&mut self) -> u64 { - let id = self.next_id; - self.next_id = self.next_id.saturating_add(1); - id - } - - pub fn last_mut(&mut self) -> Option<&mut CommandBlock> { - self.blocks.back_mut() - } - - pub fn len(&self) -> usize { - self.blocks.len() - } - - pub fn is_empty(&self) -> bool { - self.blocks.is_empty() - } - - pub fn iter_mut(&mut self) -> impl Iterator { - self.blocks.iter_mut() - } - - pub fn get_mut(&mut self, index: usize) -> Option<&mut CommandBlock> { - self.blocks.get_mut(index) - } -} diff --git a/crates/gpui_term/src/command_blocks/tracker.rs b/crates/gpui_term/src/command_blocks/tracker.rs deleted file mode 100644 index 60563da..0000000 --- a/crates/gpui_term/src/command_blocks/tracker.rs +++ /dev/null @@ -1,348 +0,0 @@ -use std::time::Instant; - -use super::{CommandBlock, CommandBlockStore}; - -#[derive(Debug)] -pub struct CommandBlockTracker { - store: CommandBlockStore, - active_id: Option, -} - -impl CommandBlockTracker { - pub fn new(capacity: usize) -> Self { - Self { - store: CommandBlockStore::new(capacity), - active_id: None, - } - } - - pub fn blocks(&self) -> Vec { - self.store.blocks() - } - - pub fn remap_after_rewrap(&mut self, lines: &[(i64, String)], cursor_stable: i64) { - let mut next_search_idx = 0usize; - let mut remapped_starts: Vec> = Vec::with_capacity(self.store.len()); - - for block in self.store.iter_mut() { - let Some(command) = block.command.as_deref() else { - remapped_starts.push(None); - continue; - }; - - let Some((start_idx, end_idx)) = find_command_span(lines, next_search_idx, command) - else { - remapped_starts.push(None); - continue; - }; - - block.output_start_line = lines[start_idx].0; - remapped_starts.push(Some(lines[start_idx].0)); - next_search_idx = end_idx.saturating_add(1); - } - - for idx in 0..self.store.len() { - let Some(start) = remapped_starts.get(idx).and_then(|v| *v) else { - continue; - }; - let next_start = remapped_starts - .iter() - .skip(idx + 1) - .flatten() - .copied() - .next(); - let active = self.active_id; - let block = self.store.get_mut(idx).expect("block index should exist"); - block.output_start_line = start; - - if block.ended_at.is_some() { - block.output_end_line = next_start - .map(prev_stable_row) - .or_else(|| (active != Some(block.id)).then(|| prev_stable_row(cursor_stable))); - } - } - } - - pub fn block_id_for_range(&self, start_line: i64, end_line: i64) -> Option { - let start_line = start_line.min(end_line); - let end_line = start_line.max(end_line); - self.store.blocks().into_iter().find_map(|block| { - (block.output_start_line == start_line && block.output_end_line == Some(end_line)) - .then_some(block.id) - }) - } - - pub fn range_for_block_id(&self, block_id: u64) -> Option<(i64, i64)> { - self.store.blocks().into_iter().find_map(|block| { - (block.id == block_id).then(|| { - let end = block.output_end_line.unwrap_or(block.output_start_line); - (block.output_start_line, end) - }) - }) - } - - pub fn apply_osc133( - &mut self, - payload: &str, - now: Instant, - bottom_line: i64, - command: Option, - ) { - let Some(ev) = Osc133Event::parse(payload) else { - return; - }; - - match ev { - Osc133Event::PromptStart => { - if let Some(active_id) = self.active_id.take() { - self.finalize_block(active_id, now, bottom_line, None); - } - } - Osc133Event::PromptEnd => {} - Osc133Event::CommandStart => self.start_block(now, bottom_line, command), - Osc133Event::CommandEnd { exit_code } => self.end_block(now, bottom_line, exit_code), - Osc133Event::Unknown(_) => {} - } - } - - fn start_block(&mut self, now: Instant, output_start_line: i64, command: Option) { - if let Some(active_id) = self.active_id.take() { - self.finalize_block(active_id, now, output_start_line, None); - } - - let id = self.store.next_id(); - self.store.push(CommandBlock { - id, - started_at: now, - ended_at: None, - exit_code: None, - command: normalize_command_text(command), - output_start_line, - output_end_line: None, - }); - self.active_id = Some(id); - } - - fn end_block(&mut self, now: Instant, output_end_line: i64, exit_code: Option) { - let Some(active_id) = self.active_id.take() else { - return; - }; - self.finalize_block(active_id, now, output_end_line, exit_code); - } - - fn finalize_block( - &mut self, - block_id: u64, - now: Instant, - output_end_line: i64, - exit_code: Option, - ) { - if let Some(block) = self - .store - .last_mut() - .filter(|b| b.id == block_id && b.ended_at.is_none()) - { - block.ended_at = Some(now); - block.exit_code = exit_code; - block.output_end_line = Some(output_end_line); - } - } -} - -fn normalize_command_text(command: Option) -> Option { - let command = command?; - let trimmed = command.trim_end().to_string(); - if trimmed.trim().is_empty() { - None - } else { - Some(trimmed) - } -} - -fn prev_stable_row(stable: i64) -> i64 { - if stable > 0 { stable - 1 } else { 0 } -} - -fn find_command_span( - lines: &[(i64, String)], - start_idx: usize, - command: &str, -) -> Option<(usize, usize)> { - let command = command.trim_end(); - if command.is_empty() { - return None; - } - - for idx in start_idx..lines.len() { - let mut combined = String::new(); - for (end, (_, line)) in lines.iter().enumerate().skip(idx) { - combined.push_str(line.trim_end()); - if combined == command { - return Some((idx, end)); - } - if combined.len() >= command.len() || !command.starts_with(&combined) { - break; - } - } - } - - None -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -enum Osc133Event<'a> { - PromptStart, - PromptEnd, - CommandStart, - CommandEnd { exit_code: Option }, - Unknown(&'a str), -} - -impl<'a> Osc133Event<'a> { - fn parse(payload: &'a str) -> Option { - let mut parts = payload.split(';'); - let kind = parts.next()?.trim(); - - match kind { - "A" => Some(Self::PromptStart), - "B" => Some(Self::PromptEnd), - "C" => Some(Self::CommandStart), - "D" => { - let exit_code = parts.next().and_then(|v| v.trim().parse::().ok()); - Some(Self::CommandEnd { exit_code }) - } - other if !other.is_empty() => Some(Self::Unknown(other)), - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn command_start_creates_active_block() { - let mut t = CommandBlockTracker::new(10); - let now = Instant::now(); - t.apply_osc133("C", now, 10, None); - - let blocks = t.blocks(); - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].output_start_line, 10); - assert_eq!(blocks[0].ended_at, None); - assert_eq!(blocks[0].output_end_line, None); - } - - #[test] - fn command_start_stores_command_text() { - let mut t = CommandBlockTracker::new(10); - let now = Instant::now(); - t.apply_osc133("C", now, 10, Some("echo hello".to_string())); - - let blocks = t.blocks(); - assert_eq!(blocks[0].command.as_deref(), Some("echo hello")); - } - - #[test] - fn command_end_finalizes_block() { - let mut t = CommandBlockTracker::new(10); - let now = Instant::now(); - t.apply_osc133("C", now, 10, None); - t.apply_osc133("D;0", now, 42, None); - - let blocks = t.blocks(); - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].exit_code, Some(0)); - assert_eq!(blocks[0].output_end_line, Some(42)); - assert!(blocks[0].ended_at.is_some()); - } - - #[test] - fn duplicate_command_start_finalizes_previous_block_best_effort() { - let mut t = CommandBlockTracker::new(10); - let now = Instant::now(); - t.apply_osc133("C", now, 10, Some("echo one".to_string())); - t.apply_osc133("C", now, 20, Some("echo two".to_string())); - - let blocks = t.blocks(); - assert_eq!(blocks.len(), 2); - assert_eq!(blocks[0].output_end_line, Some(20)); - assert!(blocks[0].ended_at.is_some()); - assert_eq!(blocks[0].command.as_deref(), Some("echo one")); - assert_eq!(blocks[1].output_start_line, 20); - assert_eq!(blocks[1].ended_at, None); - assert_eq!(blocks[1].command.as_deref(), Some("echo two")); - } - - #[test] - fn prompt_start_finalizes_active_block_best_effort() { - let mut t = CommandBlockTracker::new(10); - let now = Instant::now(); - t.apply_osc133("C", now, 10, None); - t.apply_osc133("A", now, 19, None); - - let blocks = t.blocks(); - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].output_end_line, Some(19)); - assert!(blocks[0].ended_at.is_some()); - assert_eq!(blocks[0].exit_code, None); - } - - #[test] - fn command_end_without_active_block_is_ignored() { - let mut t = CommandBlockTracker::new(10); - let now = Instant::now(); - t.apply_osc133("D;0", now, 10, None); - assert!(t.blocks().is_empty()); - } - - #[test] - fn remap_after_rewrap_updates_block_ranges_from_command_order() { - let mut t = CommandBlockTracker::new(10); - let now = Instant::now(); - t.apply_osc133("C", now, 10, Some("$ echo 123456".to_string())); - t.apply_osc133("D;0", now, 12, None); - t.apply_osc133("C", now, 13, Some("$ pwd".to_string())); - t.apply_osc133("D;0", now, 14, None); - - let lines = vec![ - (100, "$ echo 1".to_string()), - (101, "23456".to_string()), - (102, "out".to_string()), - (103, "$ pwd".to_string()), - (104, "/tmp".to_string()), - (105, "% ".to_string()), - ]; - - t.remap_after_rewrap(&lines, 105); - - let blocks = t.blocks(); - assert_eq!(blocks[0].output_start_line, 100); - assert_eq!(blocks[0].output_end_line, Some(102)); - assert_eq!(blocks[1].output_start_line, 103); - assert_eq!(blocks[1].output_end_line, Some(104)); - } - - #[test] - fn maps_exact_selected_range_to_block_and_back_after_remap() { - let mut t = CommandBlockTracker::new(10); - let now = Instant::now(); - t.apply_osc133("C", now, 10, Some("$ echo 123456".to_string())); - t.apply_osc133("D;0", now, 12, None); - - let block_id = t - .block_id_for_range(10, 12) - .expect("selection should match the command block exactly"); - - let lines = vec![ - (100, "$ echo 1".to_string()), - (101, "23456".to_string()), - (102, "out".to_string()), - (103, "% ".to_string()), - ]; - t.remap_after_rewrap(&lines, 103); - - assert_eq!(t.range_for_block_id(block_id), Some((100, 102))); - } -} diff --git a/crates/gpui_term/src/lib.rs b/crates/gpui_term/src/lib.rs index 1411d5a..eeb2553 100644 --- a/crates/gpui_term/src/lib.rs +++ b/crates/gpui_term/src/lib.rs @@ -5,7 +5,6 @@ use bitflags::bitflags; mod backends; mod builder; pub mod cast; -pub mod command_blocks; mod element; pub mod remote; mod serial; @@ -31,7 +30,7 @@ pub use builder::*; pub use cast::CastRecordingOptions; pub use serial::*; pub use settings::*; -pub use suggestions::{SuggestionHistoryProvider, SuggestionStaticProvider}; +pub use suggestions::SuggestionStaticProvider; pub use terminal::*; pub use theme::*; pub use view::*; @@ -41,7 +40,6 @@ pub fn init(cx: &mut gpui::App) { cx.set_global(TerminalSettings::new()); cx.set_global(cast::CastRecordingConfig::default()); - cx.set_global(suggestions::SuggestionHistoryConfig::default()); cx.set_global(suggestions::SuggestionStaticConfig::default()); #[cfg(target_os = "macos")] @@ -103,14 +101,6 @@ pub fn init(cx: &mut gpui::App) { cx.bind_keys(keys); } -pub fn set_suggestion_history_provider( - cx: &mut gpui::App, - provider: Option>, -) { - cx.global_mut::() - .provider = provider; -} - pub fn set_suggestion_static_provider( cx: &mut gpui::App, provider: Option>, diff --git a/crates/gpui_term/src/shell.rs b/crates/gpui_term/src/shell.rs index a0e63a1..96e6d8e 100644 --- a/crates/gpui_term/src/shell.rs +++ b/crates/gpui_term/src/shell.rs @@ -2,8 +2,6 @@ use std::collections::HashMap; pub const SHELL_ENV_KEY: &str = "SHELL"; pub const TERMUA_SHELL_ENV_KEY: &str = "TERMUA_SHELL"; -pub const TERMUA_BASH_RCFILE_ENV_KEY: &str = "TERMUA_BASH_RCFILE"; -pub const TERMUA_PWSH_INIT_ENV_KEY: &str = "TERMUA_PWSH_INIT"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ShellKind { @@ -70,42 +68,6 @@ pub fn shell_display_name(program: &str) -> String { } } -fn powershell_integration_args(bypass_execution_policy: bool) -> Vec { - let mut args = vec!["-NoLogo".to_string(), "-NoExit".to_string()]; - if bypass_execution_policy { - args.push("-ExecutionPolicy".to_string()); - args.push("Bypass".to_string()); - } - args.push("-Command".to_string()); - args.push(". \"$env:TERMUA_PWSH_INIT\"".to_string()); - args -} - -pub fn shell_integration_args_for_env(program: &str, env: &HashMap) -> Vec { - match shell_kind(program) { - ShellKind::Bash => env - .get(TERMUA_BASH_RCFILE_ENV_KEY) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|rcfile| { - vec![ - "--noprofile".to_string(), - "--rcfile".to_string(), - rcfile.to_string(), - "-i".to_string(), - ] - }) - .unwrap_or_default(), - ShellKind::Pwsh => env - .get(TERMUA_PWSH_INIT_ENV_KEY) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|_init| powershell_integration_args(cfg!(windows))) - .unwrap_or_default(), - ShellKind::Zsh | ShellKind::PowerShell | ShellKind::Cmd | ShellKind::Other => Vec::new(), - } -} - pub fn shell_program_candidates() -> &'static [&'static str] { if cfg!(windows) { // Windows: prefer PowerShell 7+ when available, then Windows PowerShell, then cmd. @@ -253,45 +215,6 @@ mod tests { assert_eq!(default_shell_program(), "bash"); } - #[test] - fn shell_integration_args_build_for_pwsh() { - let mut env = HashMap::new(); - env.insert( - TERMUA_PWSH_INIT_ENV_KEY.to_string(), - "/tmp/init.ps1".to_string(), - ); - assert_eq!( - shell_integration_args_for_env("pwsh", &env), - powershell_integration_args(cfg!(windows)) - ); - } - - #[test] - fn shell_integration_args_do_not_build_for_windows_powershell() { - let mut env = HashMap::new(); - env.insert( - TERMUA_PWSH_INIT_ENV_KEY.to_string(), - "/tmp/init.ps1".to_string(), - ); - - assert!(shell_integration_args_for_env("powershell", &env).is_empty()); - } - - #[test] - fn powershell_integration_args_can_enable_execution_policy_bypass() { - assert_eq!( - powershell_integration_args(true), - vec![ - "-NoLogo".to_string(), - "-NoExit".to_string(), - "-ExecutionPolicy".to_string(), - "Bypass".to_string(), - "-Command".to_string(), - ". \"$env:TERMUA_PWSH_INIT\"".to_string(), - ] - ); - } - #[test] fn platform_shell_candidates_are_ordered_by_preference() { let candidates = shell_program_candidates(); diff --git a/crates/gpui_term/src/suggestions.rs b/crates/gpui_term/src/suggestions.rs index db1e52e..dd33055 100644 --- a/crates/gpui_term/src/suggestions.rs +++ b/crates/gpui_term/src/suggestions.rs @@ -1,28 +1,13 @@ -use std::{ - collections::{HashMap, VecDeque}, - sync::Arc, -}; +use std::{collections::HashMap, sync::Arc}; use gpui::Global; -use crate::{TerminalContent, command_blocks::CommandBlock}; - -pub trait SuggestionHistoryProvider: Send + Sync + 'static { - fn seed(&self) -> Vec; - fn append(&self, command: &str); -} +use crate::TerminalContent; pub trait SuggestionStaticProvider: Send + Sync + 'static { fn for_each_candidate(&self, first_word: &str, f: &mut dyn FnMut(&str, Option<&str>)); } -#[derive(Default)] -pub struct SuggestionHistoryConfig { - pub provider: Option>, -} - -impl Global for SuggestionHistoryConfig {} - #[derive(Default)] pub struct SuggestionStaticConfig { pub provider: Option>, @@ -38,40 +23,7 @@ pub struct SuggestionItem { pub description: Option, } -#[derive(Debug)] -pub struct HistoryStore { - capacity: usize, - entries: VecDeque, -} - -impl HistoryStore { - pub fn new(capacity: usize) -> Self { - Self { - capacity, - entries: VecDeque::new(), - } - } - - pub fn push(&mut self, command: String) -> bool { - let command = command.trim().to_string(); - if command.is_empty() { - return false; - } - - if self.entries.back().is_some_and(|v| v == &command) { - return false; - } - - self.entries.push_back(command); - while self.entries.len() > self.capacity.max(1) { - self.entries.pop_front(); - } - true - } -} - pub struct SuggestionEngine { - pub history: HistoryStore, pub max_items: usize, static_provider: Option>, } @@ -79,7 +31,6 @@ pub struct SuggestionEngine { impl std::fmt::Debug for SuggestionEngine { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SuggestionEngine") - .field("history", &self.history) .field("max_items", &self.max_items) .field("has_static_provider", &self.static_provider.is_some()) .finish() @@ -87,9 +38,8 @@ impl std::fmt::Debug for SuggestionEngine { } impl SuggestionEngine { - pub fn new(history_capacity: usize, max_items: usize) -> Self { + pub fn new(max_items: usize) -> Self { Self { - history: HistoryStore::new(history_capacity), max_items, static_provider: None, } @@ -105,19 +55,7 @@ impl SuggestionEngine { return Vec::new(); } - let mut meta_by_full_text: HashMap = - HashMap::with_capacity(self.history.entries.len().min(256) + 16); - - // History suggestions: most recent first. - for (i, candidate) in self.history.entries.iter().rev().enumerate() { - push_candidate( - &mut meta_by_full_text, - input_prefix, - candidate, - 1000 - i as i32, - None, - ); - } + let mut meta_by_full_text: HashMap = HashMap::with_capacity(16); // Static suggestions. let first_word = input_prefix.split_whitespace().next().unwrap_or(""); @@ -380,37 +318,6 @@ fn cursor_at_eol_slow(content: &TerminalContent) -> bool { true } -pub(crate) fn drain_successful_history_commands( - pending: &mut VecDeque, - last_seen_block_id: &mut u64, - blocks: &[CommandBlock], -) -> Vec { - let old_last_seen = *last_seen_block_id; - let mut out = Vec::::new(); - - let mut new_last_seen = old_last_seen; - for block in blocks { - if block.id <= old_last_seen { - continue; - } - if block.ended_at.is_none() { - continue; - } - - new_last_seen = new_last_seen.max(block.id); - let Some(cmd) = pending.pop_front() else { - continue; - }; - - if block.exit_code == Some(0) { - out.push(cmd); - } - } - - *last_seen_block_id = new_last_seen; - out -} - #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum SelectionMove { Up, @@ -593,75 +500,6 @@ fn loose_prefix_match_end_ascii(full: &[u8], prefix: &[u8]) -> Option { Some(matched_end) } -#[cfg(test)] -mod history_drain_tests { - use super::*; - - fn block(id: u64, exit_code: Option, ended: bool) -> CommandBlock { - CommandBlock { - id, - started_at: std::time::Instant::now(), - ended_at: ended.then_some(std::time::Instant::now()), - exit_code, - command: None, - output_start_line: 0, - output_end_line: None, - } - } - - #[test] - fn drains_only_successful_commands_in_order() { - let mut pending = VecDeque::from(["ok".to_string(), "bad".to_string(), "ok2".to_string()]); - let mut last_seen = 0u64; - let blocks = vec![ - block(1, Some(0), true), - block(2, Some(1), true), - block(3, Some(0), true), - ]; - - let out = drain_successful_history_commands(&mut pending, &mut last_seen, &blocks); - assert_eq!(out, vec!["ok".to_string(), "ok2".to_string()]); - assert!(pending.is_empty()); - assert_eq!(last_seen, 3); - } - - #[test] - fn does_not_drain_for_running_blocks() { - let mut pending = VecDeque::from(["x".to_string()]); - let mut last_seen = 0u64; - let blocks = vec![block(1, None, false)]; - - let out = drain_successful_history_commands(&mut pending, &mut last_seen, &blocks); - assert!(out.is_empty()); - assert_eq!(pending.len(), 1); - assert_eq!(last_seen, 0); - } - - #[test] - fn drains_when_running_block_later_finishes() { - let mut pending = VecDeque::from(["x".to_string()]); - let mut last_seen = 0u64; - - let out = drain_successful_history_commands( - &mut pending, - &mut last_seen, - &[block(1, None, false)], - ); - assert!(out.is_empty()); - assert_eq!(pending.len(), 1); - assert_eq!(last_seen, 0); - - let out = drain_successful_history_commands( - &mut pending, - &mut last_seen, - &[block(1, Some(0), true)], - ); - assert_eq!(out, vec!["x".to_string()]); - assert!(pending.is_empty()); - assert_eq!(last_seen, 1); - } -} - #[cfg(test)] mod selection_tests { use super::*; @@ -699,7 +537,7 @@ mod tests { #[test] fn static_provider_is_used_when_set() { - let mut engine = SuggestionEngine::new(50, 8); + let mut engine = SuggestionEngine::new(8); engine.set_static_provider(Some(std::sync::Arc::new(TestStaticProvider))); let out = engine.suggest("ls"); let item = out @@ -711,7 +549,7 @@ mod tests { #[test] fn no_builtin_hints_when_no_static_provider() { - let engine = SuggestionEngine::new(50, 8); + let engine = SuggestionEngine::new(8); let out = engine.suggest("ls"); assert!( out.is_empty(), @@ -720,57 +558,46 @@ mod tests { } #[test] - fn history_extends_prefix_append_only() { - let mut engine = SuggestionEngine::new(50, 8); - engine.history.push("git status".to_string()); - - let out = engine.suggest("g"); - assert!( - out.iter().any(|s| s.full_text == "git status"), - "expected `git status` to extend `g`" - ); - - let out = engine.suggest("git status"); - assert!( - !out.iter().any(|s| s.full_text == "git status"), - "expected exact matches to not be suggested (append-only)" - ); - } + fn dedup_prefers_first_static_candidate() { + struct DuplicateStaticProvider; + + impl SuggestionStaticProvider for DuplicateStaticProvider { + fn for_each_candidate(&self, first_word: &str, f: &mut dyn FnMut(&str, Option<&str>)) { + if first_word == "ls" { + f("ls -al", Some("first")); + f("ls -al", Some("second")); + } + } + } - #[test] - fn history_suggests_when_prefix_has_extra_spaces() { - let mut engine = SuggestionEngine::new(50, 8); - engine.history.push("git status".to_string()); + let mut engine = SuggestionEngine::new(8); + engine.set_static_provider(Some(std::sync::Arc::new(DuplicateStaticProvider))); - let out = engine.suggest("git st"); - assert!( - out.iter().any(|s| s.full_text == "git status"), - "expected `git st` to match `git status`" - ); + let out = engine.suggest("ls"); + let item = out + .into_iter() + .find(|s| s.full_text == "ls -al") + .expect("expected ls -al"); + assert_eq!(item.description.as_deref(), Some("first")); } #[test] - fn dedup_prefers_higher_score() { - let mut engine = SuggestionEngine::new(50, 8); - engine.history.push("ls --all".to_string()); + fn static_suggestions_extend_prefix_append_only() { + let mut engine = SuggestionEngine::new(8); engine.set_static_provider(Some(std::sync::Arc::new(TestStaticProvider))); let out = engine.suggest("ls"); let item = out - .into_iter() - .find(|s| s.full_text == "ls --all") - .expect("expected ls --all"); - assert!(item.score >= 900, "expected history to win dedup by score"); - } - - #[test] - fn history_is_ranked_by_recency() { - let mut engine = SuggestionEngine::new(50, 8); - engine.history.push("echo one".to_string()); - engine.history.push("echo two".to_string()); + .iter() + .find(|s| s.full_text == "ls -al") + .expect("expected ls -al"); + assert_eq!(item.description.as_deref(), Some("List directory contents")); - let out = engine.suggest("e"); - assert_eq!(out.first().map(|s| s.full_text.as_str()), Some("echo two")); + let out = engine.suggest("ls -al"); + assert!( + !out.iter().any(|s| s.full_text == "ls -al"), + "expected exact matches to not be suggested (append-only)" + ); } mod prefix_extract { diff --git a/crates/gpui_term/src/terminal.rs b/crates/gpui_term/src/terminal.rs index 822934f..1d8f55c 100644 --- a/crates/gpui_term/src/terminal.rs +++ b/crates/gpui_term/src/terminal.rs @@ -258,26 +258,6 @@ pub trait TerminalBackend: Send { None } - /// Returns the terminal's recent command blocks, if supported by the backend. - fn command_blocks(&self) -> Option> { - None - } - - /// Convert a backend `GridPoint.line` coordinate into a stable row identifier suitable for - /// matching against command blocks. - /// - /// This is a best-effort adapter for UI integrations (e.g. context menus). Backends that - /// don't have a stable row concept may return `None`. - fn stable_row_for_grid_line(&self, _line: i32) -> Option { - None - } - - /// Convert a stable row identifier (as used by command blocks) into a backend `GridPoint.line` - /// coordinate suitable for creating a selection range. - fn grid_line_for_stable_row(&self, _stable_row: i64) -> Option { - None - } - /// Set the current selection range. /// /// Backends should treat this as a UI-only operation and avoid emitting PTY input. @@ -1474,18 +1454,6 @@ impl Terminal { self.inner.text_for_lines(start_line, end_line) } - pub fn command_blocks(&self) -> Option> { - self.inner.command_blocks() - } - - pub fn stable_row_for_grid_line(&self, line: i32) -> Option { - self.inner.stable_row_for_grid_line(line) - } - - pub fn grid_line_for_stable_row(&self, stable_row: i64) -> Option { - self.inner.grid_line_for_stable_row(stable_row) - } - pub fn set_selection_range(&mut self, range: Option) { self.inner.set_selection_range(range); } diff --git a/crates/gpui_term/src/view/line_number.rs b/crates/gpui_term/src/view/line_number.rs index 1274175..cb8eee5 100644 --- a/crates/gpui_term/src/view/line_number.rs +++ b/crates/gpui_term/src/view/line_number.rs @@ -76,8 +76,8 @@ pub(crate) fn compute_line_number_layout( let gutter = if show_line_numbers { cell_width / 3.0 + line_number_width } else if reserve_left_padding_without_line_numbers { - // When line numbers are hidden, still reserve enough space for interaction affordances - // in the left gutter (e.g. command block selection). + // When line numbers are hidden, still reserve enough space for left-gutter + // interaction affordances. (cell_width / 3.0).max(px(8.0)).max(px(14.0)) } else { Pixels::ZERO @@ -239,7 +239,7 @@ mod tests { #[test] fn reserves_minimum_gutter_without_line_numbers() { // Even when line numbers are hidden, we still want enough left gutter space for - // interaction affordances (e.g. command block selection). + // interaction affordances. let cell_width = px(9.0); let state = compute_line_number_layout(cell_width, false, true, 10_000); assert!(state.gutter >= px(14.0)); diff --git a/crates/gpui_term/src/view/mod.rs b/crates/gpui_term/src/view/mod.rs index 6a142f9..c723bd1 100644 --- a/crates/gpui_term/src/view/mod.rs +++ b/crates/gpui_term/src/view/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::VecDeque, ops::Range, sync::Arc, time::Duration}; +use std::{ops::Range, sync::Arc, time::Duration}; use gpui::{ Action, AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, @@ -24,9 +24,7 @@ use crate::{ record::render_recording_indicator_label, settings::{CursorShape, TerminalBlink, TerminalSettings}, snippet::{SnippetJump, SnippetJumpDir, SnippetSession}, - suggestions::{ - SuggestionEngine, SuggestionHistoryConfig, SuggestionItem, SuggestionStaticConfig, - }, + suggestions::{SuggestionEngine, SuggestionItem, SuggestionStaticConfig}, terminal::{ Clear, Event, Paste, SelectAll, ShowCharacterPalette, StartCastRecording, StopCastRecording, Terminal, TerminalBounds, ToggleCastRecording, UserInput, @@ -148,15 +146,7 @@ struct SuggestionsState { impl SuggestionsState { fn new(cx: &App) -> Self { let settings = TerminalSettings::global(cx); - let mut engine = SuggestionEngine::new(200, settings.suggestions_max_items); - if let Some(provider) = cx - .try_global::() - .and_then(|cfg| cfg.provider.clone()) - { - for cmd in provider.seed() { - let _ = engine.history.push(cmd); - } - } + let mut engine = SuggestionEngine::new(settings.suggestions_max_items); let (static_provider, static_epoch_seen) = cx .try_global::() @@ -219,15 +209,6 @@ struct ScrollbarPreviewLayoutState { total_lines: usize, } -#[derive(Clone, Copy)] -struct CommandBlockHitLayoutState { - bounds: Bounds, - line_height: Pixels, - display_offset: i32, - max_row: i32, - cols: usize, -} - /// A terminal view, maintains the PTY's file handles and communicates with the terminal pub struct TerminalView { pub terminal: Entity, @@ -242,8 +223,6 @@ pub struct TerminalView { search: SearchState, suggestions: SuggestionsState, snippet: Option, - pending_history_commands: VecDeque, - last_seen_history_block_id: Option, context_menu_enabled: bool, context_menu_provider: Option>, _subscriptions: Vec, @@ -298,17 +277,6 @@ impl TerminalView { } } - fn command_block_hit_layout_state(&self, cx: &App) -> CommandBlockHitLayoutState { - let content = self.terminal.read(cx).last_content(); - CommandBlockHitLayoutState { - bounds: content.terminal_bounds.bounds, - line_height: content.terminal_bounds.line_height, - display_offset: content.display_offset as i32, - max_row: content.terminal_bounds.num_lines().saturating_sub(1) as i32, - cols: content.terminal_bounds.num_columns().max(1), - } - } - fn cast_recording_active(&self, cx: &App) -> bool { self.terminal.read(cx).cast_recording_active() } @@ -370,8 +338,6 @@ impl TerminalView { search: SearchState::default(), suggestions: SuggestionsState::new(cx), snippet: None, - pending_history_commands: VecDeque::new(), - last_seen_history_block_id: None, context_menu_enabled, context_menu_provider, _subscriptions: vec![focus_in, focus_out], @@ -383,62 +349,6 @@ impl TerminalView { self.context_menu_enabled } - fn queue_command_for_history(&mut self, command: String, cx: &mut Context) { - let command = command.trim().to_string(); - if command.is_empty() { - return; - } - - // Only persist history when we can observe command success via OSC 133 command blocks. - let blocks = self.terminal.read(cx).command_blocks(); - if blocks.is_none() { - return; - } - - if self.last_seen_history_block_id.is_none() { - self.last_seen_history_block_id = blocks - .as_ref() - .and_then(|b| b.iter().rev().find(|v| v.ended_at.is_some()).map(|v| v.id)) - .or(Some(0)); - } - - const MAX_PENDING_HISTORY: usize = 32; - while self.pending_history_commands.len() >= MAX_PENDING_HISTORY { - self.pending_history_commands.pop_front(); - } - self.pending_history_commands.push_back(command); - } - - fn flush_successful_history_from_blocks( - &mut self, - blocks: &[crate::command_blocks::CommandBlock], - cx: &mut Context, - ) { - let Some(last_seen) = self.last_seen_history_block_id.as_mut() else { - return; - }; - - let successful = crate::suggestions::drain_successful_history_commands( - &mut self.pending_history_commands, - last_seen, - blocks, - ); - if successful.is_empty() { - return; - } - - let provider = cx - .try_global::() - .and_then(|cfg| cfg.provider.clone()); - - for cmd in successful { - let inserted = self.suggestions.engine.history.push(cmd.clone()); - if inserted && let Some(provider) = provider.as_ref() { - provider.append(&cmd); - } - } - } - fn show_toast( &mut self, level: PromptLevel, @@ -1201,114 +1111,6 @@ impl TerminalView { .separator() .menu("Clear", Box::new(Clear)) } - - fn select_command_block_at_y( - &mut self, - y: Pixels, - _shift: bool, - window: &mut Window, - cx: &mut Context, - ) { - window.focus(&self.focus_handle, cx); - let debug_toasts = cfg!(debug_assertions); - - let CommandBlockHitLayoutState { - bounds, - line_height, - display_offset, - max_row, - cols, - } = self.command_block_hit_layout_state(cx); - - if line_height <= px(0.0) || bounds.size.height <= px(0.0) { - return; - } - - let rel_y = y - bounds.origin.y; - if rel_y < px(0.0) || rel_y >= bounds.size.height { - return; - } - - let mut row = (rel_y / line_height).floor() as i32; - row = row.clamp(0, max_row); - - let grid_line = row.saturating_sub(display_offset); - - let Some((stable, blocks, block, start_line, end_line)) = (|| { - let terminal = self.terminal.read(cx); - let stable = terminal.stable_row_for_grid_line(grid_line)?; - let blocks = terminal.command_blocks()?; - let block = crate::command_blocks::block_at_stable_row(&blocks, stable).cloned(); - let (start_line, end_line) = match block.as_ref() { - Some(block) => { - let end_stable = block.output_end_line.unwrap_or(stable); - ( - terminal.grid_line_for_stable_row(block.output_start_line)?, - terminal.grid_line_for_stable_row(end_stable)?, - ) - } - None => (0, 0), - }; - Some((stable, blocks, block, start_line, end_line)) - })() else { - if debug_toasts { - self.show_toast( - PromptLevel::Info, - "Command blocks unavailable", - Some("This terminal backend doesn't expose command blocks.".to_string()), - window, - cx, - ); - } - return; - }; - - let Some(_block) = block else { - self.terminal.update(cx, |terminal, _| { - terminal.set_selection_range(None); - }); - window.refresh(); - if debug_toasts { - self.show_toast( - PromptLevel::Info, - "No command block here", - Some(no_command_block_detail(&blocks, stable)), - window, - cx, - ); - } - return; - }; - - let last_col = cols.saturating_sub(1); - - self.terminal.update(cx, |terminal, _cx| { - terminal.set_selection_range(Some(crate::SelectionRange { - start: crate::GridPoint::new(start_line, 0), - end: crate::GridPoint::new(end_line, last_col), - })); - }); - window.refresh(); - cx.notify(); - } -} - -fn no_command_block_detail(blocks: &[crate::command_blocks::CommandBlock], stable: i64) -> String { - if blocks.is_empty() { - "No OSC 133 blocks detected yet. Ensure this tab is a local bash or zsh shell with TERMUA \ - OSC 133 integration active." - .to_string() - } else { - match blocks.last() { - Some(last) => format!( - "stable_row={stable} blocks={} last_block.start={} last_block.end={:?}", - blocks.len(), - last.output_start_line, - last.output_end_line - ), - None => format!("stable_row={stable} blocks=0"), - } - } } fn subscribe_for_terminal_events( @@ -1316,10 +1118,7 @@ fn subscribe_for_terminal_events( window: &mut Window, cx: &mut Context, ) -> Vec { - let terminal_subscription = cx.observe(terminal, |terminal_view, terminal, cx| { - if let Some(blocks) = terminal.read(cx).command_blocks() { - terminal_view.flush_successful_history_from_blocks(&blocks, cx); - } + let terminal_subscription = cx.observe(terminal, |_terminal_view, _terminal, cx| { cx.notify(); }); @@ -2567,9 +2366,6 @@ mod snippet_placeholder_key_down_tests { mod tests { use std::time::Duration; - use super::no_command_block_detail; - use crate::command_blocks::CommandBlock; - fn format_clock(d: Duration) -> String { let secs = d.as_secs(); let h = secs / 3600; @@ -2591,39 +2387,4 @@ mod tests { "01:01:01" ); } - - #[test] - fn no_command_block_detail_mentions_supported_shells() { - let detail = no_command_block_detail(&[], 42); - assert!(detail.contains("bash or zsh")); - assert!(detail.contains("OSC 133")); - } - - #[test] - fn no_command_block_detail_reports_last_block_context() { - let blocks = vec![CommandBlock { - id: 1, - started_at: std::time::Instant::now(), - ended_at: None, - exit_code: None, - command: None, - output_start_line: 10, - output_end_line: Some(20), - }]; - - let detail = no_command_block_detail(&blocks, 42); - assert!(detail.contains("stable_row=42")); - assert!(detail.contains("last_block.start=10")); - assert!(detail.contains("last_block.end=Some(20)")); - } - - #[test] - fn command_block_debug_toasts_are_gated_by_debug_assertions() { - let src = include_str!("mod.rs"); - let gate = "let debug_toasts = cfg!(debug_assertions);"; - assert!( - src.contains(gate), - "expected command block selection to gate debug toasts on debug assertions" - ); - } } diff --git a/crates/gpui_term/src/view/render.rs b/crates/gpui_term/src/view/render.rs index c7d6408..9814f16 100644 --- a/crates/gpui_term/src/view/render.rs +++ b/crates/gpui_term/src/view/render.rs @@ -95,20 +95,14 @@ impl TerminalView { let root = root.on_mouse_down( MouseButton::Left, cx.listener(|this, event: &MouseDownEvent, window, cx| { - // Treat the left gutter (line numbers/padding) as UI chrome: allow selecting - // command blocks there rather than starting a terminal selection. + // Treat the left gutter (line numbers/padding) as UI chrome, not terminal input. let content_bounds = { let terminal = this.terminal.read(cx); terminal.last_content().terminal_bounds.bounds }; if event.position.x < content_bounds.origin.x { this.close_suggestions(cx); - this.select_command_block_at_y( - event.position.y, - event.modifiers.shift, - window, - cx, - ); + window.focus(&this.focus_handle, cx); cx.stop_propagation(); } }), @@ -137,13 +131,6 @@ impl TerminalView { }; let clicked_in_gutter = event.position.x < content_bounds.origin.x; if clicked_in_gutter { - // Pre-select the block (if any) so context-menu actions apply. - this.select_command_block_at_y( - event.position.y, - event.modifiers.shift, - window, - cx, - ); return; } diff --git a/crates/gpui_term/src/view/suggestions.rs b/crates/gpui_term/src/view/suggestions.rs index 7110d60..e2d1adf 100644 --- a/crates/gpui_term/src/view/suggestions.rs +++ b/crates/gpui_term/src/view/suggestions.rs @@ -114,17 +114,7 @@ impl TerminalView { return true; } - if let Some(prompt_prefix) = self.suggestions.prompt_prefix.take() { - let line_prefix = extract_cursor_line_prefix(&prompt.content); - let input = line_prefix - .strip_prefix(&prompt_prefix) - .unwrap_or("") - .trim() - .to_string(); - if !input.is_empty() { - self.queue_command_for_history(input, cx); - } - } + self.suggestions.prompt_prefix = None; self.suggestions.close(); false } diff --git a/locales/en.yml b/locales/en.yml index 2b59d96..26d28ae 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -149,7 +149,6 @@ Footbar: LockUnsupported: "Lock (unsupported)" LockDisabled: "Lock (disabled)" Issues: - Label: "Termua Issues" Tooltip: "Open Termua Issues" Transfers: @@ -219,8 +218,6 @@ MainWindow: Cancel: "Cancel" ContextMenu: OpenSftp: "Open SFTP" - CopyCommandBlockOutput: "Copy Command Block Output" - CopyCommandBlockId: "Copy Command Block Id" Menubar: FoldMenuFallback: "Menu" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index d061890..7ad13b7 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -148,7 +148,6 @@ Footbar: LockUnsupported: "锁屏(不支持)" LockDisabled: "锁屏(已禁用)" Issues: - Label: "问题反馈" Tooltip: "打开 Termua Issues" Transfers: @@ -218,8 +217,6 @@ MainWindow: Cancel: "取消" ContextMenu: OpenSftp: "打开 SFTP" - CopyCommandBlockOutput: "复制命令块输出" - CopyCommandBlockId: "复制命令块 ID" Menubar: FoldMenuFallback: "菜单" diff --git a/termua/src/assistant.rs b/termua/src/assistant.rs index 6fbc9a1..4e0d39e 100644 --- a/termua/src/assistant.rs +++ b/termua/src/assistant.rs @@ -6,10 +6,6 @@ use std::{ use gpui::{App, AppContext, Context, SharedString}; use gpui_term::Terminal; -pub const DEFAULT_TERMINAL_CONTEXT_MAX_LINES: usize = 200; -pub const DEFAULT_TERMINAL_CONTEXT_POLL_INTERVAL_MS: u64 = 500; -pub const DEFAULT_TERMINAL_CONTEXT_MAX_CHARS: usize = 12_000; - pub const ASSISTANT_SYSTEM_PROMPT: &str = r#"You are a terminal assistant embedded in a GUI app. Rules: @@ -38,7 +34,6 @@ pub struct AssistantState { pub target_panel_id: Option, pub target_follows_focus: bool, pub attach_selection: bool, - pub attach_terminal_context: bool, } impl Default for AssistantState { @@ -51,7 +46,6 @@ impl Default for AssistantState { target_panel_id: None, target_follows_focus: true, attach_selection: false, - attach_terminal_context: false, } } } @@ -194,12 +188,6 @@ pub(crate) fn ensure_app_globals(app: &mut App) { if app.try_global::().is_none() { app.set_global(FocusedTerminalState::default()); } - if app.try_global::().is_none() { - app.set_global(AssistantTerminalContextState::default()); - } - if app.try_global::().is_none() { - app.set_global(AssistantCommandOutputState::default()); - } if app.try_global::().is_none() { app.set_global(TerminalRegistryState::default()); } @@ -208,8 +196,6 @@ pub(crate) fn ensure_app_globals(app: &mut App) { pub(crate) fn ensure_globals(cx: &mut Context) { crate::globals::ensure_ctx_global::(cx); crate::globals::ensure_ctx_global::(cx); - crate::globals::ensure_ctx_global::(cx); - crate::globals::ensure_ctx_global::(cx); crate::globals::ensure_ctx_global::(cx); } @@ -233,24 +219,6 @@ pub(crate) fn target_is_available(cx: &impl Borrow, panel_id: usize) -> boo .is_some() } -pub(crate) fn terminal_context_snapshot_text( - cx: &impl Borrow, - panel_id: usize, -) -> Option { - cx.borrow() - .try_global::() - .and_then(|s| s.get_snapshot_text(panel_id)) -} - -pub(crate) fn command_output_snapshot_text( - cx: &impl Borrow, - panel_id: usize, -) -> Option { - cx.borrow() - .try_global::() - .and_then(|s| s.get_snapshot_text(panel_id)) -} - pub(crate) fn list_targets(cx: &mut impl BorrowMut) -> Vec { let app = cx.borrow_mut(); if app.try_global::().is_none() { @@ -336,210 +304,6 @@ where }) } -#[derive(Clone, Debug)] -pub struct TerminalContextSnapshot { - pub text: SharedString, -} - -#[derive(Default)] -pub struct AssistantTerminalContextState { - snapshots: HashMap, -} - -impl gpui::Global for AssistantTerminalContextState {} - -impl AssistantTerminalContextState { - pub fn upsert_snapshot(&mut self, panel_id: usize, text: impl Into) { - let text: SharedString = text.into(); - let trimmed = text.as_ref().trim(); - if trimmed.is_empty() { - self.snapshots.remove(&panel_id); - return; - } - - let trimmed = truncate_text(trimmed, DEFAULT_TERMINAL_CONTEXT_MAX_CHARS); - if self - .snapshots - .get(&panel_id) - .is_some_and(|s| s.text.as_ref() == trimmed) - { - return; - } - - self.snapshots.insert( - panel_id, - TerminalContextSnapshot { - text: trimmed.to_string().into(), - }, - ); - } - - pub fn get_snapshot_text(&self, panel_id: usize) -> Option { - self.snapshots.get(&panel_id).map(|s| s.text.clone()) - } -} - -pub fn tail_text_for_panel(cx: &C, panel_id: usize, max_lines: usize) -> Option -where - C: AppContext + Borrow, -{ - let target = cx - .borrow() - .try_global::()? - .get(panel_id)? - .terminal - .upgrade()?; - - cx.read_entity(&target, |terminal, _app| terminal.tail_text(max_lines)) -} - -#[derive(Clone, Debug)] -pub struct CommandOutputSnapshot { - pub block_id: u64, - pub text: SharedString, -} - -#[derive(Default)] -pub struct AssistantCommandOutputState { - snapshots: HashMap, -} - -impl gpui::Global for AssistantCommandOutputState {} - -impl AssistantCommandOutputState { - pub fn upsert_snapshot( - &mut self, - panel_id: usize, - block_id: u64, - text: impl Into, - ) { - let text: SharedString = text.into(); - let trimmed = text.as_ref().trim(); - if trimmed.is_empty() { - self.snapshots.remove(&panel_id); - return; - } - - let trimmed = truncate_text(trimmed, DEFAULT_TERMINAL_CONTEXT_MAX_CHARS); - if self - .snapshots - .get(&panel_id) - .is_some_and(|s| s.block_id == block_id && s.text.as_ref() == trimmed) - { - return; - } - - self.snapshots.insert( - panel_id, - CommandOutputSnapshot { - block_id, - text: trimmed.to_string().into(), - }, - ); - } - - pub fn get_snapshot_text(&self, panel_id: usize) -> Option { - self.snapshots.get(&panel_id).map(|s| s.text.clone()) - } - - pub fn get_snapshot_block_id(&self, panel_id: usize) -> Option { - self.snapshots.get(&panel_id).map(|s| s.block_id) - } -} - -pub fn last_command_block_output_for_panel( - cx: &C, - panel_id: usize, - last_seen_block_id: Option, -) -> Option<(u64, String)> -where - C: AppContext + Borrow, -{ - let target = cx - .borrow() - .try_global::()? - .get(panel_id)? - .terminal - .upgrade()?; - - cx.read_entity(&target, |terminal, _app| { - let blocks = terminal.command_blocks()?; - let b = blocks - .into_iter() - .rev() - .find(|b| b.output_end_line.is_some())?; - if Some(b.id) == last_seen_block_id { - return None; - } - let end = b.output_end_line?; - let text = terminal.text_for_lines(b.output_start_line, end)?; - Some((b.id, text)) - }) -} - -pub(crate) fn poll_terminal_context_snapshots(cx: &mut Context) { - let attach_terminal_context = cx - .try_global::() - .is_some_and(|s| s.attach_terminal_context); - if !attach_terminal_context { - return; - } - - let panel_id = cx - .try_global::() - .and_then(|s| s.target_panel_id) - .or_else(|| { - cx.try_global::() - .and_then(|s| s.focused_panel_id) - }); - let Some(panel_id) = panel_id else { - return; - }; - - let Some(text) = tail_text_for_panel(cx, panel_id, DEFAULT_TERMINAL_CONTEXT_MAX_LINES) else { - return; - }; - - if cx.try_global::().is_some() { - cx.global_mut::() - .upsert_snapshot(panel_id, text); - } - - let last_block_id = cx - .try_global::() - .and_then(|s| s.get_snapshot_block_id(panel_id)); - let Some((block_id, output)) = last_command_block_output_for_panel(cx, panel_id, last_block_id) - else { - return; - }; - - if cx.try_global::().is_some() { - cx.global_mut::() - .upsert_snapshot(panel_id, block_id, output); - } -} - -fn truncate_text(s: &str, max_chars: usize) -> &str { - if max_chars == 0 { - return ""; - } - if s.chars().count() <= max_chars { - return s; - } - - let mut end = 0usize; - for (i, _) in s.char_indices().take(max_chars) { - end = i; - } - // `end` points at the start of the last included char; include it. - if let Some((last_start, last_ch)) = s[end..].char_indices().next() { - let last_len = last_ch.len_utf8(); - &s[..end + last_start + last_len] - } else { - s - } -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct FencedCodeBlock { pub lang: Option, diff --git a/termua/src/bootstrap.rs b/termua/src/bootstrap.rs index 6b1a887..6126efc 100644 --- a/termua/src/bootstrap.rs +++ b/termua/src/bootstrap.rs @@ -19,15 +19,6 @@ fn init_app(cx: &mut App, settings: &crate::settings::SettingsFile) { gpui_dock::init(cx); crate::panel::assistant_panel::bind_keybindings(cx); - match crate::command_history::CommandHistory::load_default() { - Ok(history) => { - gpui_term::set_suggestion_history_provider(cx, Some(std::sync::Arc::new(history))); - } - Err(err) => { - log::warn!("termua: failed to load command history: {err:#}"); - } - } - match crate::static_suggestions::StaticSuggestionsDb::load_default() { Ok(db) => { gpui_term::set_suggestion_static_provider(cx, Some(std::sync::Arc::new(db))); diff --git a/termua/src/command_history.rs b/termua/src/command_history.rs deleted file mode 100644 index 82341be..0000000 --- a/termua/src/command_history.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::{ - collections::VecDeque, - fs, - io::Write, - path::{Path, PathBuf}, - sync::Mutex, -}; - -use anyhow::Context; - -pub(crate) struct CommandHistory { - path: PathBuf, - max_entries: usize, - entries: Mutex>, -} - -impl CommandHistory { - pub(crate) fn load_default() -> anyhow::Result { - let path = command_history_path(); - Self::load_from_path(path, 2000) - } - - fn load_from_path(path: PathBuf, max_entries: usize) -> anyhow::Result { - let entries = load_lines(&path, max_entries).with_context(|| format!("read {path:?}"))?; - Ok(Self { - path, - max_entries, - entries: Mutex::new(entries), - }) - } - - fn append(&self, command: &str) { - let mut command = command.trim().to_string(); - if command.is_empty() { - return; - } - command = command.replace(['\n', '\r'], " "); - - let mut entries = self.entries.lock().unwrap(); - if entries.back().is_some_and(|v| v == &command) { - return; - } - - entries.push_back(command.clone()); - - let mut needs_compact = false; - while entries.len() > self.max_entries.max(1) { - entries.pop_front(); - needs_compact = true; - } - - if let Err(err) = ensure_parent_dir(&self.path) { - log::warn!("termua: failed to create command history dir: {err:#}"); - return; - } - - if needs_compact { - let snapshot = entries.iter().cloned().collect::>(); - drop(entries); - if let Err(err) = write_compacted(&self.path, &snapshot) { - log::warn!("termua: failed to compact command history: {err:#}"); - } - return; - } - - if let Err(err) = append_line(&self.path, &command) { - log::warn!("termua: failed to append command history: {err:#}"); - } - } -} - -impl gpui_term::SuggestionHistoryProvider for CommandHistory { - fn seed(&self) -> Vec { - self.entries.lock().unwrap().iter().cloned().collect() - } - - fn append(&self, command: &str) { - self.append(command); - } -} - -fn command_history_path() -> PathBuf { - crate::settings::settings_dir_path().join("command_history.txt") -} - -fn ensure_parent_dir(path: &Path) -> anyhow::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?; - } - Ok(()) -} - -fn append_line(path: &Path, line: &str) -> anyhow::Result<()> { - let mut f = fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .with_context(|| format!("open {path:?}"))?; - writeln!(f, "{line}").with_context(|| format!("write {path:?}"))?; - Ok(()) -} - -fn write_compacted(path: &Path, lines: &[String]) -> anyhow::Result<()> { - let mut out = String::new(); - for line in lines { - out.push_str(line); - out.push('\n'); - } - crate::atomic_write::write_string(path, &out).with_context(|| format!("write {path:?}"))?; - Ok(()) -} - -fn load_lines(path: &Path, max_entries: usize) -> anyhow::Result> { - let contents = match fs::read_to_string(path) { - Ok(v) => v, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(err) => return Err(err).with_context(|| format!("read {path:?}")), - }; - - let mut out = VecDeque::new(); - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - if out.back().is_some_and(|v| v == line) { - continue; - } - out.push_back(line.to_string()); - while out.len() > max_entries.max(1) { - out.pop_front(); - } - } - Ok(out) -} - -#[cfg(test)] -mod tests { - use gpui_term::SuggestionHistoryProvider as _; - - use super::*; - - #[test] - fn persists_across_reload() { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "termua-command-history-test-{}-{unique}", - std::process::id() - )); - std::fs::create_dir_all(&dir).unwrap(); - - let settings_path = dir.join("termua").join("settings.json"); - if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - let _guard = crate::settings::override_settings_json_path(settings_path); - - let history = CommandHistory::load_default().unwrap(); - history.append("ls --all"); - history.append("git status"); - - let history2 = CommandHistory::load_default().unwrap(); - let seed = history2.seed(); - assert!(seed.contains(&"ls --all".to_string())); - assert!(seed.contains(&"git status".to_string())); - } -} diff --git a/termua/src/footbar/mod.rs b/termua/src/footbar/mod.rs index 9d51528..0ea0c59 100644 --- a/termua/src/footbar/mod.rs +++ b/termua/src/footbar/mod.rs @@ -104,11 +104,11 @@ impl FootbarView { .compact() .link() .icon(Icon::default().path(TermuaIcon::Bug)) - .label(t!("Footbar.Issues.Label").to_string()) .tooltip(t!("Footbar.Issues.Tooltip").to_string()) .debug_selector(|| "termua-footbar-issues".to_string()) .on_click(|_, _, cx| { - cx.open_url("https://github.com/iamazy/termua/issues"); + let repository = env!("CARGO_PKG_REPOSITORY"); + cx.open_url(format!("{repository}/issues").as_str()); }), ) .child( diff --git a/termua/src/main.rs b/termua/src/main.rs index ffb9e8a..a78a8e3 100644 --- a/termua/src/main.rs +++ b/termua/src/main.rs @@ -7,7 +7,6 @@ mod assistant; mod atomic_write; mod bootstrap; mod cast_player; -mod command_history; mod env; mod footbar; mod globals; @@ -23,7 +22,6 @@ mod serial; mod session; mod settings; mod sharing; -mod shell_integration; mod ssh; mod static_suggestions; mod theme_manager; diff --git a/termua/src/panel/assistant_panel.rs b/termua/src/panel/assistant_panel.rs index bd55977..2326d6c 100644 --- a/termua/src/panel/assistant_panel.rs +++ b/termua/src/panel/assistant_panel.rs @@ -22,8 +22,8 @@ use termua_zeroclaw::{Client as ZeroclawClient, ClientOptions as ZeroclawClientO use crate::assistant::{ ASSISTANT_SYSTEM_PROMPT, AssistantMessage, AssistantRole, AssistantState, - DEFAULT_TERMINAL_CONTEXT_MAX_LINES, extract_terminal_command_snippets, focused_selection_text, - sanitize_assistant_reply, strip_fenced_code_blocks, tail_text_for_panel, + extract_terminal_command_snippets, focused_selection_text, sanitize_assistant_reply, + strip_fenced_code_blocks, }; const PROMPT_KEY_CONTEXT: &str = "termua_assistant_prompt"; @@ -159,18 +159,7 @@ impl AssistantPanelView { let prompt = prompt.trim().to_string(); let attach_selection = cx.global::().attach_selection; - let attach_terminal_context = cx.global::().attach_terminal_context; - let terminal_context_panel_id = cx - .global::() - .target_panel_id - .or(crate::assistant::focused_panel_id(cx)); - - let attachments = Self::gather_prompt_attachments( - cx, - attach_selection, - attach_terminal_context, - terminal_context_panel_id, - ); + let attachments = Self::gather_prompt_attachments(cx, attach_selection); let request_id = { let state = cx.global_mut::(); @@ -201,8 +190,6 @@ impl AssistantPanelView { fn gather_prompt_attachments( cx: &mut Context, attach_selection: bool, - attach_terminal_context: bool, - terminal_context_panel_id: Option, ) -> PromptAttachments { let selection = if attach_selection { focused_selection_text(cx).unwrap_or_default() @@ -210,55 +197,15 @@ impl AssistantPanelView { String::new() }; - let (terminal_context, last_command_output) = if attach_terminal_context { - let terminal_context = terminal_context_panel_id - .and_then(|panel_id| { - crate::assistant::terminal_context_snapshot_text(cx, panel_id) - .map(|s| s.to_string()) - .or_else(|| { - tail_text_for_panel(cx, panel_id, DEFAULT_TERMINAL_CONTEXT_MAX_LINES) - }) - }) - .unwrap_or_default(); - - let last_command_output = terminal_context_panel_id - .and_then(|panel_id| { - crate::assistant::command_output_snapshot_text(cx, panel_id) - .map(|s| s.to_string()) - }) - .unwrap_or_default(); - - (terminal_context, last_command_output) - } else { - (String::new(), String::new()) - }; - - PromptAttachments { - selection, - terminal_context, - last_command_output, - } + PromptAttachments { selection } } fn compose_full_prompt(prompt: &str, attachments: &PromptAttachments) -> String { - if attachments.last_command_output.is_empty() - && attachments.terminal_context.is_empty() - && attachments.selection.is_empty() - { + if attachments.selection.is_empty() { return prompt.to_string(); } let mut out = String::new(); - if !attachments.last_command_output.is_empty() { - out.push_str("Last command output:\n"); - out.push_str(&attachments.last_command_output); - out.push_str("\n\n"); - } - if !attachments.terminal_context.is_empty() { - out.push_str("Terminal context (tail):\n"); - out.push_str(&attachments.terminal_context); - out.push_str("\n\n"); - } if !attachments.selection.is_empty() { out.push_str("Selected text:\n"); out.push_str(&attachments.selection); @@ -349,8 +296,6 @@ impl AssistantPanelView { struct PromptAttachments { selection: String, - terminal_context: String, - last_command_output: String, } #[derive(Clone)] @@ -510,7 +455,6 @@ impl AssistantPanelView { in_flight: bool, assistant_enabled: bool, attach_selection: bool, - attach_terminal_context: bool, cx: &mut Context, ) -> AnyElement { let prompt_value = self.prompt_input.read(cx).value(); @@ -598,16 +542,6 @@ impl AssistantPanelView { window.refresh(); }), ) - .item( - PopupMenuItem::new("Include terminal context") - .checked(attach_terminal_context) - .on_click(|_, window, cx| { - let state = cx.global_mut::(); - state.attach_terminal_context = !state.attach_terminal_context; - cx.refresh_windows(); - window.refresh(); - }), - ) }), ), ), @@ -1148,7 +1082,6 @@ impl Render for AssistantPanelView { let entries = state.messages.clone(); let can_rerun_user_messages = Self::compute_can_rerun_user_messages(&entries, in_flight); let attach_selection = state.attach_selection; - let attach_terminal_context = state.attach_terminal_context; let target_panel_id = state.target_panel_id; let follow_focus = state.target_follows_focus; let assistant_enabled = cx @@ -1177,13 +1110,7 @@ impl Render for AssistantPanelView { cx, )) .child(self.render_messages_area(this, entries, can_rerun_user_messages, in_flight, cx)) - .child(self.render_prompt_bar( - in_flight, - assistant_enabled, - attach_selection, - attach_terminal_context, - cx, - )) + .child(self.render_prompt_bar(in_flight, assistant_enabled, attach_selection, cx)) } } @@ -1541,8 +1468,7 @@ mod tests { .expect("expected second command in assistant reply to have a Run button"); } - // Intentionally no assistant "tool" UI in the panel. Terminal context (if enabled) - // is injected into the request payload, not exposed as tool calls. + // Intentionally no assistant "tool" UI in the panel. #[gpui::test] fn assistant_user_messages_render_rerun_button_after_assistant_reply( diff --git a/termua/src/shell_integration.rs b/termua/src/shell_integration.rs deleted file mode 100644 index 9957eea..0000000 --- a/termua/src/shell_integration.rs +++ /dev/null @@ -1,533 +0,0 @@ -use std::collections::HashMap; - -use gpui_term::shell::{ - ShellKind, TERMUA_SHELL_ENV_KEY, pick_shell_program_from_env_or_else, shell_kind, -}; - -#[cfg(target_os = "linux")] -const OSC133_BASH: &str = include_str!("../../assets/shell/termua-osc133.bash"); -#[cfg(target_os = "macos")] -const OSC133_ZSH: &str = include_str!("../../assets/shell/termua-osc133.zsh"); -#[cfg(any(windows, test))] -const OSC133_PWSH: &str = include_str!("../../assets/shell/termua-osc133.ps1"); - -pub(crate) fn maybe_inject_local_shell_osc133( - env: HashMap, - terminal_id: usize, -) -> HashMap { - let Some(shell_program) = selected_shell_program_for_env(&env) else { - return env; - }; - - match shell_kind(&shell_program) { - #[cfg(target_os = "linux")] - ShellKind::Bash => maybe_inject_local_bash_osc133(env, terminal_id), - #[cfg(target_os = "macos")] - ShellKind::Zsh => maybe_inject_local_zsh_osc133(env, terminal_id), - #[cfg(windows)] - ShellKind::Pwsh => maybe_inject_local_pwsh_osc133(env, terminal_id), - ShellKind::Other => env, - _ => env, - } -} - -#[cfg(target_os = "linux")] -pub(crate) fn maybe_inject_local_bash_osc133( - env: HashMap, - terminal_id: usize, -) -> HashMap { - #[cfg(not(unix))] - { - let _ = terminal_id; - return env; - } - - #[cfg(unix)] - { - let mut env = env; - let Some(shell_program) = selected_shell_program_for_env(&env) else { - return env; - }; - if !is_bash_program(&shell_program) { - return env; - } - - match write_bash_rcfile(terminal_id) { - Ok(rcfile_path) => { - env.insert("SHELL".to_string(), shell_program.clone()); - env.insert(TERMUA_SHELL_ENV_KEY.to_string(), shell_program); - env.insert( - gpui_term::shell::TERMUA_BASH_RCFILE_ENV_KEY.to_string(), - rcfile_path.to_string_lossy().to_string(), - ); - } - Err(err) => { - log::warn!("termua: failed to inject OSC133 bash integration: {err:#}"); - } - } - - env - } -} - -#[cfg(target_os = "macos")] -pub(crate) fn maybe_inject_local_zsh_osc133( - env: HashMap, - terminal_id: usize, -) -> HashMap { - #[cfg(not(unix))] - { - let _ = terminal_id; - return env; - } - - #[cfg(unix)] - { - let mut env = env; - let Some(shell_program) = selected_shell_program_for_env(&env) else { - return env; - }; - if !is_zsh_program(&shell_program) { - return env; - } - - let orig_zdotdir = env - .get("ZDOTDIR") - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(ToString::to_string); - - match write_zsh_dotdir(terminal_id) { - Ok(zdotdir) => { - env.insert("SHELL".to_string(), shell_program.clone()); - env.insert(TERMUA_SHELL_ENV_KEY.to_string(), shell_program); - if let Some(orig) = orig_zdotdir { - env.insert("TERMUA_ORIG_ZDOTDIR".to_string(), orig); - } - env.insert("ZDOTDIR".to_string(), zdotdir.to_string_lossy().to_string()); - } - Err(err) => { - log::warn!("termua: failed to inject OSC133 zsh integration: {err:#}"); - } - } - - env - } -} - -#[cfg(windows)] -pub(crate) fn maybe_inject_local_pwsh_osc133( - env: HashMap, - terminal_id: usize, -) -> HashMap { - let mut env = env; - let Some(shell_program) = selected_shell_program_for_env(&env) else { - return env; - }; - if !is_pwsh_program(&shell_program) { - return env; - } - - match write_powershell_init(terminal_id) { - Ok(init_path) => { - env.insert("SHELL".to_string(), shell_program.clone()); - env.insert(TERMUA_SHELL_ENV_KEY.to_string(), shell_program); - env.insert( - gpui_term::shell::TERMUA_PWSH_INIT_ENV_KEY.to_string(), - init_path.to_string_lossy().to_string(), - ); - } - Err(err) => { - log::warn!("termua: failed to inject OSC133 pwsh integration: {err:#}"); - } - } - - env -} - -fn selected_shell_program_for_env(env: &HashMap) -> Option { - pick_shell_program_from_env_or_else(env, || std::env::var("SHELL").ok()) -} - -#[cfg(target_os = "linux")] -fn is_bash_program(program: &str) -> bool { - matches!(shell_kind(program), ShellKind::Bash) -} - -#[cfg(target_os = "macos")] -fn is_zsh_program(program: &str) -> bool { - matches!(shell_kind(program), ShellKind::Zsh) -} - -#[cfg(any(windows, test))] -fn is_pwsh_program(program: &str) -> bool { - let program = program.trim(); - if program.is_empty() { - return false; - } - - std::path::Path::new(program) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(program) - == "pwsh" -} - -fn shell_runtime_dir() -> anyhow::Result { - use std::{fs, path::PathBuf}; - - let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from); - let mut dir = runtime_dir - .unwrap_or_else(std::env::temp_dir) - .join("termua-shell"); - - let ensure_dir_writable = |dir: &PathBuf| -> bool { - if fs::create_dir_all(dir).is_err() { - return false; - } - - let probe = dir.join(".termua-write-probe"); - match fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&probe) - { - Ok(_) => { - let _ = fs::remove_file(&probe); - true - } - Err(_) => false, - } - }; - - if !ensure_dir_writable(&dir) { - let fallback = std::env::temp_dir().join("termua-shell"); - fs::create_dir_all(&fallback)?; - dir = fallback; - } - - Ok(dir) -} - -#[cfg(unix)] -fn set_private_file_permissions(path: &std::path::Path) -> anyhow::Result<()> { - use std::{fs, os::unix::fs::PermissionsExt as _}; - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; - Ok(()) -} - -#[cfg(not(unix))] -fn set_private_file_permissions(_path: &std::path::Path) -> anyhow::Result<()> { - Ok(()) -} - -#[cfg(target_os = "macos")] -fn set_private_dir_permissions(path: &std::path::Path) -> anyhow::Result<()> { - use std::{fs, os::unix::fs::PermissionsExt as _}; - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; - Ok(()) -} - -fn unique_shell_path(name: &str) -> anyhow::Result { - use std::time::{SystemTime, UNIX_EPOCH}; - - let dir = shell_runtime_dir()?; - let pid = std::process::id(); - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - Ok(dir.join(format!("{name}-{pid}-{ts}"))) -} - -#[cfg(target_os = "linux")] -fn write_bash_rcfile(terminal_id: usize) -> anyhow::Result { - use std::fs; - - let rc_path = - unique_shell_path(&format!("termua-bash-rc-{terminal_id}"))?.with_extension("bashrc"); - - let mut rc = String::new(); - rc.push_str("if [ -f /etc/bash.bashrc ]; then . /etc/bash.bashrc; fi\n"); - rc.push_str("if [ -f /etc/bashrc ]; then . /etc/bashrc; fi\n"); - rc.push_str("if [ -f \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\"; fi\n"); - rc.push_str("\n# --- termua osc133 integration ---\n"); - rc.push_str(OSC133_BASH); - rc.push('\n'); - - fs::write(&rc_path, rc)?; - set_private_file_permissions(&rc_path)?; - Ok(rc_path) -} - -#[cfg(target_os = "macos")] -fn write_zsh_dotdir(terminal_id: usize) -> anyhow::Result { - use std::fs; - - let zdotdir = unique_shell_path(&format!("termua-zsh-dotdir-{terminal_id}"))?; - fs::create_dir_all(&zdotdir)?; - set_private_dir_permissions(&zdotdir)?; - - // Important: When we set `ZDOTDIR`, zsh will look for `.zshenv` and `.zshrc` *only* in that - // directory (not `$HOME`), so we must source the user's real dotfiles best-effort. - let mut zshenv = String::new(); - zshenv.push_str("# termua injected .zshenv\n"); - zshenv.push_str( - "if [ -n \"${TERMUA_ORIG_ZDOTDIR-}\" ] && [ -f \"$TERMUA_ORIG_ZDOTDIR/.zshenv\" ]; then . \ - \"$TERMUA_ORIG_ZDOTDIR/.zshenv\"; fi\n", - ); - zshenv.push_str( - "if [ -z \"${TERMUA_ORIG_ZDOTDIR-}\" ] && [ -f \"$HOME/.zshenv\" ]; then . \ - \"$HOME/.zshenv\"; fi\n", - ); - - let mut zshrc = String::new(); - zshrc.push_str("# termua injected .zshrc\n"); - zshrc.push_str("if [ -f /etc/zsh/zshrc ]; then . /etc/zsh/zshrc; fi\n"); - zshrc.push_str("if [ -f /etc/zshrc ]; then . /etc/zshrc; fi\n"); - zshrc.push_str( - "if [ -n \"${TERMUA_ORIG_ZDOTDIR-}\" ] && [ -f \"$TERMUA_ORIG_ZDOTDIR/.zshrc\" ]; then . \ - \"$TERMUA_ORIG_ZDOTDIR/.zshrc\"; fi\n", - ); - zshrc.push_str( - "if [ -z \"${TERMUA_ORIG_ZDOTDIR-}\" ] && [ -f \"$HOME/.zshrc\" ]; then . \ - \"$HOME/.zshrc\"; fi\n", - ); - zshrc.push_str("\n# --- termua osc133 integration ---\n"); - zshrc.push_str(OSC133_ZSH); - zshrc.push('\n'); - - let zshenv_path = zdotdir.join(".zshenv"); - let zshrc_path = zdotdir.join(".zshrc"); - fs::write(&zshenv_path, zshenv)?; - fs::write(&zshrc_path, zshrc)?; - set_private_file_permissions(&zshenv_path)?; - set_private_file_permissions(&zshrc_path)?; - - Ok(zdotdir) -} - -#[cfg(windows)] -fn write_powershell_init(terminal_id: usize) -> anyhow::Result { - use std::fs; - - let init_path = - unique_shell_path(&format!("termua-pwsh-init-{terminal_id}"))?.with_extension("ps1"); - fs::write(&init_path, OSC133_PWSH)?; - set_private_file_permissions(&init_path)?; - Ok(init_path) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(target_os = "linux")] - #[test] - fn detects_bash_program_by_basename() { - assert!(is_bash_program("bash")); - assert!(is_bash_program("/bin/bash")); - assert!(!is_bash_program("zsh")); - } - - #[cfg(target_os = "macos")] - #[test] - fn detects_zsh_program_by_basename() { - assert!(is_zsh_program("zsh")); - assert!(is_zsh_program("/bin/zsh")); - assert!(!is_zsh_program("bash")); - } - - #[test] - fn detects_pwsh_program_by_basename() { - assert!(is_pwsh_program("pwsh")); - assert!(is_pwsh_program("/snap/bin/pwsh")); - assert!(!is_pwsh_program("powershell")); - assert!(!is_pwsh_program("bash")); - } - - #[cfg(target_os = "linux")] - #[test] - fn injection_writes_rcfile_and_sets_env() { - // First, ensure the underlying filesystem write succeeds (helps provide - // a useful failure message if the environment is restricted). - let rcfile = write_bash_rcfile(7).expect("write rcfile"); - assert!(rcfile.exists(), "rcfile should exist"); - - let mut env = HashMap::new(); - env.insert("SHELL".to_string(), "bash".to_string()); - - let env = maybe_inject_local_bash_osc133(env, 7); - assert_eq!(env.get("TERMUA_SHELL").map(String::as_str), Some("bash")); - let rcfile_path = env - .get("TERMUA_BASH_RCFILE") - .expect("expected TERMUA_BASH_RCFILE to be set"); - assert!( - std::path::Path::new(rcfile_path).exists(), - "rcfile should exist" - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn zsh_injection_writes_dotdir_and_sets_env() { - let dotdir = write_zsh_dotdir(7).expect("write dotdir"); - assert!(dotdir.exists(), "dotdir should exist"); - assert!(dotdir.join(".zshrc").exists(), ".zshrc should exist"); - - let mut env = HashMap::new(); - env.insert("SHELL".to_string(), "zsh".to_string()); - - let env = maybe_inject_local_zsh_osc133(env, 7); - assert_eq!(env.get("TERMUA_SHELL").map(String::as_str), Some("zsh")); - let zdotdir = env.get("ZDOTDIR").expect("expected ZDOTDIR to be set"); - assert!( - std::path::Path::new(zdotdir).join(".zshrc").exists(), - ".zshrc should exist" - ); - } - - #[cfg(windows)] - #[test] - fn pwsh_injection_writes_init_and_sets_env() { - let init = write_powershell_init(7).expect("write powershell init"); - assert!(init.exists(), "powershell init should exist"); - - let mut env = HashMap::new(); - env.insert("SHELL".to_string(), "pwsh".to_string()); - - let env = maybe_inject_local_shell_osc133(env, 7); - assert_eq!(env.get("TERMUA_SHELL").map(String::as_str), Some("pwsh")); - let init_path = env - .get("TERMUA_PWSH_INIT") - .expect("expected TERMUA_PWSH_INIT to be set"); - assert!( - std::path::Path::new(init_path).exists(), - "powershell init should exist" - ); - } - - #[test] - fn windows_powershell_does_not_inject_pwsh_init() { - let mut env = HashMap::new(); - env.insert("SHELL".to_string(), "powershell".to_string()); - - let env = maybe_inject_local_shell_osc133(env, 7); - assert_eq!(env.get("TERMUA_SHELL").map(String::as_str), None); - assert_eq!(env.get("TERMUA_PWSH_INIT").map(String::as_str), None); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_only_integrates_bash() { - let mut bash_env = HashMap::new(); - bash_env.insert("SHELL".to_string(), "bash".to_string()); - let bash_env = maybe_inject_local_shell_osc133(bash_env, 7); - assert_eq!( - bash_env.get("TERMUA_SHELL").map(String::as_str), - Some("bash") - ); - assert!(bash_env.contains_key("TERMUA_BASH_RCFILE")); - - let mut zsh_env = HashMap::new(); - zsh_env.insert("SHELL".to_string(), "zsh".to_string()); - let zsh_env = maybe_inject_local_shell_osc133(zsh_env, 7); - assert_eq!(zsh_env.get("TERMUA_SHELL").map(String::as_str), None); - assert_eq!(zsh_env.get("ZDOTDIR").map(String::as_str), None); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_only_integrates_zsh() { - let mut zsh_env = HashMap::new(); - zsh_env.insert("SHELL".to_string(), "zsh".to_string()); - let zsh_env = maybe_inject_local_shell_osc133(zsh_env, 7); - assert_eq!(zsh_env.get("TERMUA_SHELL").map(String::as_str), Some("zsh")); - assert!(zsh_env.contains_key("ZDOTDIR")); - - let mut bash_env = HashMap::new(); - bash_env.insert("SHELL".to_string(), "bash".to_string()); - let bash_env = maybe_inject_local_shell_osc133(bash_env, 7); - assert_eq!(bash_env.get("TERMUA_SHELL").map(String::as_str), None); - assert_eq!(bash_env.get("TERMUA_BASH_RCFILE").map(String::as_str), None); - } - - #[cfg(windows)] - #[test] - fn windows_only_integrates_pwsh() { - let mut pwsh_env = HashMap::new(); - pwsh_env.insert("SHELL".to_string(), "pwsh".to_string()); - let pwsh_env = maybe_inject_local_shell_osc133(pwsh_env, 7); - assert_eq!( - pwsh_env.get("TERMUA_SHELL").map(String::as_str), - Some("pwsh") - ); - assert!(pwsh_env.contains_key("TERMUA_PWSH_INIT")); - - let mut powershell_env = HashMap::new(); - powershell_env.insert("SHELL".to_string(), "powershell".to_string()); - let powershell_env = maybe_inject_local_shell_osc133(powershell_env, 7); - assert_eq!(powershell_env.get("TERMUA_SHELL").map(String::as_str), None); - assert_eq!( - powershell_env.get("TERMUA_PWSH_INIT").map(String::as_str), - None - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn zsh_osc133_script_avoids_readonly_status_parameter() { - assert!( - !OSC133_ZSH.contains("local status="), - "zsh reserves `status` as a readonly special parameter" - ); - assert!(OSC133_ZSH.contains("local exit_status=$?")); - } - - #[cfg(target_os = "linux")] - #[test] - fn osc133_shell_scripts_emit_prompt_markers() { - for script in [OSC133_BASH, OSC133_PWSH] { - assert!( - script.contains("133;A") || script.contains("\"A\""), - "expected script to emit prompt start marker" - ); - assert!( - script.contains("133;B") || script.contains("\"B\""), - "expected script to emit prompt end marker" - ); - assert!( - script.contains("133;C") || script.contains("\"C\""), - "expected script to emit command start marker" - ); - assert!( - script.contains("133;D") || script.contains("\"D;"), - "expected script to emit command end marker" - ); - } - } - - #[cfg(target_os = "macos")] - #[test] - fn osc133_shell_scripts_emit_prompt_markers() { - for script in [OSC133_ZSH, OSC133_PWSH] { - assert!( - script.contains("133;A") || script.contains("\"A\""), - "expected script to emit prompt start marker" - ); - assert!( - script.contains("133;B") || script.contains("\"B\""), - "expected script to emit prompt end marker" - ); - assert!( - script.contains("133;C") || script.contains("\"C\""), - "expected script to emit command start marker" - ); - assert!( - script.contains("133;D") || script.contains("\"D;"), - "expected script to emit command end marker" - ); - } - } -} diff --git a/termua/src/window/main_window/actions/terminal.rs b/termua/src/window/main_window/actions/terminal.rs index 0586090..7d0cfe1 100644 --- a/termua/src/window/main_window/actions/terminal.rs +++ b/termua/src/window/main_window/actions/terminal.rs @@ -589,11 +589,6 @@ impl TermuaWindow { let id = self.next_terminal_id; self.next_terminal_id += 1; - let env = match kind { - PanelKind::Local => crate::shell_integration::maybe_inject_local_shell_osc133(env, id), - PanelKind::Ssh | PanelKind::Serial | PanelKind::Recorder => env, - }; - let tab_label = match kind { PanelKind::Local => crate::panel::local_terminal_panel_tab_name( &env, diff --git a/termua/src/window/main_window/state.rs b/termua/src/window/main_window/state.rs index d80b53e..023b24c 100644 --- a/termua/src/window/main_window/state.rs +++ b/termua/src/window/main_window/state.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; -use gpui::{App, AppContext, ClipboardItem, Context, Focusable, Styled, Subscription, Window}; +use gpui::{App, AppContext, Context, Focusable, Styled, Subscription, Window}; use gpui_common::TermuaIcon; use gpui_component::{ActiveTheme, Icon, IconName}; use gpui_dock::{DockArea, DockItem, DockPlacement, PanelView}; @@ -173,79 +173,10 @@ impl gpui_term::ContextMenuProvider for TermuaContextMenuProvider { } } - if !cfg!(debug_assertions) { - return menu; - } - - let Some(block) = command_block_at_current_selection(&terminal, cx) else { - return menu; - }; - - let Some(output_end) = block.output_end_line else { - // The block is still running (no end marker yet). Skip export actions. - return menu; - }; - let mut output_start = block.output_start_line; - - const MAX_EXPORT_LINES: i64 = 2_000; - if output_end.saturating_sub(output_start).saturating_add(1) > MAX_EXPORT_LINES { - output_start = output_end.saturating_sub(MAX_EXPORT_LINES.saturating_sub(1)); - } - - menu = menu - .separator() - .item( - gpui_component::menu::PopupMenuItem::new( - t!("MainWindow.ContextMenu.CopyCommandBlockOutput").to_string(), - ) - .on_click({ - let terminal = terminal.clone(); - move |_, _window, cx| { - let text = terminal - .read(cx) - .text_for_lines(output_start, output_end) - .unwrap_or_default(); - terminal.update(cx, |_terminal, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(text)); - }); - } - }), - ) - .item( - gpui_component::menu::PopupMenuItem::new( - t!("MainWindow.ContextMenu.CopyCommandBlockId").to_string(), - ) - .on_click({ - let id_text = format!("block_id={}", block.id); - move |_, _window, cx| { - terminal.update(cx, |_terminal, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(id_text.clone())); - }); - } - }), - ); - menu } } -fn command_block_at_current_selection( - terminal: &gpui::Entity, - cx: &gpui::App, -) -> Option { - let (stable, blocks) = { - let terminal = terminal.read(cx); - let selection_start_line = terminal.last_content().selection.as_ref()?.start.line; - let stable = terminal.stable_row_for_grid_line(selection_start_line)?; - let blocks = terminal.command_blocks()?; - Some((stable, blocks)) - }?; - blocks.into_iter().rev().find(|b| match b.output_end_line { - Some(end) => stable >= b.output_start_line && stable <= end, - None => stable >= b.output_start_line, - }) -} - impl TermuaWindow { pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { let ssh_terminal_builder: SshTerminalBuilderFn = Arc::new( @@ -292,7 +223,6 @@ impl TermuaWindow { this.install_language_subscription(window, cx); Self::spawn_lock_state_monitor(cx); - Self::spawn_terminal_context_poll(cx); this.install_app_state_subscription(window, cx); this.install_lock_state_subscription(window, cx); this.install_sessions_sidebar_subscription(window, cx); @@ -399,27 +329,6 @@ impl TermuaWindow { .detach(); } - fn spawn_terminal_context_poll(cx: &mut Context) { - cx.spawn(async move |this, cx| { - loop { - cx.background_executor() - .timer(Duration::from_millis( - crate::assistant::DEFAULT_TERMINAL_CONTEXT_POLL_INTERVAL_MS, - )) - .await; - - let _ = this.update(cx, |_this, cx| { - Self::poll_terminal_context_snapshots(cx); - }); - } - }) - .detach(); - } - - fn poll_terminal_context_snapshots(cx: &mut Context) { - crate::assistant::poll_terminal_context_snapshots(cx); - } - fn install_app_state_subscription(&mut self, window: &mut Window, cx: &mut Context) { self._subscriptions .push( @@ -487,46 +396,3 @@ impl TermuaWindow { })); } } - -#[cfg(test)] -mod command_block_context_menu_tests { - #[test] - fn command_block_menu_items_are_debug_only_and_no_select_action() { - // Source-level guardrail: the command-block context menu entries are a debug-only - // developer feature, and we should not expose a "select block" action. - // - // This lives here because the context menu builder depends on GPUI window/app wiring - // that isn't trivial to instantiate in unit tests. - let src = include_str!("state.rs"); - - let select_label = ["Select", " command", " block"].concat(); - let select_item = format!("PopupMenuItem::new(\"{}\")", select_label); - assert!( - !src.contains(&select_item), - "unexpected command-block select action is still present" - ); - - let debug_gate = "cfg!(debug_assertions)"; - let gate_pos = src - .find(debug_gate) - .expect("expected a debug-assertions gate for command-block context menu items"); - - let copy_output_item = "t!(\"MainWindow.ContextMenu.CopyCommandBlockOutput\")"; - let copy_output_pos = src - .find(©_output_item) - .expect("expected a command-block output copy action"); - assert!( - gate_pos < copy_output_pos, - "debug-assertions gate should appear before command-block actions" - ); - - assert!( - { - let hint_label = ["Command blocks", " (no block", " at cursor)"].concat(); - let hint_item = format!("PopupMenuItem::new(\"{}\")", hint_label); - !src.contains(&hint_item) - }, - "no-block hint menu item should not exist" - ); - } -} diff --git a/termua/src/window/settings/meta.rs b/termua/src/window/settings/meta.rs index 5a0a379..a2f5581 100644 --- a/termua/src/window/settings/meta.rs +++ b/termua/src/window/settings/meta.rs @@ -200,13 +200,7 @@ static ALL_SETTINGS_META: &[SettingMeta] = &[ id: "terminal.suggestions_enabled", title: "Suggestions", description: "Show inline command suggestions in shell-like contexts.", - keywords: &[ - "terminal", - "suggestions", - "autocomplete", - "completion", - "history", - ], + keywords: &["terminal", "suggestions", "autocomplete", "completion"], section: SettingsNavSection::Terminal, page: SettingsPage::TerminalSuggestions, },