Skip to content

Commit 8b22cd7

Browse files
beamiterclaude
andcommitted
Add copy-last-command-output via OSC 133 zones
Ctrl+Shift+G (terminal:copy_last_output) copies the previous command's output to the clipboard, joining soft-wrapped rows, trimming per-line cell padding, and capping the write at 1 MiB. Exposed through the command palette and help panel; toasts report the copied size or point at missing OSC 133 shell integration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a4561e3 commit 8b22cd7

5 files changed

Lines changed: 142 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jterm3 是一个面向 Linux 的现代终端模拟器,使用 Rust、iced 和 w
1212
- 文件侧栏按目录异步懒加载,支持返回上级与刷新;慢盘、NFS/FUSE 不再阻塞主界面
1313
- 自动保存标签工作目录并恢复会话;多实例之间不会互相覆盖恢复数据
1414
- OSC 10/11/12 动态颜色、OSC 52/5522 剪贴板和桌面通知
15-
- OSC 133 shell 集成:在回滚中沿命令提示符逐条跳转(`Ctrl+Shift+↑/↓`),历史修剪时命令区保持对齐
15+
- OSC 133 shell 集成:沿命令提示符逐条跳转(`Ctrl+Shift+↑/↓`)、一键复制上一条命令输出(`Ctrl+Shift+G`),历史修剪时命令区保持对齐
1616
- 有界 PTY 输入/输出队列、稳定会话身份校验和繁忙进程关闭保护
1717
- PTY 启动采用 fork→exec 错误握手;无效目录、shell/exec 失败会显示可重试诊断而不是崩溃
1818
- 配置与快捷键热重载采用 last-known-good;坏文件会显示路径/行列并暂停自动写回
@@ -52,6 +52,7 @@ install -Dm755 target/release/jterm3 "$HOME/.local/bin/jterm3"
5252
| 复制 / 粘贴 | `Ctrl+Shift+C` / `Ctrl+Shift+V` |
5353
| 搜索全部回滚 | `Ctrl+Shift+F` |
5454
| 上/下一个命令提示符 | `Ctrl+Shift+↑` / `Ctrl+Shift+↓`(需 shell 发送 OSC 133 集成序列) |
55+
| 复制上一条命令输出 | `Ctrl+Shift+G`(同样依赖 OSC 133) |
5556
| 命令面板 | `Ctrl+Shift+P` |
5657
| 快速切换标签 | `Ctrl+Shift+L` |
5758
| 标签 1–8 / 最后一个 | `Ctrl+1`…`Ctrl+8` / `Ctrl+9` |

‎src/command_palette.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub enum PaletteAction {
3636
ScrollToBottom,
3737
PromptJumpPrev,
3838
PromptJumpNext,
39+
CopyLastOutput,
3940
ClearScreen,
4041
}
4142

