diff --git a/Cargo.lock b/Cargo.lock index 6e3dfd2..c458168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atomic" version = "0.6.1" @@ -2108,6 +2114,7 @@ dependencies = [ "toml_edit", "ulid", "unicode-width", + "vte", "windows-sys 0.60.2", "zip", "zstd", @@ -3139,6 +3146,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" dependencies = [ + "arrayvec", "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 5839c1b..05d982d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ tar = "0.4.44" toml = "1.1.5" ulid = { version = "3.0.0", features = ["serde"] } unicode-width = "0.2.2" +vte = "0.14.1" zstd = "0.14.0" zip = { version = "8.2.0", default-features = false, features = ["deflate"] } toml_edit = "0.25.15" diff --git a/README.md b/README.md index a8d2f61..d29beab 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ path. On Windows the database defaults to `%LOCALAPPDATA%\recall\recall.db`. ```toml [general] max_output_bytes = 1048576 # per-command output cap (before compression) -strip_ansi = true +strip_ansi = false [proxy] login_shell = true # spawn the shell with -l (default: true on macOS) @@ -395,7 +395,7 @@ terminal emulator ──▶ recall proxy (PTY/ConPTY) ──▶ shell (zsh/bash/ - The proxy parses and strips these markers, so command boundaries are exact and the prompt is never captured. There is no side channel or socket, which keeps recall shell- and OS-agnostic. -- Output is ANSI-stripped, classified, capped, zstd-compressed and stored in +- Output is classified, capped, zstd-compressed and stored in SQLite together with self-contained command metadata. Optional imports are tracked in a separate, source-neutral provenance table. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f7efe9..f76ee63 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -316,7 +316,7 @@ Linux/macOS 为 `~/.config/recall/config.toml`,Windows 为 ```toml [general] max_output_bytes = 1048576 # 单条命令输出上限(压缩前) -strip_ansi = true +strip_ansi = false [proxy] login_shell = true # 以 -l 启动 shell(macOS 默认开启) @@ -352,7 +352,7 @@ date_format = "%Y-%m-%d %H:%M:%S" 标记,携带 `{command, cwd, start}`;并在下一个提示符绘制前写入带退出码的结束标记。 - 代理解析并剥离这些标记,因此命令边界精确,提示符永远不会被捕获。没有旁路通道 或 socket,这让 recall 与 shell、操作系统无关。 -- 输出经过去 ANSI、分类、限长、zstd 压缩后,与完整命令元数据一起存入 SQLite。 +- 输出经过分类、限长、zstd 压缩后,与完整命令元数据一起存入 SQLite。 可选导入的来源关系保存在独立、与具体来源无关的记录表中。 ## 兼容性 diff --git a/install.sh b/install.sh index 00f826b..91b42c9 100755 --- a/install.sh +++ b/install.sh @@ -243,7 +243,7 @@ choose_search_key() { case "${RECALL_SEARCH_KEY:-}" in "" ) ;; - default | alt-r ) return 0 ;; + default | alt-r ) search_key="alt-r"; return 0 ;; * ) search_key=$RECALL_SEARCH_KEY; return 0 ;; esac @@ -261,7 +261,7 @@ choose_search_key() { IFS= read -r answer < /dev/tty || answer="" case "$answer" in 2) search_key="ctrl-t" ;; - 3) search_key="" ;; + 3) search_key="alt-r" ;; *) search_key="ctrl-x ctrl-r" ;; esac } diff --git a/scripts/dev-install.sh b/scripts/dev-install.sh index c56dd43..0f90759 100755 --- a/scripts/dev-install.sh +++ b/scripts/dev-install.sh @@ -240,7 +240,7 @@ else if [ -n "$shell_path" ]; then say "" say "Start an isolated shell with:" - say " env HOME=\"$install_home\" ZDOTDIR=\"$install_home\" XDG_CONFIG_HOME=\"$config_home\" XDG_DATA_HOME=\"$data_home\" RECALL_CONFIG=\"$recall_config\" PATH=\"$install_dir:\$PATH\" SHELL=\"$shell_path\" TERM=\"$test_term\" \"$shell_path\" -i" + say " env -u RECALL_PROXY_ACTIVE -u RECALL_AUTO_LAUNCH -u RECALL_SESSION -u RECALL_PROXY HOME=\"$install_home\" ZDOTDIR=\"$install_home\" XDG_CONFIG_HOME=\"$config_home\" XDG_DATA_HOME=\"$data_home\" RECALL_CONFIG=\"$recall_config\" PATH=\"$install_dir:\$PATH\" SHELL=\"$shell_path\" TERM=\"$test_term\" \"$shell_path\" -i" fi say "" say "Remove the sandbox when finished:" diff --git a/src/capture/classifier.rs b/src/capture/classifier.rs index 74757a2..0947aba 100644 --- a/src/capture/classifier.rs +++ b/src/capture/classifier.rs @@ -25,7 +25,7 @@ pub fn detect_interactive(raw: &[u8]) -> bool { .any(|marker| raw.windows(marker.len()).any(|w| w == *marker)) } -/// Classify captured output and produce the storable plain-text form. +/// Classify visible text while optionally retaining terminal styling in storage. pub fn classify(input: ClassifyInput<'_>) -> Classified { let ClassifyInput { raw, @@ -43,13 +43,7 @@ pub fn classify(input: ClassifyInput<'_>) -> Classified { }; } - let text = if strip_ansi { - crate::util::strip_ansi(raw) - } else { - raw.to_vec() - }; - - let text = trim_trailing(&text); + let text = trim_trailing(&crate::util::strip_ansi(raw)); if text.is_empty() { return Classified { @@ -67,8 +61,14 @@ pub fn classify(input: ClassifyInput<'_>) -> Classified { }; } - if text.len() > max_output_bytes { - let mut out = text[..max_output_bytes].to_vec(); + let output = if strip_ansi { + text + } else { + trim_trailing(&normalize_crlf(raw)) + }; + + if output.len() > max_output_bytes { + let mut out = output[..max_output_bytes].to_vec(); out.extend_from_slice(b"\n[recall: output truncated]\n"); return Classified { kind: BlockKind::Normal, @@ -79,11 +79,26 @@ pub fn classify(input: ClassifyInput<'_>) -> Classified { Classified { kind: BlockKind::Normal, - output: Some(text), + output: Some(output), truncated: false, } } +fn normalize_crlf(data: &[u8]) -> Vec { + let mut normalized = Vec::with_capacity(data.len()); + let mut i = 0; + while i < data.len() { + if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') { + normalized.push(b'\n'); + i += 2; + } else { + normalized.push(data[i]); + i += 1; + } + } + normalized +} + fn trim_trailing(data: &[u8]) -> Vec { let end = data .iter() @@ -164,4 +179,20 @@ mod tests { assert!(out.truncated); assert!(out.output.unwrap().len() < 4096); } + + #[test] + fn retains_style_without_treating_escapes_as_binary() { + let out = classify(ClassifyInput { + raw: b"\x1b[31mred\x1b[0m\n", + interactive: false, + max_output_bytes: 1024, + strip_ansi: false, + mark_interactive: true, + }); + assert_eq!(out.kind, BlockKind::Normal); + assert_eq!( + out.output.as_deref(), + Some(b"\x1b[31mred\x1b[0m".as_slice()) + ); + } } diff --git a/src/capture/proxy.rs b/src/capture/proxy.rs index cb67e7c..10c949b 100644 --- a/src/capture/proxy.rs +++ b/src/capture/proxy.rs @@ -322,7 +322,10 @@ fn finalize( if !active.exclude_output && shared.config.proxy.secrets_filter - && looks_secret(&active.command, &classified.output) + && looks_secret( + &active.command, + &classified.output.as_ref().map(|out| util::strip_ansi(out)), + ) { classified = classifier::Classified { kind: BlockKind::Filtered, @@ -334,7 +337,12 @@ fn finalize( let output_lines = classified .output .as_ref() - .map(|out| out.iter().filter(|&&b| b == b'\n').count() as i64) + .map(|out| { + util::strip_ansi(out) + .iter() + .filter(|&&b| b == b'\n') + .count() as i64 + }) .unwrap_or(0); Block { diff --git a/src/config.rs b/src/config.rs index f3ca35b..ab45868 100644 --- a/src/config.rs +++ b/src/config.rs @@ -88,7 +88,7 @@ impl Default for General { Self { db_path: default_data_dir().join("recall.db"), max_output_bytes: 1024 * 1024, - strip_ansi: true, + strip_ansi: false, hostname: None, } } diff --git a/src/db/queries.rs b/src/db/queries.rs index 261419e..17ee7b5 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -85,8 +85,9 @@ fn insert_block(conn: &Connection, block: &Block) -> Result<()> { let (blob, codec, text) = match &block.output { Some(raw) if !raw.is_empty() => { let compressed = compress(raw)?; - let projection_len = raw.len().min(SEARCH_PROJECTION_BYTES); - let projection = String::from_utf8_lossy(&raw[..projection_len]).into_owned(); + let plain = crate::util::strip_ansi(raw); + let projection_len = plain.len().min(SEARCH_PROJECTION_BYTES); + let projection = String::from_utf8_lossy(&plain[..projection_len]).into_owned(); (Some(compressed), Some("zstd".to_string()), Some(projection)) } _ => (None, None, None), @@ -369,6 +370,35 @@ mod tests { assert_eq!(hits.len(), 1); } + #[test] + fn styled_output_has_plain_search_projection() { + let db = Db::open_in_memory().unwrap(); + let block = Block { + id: "styled".to_string(), + command: "printf color".to_string(), + output: Some(b"\x1b[31mred\x1b[0m".to_vec()), + ..Block::default() + }; + insert(&db.conn, &block).unwrap(); + let projection: String = db + .conn + .query_row( + "SELECT output_text FROM blocks WHERE id = 'styled'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(projection, "red"); + assert_eq!( + recent(&db.conn, 1).unwrap()[0].output.as_deref(), + Some(b"red".as_slice()) + ); + assert_eq!( + get(&db.conn, "styled").unwrap().unwrap().output, + block.output + ); + } + #[test] fn settings_roundtrip() { let db = Db::open_in_memory().unwrap(); diff --git a/src/tui/ansi.rs b/src/tui/ansi.rs new file mode 100644 index 0000000..57bea1a --- /dev/null +++ b/src/tui/ansi.rs @@ -0,0 +1,184 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use vte::{Params, Parser, Perform}; + +#[derive(Default)] +struct StyledText { + lines: Vec>, + spans: Vec>, + pending: String, + fg: Option, + bg: Option, + modifiers: Modifier, +} + +impl StyledText { + fn style(&self) -> Style { + let mut style = Style::default().add_modifier(self.modifiers); + if let Some(fg) = self.fg { + style = style.fg(fg); + } + if let Some(bg) = self.bg { + style = style.bg(bg); + } + style + } + + fn flush(&mut self) { + if !self.pending.is_empty() { + let style = self.style(); + self.spans + .push(Span::styled(std::mem::take(&mut self.pending), style)); + } + } + + fn end_line(&mut self) { + self.flush(); + self.lines.push(Line::from(std::mem::take(&mut self.spans))); + } + + fn sgr(&mut self, params: &Params) { + self.flush(); + let values: Vec = params + .iter() + .flat_map(|part| part.iter().copied()) + .collect(); + let mut i = 0; + while i < values.len() { + match values[i] { + 0 => { + self.fg = None; + self.bg = None; + self.modifiers = Modifier::empty(); + } + 1 => self.modifiers.insert(Modifier::BOLD), + 2 => self.modifiers.insert(Modifier::DIM), + 3 => self.modifiers.insert(Modifier::ITALIC), + 4 => self.modifiers.insert(Modifier::UNDERLINED), + 7 => self.modifiers.insert(Modifier::REVERSED), + 9 => self.modifiers.insert(Modifier::CROSSED_OUT), + 22 => self.modifiers.remove(Modifier::BOLD | Modifier::DIM), + 23 => self.modifiers.remove(Modifier::ITALIC), + 24 => self.modifiers.remove(Modifier::UNDERLINED), + 27 => self.modifiers.remove(Modifier::REVERSED), + 29 => self.modifiers.remove(Modifier::CROSSED_OUT), + 30..=37 | 90..=97 => self.fg = basic_color(values[i]), + 40..=47 | 100..=107 => self.bg = basic_color(values[i] - 10), + 39 => self.fg = None, + 49 => self.bg = None, + 38 | 48 => { + let target = if values[i] == 38 { + &mut self.fg + } else { + &mut self.bg + }; + match values.get(i + 1) { + Some(5) if values.get(i + 2).is_some_and(|n| *n <= 255) => { + *target = Some(Color::Indexed(values[i + 2] as u8)); + i += 2; + } + Some(2) + if values.get(i + 4).is_some() + && values[i + 2..=i + 4].iter().all(|n| *n <= 255) => + { + *target = Some(Color::Rgb( + values[i + 2] as u8, + values[i + 3] as u8, + values[i + 4] as u8, + )); + i += 4; + } + _ => {} + } + } + _ => {} + } + i += 1; + } + } +} + +impl Perform for StyledText { + fn print(&mut self, c: char) { + self.pending.push(c); + } + + fn execute(&mut self, byte: u8) { + match byte { + b'\n' => self.end_line(), + b'\t' => self.pending.push('\t'), + _ => {} + } + } + + fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) { + if action == 'm' && intermediates.is_empty() && !ignore { + self.sgr(params); + } + } +} + +fn basic_color(code: u16) -> Option { + Some(match code { + 30 => Color::Black, + 31 => Color::Red, + 32 => Color::Green, + 33 => Color::Yellow, + 34 => Color::Blue, + 35 => Color::Magenta, + 36 => Color::Cyan, + 37 => Color::Gray, + 90 => Color::DarkGray, + 91 => Color::LightRed, + 92 => Color::LightGreen, + 93 => Color::LightYellow, + 94 => Color::LightBlue, + 95 => Color::LightMagenta, + 96 => Color::LightCyan, + 97 => Color::White, + _ => return None, + }) +} + +pub(super) fn styled_lines(output: &[u8]) -> Vec> { + let mut parser = Parser::new(); + let mut text = StyledText::default(); + parser.advance(&mut text, output); + text.flush(); + if !text.spans.is_empty() { + text.lines.push(Line::from(text.spans)); + } + text.lines +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_colors_modifiers_and_resets_across_lines() { + let lines = styled_lines( + b"\x1b[1;31mred\nnext\x1b[0m plain\n\x1b[38;5;42mgreen\x1b[48;2;1;2;3m!\x1b[0m", + ); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].spans[0].content, "red"); + assert_eq!(lines[0].spans[0].style.fg, Some(Color::Red)); + assert!( + lines[0].spans[0] + .style + .add_modifier + .contains(Modifier::BOLD) + ); + assert_eq!(lines[1].spans[0].style.fg, Some(Color::Red)); + assert_eq!(lines[1].spans[1].style.fg, None); + assert_eq!(lines[2].spans[0].style.fg, Some(Color::Indexed(42))); + assert_eq!(lines[2].spans[1].style.bg, Some(Color::Rgb(1, 2, 3))); + } + + #[test] + fn ignores_non_sgr_controls_and_partial_escapes() { + let lines = styled_lines(b"a\x1b]52;c;secret\x07b\x1b[2Jc\x1b[31"); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].spans[0].content, "abc"); + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs index 9e3a0db..eda02f9 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -304,7 +304,7 @@ impl App { .detail .as_ref() .and_then(|block| block.output.as_ref()) - .map(|bytes| String::from_utf8_lossy(bytes).into_owned()); + .map(|bytes| String::from_utf8_lossy(&crate::util::strip_ansi(bytes)).into_owned()); match output { Some(text) if !text.is_empty() => self.copy_text(&text, "output"), _ => self.set_error("no output to copy".to_string()), @@ -422,7 +422,7 @@ fn format_transcript(blocks: &[Block]) -> String { let mut section = format!("$ {}", block.command); if let Some(output) = block.output.as_deref().filter(|output| !output.is_empty()) { section.push('\n'); - section.push_str(&String::from_utf8_lossy(output)); + section.push_str(&String::from_utf8_lossy(&crate::util::strip_ansi(output))); } section }) @@ -554,6 +554,22 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn copying_styled_output_uses_plain_text() { + let (mut app, copied, dir) = + test_app(&[block("styled", "printf color", "\x1b[31mred\x1b[0m", 100)]); + app.copy_output(); + assert_eq!(copied.lock().unwrap().as_deref(), Some("red")); + + app.selected_ids.insert("styled".to_string()); + app.copy_selected_transcript(); + assert_eq!( + copied.lock().unwrap().as_deref(), + Some("$ printf color\nred") + ); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn arrows_resize_and_persist_list_width() { let (mut app, _, dir) = test_app(&[block("one", "command", "output", 100)]); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d18935c..d3be1b4 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,3 +1,4 @@ +mod ansi; mod app; pub(crate) mod config; mod runtime; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index fd283d9..dd9d2ee 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap}; use unicode_width::UnicodeWidthStr; use crate::model::{Block as RecallBlock, BlockKind}; +use crate::tui::ansi; use crate::tui::app::{App, Focus}; use crate::util; @@ -71,13 +72,14 @@ fn draw_list(frame: &mut Frame, app: &mut App, area: Rect) { let block = Block::bordered() .title(title) .border_style(border_style(!focused)); + let inner_width = block.inner(area).width; let items: Vec = app .results .iter() .map(|entry| { let selected = app.selected_ids.contains(&entry.id); - ListItem::new(block_lines(entry, &app.config, selected)) + ListItem::new(block_lines(entry, &app.config, selected, inner_width)) .style(block_selection_style(selected)) }) .collect(); @@ -107,12 +109,10 @@ fn block_lines( block: &RecallBlock, config: &crate::config::Config, selected: bool, + width: u16, ) -> Text<'static> { let mut lines = Vec::new(); - lines.push(Line::from(Span::styled( - "─".repeat(200), - Style::default().fg(Color::Indexed(240)), - ))); + lines.push(separator(width)); lines.push(header_line(block, config)); lines.push(Line::from(vec![ Span::styled( @@ -178,9 +178,10 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect) { let block = Block::bordered() .title(" detail ") .border_style(border_style(focused)); + let inner_width = block.inner(area).width; let text = match &app.detail { - Some(entry) => detail_text(entry, &app.config), + Some(entry) => detail_text(entry, &app.config, inner_width), None => Text::from(Line::from(Span::styled( "no block selected", Style::default().fg(Color::DarkGray), @@ -194,7 +195,7 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(paragraph, area); } -fn detail_text(block: &RecallBlock, config: &crate::config::Config) -> Text<'static> { +fn detail_text(block: &RecallBlock, config: &crate::config::Config, width: u16) -> Text<'static> { let mut lines: Vec = vec![ Line::from(Span::styled( block.command.clone(), @@ -209,18 +210,12 @@ fn detail_text(block: &RecallBlock, config: &crate::config::Config) -> Text<'sta Span::styled(" lines: ", Style::default().fg(Color::DarkGray)), Span::raw(block.output_lines.to_string()), ]), - Line::from(Span::styled( - "─".repeat(200), - Style::default().fg(Color::Indexed(240)), - )), + separator(width), ]; match &block.output { Some(output) => { - let text = String::from_utf8_lossy(output); - for line in text.lines() { - lines.push(Line::raw(line.to_string())); - } + lines.extend(ansi::styled_lines(output)); } None => lines.push(Line::from(Span::styled( format!("[{}]", kind_label(block.kind)), @@ -231,6 +226,13 @@ fn detail_text(block: &RecallBlock, config: &crate::config::Config) -> Text<'sta Text::from(lines) } +fn separator(width: u16) -> Line<'static> { + Line::from(Span::styled( + "─".repeat(usize::from(width)), + Style::default().fg(Color::Indexed(240)), + )) +} + fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let style = if app.status_is_error { Style::default().fg(Color::Red) @@ -343,3 +345,24 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { ]) .split(vertical[1])[1] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn separators_follow_each_pane_width() { + let entry = RecallBlock { + command: "echo hello".to_string(), + ..RecallBlock::default() + }; + let config = crate::config::Config::default(); + for width in [0, 1, 12, 64] { + let list = block_lines(&entry, &config, false, width); + let detail = detail_text(&entry, &config, width); + let expected = "─".repeat(usize::from(width)); + assert_eq!(list.lines[0].spans[0].content, expected); + assert_eq!(detail.lines[3].spans[0].content, expected); + } + } +}