feat(ai-session): show short session ids, and select text with the mouse in the resume picker (atuinsh/atuin#4404)

This commit is contained in:
Ellie Huxtable authored and Ellie Huxtable committed 2026-10-13 04:21:32 +00:00
1 parent c2e8e68bc6
commit 53e1fdd89b
19 files changed
+554 -87

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
section ":package: Install QEMU" \
apt_install qemu-system-x86 qemu-utils zstd xz-utils rsync openssh-client
section ":package: Fetch anyvm ${ANYVM_VERSION}" \
fetch_verified "https://dl.tty0.dev/anyvm-org/anyvm/releases/download/v${ANYVM_VERSION}/anyvm.py" \
fetch_verified "https://github.com/anyvm-org/anyvm/releases/download/v${ANYVM_VERSION}/anyvm.py" \
"$ANYVM_SHA256" /tmp/anyvm.py
profile=dev
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
version=0.4.0-devel3
mkdir -p "$HOME/.local/share"
curl -fsSL "https://dl.tty0.dev/akinomyoga/ble.sh/releases/download/v${version}/ble-${version}.tar.xz" |
curl -fsSL "https://github.com/akinomyoga/ble.sh/releases/download/v${version}/ble-${version}.tar.xz" |
tar xJ -C "$HOME/.local/share"
rm -rf "$HOME/.local/share/blesh"
mv "$HOME/.local/share/ble-${version}" "$HOME/.local/share/blesh"
+3 -3
View File
@@ -93,7 +93,7 @@ NIX_INSTALLER_VERSION=3.22.5
NIX_INSTALLER_SHA256=9432d40ec1d0d4ebb6284848cc389bdb1bbf7aeb5c1cea11e7efc9daf0a1ec82
install_shellcheck() {
fetch_verified "https://dl.tty0.dev/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.gz" \
fetch_verified "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.gz" \
"$SHELLCHECK_SHA256" /tmp/shellcheck.tgz
tar -xzf /tmp/shellcheck.tgz -C /tmp
mv "/tmp/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "$TOOLS_BIN/"
@@ -101,7 +101,7 @@ install_shellcheck() {
}
install_vale() {
fetch_verified "https://dl.tty0.dev/errata-ai/vale/releases/download/v${VALE_VERSION}/vale_${VALE_VERSION}_Linux_64-bit.tar.gz" \
fetch_verified "https://github.com/errata-ai/vale/releases/download/v${VALE_VERSION}/vale_${VALE_VERSION}_Linux_64-bit.tar.gz" \
"$VALE_SHA256" /tmp/vale.tgz
tar -xzf /tmp/vale.tgz -C "$TOOLS_BIN" vale
vale --version
@@ -123,7 +123,7 @@ install_codespell() {
install_nix() {
local sudo=""
[ "$(id -u)" -eq 0 ] || sudo=sudo
fetch_verified "https://dl.tty0.dev/DeterminateSystems/nix-installer/releases/download/v${NIX_INSTALLER_VERSION}/nix-installer-x86_64-linux" \
fetch_verified "https://github.com/DeterminateSystems/nix-installer/releases/download/v${NIX_INSTALLER_VERSION}/nix-installer-x86_64-linux" \
"$NIX_INSTALLER_SHA256" /tmp/nix-installer
chmod +x /tmp/nix-installer
$sudo /tmp/nix-installer install linux --no-confirm --init none --prefer-upstream-nix \
+3 -3
View File
@@ -78,18 +78,18 @@ mkdir -p "$CARGO_HOME/bin"
for tool in ${CARGO_TOOLS:-}; do
case "$tool" in
nextest)
fetch_verified "https://dl.tty0.dev/nextest-rs/nextest/releases/download/cargo-nextest-${NEXTEST_VERSION}/cargo-nextest-${NEXTEST_VERSION}-${nextest_asset}.tar.gz" \
fetch_verified "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-${NEXTEST_VERSION}/cargo-nextest-${NEXTEST_VERSION}-${nextest_asset}.tar.gz" \
"$nextest_sha256" /tmp/nextest.tgz
tar -xzf /tmp/nextest.tgz -C "$CARGO_HOME/bin"
;;
deny)
fetch_verified "https://dl.tty0.dev/EmbarkStudios/cargo-deny/releases/download/${CARGO_DENY_VERSION}/cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
fetch_verified "https://github.com/EmbarkStudios/cargo-deny/releases/download/${CARGO_DENY_VERSION}/cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
"$CARGO_DENY_SHA256" /tmp/cargo-deny.tgz
tar -xzf /tmp/cargo-deny.tgz -C /tmp
mv "/tmp/cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl/cargo-deny" "$CARGO_HOME/bin/"
;;
bktec)
fetch_verified "https://dl.tty0.dev/buildkite/test-engine-client/releases/download/v${BKTEC_VERSION}/bktec_${BKTEC_VERSION}_${bktec_asset}" \
fetch_verified "https://github.com/buildkite/test-engine-client/releases/download/v${BKTEC_VERSION}/bktec_${BKTEC_VERSION}_${bktec_asset}" \
"$bktec_sha256" "$CARGO_HOME/bin/bktec"
chmod +x "$CARGO_HOME/bin/bktec"
;;
Generated
+1
View File
@@ -356,6 +356,7 @@ dependencies = [
"atuin-daemon",
"atuin-domain",
"atuin-vt100",
"base64",
"chrono",
"chrono-humanize",
"clap",
+1
View File
@@ -26,6 +26,7 @@ atuin-vt100 = { workspace = true }
async-stream = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true }
chrono-humanize = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
+5 -2
View File
@@ -203,11 +203,12 @@ impl Cmd {
}
}
/// A query that looks like a session id: one word of id characters, long enough.
/// A query that looks like a session id: one word of id characters, long enough, with a digit
/// unless it could be the end of an atuin id (which can be letters alone).
fn looks_like_id(query: &str) -> bool {
query.len() >= MIN_ID_PREFIX
&& query.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
&& query.chars().any(|c| c.is_ascii_digit())
&& (query.chars().any(|c| c.is_ascii_digit()) || AtuinSessionId::is_suffix(query))
}
/// The single session `query` names, if it is an exact id or a unique prefix (an id several
@@ -829,6 +830,8 @@ mod tests {
#[case("7f3c9", false)]
#[case("fix flaky", false)]
#[case("release", false)]
#[case::a_short_id_of_letters("deadbeef", true)]
#[case::too_short_for_one("beef", false)]
fn id_like_queries(#[case] query: &str, #[case] want: bool) {
assert_eq!(looks_like_id(query), want);
}
+51 -38
View File
@@ -41,9 +41,10 @@ enum SubCmd {
/// List captured sessions, newest first.
List,
/// Show a session and its messages. Accepts a session id or `latest`.
/// Show a session and its messages. Accepts an id, a harness id or `latest`.
Show {
/// Session id (a unique prefix is enough), or `latest` for the most recently active session.
/// Id (its last 8 digits, as `list` shows them, are enough) or harness id (a unique prefix
/// is enough), or `latest` for the most recently active session.
#[arg(value_name = "ID|latest")]
session: String,
},
@@ -221,16 +222,12 @@ async fn list(client: &mut AiClient, style: Style) -> Result<()> {
writeln!(out, "No sessions captured yet.")?;
return Ok(());
}
writeln!(
out,
"{:<14} {:<12} {:<16} {:>5} TITLE",
"SESSION", "HARNESS", "UPDATED", "MSGS"
)?;
writeln!(out, "{:<8} {:<12} {:<16} {:>5} TITLE", "ID", "HARNESS", "UPDATED", "MSGS")?;
for s in &sessions {
writeln!(
out,
"{:<14} {:<12} {:<16} {:>5} {}",
short_id(s.handle.session.as_ref()),
"{:<8} {:<12} {:<16} {:>5} {}",
s.atuin_id.short(),
harness_name(s.handle.harness),
age(s.updated_at),
s.message_count,
@@ -322,7 +319,7 @@ async fn search(
writeln!(out, "No sessions matched `{query}`.")?;
return Ok(());
}
writeln!(out, "{:<14} {:<12} {:<16} MATCH", "SESSION", "HARNESS", "UPDATED")?;
writeln!(out, "{:<8} {:<12} {:<16} MATCH", "ID", "HARNESS", "UPDATED")?;
for m in &matches {
let title = m.title.to_plain().text;
let label = if title.trim().is_empty() {
@@ -332,8 +329,8 @@ async fn search(
};
writeln!(
out,
"{:<14} {:<12} {:<16} {}",
short_id(m.session.handle.session.as_ref()),
"{:<8} {:<12} {:<16} {}",
m.session.atuin_id.short(),
harness_name(m.session.handle.harness),
age(m.session.updated_at),
one_line(&label, 80),
@@ -557,7 +554,7 @@ pub enum SelectError {
#[error("id `{0}` matches more than one session; use a longer or full id")]
Ambiguous(String),
/// Several agents' sessions have the id (a session continued in another agent can keep it).
#[error("id `{id}` names a session in more than one agent; use its atuin id:{choices}")]
#[error("id `{id}` names a session in more than one agent; use its id:{choices}")]
Shared {
id: String,
choices: String,
@@ -574,13 +571,15 @@ pub fn select_session(
return sessions.into_iter().next().map(|s| s.handle).ok_or(SelectError::NoSessions);
}
// `list` prints ids truncated to 12 chars, so accept a unique id prefix as well as a full id.
// Accept a unique prefix of either id, or the end of the atuin id, as well as a full one.
// An id given whole names its session, whatever longer ids it is a prefix of.
let atuin_id = selector.parse::<AtuinSessionId>().ok();
let (exact, prefixed): (Vec<Session>, Vec<Session>) = sessions
.into_iter()
.filter(|s| {
s.handle.session.as_ref().starts_with(selector) || s.atuin_id.has_prefix(selector)
s.handle.session.as_ref().starts_with(selector)
|| s.atuin_id.has_prefix(selector)
|| s.atuin_id.has_suffix(selector)
})
.partition(|s| s.handle.session.as_ref() == selector || atuin_id == Some(s.atuin_id));
match (exact.as_slice(), prefixed.as_slice()) {
@@ -591,7 +590,9 @@ pub fn select_session(
id: selector.to_owned(),
choices: several
.iter()
.map(|s| format!("\n {:<12} {}", harness_name(s.handle.harness), s.atuin_id))
.map(|s| {
format!("\n {:<12} {}", harness_name(s.handle.harness), s.atuin_id.short())
})
.collect(),
}),
}
@@ -600,38 +601,38 @@ pub fn select_session(
// --- human rendering ----------------------------------------------------------------------------
fn write_session_header(out: &mut dyn Write, s: &Session) -> io::Result<()> {
writeln!(out, "session {}", sanitize(s.handle.session.as_ref()))?;
writeln!(out, "atuin id {}", s.atuin_id)?;
writeln!(out, "harness {}", harness_name(s.handle.harness))?;
writeln!(out, "id {}", s.atuin_id)?;
writeln!(out, "harness id {}", sanitize(s.handle.session.as_ref()))?;
writeln!(out, "harness {}", harness_name(s.handle.harness))?;
if let Some(title) = &s.title {
writeln!(out, "title {}", sanitize(title))?;
writeln!(out, "title {}", sanitize(title))?;
}
if let Some(cwd) = &s.cwd {
writeln!(out, "cwd {}", sanitize(&cwd.to_string_lossy()))?;
writeln!(out, "cwd {}", sanitize(&cwd.to_string_lossy()))?;
}
if let Some(branch) = &s.git_branch {
writeln!(out, "branch {}", sanitize(branch))?;
writeln!(out, "branch {}", sanitize(branch))?;
}
if let Some(model) = &s.model {
writeln!(out, "model {}", sanitize(model))?;
writeln!(out, "model {}", sanitize(model))?;
}
// Links by atuin id, for the stored sessions they name.
// Links by id, for the stored sessions they name.
if let Some(parent) = s.parent_atuin_id {
writeln!(out, "parent {parent}")?;
writeln!(out, "parent {parent}")?;
}
if let Some(root) = s.root_atuin_id {
writeln!(out, "root {root}")?;
writeln!(out, "root {root}")?;
}
if !s.child_atuin_ids.is_empty() {
let children: Vec<String> = s.child_atuin_ids.iter().map(ToString::to_string).collect();
writeln!(out, "children {}", children.join(", "))?;
writeln!(out, "children {}", children.join(", "))?;
}
writeln!(out, "started {}", age(s.started_at))?;
writeln!(out, "updated {}", age(s.updated_at))?;
writeln!(out, "messages {}", s.message_count)?;
writeln!(out, "started {}", age(s.started_at))?;
writeln!(out, "updated {}", age(s.updated_at))?;
writeln!(out, "messages {}", s.message_count)?;
writeln!(
out,
"tokens in {} / out {} / cache {}+{}",
"tokens in {} / out {} / cache {}+{}",
s.usage.input.unwrap_or_default(),
s.usage.output.unwrap_or_default(),
s.usage.cache_read.unwrap_or_default(),
@@ -815,10 +816,13 @@ fn display_role(m: &Message, summary: &Summary) -> (String, Ansi) {
}
/// The session-group header line printed the first time a session appears in the pretty `tail`
/// view and whenever the active session changes.
/// view and whenever the active session changes. Names the session by its id once its metadata
/// has arrived.
fn tail_header(handle: &HarnessSession, session: Option<&Session>, color: bool) -> String {
let bullet = paint("●", harness_color(handle.harness), color);
let id = paint(short_id(handle.session.as_ref()), Ansi::Bold, color);
let id = session
.map_or_else(|| short_id(handle.session.as_ref()).to_owned(), |s| s.atuin_id.short());
let id = paint(&id, Ansi::Bold, color);
let harness_label = paint(harness_name(handle.harness), Ansi::Dim, color);
let title = session.map(title_of).map(|t| one_line(t, SUMMARY_WIDTH)).unwrap_or_default();
if title.is_empty() {
@@ -1378,6 +1382,15 @@ mod tests {
assert_eq!(selected, handle(HarnessKind::ClaudeCode, "bbb"));
}
#[rstest]
fn the_short_id_resolves_a_session() {
let sessions =
vec![session(HarnessKind::Codex, "aaa"), session(HarnessKind::ClaudeCode, "bbb")];
let short = sessions[1].atuin_id.short();
let selected = select_session(sessions, &short).unwrap();
assert_eq!(selected, handle(HarnessKind::ClaudeCode, "bbb"));
}
#[rstest]
fn ambiguous_prefix_is_an_error() {
let sessions =
@@ -1432,7 +1445,7 @@ mod tests {
session(HarnessKind::ClaudeCode, "dup"),
session(HarnessKind::Pi, "dupe"),
];
let ids: Vec<String> = sessions.iter().map(|s| s.atuin_id.to_string()).collect();
let ids: Vec<String> = sessions.iter().map(|s| s.atuin_id.short()).collect();
let err = select_session(sessions.clone(), "dup").unwrap_err().to_string();
assert!(err.contains(&ids[0]) && err.contains(&ids[1]), "{err}");
assert!(!err.contains(&ids[2]), "{err}");
@@ -1506,9 +1519,9 @@ mod tests {
let mut out = Vec::new();
write_session_header(&mut out, &s).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains(&format!("parent {}\n", id(1))), "{text}");
assert!(text.contains(&format!("root {}\n", id(2))), "{text}");
assert!(text.contains(&format!("children {}, {}\n", id(3), id(4))), "{text}");
assert!(text.contains(&format!("parent {}\n", id(1))), "{text}");
assert!(text.contains(&format!("root {}\n", id(2))), "{text}");
assert!(text.contains(&format!("children {}, {}\n", id(3), id(4))), "{text}");
let alone = session(HarnessKind::Codex, "alone");
let v = serde_json::to_value(session_json(&alone)).unwrap();
@@ -1671,7 +1684,7 @@ mod tests {
s.title = Some("My Session".to_owned());
assert_eq!(
tail_header(&s.handle, Some(&s), false),
"● abcdef012345 · claude-code · My Session"
format!("● {} · claude-code · My Session", s.atuin_id.short())
);
}
+3 -1
View File
@@ -921,7 +921,9 @@ impl SessionSource for FakeSource {
.sessions
.iter()
.filter(|s| {
s.row.handle.session.as_ref().starts_with(id) || s.row.atuin_id.has_prefix(id)
s.row.handle.session.as_ref().starts_with(id)
|| s.row.atuin_id.has_prefix(id)
|| s.row.atuin_id.has_suffix(id)
})
.map(|s| s.row.clone())
.collect())
+34 -14
View File
@@ -21,6 +21,7 @@ pub mod query;
pub mod rebuild;
pub mod render;
pub mod resumer;
mod select;
pub mod sidecar;
pub mod source;
pub mod state;
@@ -598,7 +599,7 @@ fn start_fork(
requests: &Requests,
) {
if action == Pending::Copy {
let id = resumer::quote(row.handle.session.as_ref());
let id = row.atuin_id;
let pick = branch.map(|b| format!(" --branch {}", resumer::quote(&b.selector)));
copy(state, &format!("atuin resume {id} --fork{}", pick.unwrap_or_default()));
return;
@@ -654,7 +655,7 @@ fn start_switch(
requests: &Requests,
) {
if action == Pending::Copy {
let id = resumer::quote(row.handle.session.as_ref());
let id = row.atuin_id;
let pick = resumer::quote(&branch.selector);
copy(state, &format!("atuin resume {id} --switch --branch {pick}"));
return;
@@ -722,7 +723,7 @@ fn start_continuation(
requests: &Requests,
) {
if action == Pending::Copy {
let id = resumer::quote(row.handle.session.as_ref());
let id = row.atuin_id;
let into = source::harness_arg(target).unwrap_or_default();
copy(state, &format!("atuin resume {id} --in {into}"));
return;
@@ -774,19 +775,26 @@ fn finish_continuation(
/// What ctrl-y copies to resume `row`, planned as `resume`, in its own harness. Copying writes
/// nothing, so when running the harness's own command wouldn't do (the session has to be
/// restored from sync, or caught up with it, first), it is `atuin resume <id>`, which does
/// restored from sync, or caught up with it, first), it is `atuin resume <atuin id>`, which does
/// that when run.
fn resume_line(row: &SessionRow, resume: &Resume) -> String {
if resume.restore.is_none() && !resume.catch_up {
return resumer::shell_line(&resume.plan);
}
format!("atuin resume {}", resumer::quote(row.handle.session.as_ref()))
format!("atuin resume {}", row.atuin_id)
}
/// Put `line` on the clipboard, saying so in the status row.
/// Put `line` on the clipboard, saying so in the status row (its first line, when it has more).
fn copy(state: &mut State, line: &str) {
let mut lines = line.lines();
let first = lines.next().unwrap_or_default();
let more = if lines.next().is_some() {
" …"
} else {
""
};
state.status = Some(match set_clipboard(line) {
Ok(()) => (format!("copied: {line}"), Meaning::AlertInfo),
Ok(()) => (format!("copied: {first}{more}"), Meaning::AlertInfo),
Err(e) => (format!("copy failed: {e}"), Meaning::AlertError),
});
}
@@ -876,6 +884,10 @@ impl Picker<'_> {
InputAction::Resume => Some(Pending::Resume),
InputAction::ReturnCommand => Some(Pending::Edit),
InputAction::Copy => Some(Pending::Copy),
InputAction::CopyText(text) => {
copy(&mut state, &text);
None
}
InputAction::Fork => {
let enter = if settings.enter_accept {
Pending::Resume
@@ -1001,19 +1013,27 @@ impl Picker<'_> {
any(target_os = "windows", target_os = "macos", target_os = "linux")
))]
fn set_clipboard(s: &str) -> Result<()> {
let mut ctx = arboard::Clipboard::new()?;
ctx.set_text(s.to_owned())?;
// Read it back so the clipboard owner keeps it after we exit.
ctx.get_text()?;
Ok(())
// Over SSH the system clipboard is the remote machine's, if it has one: the terminal's is
// the one wanted.
if std::env::var_os("SSH_CONNECTION").is_some() {
return Ok(terminal::copy(s)?);
}
let system = || -> Result<()> {
let mut ctx = arboard::Clipboard::new()?;
ctx.set_text(s.to_owned())?;
// Read it back so the clipboard owner keeps it after we exit.
ctx.get_text()?;
Ok(())
};
system().or_else(|_| Ok(terminal::copy(s)?))
}
#[cfg(not(all(
feature = "clipboard",
any(target_os = "windows", target_os = "macos", target_os = "linux")
)))]
fn set_clipboard(_s: &str) -> Result<()> {
eyre::bail!("this build has no clipboard support")
fn set_clipboard(s: &str) -> Result<()> {
Ok(terminal::copy(s)?)
}
#[cfg(test)]
+1
View File
@@ -187,6 +187,7 @@ impl State {
if let Some(t) = tokens(&row.usage) {
meta.push(Line::from(Span::styled(format!("{t} tokens"), muted)));
}
meta.push(Line::from(Span::styled(format!("id {}", row.atuin_id), muted)));
for line in meta {
lines.extend(markdown::wrap_plain(&line.spans, width, 0, muted));
}
+19 -6
View File
@@ -742,13 +742,13 @@ impl State {
(lines, Some((row.handle.clone(), top, body)))
}
/// Note how `pane` was drawn: for `session`, in `area` (what the mouse wheel scrolls), with
/// `height` lines of text showing `body`.
/// Note how `pane` was drawn: for `session`, in `area` (what the mouse wheel scrolls), its
/// text in `text` (what a drag selects), with `height` lines of text showing `body`.
fn drawn(
&mut self,
pane: Pane,
session: HarnessSession,
area: Rect,
(area, text): (Rect, Rect),
height: usize,
body: &Body,
) {
@@ -756,6 +756,7 @@ impl State {
scroll.session = Some(session);
scroll.offset = body.offset;
scroll.area = Some(area);
scroll.text = Some(text);
scroll.height = height;
scroll.len = body.len;
scroll.more = body.more;
@@ -823,6 +824,11 @@ impl State {
if self.chooser.is_some() {
self.draw_chooser(f, settings, theme);
}
let buf = f.buffer_mut();
self.screen = buf.clone();
if let Some(selection) = &self.selection {
selection.highlight(buf);
}
}
#[allow(clippy::too_many_lines)]
@@ -831,6 +837,7 @@ impl State {
self.list_anchor = None;
for scroll in &mut self.scrolls {
scroll.area = None;
scroll.text = None;
}
f.render_widget(Clear, area);
let compactness = to_compactness(area, settings);
@@ -1121,7 +1128,8 @@ impl State {
height,
};
draw_scrollbar(f, track, &body, usize::from(height), theme);
self.drawn(Pane::Strip, session, preview_chunk, usize::from(height), &body);
let areas = (preview_chunk, strip);
self.drawn(Pane::Strip, session, areas, usize::from(height), &body);
}
// Join the divider to the box's top and bottom borders.
@@ -1405,7 +1413,12 @@ impl State {
..area
};
draw_scrollbar(f, track, &body, left, theme);
self.drawn(Pane::Inspect, row.handle.clone(), area, left, &body);
// All of it but the scrollbar's column, the metadata above the conversation included.
let text = Rect {
width: track.x.saturating_sub(inner.x),
..inner
};
self.drawn(Pane::Inspect, row.handle.clone(), (area, text), left, &body);
}
/// The detail pane beside the list, in `pane`: the session previewed (see
@@ -1444,7 +1457,7 @@ impl State {
height: text.height.saturating_sub(top),
};
draw_scrollbar(f, track, &body, left, theme);
self.drawn(Pane::Side, row.handle, pane, left, &body);
self.drawn(Pane::Side, row.handle, (pane, text), left, &body);
}
/// Inspect's list of the forks grouped under `row`, in at most `room` lines: a blank line, a
+178
View File
@@ -0,0 +1,178 @@
//! Selecting text with the mouse. The picker has the mouse (for the wheel), so the terminal can't
//! select; a drag selects instead, kept to the pane it started in, and letting go copies it.
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::Modifier;
use unicode_width::UnicodeWidthStr;
/// A drag, from where it started to where it is, in screen cells, within `area`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selection {
pub area: Rect,
pub from: Position,
pub to: Position,
}
impl Selection {
/// Start at `at` (on a pane's border, at the nearest of its text).
pub fn new(area: Rect, at: Position) -> Self {
let at = clamp(area, at);
Self {
area,
from: at,
to: at,
}
}
/// Drag to `at`, kept within the area.
pub fn extend(&mut self, at: Position) {
self.to = clamp(self.area, at);
}
/// Each row selected, with its first and last column: as a terminal selects, from the start
/// to the end of the area's width, across whole rows between.
fn rows(&self) -> impl Iterator<Item = (u16, u16, u16)> + '_ {
let (start, end) = if (self.from.y, self.from.x) <= (self.to.y, self.to.x) {
(self.from, self.to)
} else {
(self.to, self.from)
};
let last = self.area.right().saturating_sub(1);
(start.y..=end.y).map(move |y| {
let first = if y == start.y {
start.x
} else {
self.area.left()
};
(
y,
first,
if y == end.y {
end.x
} else {
last
},
)
})
}
/// Show the selection in `buf`, reversed.
pub fn highlight(&self, buf: &mut Buffer) {
for (y, first, last) in self.rows() {
for x in first..=last {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.modifier.insert(Modifier::REVERSED);
}
}
}
}
/// The text selected in `buf`, each row without trailing blanks, and the rows without the
/// indent they share (the pane's margin) or blank rows before and after them; `None` for a
/// click (no drag) or nothing but blanks.
pub fn text(&self, buf: &Buffer) -> Option<String> {
if self.from == self.to {
return None;
}
let rows: Vec<String> = self
.rows()
.map(|(y, first, last)| {
let mut row = String::new();
let mut x = first;
while x <= last {
let Some(cell) = buf.cell((x, y)) else {
break;
};
row.push_str(cell.symbol());
// A wide character's cell stands for the ones it covers.
x += u16::try_from(cell.symbol().width().max(1)).unwrap_or(1);
}
row.trim_end().to_owned()
})
.collect();
let indent = rows
.iter()
.filter(|row| !row.is_empty())
.map(|row| row.len() - row.trim_start_matches(' ').len())
.min()
.unwrap_or(0);
let rows: Vec<&str> =
rows.iter().map(|row| row.get(indent..).unwrap_or_default()).collect();
let text = rows.join("\n").trim_matches('\n').to_owned();
(!text.trim().is_empty()).then_some(text)
}
}
fn clamp(area: Rect, at: Position) -> Position {
Position::new(
at.x.clamp(area.left(), area.right().saturating_sub(1)),
at.y.clamp(area.top(), area.bottom().saturating_sub(1)),
)
}
#[cfg(test)]
mod tests {
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Widget};
use rstest::rstest;
use super::*;
fn screen() -> Buffer {
let area = Rect::new(0, 0, 12, 3);
let mut buf = Buffer::empty(area);
Paragraph::new(vec![Line::from("first line"), Line::from("宽 wide"), Line::from("third")])
.render(area, &mut buf);
buf
}
fn drag(from: (u16, u16), to: (u16, u16)) -> Selection {
let mut s = Selection::new(Rect::new(0, 0, 12, 3), from.into());
s.extend(to.into());
s
}
#[rstest]
#[case::in_a_row((0, 0), (4, 0), Some("first"))]
#[case::backwards((4, 0), (0, 0), Some("first"))]
#[case::across_rows((6, 0), (2, 2), Some("line\n宽 wide\nthi"))]
#[case::past_the_area((3, 1), (40, 9), Some("wide\nthird"))]
#[case::a_click((3, 0), (3, 0), None)]
#[case::blanks((10, 0), (11, 0), None)]
fn a_drag_selects_text(
#[case] from: (u16, u16),
#[case] to: (u16, u16),
#[case] want: Option<&str>,
) {
assert_eq!(drag(from, to).text(&screen()).as_deref(), want);
}
/// The margin and blank rows around the text aren't part of it; indents and box drawing
/// within it are.
#[rstest]
fn the_shared_indent_is_left_out() {
let area = Rect::new(0, 0, 12, 5);
let mut buf = Buffer::empty(area);
let lines = ["", " first", " • nested", " ├─ │foo│", ""];
Paragraph::new(lines.map(Line::from).to_vec()).render(area, &mut buf);
let mut s = Selection::new(area, (0, 0).into());
s.extend((11, 4).into());
assert_eq!(
s.text(&buf).as_deref(),
Some(
"first
• nested
├─ │foo│"
)
);
}
#[rstest]
fn the_selection_is_reversed() {
let mut buf = screen();
drag((0, 0), (4, 0)).highlight(&mut buf);
assert!(buf[(4, 0)].modifier.contains(Modifier::REVERSED));
assert!(!buf[(5, 0)].modifier.contains(Modifier::REVERSED));
}
}
+50 -9
View File
@@ -14,7 +14,8 @@ use atuin_client::tui::key::{KeyCodeValue, KeyInput, SingleKey};
use atuin_common::harnesstools::continuation::Flattened;
use atuin_common::time::OffsetDateTimeExt as _;
use atuin_domain::record::HostId;
use crossterm::event::{Event, KeyEvent, KeyEventKind, MouseEvent, MouseEventKind};
use crossterm::event::{Event, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use time::OffsetDateTime;
@@ -23,6 +24,7 @@ use super::keymap::{Action, Keymap, KeymapSet};
use super::query::{self, ParsedQuery};
use super::rebuild::Rebuilding;
use super::resumer::{NotResumable, Resume};
use super::select::Selection;
use super::source::{SessionFilter, SessionPreview, SessionRow};
use super::{ResumeContext, panel};
@@ -75,6 +77,8 @@ pub struct PaneScroll {
/// Where the pane was drawn this frame (screen coordinates, so an inline viewport's rows are
/// as the mouse reports them), or `None` when it wasn't.
pub area: Option<Rect>,
/// Where its text was drawn, without borders or scrollbar (what a drag selects).
pub text: Option<Rect>,
/// How many lines of text it had room for.
pub height: usize,
/// The lines of the parts in full rendered so far.
@@ -121,6 +125,8 @@ pub enum InputAction {
ReturnCommand,
/// Copy the session's resume command, and stay open.
Copy,
/// Copy text selected with the mouse.
CopyText(String),
/// A line of the chooser picked.
Pick(Box<Picked>),
/// Ask where to resume the session acted on, the fork selected.
@@ -274,6 +280,11 @@ pub struct State {
/// What catching the session being resumed up with sync wrote, to say once the picker is gone.
pub note: Option<String>,
/// Text being selected with the mouse.
pub selection: Option<Selection>,
/// The last frame drawn, which a selection copies from.
pub screen: Buffer,
/// A one-line message in the status row (copied, can't resume, search failed).
pub status: Option<(String, Meaning)>,
/// The daemon is rebuilding the session index, so results may be incomplete: said in the
@@ -340,6 +351,8 @@ impl State {
continuing: None,
continued: 0,
note: None,
selection: None,
screen: Buffer::empty(Rect::default()),
status: None,
rebuilding: None,
now: if settings.prefers_reduced_motion {
@@ -683,10 +696,42 @@ impl State {
}
}
/// The wheel over a preview scrolls it; anywhere else (the list, the input) it moves the
/// selection, as in the history search. The mouse reports screen positions, which is what
/// the panes' areas are, inline or not.
/// The scrolling pane drawn at `at`, if any.
fn pane_at(&self, at: Position) -> Option<Pane> {
PANES.into_iter().find(|p| self.scrolls[*p as usize].area.is_some_and(|a| a.contains(at)))
}
/// A drag selects text within the pane it starts in (the whole picker outside one's text), and
/// letting go copies it. The wheel over a preview scrolls it; anywhere else (the list, the
/// input) it moves the selection, as in the history search. The mouse reports screen
/// positions, which is what the panes' areas are, inline or not.
fn handle_mouse_input(&mut self, settings: &Settings, event: MouseEvent) -> InputAction {
let at = Position::new(event.column, event.row);
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
let area = self
.pane_at(at)
.and_then(|p| self.scrolls[p as usize].text)
.unwrap_or(self.screen.area);
// Nothing to select in a pane squeezed to nothing, or before the first frame.
self.selection = (!area.is_empty()).then(|| Selection::new(area, at));
return InputAction::Continue;
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(selection) = &mut self.selection {
selection.extend(at);
}
return InputAction::Continue;
}
MouseEventKind::Up(MouseButton::Left) => {
return self
.selection
.take()
.and_then(|s| s.text(&self.screen))
.map_or(InputAction::Continue, InputAction::CopyText);
}
_ => {}
}
// The chooser is for the session it opened on.
if self.chooser.is_some() {
return InputAction::Continue;
@@ -696,11 +741,7 @@ impl State {
MouseEventKind::ScrollUp => false,
_ => return InputAction::Continue,
};
let at = Position::new(event.column, event.row);
let over = PANES
.into_iter()
.find(|p| self.scrolls[*p as usize].area.is_some_and(|a| a.contains(at)));
if let Some(pane) = over {
if let Some(pane) = self.pane_at(at) {
let lines = isize::try_from(WHEEL_LINES).unwrap_or(1);
self.scroll_pane(
pane,
@@ -125,6 +125,16 @@ impl Write for Writer {
}
}
/// Put `text` on the terminal's own clipboard (OSC 52), which reaches it over SSH. Terminals that
/// don't take OSC 52 ignore it, and tmux passes it on only with `set-clipboard on`.
pub fn copy(text: &str) -> io::Result<()> {
use base64::Engine as _;
let mut writer = Writer::new()?;
write!(writer, "\x1b]52;c;{}\x07", base64::engine::general_purpose::STANDARD.encode(text))?;
writer.flush()
}
/// How the terminal was set up, while it is: what a panic has to undo.
static SETUP: Mutex<Option<(bool, bool)>> = Mutex::new(None);
+105 -3
View File
@@ -899,8 +899,7 @@ async fn a_session_from_another_machine_is_restored_first() {
assert_ne!(row.host_id, fake::THIS_HOST_ID, "another machine's session");
let resume = state.plans[&row.handle].clone().unwrap();
assert!(resume.restore.is_some());
let id = row.handle.session.to_string();
assert_eq!(resume_line(&row, &resume), format!("atuin resume {id}"));
assert_eq!(resume_line(&row, &resume), format!("atuin resume {}", row.atuin_id));
let outcome = accept(&mut state, Pending::Resume, resumer.as_ref(), &requests, false);
assert_eq!(outcome, None, "waits for the restore");
@@ -914,7 +913,10 @@ async fn a_session_from_another_machine_is_restored_first() {
let Some(Outcome::Resume(plan)) = outcome else {
panic!("{outcome:?}");
};
assert_eq!(plan.native_path, Some(std::path::PathBuf::from(format!("/restored/{id}.jsonl"))));
assert_eq!(
plan.native_path,
Some(std::path::PathBuf::from(format!("/restored/{}.jsonl", row.handle.session)))
);
}
/// An enter waiting on its session's plan, or on its restore from sync, and then an idle refresh
@@ -2508,3 +2510,103 @@ async fn a_session_open_here_preselects_the_fork(
state.open_chooser(&row, targets, true, Pending::Resume);
assert_eq!(state.chooser.as_ref().unwrap().selected, selected);
}
fn mouse(
state: &mut State,
settings: &Settings,
kind: crossterm::event::MouseEventKind,
at: (u16, u16),
) -> super::state::InputAction {
use crossterm::event::{Event, KeyModifiers, MouseEvent};
let event = Event::Mouse(MouseEvent {
kind,
column: at.0,
row: at.1,
modifiers: KeyModifiers::NONE,
});
state.handle_input(settings, &event)
}
/// A drag selects the preview's text, shown reversed, kept to the pane it started in, and
/// letting go copies it; a click copies nothing.
#[rstest]
#[tokio::test]
async fn a_drag_copies_the_text_under_it() {
use crossterm::event::{MouseButton, MouseEventKind};
use super::state::{InputAction, Pane};
let s = settings();
let mut state = loaded(&s, "", 0).await;
let _ = render(&mut state, &s, 100, 30);
let area = state.scrolls[Pane::Strip as usize].area.unwrap();
let row = (area.y..area.bottom())
.find(|&y| {
let line: String =
(area.x..area.right()).map(|x| state.screen[(x, y)].symbol()).collect();
line.contains("first Build atuin ai resume")
})
.unwrap();
let left = MouseButton::Left;
mouse(&mut state, &s, MouseEventKind::Down(left), (area.x, row));
// Past the bottom of the screen: kept to the strip.
mouse(&mut state, &s, MouseEventKind::Drag(left), (area.right() + 5, 200));
let out = render(&mut state, &s, 100, 30);
assert!(out[(area.x + 2, row)].modifier.contains(ratatui::style::Modifier::REVERSED));
let InputAction::CopyText(text) = mouse(&mut state, &s, MouseEventKind::Up(left), (0, 0))
else {
panic!("nothing copied");
};
assert!(text.starts_with("first Build atuin ai resume"), "no border or margin: {text}");
assert!(!text.contains("[ GLOBAL"), "the input row is outside the strip: {text}");
assert!(!text.contains('─'), "not the strip's bottom border: {text}");
assert!(state.selection.is_none());
mouse(&mut state, &s, MouseEventKind::Down(left), (area.x + 3, row));
let click = mouse(&mut state, &s, MouseEventKind::Up(left), (area.x + 3, row));
assert_eq!(click, InputAction::Continue);
}
/// The detail pane beside the list has no border: a drag over all of it copies its text,
/// without the scrollbar beside it.
#[rstest]
#[tokio::test]
async fn a_drag_over_the_side_pane_copies_its_text() {
use crossterm::event::{MouseButton, MouseEventKind};
use super::state::{InputAction, Pane};
let s = settings();
let mut state = loaded(&s, "", 0).await;
let _ = render(&mut state, &s, 200, 30);
let text = state.scrolls[Pane::Side as usize].text.unwrap();
let left = MouseButton::Left;
mouse(&mut state, &s, MouseEventKind::Down(left), (text.x, text.y));
mouse(&mut state, &s, MouseEventKind::Drag(left), (300, 300));
let InputAction::CopyText(copied) = mouse(&mut state, &s, MouseEventKind::Up(left), (0, 0))
else {
panic!("nothing copied");
};
assert!(copied.contains("First prompt"), "{copied}");
assert!(copied.lines().all(|l| !l.ends_with('┃') && !l.ends_with('│')), "{copied}");
}
/// A click before the first frame (nothing drawn to select) selects nothing, rather than
/// clamping to an empty area.
#[rstest]
#[tokio::test]
async fn a_click_with_nothing_drawn_selects_nothing() {
use crossterm::event::{MouseButton, MouseEventKind};
use super::state::InputAction;
let s = settings();
let mut state = loaded(&s, "", 0).await;
let left = MouseButton::Left;
mouse(&mut state, &s, MouseEventKind::Down(left), (3, 3));
assert!(state.selection.is_none());
mouse(&mut state, &s, MouseEventKind::Drag(left), (9, 4));
let up = mouse(&mut state, &s, MouseEventKind::Up(left), (9, 4));
assert_eq!(up, InputAction::Continue);
}
+42 -5
View File
@@ -704,9 +704,10 @@ impl AiSessionDatabase {
self.recent_sessions(filter, 0).await
}
/// Every session whose id or [atuin id](AtuinSessionId) starts with `prefix`, across
/// harnesses, newest first (to find a session by an abbreviated id). Wildcards mean nothing
/// here: the prefix is a byte range, not a pattern.
/// Every session whose id or [atuin id](AtuinSessionId) starts with `prefix`, or whose atuin
/// id ends with it (see [`AtuinSessionId::has_suffix`]), across harnesses, newest first (to
/// find a session by an abbreviated id). Wildcards mean nothing here: the prefix is a byte
/// range, and the suffix is only hex.
pub async fn sessions_with_id_prefix(&self, prefix: &str) -> Result<Vec<Session>, DbError> {
// Text compares by its UTF-8 bytes, which order as the code points do: the ids starting
// with `prefix` are those at or past it and before its successor.
@@ -723,9 +724,18 @@ impl AiSessionDatabase {
""
};
// Hex has no LIKE wildcards, and LIKE ignores ASCII case, as hex() is upper.
let suffix = AtuinSessionId::is_suffix(prefix).then(|| format!("%{prefix}"));
let suffix_clause = if suffix.is_some() {
" OR hex(s.atuin_id) LIKE ?"
} else {
""
};
let sql = format!(
"SELECT {}, {} FROM sessions s WHERE (s.session_id >= ?{upper_clause}){atuin_clause} \
ORDER BY s.updated_at DESC, s.session_id",
"SELECT {}, {} FROM sessions s WHERE (s.session_id >= \
?{upper_clause}){atuin_clause}{suffix_clause} ORDER BY s.updated_at DESC, \
s.session_id",
session_columns!(),
no_group_columns!(),
);
@@ -736,6 +746,9 @@ impl AiSessionDatabase {
if let Some((low, high)) = &atuin_ids {
query = query.bind(low.as_bytes().as_slice()).bind(high.as_bytes().as_slice());
}
if let Some(suffix) = suffix {
query = query.bind(suffix);
}
let rows: Vec<SessionRow> = query.fetch_all(self.db.pool()).await?;
rows.into_iter().map(Self::session_from_row).collect()
}
@@ -4585,6 +4598,30 @@ mod tests {
assert!(sessions.iter().any(|s| s.handle == wanted));
}
#[rstest]
#[case::short(8, 1)]
#[case::shortest(6, 1)]
#[case::too_short(4, 0)]
#[tokio::test]
async fn the_end_of_an_atuin_id_finds_a_session(#[case] len: usize, #[case] found: usize) {
let db = AiSessionDatabase::in_memory().await.unwrap();
let wanted = handle(HarnessKind::ClaudeCode, "wanted");
// The ids differ only in their last six digits, given in upper case.
let id = |n: u128| {
AtuinSessionId::from(Uuid::from_u128(0x0199_6dc4_0800_7000_8000_00ab_cd00_0000 | n))
};
for (session, n) in [(&wanted, 0x12_3456), (&handle(HarnessKind::Pi, "other"), 0x65_4321)] {
let mut msg = message_in(session, 1_790_000_000, "words");
msg.atuin_id = Some(id(n));
db.append(&msg).await.unwrap();
}
let hex = id(0x12_3456).to_string().to_uppercase();
let sessions = db.sessions_with_id_prefix(&hex[hex.len() - len..]).await.unwrap();
assert_eq!(sessions.len(), found);
assert!(sessions.iter().all(|s| s.handle == wanted));
}
#[rstest]
#[tokio::test]
async fn rows_disagreeing_on_the_id_settle_on_the_smaller(
@@ -121,6 +121,34 @@ impl AtuinSessionId {
pub fn has_prefix(&self, prefix: &str) -> bool {
Self::prefix_range(prefix).is_some_and(|(low, high)| (low..=high).contains(self))
}
/// The id as the UI shows it: its last [`Self::SHORT_LEN`] hex digits. The leading digits
/// are the start time, shared by every session started around then; these are random.
#[must_use]
pub fn short(&self) -> String {
let hex = self.to_string();
hex[hex.len() - Self::SHORT_LEN..].to_owned()
}
pub const SHORT_LEN: usize = 8;
/// The fewest hex digits a suffix takes to name an id, so a short word doesn't match one by
/// chance.
pub const MIN_SUFFIX_LEN: usize = 6;
/// Whether the id's displayed form ends with `suffix`, of at least [`Self::MIN_SUFFIX_LEN`]
/// hex digits.
#[must_use]
pub fn has_suffix(&self, suffix: &str) -> bool {
Self::is_suffix(suffix) && self.to_string().ends_with(&suffix.to_ascii_lowercase())
}
/// Whether `suffix` is one [`Self::has_suffix`] can match.
#[must_use]
pub fn is_suffix(suffix: &str) -> bool {
(Self::MIN_SUFFIX_LEN..=32).contains(&suffix.len())
&& suffix.bytes().all(|b| b.is_ascii_hexdigit())
}
}
impl std::str::FromStr for AtuinSessionId {
@@ -736,4 +764,18 @@ mod tests {
assert_eq!(id.to_string(), "01a0d147e7457ae280000123456789ab");
assert_eq!(id.has_prefix(prefix), matches);
}
#[rstest]
#[case::short("456789ab", true)]
#[case::shortest("6789ab", true)]
#[case::upper("6789AB", true)]
#[case::whole("01a0d147e7457ae280000123456789ab", true)]
#[case::too_short("789ab", false)]
#[case::other("6789ac", false)]
#[case::not_hex("6789ag", false)]
fn a_suffix_names_the_ids_it_ends(#[case] suffix: &str, #[case] matches: bool) {
let id: AtuinSessionId = "01a0d147-e745-7ae2-8000-0123456789ab".parse().unwrap();
assert_eq!(id.short(), "456789ab");
assert_eq!(id.has_suffix(suffix), matches);
}
}
+4 -1
View File
@@ -281,7 +281,7 @@ fn the_picker_needs_a_terminal_to_read_keys_from(machine: Machine) {
}
/// A session whose transcript is on this machine resumes in its agent where it ran, named by the
/// agent's id, by its atuin id, or by enough of that, and is left as it was. How a name is
/// agent's id, by its atuin id, by enough of the start or end of that, and is left as it was. How a name is
/// resolved is the same for every agent; where its transcript is, and how it resumes, isn't.
#[rstest]
#[case::claude(Agent::Claude, "agent id")]
@@ -290,6 +290,7 @@ fn the_picker_needs_a_terminal_to_read_keys_from(machine: Machine) {
#[case::pi(Agent::Pi, "agent id")]
#[case::by_atuin_id(Agent::Opencode, "atuin id")]
#[case::by_atuin_id_prefix(Agent::Opencode, "atuin id prefix")]
#[case::by_short_id(Agent::Opencode, "short id")]
fn a_local_session_resumes_in_its_agent_where_it_ran(
machine: Machine,
project: TempDir,
@@ -306,6 +307,8 @@ fn a_local_session_resumes_in_its_agent_where_it_ran(
let name = match named_by {
"agent id" => transcript.id.clone(),
"atuin id" => atuin_id,
// Its end, as the UI shows it.
"short id" => atuin_id[atuin_id.len() - 8..].to_owned(),
// Past the timestamp the id starts with, which sessions started together share.
_ => atuin_id[..16].to_owned(),
};