feat(terminal): copy rejoins the lines Claude Code wrapped itself (#1141)

* feat(terminal): copy rejoins the lines Claude Code wrapped itself

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(terminal): keep copy_unwrap fixtures LF on Windows checkouts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(settings): build the join-wrapped row with its Selection & Clipboard siblings

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-09 11:10:27 +08:00
1 parent b50daebe0b
commit 2f79dccd5b
17 files changed
+771 -1

No files matched your search

+3
View File
@@ -3,3 +3,6 @@
# release time. Say so explicitly so no future line-ending rule can rewrite it.
assets/windows/conpty/**/*.dll binary
assets/windows/conpty/**/*.exe binary
# include_str! fixtures are compared byte-for-byte; autocrlf would add \r on Windows.
src/terminal/view/fixtures/*.txt -text
+5
View File
@@ -381,6 +381,10 @@ pub struct Config {
#[serde(default, deserialize_with = "de_lenient")]
pub mouse_zoom_modifier: MouseZoomModifier,
pub clipboard_trim_trailing_spaces: bool,
/// In a Claude Code pane, copy joins the rows Claude wrapped itself, so a
/// paragraph pastes as one line. ⌘⌥C and Copy Raw copy the rows as is.
#[serde(default)]
pub copy_join_wrapped: bool,
pub copy_on_select: bool,
/// Optional HTTP/SOCKS proxy for tty7's *own* update checks and release
/// downloads, and (HTTP only) the mobile gateway's relay; when set it is
@@ -854,6 +858,7 @@ impl Default for Config {
mouse_reporting: true,
mouse_zoom_modifier: MouseZoomModifier::default(),
clipboard_trim_trailing_spaces: false,
copy_join_wrapped: false,
copy_on_select: false,
http_proxy: None,
smart_select: true,
+1
View File
@@ -145,6 +145,7 @@ their id from the file name. [More about themes →](/customization/themes)
| `word_separators` | string | see below | Characters that end a word. Used when smart selection is off. |
| `copy_on_select` | bool | `false` | |
| `clipboard_trim_trailing_spaces` | bool | `false` | |
| `copy_join_wrapped` | bool | `false` | In a Claude Code pane, copy rejoins the lines Claude wrapped at the pane's edge, so a paragraph pastes as one line; other panes copy as shown. ⌘⌥C and Copy Raw copy the rows as shown. |
| `macos_option_as_alt` | bool | `false` | <kbd>⌥</kbd>+key sends the escape chord instead of typing a special character. |
| `keybindings` | object | `{}` | `{"SplitRight": "cmd-d"}`. [Syntax →](/customization/keybindings) |
| `keybinding_preset` | string | `"default"` | Or `"tmux"`. |
+11 -1
View File
@@ -35,6 +35,8 @@ use crate::core::shell_quote::quote_for_shell;
use crate::daemon::protocol::{RemoteContext, ShellSpec};
use crate::ui::i18n::{L10nKey, t, t_fmt};
mod copy_unwrap;
pub(super) const GRID_PAD_X: f32 = 8.;
pub(super) const GRID_PAD_Y: f32 = 4.;
@@ -2638,6 +2640,10 @@ impl TerminalView {
return;
}
if m.platform && m.alt && !m.control && ks.key == "c" && self.copy_raw(cx) {
cx.stop_propagation();
return;
}
if m.platform && !m.control && !m.alt {
match self.handle_cmd_shortcut(ks, window, cx) {
CmdKey::Consumed => {
@@ -3554,7 +3560,11 @@ impl TerminalView {
}
pub fn copy_selection(&mut self, cx: &mut Context<Self>) {
let text = self.terminal.term.lock().selection_to_string();
self.copy_selection_as(false, cx);
}
fn copy_selection_as(&mut self, raw: bool, cx: &mut Context<Self>) {
let text = copy_unwrap::selection_text(self, raw, cx);
if let Some(mut text) = text {
if cx.global::<Config>().clipboard_trim_trailing_spaces {
text = trim_trailing_spaces(&text);
+503
View File
@@ -0,0 +1,503 @@
//! Copy rejoins the rows a program wrapped itself. Claude Code (Ink) wraps at
//! the pane's width by writing CRLF plus the block's indent, so the grid never
//! sets WRAPLINE on those rows and a plain copy keeps every break. The rule
//! was measured on 16 captures of Claude panes; `fixtures/` holds three.
use alacritty_terminal::Term;
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line, Point};
use alacritty_terminal::term::cell::Flags;
use gpui::Context;
use unicode_width::UnicodeWidthChar;
use super::TerminalView;
use crate::core::cli_agent::CLIAgent;
use crate::core::config::Config;
/// One line as the copy sees it: the grid's rows up to the next one without
/// WRAPLINE, read from column 0 whatever the selection's start.
struct Row {
text: String,
/// Where the last physical row's text ends, in cells.
end: usize,
}
/// What `copy_selection` copies: the selection with Claude's wrapped rows
/// joined, in a Claude pane with `copy_join_wrapped` on and `raw` off. Every
/// other program's output is copied as the grid holds it: `ls` columns,
/// separators and an editor's own wraps all look like Claude's.
pub(super) fn selection_text(
view: &TerminalView,
raw: bool,
cx: &Context<TerminalView>,
) -> Option<String> {
let join = !raw
&& cx.global::<Config>().copy_join_wrapped
&& view.terminal.foreground_agent() == Some(CLIAgent::Claude);
let term = view.terminal.term.lock();
if join {
joined(&term)
} else {
term.selection_to_string()
}
}
/// The selection's text with app-wrapped rows joined. Any doubt (block
/// selection, a row count that doesn't line up) copies raw.
fn joined<T>(term: &Term<T>) -> Option<String> {
let raw = term.selection_to_string()?;
let Some(range) = term.selection.as_ref().and_then(|s| s.to_range(term)) else {
return Some(raw);
};
if range.is_block {
return Some(raw);
}
let rows = grid_rows(term, range.start.line, range.end.line);
Some(rejoin(&raw, &rows, term.columns()))
}
/// `raw` split at its newlines, which must be one per row boundary.
fn rejoin(raw: &str, rows: &[Row], cols: usize) -> String {
// A Lines selection ends in a newline its rows don't account for.
let (body, tail) = match raw.strip_suffix('\n') {
Some(body) if body.split('\n').count() == rows.len() => (body, "\n"),
_ => (raw, ""),
};
let parts: Vec<&str> = body.split('\n').collect();
if parts.len() != rows.len() {
return raw.to_string();
}
apply(&parts, &joins(rows, cols)) + tail
}
fn grid_rows<T>(term: &Term<T>, first: Line, last: Line) -> Vec<Row> {
let cols = term.columns();
let mut rows = Vec::new();
let mut text = String::new();
let mut line = first;
while line <= last {
let grid_row = &term.grid()[line];
text += &term.bounds_to_string(
Point::new(line, Column(0)),
Point::new(line, Column(cols - 1)),
);
let wraps = grid_row[Column(cols - 1)].flags.contains(Flags::WRAPLINE);
if !wraps || line == last {
let end = (0..cols)
.rev()
.find(|&c| {
let cell = &grid_row[Column(c)];
cell.c != ' ' && !cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER)
})
.map_or(0, |c| {
c + 1 + grid_row[Column(c)].flags.contains(Flags::WIDE_CHAR) as usize
});
rows.push(Row {
text: std::mem::take(&mut text).trim_end_matches('\n').to_string(),
end,
});
}
line += 1;
}
rows
}
/// What goes between two rows.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Sep {
Newline,
Space,
/// Ink broke inside a word: a row that is one word, or wide (CJK) text,
/// which wraps between any two characters.
Nothing,
}
/// For each boundary between `rows[i]` and `rows[i + 1]`, what goes there.
fn joins(rows: &[Row], cols: usize) -> Vec<Sep> {
let mut out = Vec::with_capacity(rows.len().saturating_sub(1));
// (leading spaces, text column) of the row that opened the current run.
let mut opened: Option<(usize, usize)> = None;
let mut fenced = false;
// The indent of a `⎿` tool result whose rows we are inside.
let mut tool: Option<usize> = None;
for pair in rows.windows(2) {
let (a, b) = (&pair[0], &pair[1]);
let a_trim = a.text.trim();
if a_trim.starts_with("```") {
fenced = !fenced;
}
if !a_trim.is_empty() {
if tool.is_some_and(|t| lead(&a.text) <= t) {
tool = None;
}
if a_trim.starts_with('⎿') {
tool = Some(lead(&a.text));
}
}
let (lo, hi) = *opened.get_or_insert_with(|| (lead(&a.text), text_col(&a.text)));
let b_trim = b.text.trim();
let word = cells(b_trim.split(' ').next().unwrap_or(""));
// Ink breaks one cell early when the next word would land exactly on
// the edge, so a word that just fits still counts as not fitting.
// A word too long for any row tells nothing about where `a` ended.
let full = a.end + 1 + word >= cols && lead(&b.text) + word <= cols;
let join = full
&& !fenced
&& tool.is_none()
&& !b_trim.is_empty()
&& b_trim
.chars()
.next()
.is_some_and(|c| c.is_ascii() || c.is_alphabetic())
&& !a_trim.ends_with('…')
&& (lo..=hi).contains(&lead(&b.text))
&& text_col(&b.text) == lead(&b.text)
&& !structural(&a.text)
&& !structural(&b.text);
let wide = |c: Option<char>| c.is_some_and(|c| c.width() == Some(2));
// A one-word row that fills the row is a word Ink cut in two: its hard
// breaks fill to the edge, its word wraps stop short of it.
let one_word = a.end >= cols && !a.text[text_byte(&a.text)..].trim_end().contains(' ');
out.push(if !join {
opened = None;
Sep::Newline
} else if one_word || (wide(a_trim.chars().last()) && wide(b_trim.chars().next())) {
Sep::Nothing
} else {
Sep::Space
});
}
out
}
fn apply(parts: &[&str], seps: &[Sep]) -> String {
let mut out = parts[0].to_string();
for (part, &sep) in parts[1..].iter().zip(seps) {
if sep == Sep::Newline {
out.push('\n');
out.push_str(part);
continue;
}
out.truncate(out.trim_end().len());
if sep == Sep::Space {
out.push(' ');
}
out.push_str(part.trim_start());
}
out
}
/// Cells `text` takes in the grid, which sizes each char on its own: VS16
/// adds nothing, so `⚠️` is one cell, not the two `str` width gives it.
fn cells(text: &str) -> usize {
text.chars().map(|c| c.width().unwrap_or(0)).sum()
}
fn lead(text: &str) -> usize {
text.len() - text.trim_start_matches(' ').len()
}
/// Where a row's text starts once a list or block marker is skipped: `- `,
/// `1. `, or a run of symbols and a space (`⏺ `, `⚠️ `, `◯ `).
fn text_col(text: &str) -> usize {
let at = text_byte(text);
lead(text) + cells(&text[lead(text)..at])
}
/// The byte where a row's text starts, past its indent and any marker.
fn text_byte(text: &str) -> usize {
let indent = lead(text);
let rest = &text[indent..];
let Some((marker, _)) = rest.split_once(' ') else {
return indent;
};
let digits = marker.trim_end_matches(['.', ')']);
let is_marker = matches!(marker, "-" | "*" | "+" | "•")
|| (marker.len() > digits.len()
&& (1..=3).contains(&digits.len())
&& digits.bytes().all(|b| b.is_ascii_digit()))
|| (!marker.is_empty()
&& marker
.chars()
.all(|c| !c.is_ascii() && !c.is_alphanumeric()));
if is_marker {
indent + marker.len() + 1
} else {
indent
}
}
/// Box drawing, block elements, a raw table row, or a prompt: never joined.
fn structural(text: &str) -> bool {
let t = text.trim();
text.chars().any(|c| ('\u{2500}'..='\u{259f}').contains(&c))
|| ["|", "$ ", "> ", "❯ ", "```", "⎿"]
.iter()
.any(|p| t.starts_with(p))
}
impl TerminalView {
/// ⌘⌥C and Copy Raw: Copy, with the grid's rows as it holds them.
pub(crate) fn copy_raw(&mut self, cx: &mut Context<Self>) -> bool {
let editor_selection = self.input_active() && self.cmd.selected_text().is_some();
if editor_selection || !self.has_selection() {
return self.copy_contextual(false, cx);
}
self.copy_selection_as(true, cx);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use alacritty_terminal::event::VoidListener;
use alacritty_terminal::index::Side;
use alacritty_terminal::selection::{Selection, SelectionType};
/// Rows the way `tty7 capture --plain` prints them; a capture line longer
/// than the pane was soft-wrapped, so its last row is the remainder.
fn unwrap(text: &str, cols: usize) -> String {
let rows: Vec<Row> = text
.split('\n')
.map(|p| {
let w = cells(p.trim_end());
Row {
text: p.to_string(),
end: if w > cols { (w - 1) % cols + 1 } else { w },
}
})
.collect();
rejoin(text, &rows, cols)
}
fn term(cols: usize, lines: usize, input: &str) -> Term<VoidListener> {
let mut term = Term::new(
alacritty_terminal::term::Config::default(),
&crate::terminal::size::TermSize::new(cols, lines),
VoidListener,
);
alacritty_terminal::vte::ansi::Processor::<alacritty_terminal::vte::ansi::StdSyncHandler>::new()
.advance(&mut term, input.as_bytes());
term
}
fn select(
term: &mut Term<VoidListener>,
ty: SelectionType,
from: (i32, usize),
to: (i32, usize),
) {
let mut sel = Selection::new(ty, Point::new(Line(from.0), Column(from.1)), Side::Left);
sel.update(Point::new(Line(to.0), Column(to.1)), Side::Right);
term.selection = Some(sel);
}
const C111: &str = include_str!("fixtures/copy_unwrap_111.txt");
const C172: &str = include_str!("fixtures/copy_unwrap_172.txt");
const C_CODE: &str = include_str!("fixtures/copy_unwrap_code.txt");
fn has_line(out: &str, line: &str) -> bool {
out.lines().any(|l| l == line)
}
#[test]
fn wrapped_prose_joins() {
let out = unwrap(C111, 111);
assert!(has_line(
&out,
"⏺ #84 is now based on the new patch-stack main-niu, and GitHub reports it can merge cleanly. Its code is the same as what passed review: the only change is that its notes moved into the hotkey-window feature page. Its tests pass locally."
));
assert!(has_line(
&out,
"※ recap: The goal is for tty7 to show the hotkey window on launch, instead of creating a new workspace, when it's your only workspace. That's PR #84, which passed review and is waiting on CI. Next, it merges and tty7 reloads on its own, and I'll check your sessions survive. (disable recaps in /config)"
));
// A system line Claude continues at column 0.
assert!(has_line(
&out,
"⏺ Background command \"Merge #84 once CI passes (network-error tolerant), then reload tty7\" completed (exit code 0)"
));
let out = unwrap(C172, 172);
assert!(has_line(
&out,
"※ recap: Goal: clean up shuck findings in your dotfiles, most-used first; the shell startup files are done and committed locally. Next, pick Z1, Z2 or Z3 for the no-op `unset GLOBAL_RCS` line, and say \"push\" for the 19 unpushed commits. (disable recaps in /config)"
));
// `&&/||` in prose is not a table.
assert!(out.contains(
"fixing &&/|| logic into proper if/else, correcting a comment-continuation bug, adding"
));
}
#[test]
fn the_reported_paragraph_joins() {
let rows = "⏺ If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would\n be a bug.";
assert_eq!(
unwrap(rows, 111),
"⏺ If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would be a bug."
);
}
#[test]
fn list_items_keep_their_newlines() {
let out = unwrap(C111, 111);
assert!(out.contains(" Here's how to try it:\n 1. Close every workspace except the hotkey one.\n 2. Quit tty7 and open it again.\n"));
let bullets = " - Rule location: the rule is .claude/rules/app-layout.md, plus its Cursor copy and a pointer from the backend\n skill.\n - What it says: nothing new goes under <app>/services/, and other apps import only <app>.public and\n <app>.models.";
assert_eq!(
unwrap(bullets, 111),
" - Rule location: the rule is .claude/rules/app-layout.md, plus its Cursor copy and a pointer from the backend skill.\n - What it says: nothing new goes under <app>/services/, and other apps import only <app>.public and <app>.models."
);
}
#[test]
fn code_tables_and_tool_output_never_join() {
let out = unwrap(C111, 111);
// A box drawn round a summary.
assert!(out.contains(" │ joins when ... the line runs to the right edge (the next word wouldn't\n │ have fit)"));
// A tool call's command and its char-wrapped output stay as the grid has them.
assert!(out.contains("tasks/befky\n 62qi.output;"));
assert!(out.contains(
"https://github.com/NorthIsUp/tty7/actions/runs/369394730\n 36/job/110627346251"
));
assert!(out.contains("panicked at crates/tty7\n -core/src/host/server.rs"));
let fenced = "```\nlet joined = rows.iter().map(|r| r.text.as_str()).collect::<Vec<_>>().join(\" \"); // a long line of code\nthat continues\n```";
let code_width = cells(fenced.lines().nth(1).unwrap());
assert_eq!(unwrap(fenced, code_width), fenced);
let table = "| a long table cell that runs right up to the edge of the pane, wider than the |\nrest |";
assert_eq!(unwrap(table, 80), table);
}
#[test]
fn a_short_row_before_an_indented_row_does_not_join() {
let out = unwrap(C111, 111);
assert!(out.contains(" Here's how to try it:\n 1."));
let rows = " Still open on your side: rotate the key.\n Then rerun the push.";
assert_eq!(unwrap(rows, 111), rows);
}
#[test]
fn a_narrow_pane_joins_the_same() {
// The reported paragraph and a list item, as Ink wraps them at 52 columns.
let rows = "⏺ If the Theme field won't change or the popup comes\n back after they pick it, send me a screenshot.\n That would be a bug.\n\n 1. Close every workspace except the hotkey one,\n then quit tty7 and open it again.\n 2. Quit tty7.";
assert_eq!(
unwrap(rows, 52),
"⏺ If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would be a bug.\n\n 1. Close every workspace except the hotkey one, then quit tty7 and open it again.\n 2. Quit tty7."
);
}
#[test]
fn the_grid_selection_joins_from_a_mid_row_start() {
let soft = "a".repeat(45);
let mut term = term(
40,
6,
&format!(
"⏺ If the Theme field won't change or\r\n the popup comes back after they pick\r\n it.\r\n{soft}"
),
);
select(&mut term, SelectionType::Simple, (0, 2), (4, 39));
assert_eq!(
joined(&term).unwrap(),
format!(
"If the Theme field won't change or the popup comes back after they pick it.\n{soft}"
)
);
}
#[test]
fn wide_text_joins_without_a_space() {
// Ten CJK chars fill 20 cells; Ink wraps between any two.
let mut term = term(20, 3, "这是一个很长的中文句\r\n继续写完。");
select(&mut term, SelectionType::Simple, (0, 0), (1, 19));
assert_eq!(joined(&term).unwrap(), "这是一个很长的中文句继续写完。");
}
#[test]
fn wide_on_one_side_only_keeps_the_space() {
let emoji = format!(" {} 🎉\n Next steps", "w".repeat(36));
assert_eq!(
unwrap(&emoji, 42),
format!(" {} 🎉 Next steps", "w".repeat(36))
);
let mixed = format!(" {} 詳しくは\n README を", "w".repeat(30));
assert_eq!(
unwrap(&mixed, 42),
format!(" {} 詳しくは README を", "w".repeat(30))
);
}
#[test]
fn a_block_selection_copies_raw() {
let mut term = term(
40,
3,
"⏺ If the Theme field won't change or\r\n the popup comes back",
);
select(&mut term, SelectionType::Block, (0, 0), (1, 39));
assert_eq!(joined(&term), term.selection_to_string());
assert!(joined(&term).unwrap().contains("or\n"));
}
#[test]
fn a_lines_selection_keeps_its_trailing_newline() {
let mut term = term(
40,
3,
"⏺ If the Theme field won't change or\r\n the popup comes back",
);
select(&mut term, SelectionType::Lines, (0, 0), (1, 0));
assert_eq!(
joined(&term).unwrap(),
"⏺ If the Theme field won't change or the popup comes back\n"
);
}
#[test]
fn rows_that_dont_line_up_with_the_text_copy_raw() {
let rows = [Row {
text: "x".repeat(40),
end: 40,
}];
let raw = format!("{}\nmore", "x".repeat(40));
assert_eq!(rejoin(&raw, &rows, 40), raw);
}
#[test]
fn a_one_word_row_at_the_edge_joins_without_a_space() {
let url = "https://github.com/NorthIsUp/tty7/actions/runs/36939473036";
let (head, tail) = url.split_at(40);
let rows = format!(" {head}\n {tail} is the run.");
assert_eq!(unwrap(&rows, 42), format!(" {url} is the run."));
}
#[test]
fn a_vs16_emoji_is_one_cell() {
assert_eq!(cells("⚠️"), 1);
// 2 + "⚠️ " (2 cells) + 37 = 41 cells: the next word would not fit at 44.
let rows = format!(" ⚠️ {}\n more words", "w".repeat(37));
assert_eq!(
unwrap(&rows, 44),
format!(" ⚠️ {} more words", "w".repeat(37))
);
assert_eq!(unwrap(&rows, 48), rows);
}
#[test]
fn a_wrapped_code_line_joins_and_separate_code_lines_dont() {
let out = unwrap(C_CODE, 111);
assert!(has_line(
&out,
" ! themes env version history sampleorg/tty/testing | head -1; cd ~/src/sampleorg/Sample_1/.worktrees/sm-12345-tty-set-run/src/sampl && .venv/bin/python ~/tmp/set_sync_testing.py --apply"
));
assert!(out.contains(" ! cd /Users/adam/src/tty7-mdfix && mise run reload\n"));
assert!(out.contains(
" # themes/palette.py: drop `import random`\n from colour.utils.random import get_random_string\n ...\n"
));
}
#[test]
fn the_status_bar_never_joins() {
let rows = " #14 #15 #16 #17 #18 #19 #20 #21 #24 #25 #27 #28 #29 #30 #31 #32 #33 #34 #35 #36 #37 #38 #39 #40 #41 #42 …\n ⏵⏵ auto mode on (shift+tab to cycle) · ← 19 agents";
assert_eq!(unwrap(rows, 111), rows);
}
}
@@ -0,0 +1,119 @@
⏺ #84 is now based on the new patch-stack main-niu, and GitHub reports it can merge cleanly. Its code is the
same as what passed review: the only change is that its notes moved into the hotkey-window feature page. Its
tests pass locally.
Once CI passes, it merges on its own and tty7 reloads on the new main-niu. That reload will be the first run
of the restructured branch, so I'll check that your sessions survive it.
✻ Crunched for 28m 10s · done 4:13 PM
※ recap: The goal is for tty7 to show the hotkey window on launch, instead of creating a new workspace, when
it's your only workspace. That's PR #84, which passed review and is waiting on CI. Next, it merges and tty7
reloads on its own, and I'll check your sessions survive. (disable recaps in /config)
⏺ Background command "Merge #84 once CI passes (network-error tolerant), then reload tty7" completed (exit code
0)
⏺ Bash(tail -8 /private/tmp/claude-501/-Users-adam-src-clawmux/f14681bd-4bf7-4808-b727-0cb6ed1a8c5b/tasks/befky
62qi.output; tty7 status 2>&1 | head -5; tty7 agents 2>&…)
⎿  build & test (aarch64-apple-darwin) fail 6m8s https://github.com/NorthIsUp/tty
7/actions/runs/36939473036
/job/110627383842
host boundary pass 5s https://github.com/NorthIsUp/tty7/actions/runs/369394730
36/job/110627346251
… +13 lines (ctrl+o to expand)
⎿  Allowed by auto mode classifier
⏺ Bash(gh api --allow-escape-sequences repos/NorthIsUp/tty7/actions/jobs/110627383842/logs 2>&1 | sed
's/\x1b\[[0-9;]*m//g' | grep -E 'FAILED|panicked at|error\[|^.{29…)
⎿  test host::server::pool_tests::closing_the_pool_drops_queued_work ... FAILED
thread 'host::server::pool_tests::closing_the_pool_drops_queued_work' (43435) panicked at crates/tty7
-core/src/host/server.rs:2419:9:
… +6 lines (ctrl+o to expand)
⏺ The CI failure is in an unrelated thread-pool test that #84 doesn't touch and main's own CI passes, so it
looks flaky. I'm re-running the failed job.
⏺ Bash(cd ~/src/tty7 && git diff ed406c19 24f7c1eb --stat | tail -2; gh run rerun 36939473036 -R NorthIsUp/tty7
--failed && echo rerun-started)
⎿   src/ui/windows.rs | 46 ++++++++-
6 files changed, 255 insertions(+), 23 deletions(-)
rerun-started
⎿  Allowed by auto mode classifier
⏺ Bash(cd ~/src/tty7 && sleep 60; while :; do out=$(gh pr checks 84 -R NorthIsUp/tty7 --required 2>&1); rc=$?;
if echo "$out" | grep -qiE 'pending|no checks|queued|err…)
⎿  Running in the background (↓ to manage)
⎿  Allowed by auto mode classifier
⏺ #84 isn't merged yet. Its CI failed in a tty7-core test (closing_the_pool_drops_queued_work) that #84 doesn't
touch: the PR only changes 6 files in the hotkey window and window code. main-niu's own CI passes, so this
looks like a flaky test. I've re-run the failed job. If it passes, #84 merges and tty7 reloads, and I'll
check your 24 panes are still there afterwards.
If it fails again, I'll treat that test as a real problem and investigate it instead of re-running.
✻ Crunched for 2m 42s · done 4:22 PM
※ recap: I'm finishing the fix that opens the hotkey window instead of a blank workspace when it's your only
workspace. Its CI hit an unrelated flaky test, so I re-ran it; once it passes, it merges and tty7 reloads on
its own. (disable recaps in /config)
⏺ Background command "Wait for #84's rerun, merge on green, reload and confirm panes survived" completed (exit
code 0)
Read 1 file (ctrl+o to expand)
⏺ ✅ #84 is merged, and tty7 has reloaded on the restructured main-niu. All 24 of your panes came through the
reload. The re-run passed, so the earlier CI failure was a flaky test.
Here's how to try it:
1. Close every workspace except the hotkey one.
2. Quit tty7 and open it again.
It should open straight into the hotkey window without making a new workspace. Clicking the Dock icon should
do the same.
✻ Sautéed for 27s · done 4:45 PM
※ recap: The goal was making tty7 restore your agents reliably. That's all merged and live now (#72–#84),
including the hotkey window opening on its own when it's your only workspace. Next: quit and reopen tty7 to
confirm the spinners and that it opens straight into the hotkey window.
❯ [Image #47] when coppying this i'd like it to not have a hard newline on the wrap
what coppies:
If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would
be a bug.
what should copy:
If the Theme field won't change or the popup comes back after they pick it, send me a screenshot. That would
be a bug.
⎿  [Image #47]
⏺ Agent(Unwrap app-wrapped lines on copy)
⎿  Backgrounded agent (↓ to manage · ctrl+o to expand)
⏺ The newline comes from Claude Code itself. It wraps long lines by writing a real line break plus an indent,
so to tty7 they look like two separate lines. Your terminal's own soft-wrap tracking never sees it.
I've started a subagent on the fix. On copy, tty7 will rejoin lines Claude wrapped:
┌──────────────────────────────
│ 📦 copy joins Claude's wrapped lines
├──────────────────────────────
│ joins when ... the line runs to the right edge (the next word wouldn't
│ have fit) and the next line continues at the same indent
│ never joins .. code, tables, box drawing, prompts, list items
│ setting ...... "Join wrapped lines when copying", on for you
│ (off by default upstream)
│ raw copy ..... a Copy Raw command in ⌘P, for the exact text
│ 🧪 tuned on captures from your Claude tabs: prose, bullets, code, tables
└──────────────────────────────
The subagent only reads your tabs to build test cases and doesn't type into them or take focus. Once the PR
is up, I'll review it, merge it after CI passes, turn the setting on in your config, and reload tty7. Expect
about an hour.
@@ -0,0 +1,34 @@
⏺ I've laid out 12 fixes across the dotfiles, each to be its own commit — removing stray shebangs, fixing &&/|| logic into proper if/else, correcting a comment-continuation
bug, adding a default in slow-cmd, using exact-match in path-fixup, scoping the loop in 10-focus-events, and adding suppression comments for the three intentional cases.
I'll start committing these one at a time now.
✻ Cogitated for 23m 19s · done Wednesday 5:00 PM
※ recap: Goal: clean up shuck findings in your dotfiles, most-used first; the shell startup files are done and committed locally. Next, pick Z1, Z2 or Z3 for the no-op
`unset GLOBAL_RCS` line, and say "push" for the 19 unpushed commits. (disable recaps in /config)
❯ continue
⏺ Continuing without changing GLOBAL_RCS and without pushing, since you haven't answered either. The next most-run code is the shared helpers that the scripts I've already
fixed call into: argparse, usage2, log-*, subcommander, scale-it. Ranking the remaining flagged files by how many places call them:
⏺ Bash(git status --short; python3 - <<'EOF'
import collections,os,re,subprocess…)
⎿  Error: Exit code 1
M home/.claude/settings.json
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/claude-501/sk3.txt'
⏺ Temp files were cleared since the last session. Regenerating the findings list and rerunning the ranking:
⏺ Bash(mkdir -p /tmp/claude-501 && cd /Users/adam/src/dotfiles && git log --oneline -1 && cmd=$(grep -o "shuck check --output-format concise[^\"]*" hk.pkl); MISE_QUIET…)
⎿  Running in the background (↓ to manage)
⎿  Loaded home/.claude/CLAUDE.md
⎿  Loaded 5 skills from home/.claude/skills
⎿  5 skills available
⏺ The findings scan plus caller ranking is still running in the background. It went over the 5-minute foreground limit, probably because shuck is slower on a cold cache.
I'll start on the top-ranked helper when it finishes.
@@ -0,0 +1,26 @@
You didn't pick between U1 and U2, so I did U1: rewrote the PR title and body. The new title is feat(infra)!:
source the app colours from themes set at boot.
The testing sync is a config-files write, which auto-mode won't let me do. It prints the rollback revision
first, then key names only:
! themes env version history sampleorg/tty/testing | head -1; cd
~/src/sampleorg/Sample_1/.worktrees/sm-12345-tty-set-run/src/sampl && .venv/bin/python
~/tmp/set_sync_testing.py --apply
⏺ Background command "Rebuild and reload dev app with both fixes" was stopped because the system is running low
on memory
⏺ Rebuild the dev app yourself when you're ready:
! cd /Users/adam/src/tty7-mdfix && mise run reload
Fixes for #1 and #2 (about 15 minutes with a test):
# themes/palette.py: drop `import random`
from colour.utils.random import get_random_string
...
return get_random_string(6, allowed_chars="0123456789")
# settings/base.py
PALETTE_SIZE_LIMITS = {"accent_colour": "1/55s/key,10/m/ip"}
+8
View File
@@ -6800,6 +6800,11 @@ impl Tty7App {
});
}
}
CopyRaw => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| view.copy_raw(cx));
}
}
CutText => {
if let Some(leaf) = self.focused_leaf(window, cx) {
leaf.update(cx, |view, cx| {
@@ -7128,6 +7133,9 @@ impl Tty7App {
L10nKey::SettingsTrimTrailingSpaces => {
self.set_clipboard_trim(defaults.clipboard_trim_trailing_spaces, cx)
}
L10nKey::SettingsCopyJoinWrapped => {
self.update_config(cx, |c| c.copy_join_wrapped = defaults.copy_join_wrapped)
}
L10nKey::SettingsCopyOnSelect => self.set_copy_on_select(defaults.copy_on_select, cx),
L10nKey::SettingsSmartSelection => self.set_smart_select(defaults.smart_select, cx),
L10nKey::SettingsPromptEditor => self.set_prompt_editor(defaults.prompt_editor, cx),
+8
View File
@@ -2340,6 +2340,14 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::GitHubShowFullText => "Show full text",
L10nKey::GitHubShowHiddenComments => "Show {count} more comments",
L10nKey::GitHubShowAllReviewers => "Show all {count} reviewers",
L10nKey::CmdCopyRaw => "Copy Raw",
L10nKey::SettingsSearchCopyJoinWrappedKeywords => {
"clipboard copy wrap unwrap join lines claude raw"
}
L10nKey::SettingsCopyJoinWrapped => "Join wrapped lines when copying",
L10nKey::SettingsCopyJoinWrappedDesc => {
"In a Claude Code pane, rejoin the lines Claude wrapped at the pane's edge, so a paragraph pastes as one line. ⌘⌥C or Copy Raw copies them as shown"
}
}
}
+8
View File
@@ -2411,6 +2411,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::GitHubShowFullText => "全文を表示",
L10nKey::GitHubShowHiddenComments => "ほか {count} 件のコメントを表示",
L10nKey::GitHubShowAllReviewers => "{count} 人のレビュアーをすべて表示",
L10nKey::CmdCopyRaw => "そのままコピー",
L10nKey::SettingsSearchCopyJoinWrappedKeywords => {
"クリップボード コピー 折り返し 行 結合 clipboard copy wrap unwrap join lines claude raw"
}
L10nKey::SettingsCopyJoinWrapped => "コピー時に折り返し行をつなげる",
L10nKey::SettingsCopyJoinWrappedDesc => {
"Claude Code のペインで、Claude が画面端で折り返した行をつなげ、段落を 1 行として貼り付けます。⌘⌥C または「そのままコピー」で表示どおりにコピーします"
}
})
}
+4
View File
@@ -1855,6 +1855,10 @@ l10n_keys! {
GitHubShowFullText,
GitHubShowHiddenComments,
GitHubShowAllReviewers,
CmdCopyRaw,
SettingsCopyJoinWrapped,
SettingsCopyJoinWrappedDesc,
SettingsSearchCopyJoinWrappedKeywords,
}
/// The source control strings that are translated but not yet displayed.
+8
View File
@@ -2435,6 +2435,14 @@ pub fn translate_ru(key: L10nKey) -> Option<&'static str> {
L10nKey::GitHubShowFullText => "Показать полный текст",
L10nKey::GitHubShowHiddenComments => "Показать ещё комментарии ({count})",
L10nKey::GitHubShowAllReviewers => "Показать всех рецензентов ({count})",
L10nKey::CmdCopyRaw => "Копировать как есть",
L10nKey::SettingsSearchCopyJoinWrappedKeywords => {
"буфер обмена копирование перенос склеить строки claude clipboard copy wrap unwrap join lines raw"
}
L10nKey::SettingsCopyJoinWrapped => "Склеивать перенесённые строки при копировании",
L10nKey::SettingsCopyJoinWrappedDesc => {
"В панели Claude Code склеивать строки, которые Claude перенёс у края панели, чтобы абзац вставлялся одной строкой. ⌘⌥C или «Копировать как есть» копируют их как на экране"
}
})
}
+8
View File
@@ -2185,6 +2185,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::GitHubShowFullText => "展开全文",
L10nKey::GitHubShowHiddenComments => "显示另外 {count} 条评论",
L10nKey::GitHubShowAllReviewers => "显示全部 {count} 位审查人",
L10nKey::CmdCopyRaw => "原样复制",
L10nKey::SettingsSearchCopyJoinWrappedKeywords => {
"剪贴板 复制 折行 合并 clipboard copy wrap unwrap join lines claude raw"
}
L10nKey::SettingsCopyJoinWrapped => "复制时合并折行",
L10nKey::SettingsCopyJoinWrappedDesc => {
"在 Claude Code 窗格中,将 Claude 在窗格边缘折行的内容重新合并,段落粘贴为一行。⌘⌥C 或“原样复制”按显示内容复制"
}
})
}
+6
View File
@@ -67,6 +67,7 @@ pub enum CommandKind {
FindNext,
FindPrevious,
CopyText,
CopyRaw,
CutText,
PasteText,
SelectAllText,
@@ -248,6 +249,7 @@ impl CommandKind {
FindNext => "find-next",
FindPrevious => "find-previous",
CopyText => "copy",
CopyRaw => "copy-raw",
CutText => "cut",
PasteText => "paste",
SelectAllText => "select-all",
@@ -326,6 +328,7 @@ impl CommandKind {
|spec: &str| -> Option<String> { cfg!(target_os = "macos").then(|| spec.to_string()) };
match self {
CopyText => return inline("secondary-c"),
CopyRaw => return inline("secondary-alt-c"),
CutText => return inline("secondary-x"),
PasteText => return inline("secondary-v"),
SelectAllText => return inline("secondary-a"),
@@ -440,6 +443,7 @@ impl CommandKind {
ToggleDiffViewMode => "ToggleDiffViewMode",
QuickOpenFile => "QuickOpenFile",
CopyText
| CopyRaw
| CutText
| PasteText
| SelectAllText
@@ -765,6 +769,7 @@ impl Item {
Item::localized(L10nKey::CmdFindNext, FindNext),
Item::localized(L10nKey::CmdFindPrevious, FindPrevious),
Item::localized(L10nKey::CmdCopy, CopyText),
Item::localized(L10nKey::CmdCopyRaw, CopyRaw),
Item::localized(L10nKey::CmdCut, CutText),
Item::localized(L10nKey::CmdPaste, PasteText),
Item::localized(L10nKey::CmdSelectAll, SelectAllText),
@@ -928,6 +933,7 @@ mod tests {
CommandKind::SplitRight,
CommandKind::ClearTerminal,
CommandKind::CopyText,
CommandKind::CopyRaw,
CommandKind::CutText,
CommandKind::PasteText,
CommandKind::SelectAllText,
+8
View File
@@ -539,6 +539,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsTrimTrailingSpaces,
keywords: SettingsSearchTrimTrailingSpacesKeywords,
},
SearchEntry {
section: KeyboardMouse,
title: SettingsCopyJoinWrapped,
keywords: SettingsSearchCopyJoinWrappedKeywords,
},
SearchEntry {
section: Ssh,
title: SettingsHosts,
@@ -845,6 +850,7 @@ impl SearchEntry {
L10nKey::SettingsSmoothScroll => "smooth_scroll",
L10nKey::SettingsMouseZoom => "mouse_zoom_modifier",
L10nKey::SettingsTrimTrailingSpaces => "clipboard_trim_trailing_spaces",
L10nKey::SettingsCopyJoinWrapped => "copy_join_wrapped",
L10nKey::SettingsCopyOnSelect => "copy_on_select",
L10nKey::SettingsSmartSelection => "smart_select",
L10nKey::SettingsPromptEditor => "prompt_editor",
@@ -936,6 +942,7 @@ impl SearchEntry {
L10nKey::SettingsSmartSelection => t(L10nKey::SettingsSmartSelectionDesc),
L10nKey::SettingsCopyOnSelect => t(L10nKey::SettingsCopyOnSelectDesc),
L10nKey::SettingsTrimTrailingSpaces => t(L10nKey::SettingsTrimTrailingSpacesDesc),
L10nKey::SettingsCopyJoinWrapped => t(L10nKey::SettingsCopyJoinWrappedDesc),
L10nKey::SettingsVerifyHostKeys => t(L10nKey::SettingsVerifyHostKeysDesc),
L10nKey::SettingsStartupWindow => t(L10nKey::SettingsStartupWindowDesc),
L10nKey::SettingsRememberWindowSize => t(L10nKey::SettingsRememberWindowSizeDesc),
@@ -1033,6 +1040,7 @@ impl SearchEntry {
L10nKey::SettingsTrimTrailingSpaces => {
cfg.clipboard_trim_trailing_spaces != defaults.clipboard_trim_trailing_spaces
}
L10nKey::SettingsCopyJoinWrapped => cfg.copy_join_wrapped != defaults.copy_join_wrapped,
L10nKey::SettingsCopyOnSelect => cfg.copy_on_select != defaults.copy_on_select,
L10nKey::SettingsSmartSelection => cfg.smart_select != defaults.smart_select,
L10nKey::SettingsPromptEditor => cfg.prompt_editor != defaults.prompt_editor,
+11
View File
@@ -1233,6 +1233,11 @@ impl Tty7App {
let trim = self.settings_switch("term-clip-trim", clip_trim, cx, |this, on, _, cx| {
this.set_clipboard_trim(on, cx)
});
let join_wrapped = cx.global::<Config>().copy_join_wrapped;
let join =
self.settings_switch("copy-join-wrapped", join_wrapped, cx, |this, on, _, cx| {
this.update_config(cx, |c| c.copy_join_wrapped = on)
});
// Ctrl only earns a cell where it is a different key from the platform
// modifier: off macOS the two are the same key. A config that names
// `ctrl` there still highlights it, in the one cell that means it.
@@ -1335,6 +1340,12 @@ impl Tty7App {
trim,
cx,
),
self.settings_row(
t(L10nKey::SettingsCopyJoinWrapped),
t(L10nKey::SettingsCopyJoinWrappedDesc),
join,
cx,
),
]
.map(IntoElement::into_any_element),
cx,