@@ -258,6 +259,12 @@ impl PaletteState {
258259
shortcut: "Ctrl+Shift+Down",
259260
action: PaletteAction::PromptJumpNext,
260261
},
262+
PaletteItem {
263+
name: "Copy Last Command Output",
264+
description: "Copy the previous command's output (OSC 133)",
265+
shortcut: "Ctrl+Shift+G",
266+
action: PaletteAction::CopyLastOutput,
267+
},
261268
PaletteItem {
262269
name: "Clear Screen",
263270
description: "Clear the terminal screen",
@@ -407,6 +414,7 @@ mod tests {
407414
(PaletteAction::ZoomIn, "Ctrl+="),
408415
(PaletteAction::PromptJumpPrev, "Ctrl+Shift+Up"),
409416
(PaletteAction::PromptJumpNext, "Ctrl+Shift+Down"),
417+
(PaletteAction::CopyLastOutput, "Ctrl+Shift+G"),
410418
];
411419
for (action, expected) in cases {
412420
assert_eq!(shortcut(action), Some(expected), "{action:?}");

‎src/keybindings.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub enum Command {
3434
TerminalScrollDown,
3535
TerminalPromptPrev,
3636
TerminalPromptNext,
37+
TerminalCopyLastOutput,
3738

3839
// === 分屏操作 ===
3940
TerminalSplitVertical, // Ctrl+Shift+E (left/right)
@@ -91,6 +92,7 @@ impl std::fmt::Display for Command {
9192
Command::TerminalScrollDown => write!(f, "terminal:scroll_down"),
9293
Command::TerminalPromptPrev => write!(f, "terminal:prompt_prev"),
9394
Command::TerminalPromptNext => write!(f, "terminal:prompt_next"),
95+
Command::TerminalCopyLastOutput => write!(f, "terminal:copy_last_output"),
9496
Command::TerminalSplitVertical => write!(f, "terminal:split_vertical"),
9597
Command::TerminalSplitHorizontal => write!(f, "terminal:split_horizontal"),
9698
Command::TerminalClosePane => write!(f, "terminal:close_pane"),
@@ -143,6 +145,7 @@ impl std::str::FromStr for Command {
143145
"terminal:scroll_down" => Ok(Command::TerminalScrollDown),
144146
"terminal:prompt_prev" => Ok(Command::TerminalPromptPrev),
145147
"terminal:prompt_next" => Ok(Command::TerminalPromptNext),
148+
"terminal:copy_last_output" => Ok(Command::TerminalCopyLastOutput),
146149
"terminal:split_vertical" => Ok(Command::TerminalSplitVertical),
147150
"terminal:split_horizontal" => Ok(Command::TerminalSplitHorizontal),
148151
"terminal:close_pane" => Ok(Command::TerminalClosePane),
@@ -467,6 +470,10 @@ impl KeyBindings {
467470
"ctrl+shift+down".to_string(),
468471
"terminal:prompt_next".to_string(),
469472
);
473+
bindings.bindings.insert(
474+
"ctrl+shift+g".to_string(),
475+
"terminal:copy_last_output".to_string(),
476+
);
470477

471478
// 字体缩放
472479
bindings

‎src/main.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2560,6 +2560,7 @@ impl Jterm {
25602560
}
25612561
Task::none()
25622562
}
2563+
C::TerminalCopyLastOutput => self.copy_last_output_task(),
25632564
C::TerminalPromptPrev | C::TerminalPromptNext => {
25642565
if let Some(sess) = self.sessions.get_mut(self.active) {
25652566
let moved = if matches!(cmd, C::TerminalPromptPrev) {
@@ -3307,6 +3308,7 @@ impl Jterm {
33073308
}
33083309
Task::none()
33093310
}
3311+
PaletteAction::CopyLastOutput => self.copy_last_output_task(),
33103312
PaletteAction::PromptJumpPrev | PaletteAction::PromptJumpNext => {
33113313
if let Some(sess) = self.sessions.get_mut(self.active) {
33123314
let moved = if matches!(action, PaletteAction::PromptJumpPrev) {
@@ -3332,6 +3334,32 @@ impl Jterm {
33323334
}
33333335
}
33343336

3337+
/// Copy the previous command's output (OSC 133 zones) to the clipboard,
3338+
/// shared by the Ctrl+Shift+G binding and the command palette.
3339+
fn copy_last_output_task(&mut self) -> Task<Message> {
3340+
let text = self
3341+
.sessions
3342+
.get(self.active)
3343+
.and_then(|s| s.terminal.last_command_output_text());
3344+
match text {
3345+
Some(text) => {
3346+
let n = text.chars().count();
3347+
self.push_toast(
3348+
format!("Copied last output ({} chars)", n),
3349+
ToastKind::Success,
3350+
);
3351+
iced::clipboard::write(text)
3352+
}
3353+
None => {
3354+
self.push_toast(
3355+
"No command output to copy (needs OSC 133 shell integration)".to_string(),
3356+
ToastKind::Info,
3357+
);
3358+
Task::none()
3359+
}
3360+
}
3361+
}
3362+
33353363
fn update(&mut self, message: Message) -> Task<Message> {
33363364
match message {
33373365
Message::PtyOutput(id, fd, data) => {
@@ -6089,6 +6117,7 @@ impl Jterm {
60896117
section("Edit / Clipboard"),
60906118
kb("Ctrl+Shift+C", "Copy selection"),
60916119
kb("Ctrl+Shift+V", "Paste"),
6120+
kb("Ctrl+Shift+G", "Copy last command output (OSC 133)"),
60926121
kb("Drag", "Select text"),
60936122
kb("Ctrl+Click", "Open link under cursor"),
60946123
section("Scroll / Search"),

‎src/terminal.rs‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,69 @@ impl TerminalState {
14521452
.chain(active)
14531453
}
14541454

1455+
/// Plain text of the most recent completed command's output (OSC 133),
1456+
/// soft-wrapped rows joined and per-line trailing padding trimmed.
1457+
/// Returns None when no zone recorded any output or the output is blank.
1458+
/// Capped at 1 MiB so a huge scrollback range cannot balloon a clipboard
1459+
/// write.
1460+
pub fn last_command_output_text(&self) -> Option<String> {
1461+
const MAX_BYTES: usize = 1 << 20;
1462+
let zone = self
1463+
.command_zones
1464+
.iter()
1465+
.rev()
1466+
.find(|zone| zone.output_start.is_some())?;
1467+
let start = zone.output_start?;
1468+
let scrollback_len = self.scrollback.len();
1469+
let end = zone
1470+
.output_end
1471+
.unwrap_or(start)
1472+
.min(scrollback_len + self.grid.rows());
1473+
if end <= start {
1474+
return None;
1475+
}
1476+
let mut out = String::new();
1477+
for abs_row in start..end {
1478+
let (mut segment, wrapped) = if abs_row < scrollback_len {
1479+
let line = &self.scrollback[abs_row];
1480+
let text: String = line
1481+
.decompress()
1482+
.iter()
1483+
.filter(|cell| !cell.flags.wide_continuation())
1484+
.map(|cell| cell.character)
1485+
.collect();
1486+
(text, line.is_wrapped)
1487+
} else {
1488+
let grid_row = abs_row - scrollback_len;
1489+
if grid_row >= self.grid.rows() {
1490+
break;
1491+
}
1492+
let text: String = (0..self.grid.row_len())
1493+
.map(|col| self.grid.get(grid_row, col))
1494+
.filter(|cell| !cell.flags.wide_continuation())
1495+
.map(|cell| cell.character)
1496+
.collect();
1497+
(text, self.grid.row_wrapped[grid_row])
1498+
};
1499+
if !wrapped {
1500+
// Hard line end: trailing cell padding is not real output.
1501+
segment.truncate(segment.trim_end_matches(' ').len());
1502+
}
1503+
out.push_str(&segment);
1504+
if out.len() >= MAX_BYTES {
1505+
break;
1506+
}
1507+
if !wrapped && abs_row + 1 < end {
1508+
out.push('\n');
1509+
}
1510+
}
1511+
if out.trim().is_empty() {
1512+
None
1513+
} else {
1514+
Some(out)
1515+
}
1516+
}
1517+
14551518
/// Scroll so the closest prompt above the current view lands at the top
14561519
/// of the viewport. Returns true when the viewport moved.
14571520
pub fn jump_to_prev_prompt(&mut self) -> bool {
@@ -6261,6 +6324,39 @@ mod tests {
62616324
);
62626325
}
62636326

6327+
#[test]
6328+
fn last_command_output_text_returns_trimmed_output_lines() {
6329+
let mut terminal = TerminalState::new(20, 6);
6330+
assert_eq!(terminal.last_command_output_text(), None);
6331+
6332+
emit_zone(&mut terminal, 0);
6333+
assert_eq!(
6334+
terminal.last_command_output_text().as_deref(),
6335+
Some("out\nout\nout")
6336+
);
6337+
6338+
// A newer command's output supersedes the previous zone.
6339+
terminal.process_input(b"\x1b]133;A\x07\x1b]133;B\x07");
6340+
terminal.process_input(b"$ true\r\n");
6341+
terminal.process_input(b"\x1b]133;C\x07final line\r\n\x1b]133;D;0\x07");
6342+
assert_eq!(
6343+
terminal.last_command_output_text().as_deref(),
6344+
Some("final line")
6345+
);
6346+
}
6347+
6348+
#[test]
6349+
fn last_command_output_survives_scrollback_trim_or_disappears() {
6350+
let mut terminal = TerminalState::new(20, 4);
6351+
terminal.set_max_scrollback(6);
6352+
emit_zone(&mut terminal, 0);
6353+
// Trim until the zone's output is gone along with its rows.
6354+
for _ in 0..30 {
6355+
terminal.process_input(b"fill\r\n");
6356+
}
6357+
assert_eq!(terminal.last_command_output_text(), None);
6358+
}
6359+
62646360
#[test]
62656361
fn prompt_jumps_walk_history_and_return_to_live_view() {
62666362
let mut terminal = TerminalState::new(20, 4);

0 commit comments

Comments
 (0)