mirror of
https://github.com/atuinsh/atuin.git
synced 2026-10-16 04:26:53 +02:00
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:
1 parent
c2e8e68bc6
commit
53e1fdd89b
19 files changed
+554
-87
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -356,6 +356,7 @@ dependencies = [
|
||||
"atuin-daemon",
|
||||
"atuin-domain",
|
||||
"atuin-vt100",
|
||||
"base64",
|
||||
"chrono",
|
||||
"chrono-humanize",
|
||||
"clap",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user