mirror of
https://github.com/atuinsh/atuin.git
synced 2026-10-16 04:26:53 +02:00
feat(ai): rename AI sessions to threads: atuin threads, [threads] and threads.db (atuinsh/atuin#4418)
This commit is contained in:
1 parent
7f57491af6
commit
5a76079561
76 files changed
+911
-657
No files matched your search
@@ -34,7 +34,7 @@ atuin-server-sqlite SQLite implementation (sqlx)
|
||||
- **Client**: SQLite everywhere. Separate DBs for history, record store, KV, scripts. All use sqlx + WAL mode.
|
||||
- **Server**: Postgres (primary) or SQLite. Auto-detected from URI prefix.
|
||||
- Migrations live alongside each crate. Never modify existing migrations, only add new ones.
|
||||
- Exception: the AI session sidecar's migrations (`atuin-client/src/ai_session/migrations`) were squashed into one `0001_init.sql` before release, and the sidecar file was renamed (`ai_harness_sessions.db` → `ai_session_sidecar.db`) so no old sidecar meets the new history. Don't add back compatibility with the old migrations; from here on, add new ones as usual.
|
||||
- Exception: the AI session sidecar's migrations (`atuin-client/src/ai_session/migrations`) were squashed into one `0001_init.sql` before release, and the sidecar file was renamed (`ai_harness_sessions.db` → `ai_session_sidecar.db`) so no old sidecar meets the new history. It is now `threads.db`; the daemon renames `ai_session_sidecar.db` on start. Don't add back compatibility with the old migrations; from here on, add new ones as usual.
|
||||
|
||||
## Hot paths
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ pub(crate) mod resume;
|
||||
pub(crate) mod session;
|
||||
|
||||
pub use resume::Cmd as ResumeCmd;
|
||||
pub use session::import_all;
|
||||
pub use session::{Cmd as ThreadsCmd, import_all};
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AiArgs {
|
||||
@@ -51,13 +51,25 @@ pub enum Command {
|
||||
_shell: Option<std::ffi::OsString>,
|
||||
},
|
||||
|
||||
/// Browse AI sessions captured by the daemon
|
||||
Session(session::Cmd),
|
||||
/// `atuin threads`, as it was called before.
|
||||
#[command(subcommand, name = "session", hide = true)]
|
||||
Threads(session::Cmd),
|
||||
|
||||
/// Pick a captured AI coding-agent session and resume it
|
||||
/// `atuin threads resume`, as it was called before.
|
||||
#[command(hide = true)]
|
||||
Resume(resume::Cmd),
|
||||
}
|
||||
|
||||
/// `atuin threads resume` is `atuin ai resume`, so it is set up and run in one place.
|
||||
impl From<session::Cmd> for Command {
|
||||
fn from(cmd: session::Cmd) -> Self {
|
||||
match cmd {
|
||||
session::Cmd::Resume(cmd) => Self::Resume(cmd),
|
||||
cmd => Self::Threads(cmd),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn log_config(&self, settings: &Settings) -> Option<LogConfig> {
|
||||
match self {
|
||||
@@ -70,7 +82,7 @@ impl Command {
|
||||
file: FileConfig::from_settings(&settings.logs, &settings.logs.ai),
|
||||
stderr: None,
|
||||
}),
|
||||
Self::Init { .. } | Self::Session(_) => None,
|
||||
Self::Init { .. } | Self::Threads(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +107,7 @@ pub async fn run(command: Command, settings: Settings) -> eyre::Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Command::Session(cmd) => session::run(cmd, &settings).await,
|
||||
Command::Threads(cmd) => session::run(cmd, settings).await,
|
||||
Command::Resume(cmd) => resume::run(cmd, settings).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `atuin ai resume [QUERY]`: pick a captured AI coding-agent session and resume it.
|
||||
//! `atuin threads resume [QUERY]`: pick a captured AI coding-agent session and resume it.
|
||||
//!
|
||||
//! - A QUERY that is a session id (or a unique prefix of one) resumes it directly, no picker.
|
||||
//! From the shell widget, `enter_accept` decides whether it runs or lands on the command line;
|
||||
@@ -17,7 +17,7 @@
|
||||
//! that went on separately on several machines is continued along one branch, which `--branch`
|
||||
//! must name: without it, it fails listing them (the picker takes the newest, saying so). In the
|
||||
//! picker, accepting a session asks where to resume it: its own harness first, then the others
|
||||
//! installed here (see [`crate::resume_tui::chooser`]; `[ai.sessions] resume_chooser = false`
|
||||
//! installed here (see [`crate::resume_tui::chooser`]; `[threads] resume_chooser = false`
|
||||
//! skips that).
|
||||
//! - A session whose transcript is here is caught up with sync first (see
|
||||
//! [`crate::resume_tui::catchup`]): when it went on on another machine, the messages this copy
|
||||
@@ -83,7 +83,7 @@ fn title_line(title: &str) -> String {
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct Cmd {
|
||||
/// Search text, or a session id (or unique id prefix) to resume without the picker.
|
||||
/// Search text, or a thread id (or unique id prefix) to resume without the picker.
|
||||
#[arg(value_name = "QUERY")]
|
||||
query: Vec<String>,
|
||||
|
||||
@@ -91,25 +91,25 @@ pub struct Cmd {
|
||||
#[arg(long)]
|
||||
print: bool,
|
||||
|
||||
/// Continue the session QUERY names (by id, or a unique id prefix) in another agent: it is
|
||||
/// Continue the thread QUERY names (by id, or a unique id prefix) in another agent: it is
|
||||
/// written out as a new session there, tool calls carried over as its own where it has a like
|
||||
/// tool (notes where they weren't captured whole), and resumed. One that went on separately on
|
||||
/// several machines needs `--branch`.
|
||||
#[arg(long = "in", value_enum, value_name = "HARNESS")]
|
||||
continue_in: Option<ContinueIn>,
|
||||
|
||||
/// Fork the session QUERY names (by id, or a unique id prefix): it is written out as a new
|
||||
/// Fork the thread QUERY names (by id, or a unique id prefix): it is written out as a new
|
||||
/// session of the same agent, with the same history, and resumed. The original is left as
|
||||
/// it is.
|
||||
#[arg(long, conflicts_with = "continue_in")]
|
||||
fork: bool,
|
||||
|
||||
/// Resume this machine's copy of the session QUERY names as it is, without catching it up
|
||||
/// Resume this machine's copy of the thread QUERY names as it is, without catching it up
|
||||
/// with sync.
|
||||
#[arg(long, conflicts_with_all = ["fork", "continue_in"])]
|
||||
as_is: bool,
|
||||
|
||||
/// Switch this machine's copy of the session QUERY names to another branch (the one
|
||||
/// Switch this machine's copy of the thread QUERY names to another branch (the one
|
||||
/// `--branch` names, else the only one it can be switched to), in place, and resume it: the
|
||||
/// history it shares with that branch is kept as it is, and the branch's messages since are
|
||||
/// appended from sync. The branch it was on stays in atuin, for `--fork` to bring back, and
|
||||
@@ -118,7 +118,7 @@ pub struct Cmd {
|
||||
#[arg(long, conflicts_with_all = ["fork", "continue_in", "as_is"])]
|
||||
switch: bool,
|
||||
|
||||
/// The branch of the session QUERY names to catch up to, to switch to with `--switch`, to
|
||||
/// The branch of the thread QUERY names to catch up to, to switch to with `--switch`, to
|
||||
/// fork from with `--fork`, or to continue with `--in`: `this`, `@<host id>`, or the start of
|
||||
/// its head's id. The newest by default; `--in`, and `--switch` with several branches to
|
||||
/// switch to, need it named.
|
||||
@@ -263,7 +263,7 @@ fn picker_has_a_terminal() -> bool {
|
||||
/// on a line of its own, with its harness, when it last changed (in `offset`) and its title.
|
||||
fn ambiguous_note(query: &str, found: &[SessionRow], offset: time::UtcOffset) -> String {
|
||||
let note = format!(
|
||||
"{query} names {} sessions; run `atuin ai resume {query}` on a terminal to pick one:",
|
||||
"{query} names {} threads; run `atuin threads resume {query}` on a terminal to pick one:",
|
||||
found.len()
|
||||
);
|
||||
note + &listing(found, offset)
|
||||
@@ -298,9 +298,9 @@ fn be_more_specific(
|
||||
) -> String {
|
||||
let query = query.escape_non_printable();
|
||||
match found.len() {
|
||||
0 => format!("`{flag}` resumes the session an id names: no session has the id \"{query}\""),
|
||||
0 => format!("`{flag}` resumes the thread an id names: no thread has the id \"{query}\""),
|
||||
n => format!(
|
||||
"`{flag}` resumes the session an id names: {query} names {n} sessions; be more \
|
||||
"`{flag}` resumes the thread an id names: {query} names {n} threads; be more \
|
||||
specific:{}",
|
||||
listing(found, offset)
|
||||
),
|
||||
@@ -417,7 +417,7 @@ async fn direct_target(
|
||||
pub async fn run(cmd: Cmd, mut settings: Settings) -> Result<()> {
|
||||
startup("started");
|
||||
if let Some(mode) = cmd.filter_mode {
|
||||
settings.ai.sessions.filter_mode = Some(mode);
|
||||
settings.threads.filter_mode = Some(mode);
|
||||
}
|
||||
if let Some(mode) = cmd.keymap_mode {
|
||||
settings.keymap_mode = mode;
|
||||
@@ -428,13 +428,13 @@ pub async fn run(cmd: Cmd, mut settings: Settings) -> Result<()> {
|
||||
|
||||
let context = ResumeContext::current(&settings).await?;
|
||||
startup("context read");
|
||||
let path = settings.ai_session_sidecar_path();
|
||||
let path = atuin_client::ai_session::sidecar_path(&settings);
|
||||
// The daemon makes it the first time it runs with sessions on.
|
||||
if !path.exists() {
|
||||
if settings.ai.capture_sessions {
|
||||
bail!("no AI sessions here yet: the Atuin daemon hasn't recorded any");
|
||||
if settings.threads.capture {
|
||||
bail!("no threads here yet: the Atuin daemon hasn't recorded any");
|
||||
}
|
||||
bail!("no AI sessions here yet: turn them on with `atuin config enable ai-sessions`");
|
||||
bail!("no threads here yet: turn them on with `atuin config enable threads`");
|
||||
}
|
||||
let source: Arc<dyn SessionSource> = Arc::new(SidecarSource::open(&path, &context).await?);
|
||||
startup("session database open");
|
||||
@@ -501,8 +501,8 @@ pub async fn run(cmd: Cmd, mut settings: Settings) -> Result<()> {
|
||||
startup("query checked");
|
||||
if !picker_has_a_terminal() {
|
||||
bail!(
|
||||
"the picker needs a terminal; to resume without one, name the session: `atuin ai \
|
||||
resume <id>` (`atuin ai session list` lists them)"
|
||||
"the picker needs a terminal; to resume without one, name the thread: `atuin threads \
|
||||
resume <id>` (`atuin threads list` lists them)"
|
||||
);
|
||||
}
|
||||
let mut themes = ThemeManager::from_settings(&settings.theme);
|
||||
@@ -528,7 +528,7 @@ pub async fn run(cmd: Cmd, mut settings: Settings) -> Result<()> {
|
||||
finish(outcome, output)
|
||||
}
|
||||
|
||||
/// How `atuin ai resume <id>` resumes the session in its own agent.
|
||||
/// How `atuin threads resume <id>` resumes the session in its own agent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Direct {
|
||||
/// Caught up with sync first.
|
||||
@@ -539,7 +539,7 @@ enum Direct {
|
||||
Switch,
|
||||
}
|
||||
|
||||
/// `atuin ai resume <id>`: the plan resuming `row` in its own agent, caught up with sync first
|
||||
/// `atuin threads resume <id>`: the plan resuming `row` in its own agent, caught up with sync first
|
||||
/// (`how`: or as it is, or switched) to the head `branch` names, and what to say of it; inside,
|
||||
/// why it can't resume here. A choice to make (see [`Held`]) is an error saying how to make it.
|
||||
async fn direct_plan(
|
||||
@@ -613,7 +613,7 @@ fn choice_note(row: &SessionRow, held: &Held, here: &str) -> String {
|
||||
format!(" --branch {}", quote(&branch.selector).escape_non_printable())
|
||||
};
|
||||
note.push_str(&format!(
|
||||
", `atuin ai resume {id} --fork{pick}` forks it from {}'s branch",
|
||||
", `atuin threads resume {id} --fork{pick}` forks it from {}'s branch",
|
||||
branch.host
|
||||
));
|
||||
}
|
||||
@@ -628,7 +628,7 @@ fn choice_note(row: &SessionRow, held: &Held, here: &str) -> String {
|
||||
format!(" --branch {}", quote(&branch.selector).escape_non_printable())
|
||||
};
|
||||
note.push_str(&format!(
|
||||
", `atuin ai resume {id} --switch{pick}` switches this copy to {}'s branch",
|
||||
", `atuin threads resume {id} --switch{pick}` switches this copy to {}'s branch",
|
||||
branch.host
|
||||
));
|
||||
}
|
||||
@@ -643,7 +643,7 @@ fn choice_note(row: &SessionRow, held: &Held, here: &str) -> String {
|
||||
note
|
||||
}
|
||||
|
||||
/// `atuin ai resume <id> --in <harness>`: write the session `query` names out as a new session
|
||||
/// `atuin threads resume <id> --in <harness>`: write the session `query` names out as a new session
|
||||
/// of `into`, along the branch `branch` names, and the plan that resumes it, with the status line
|
||||
/// saying what was flattened. A session that went on separately on several machines needs one
|
||||
/// named: it is an error listing them. A session of `into` already is resumed, as without
|
||||
@@ -657,7 +657,7 @@ async fn continue_plan(
|
||||
here: &str,
|
||||
) -> Result<(ResumePlan, String)> {
|
||||
let Some((row, redirected)) = direct_target(source, query).await? else {
|
||||
bail!("`--in` continues the session an id names: no single session has the id {query:?}");
|
||||
bail!("`--in` continues the thread an id names: no single thread has the id {query:?}");
|
||||
};
|
||||
let kind = row.handle.harness;
|
||||
if into.kind() == kind {
|
||||
@@ -704,7 +704,7 @@ async fn continue_plan(
|
||||
Ok((continued.plan, status))
|
||||
}
|
||||
|
||||
/// `atuin ai resume <id> --fork`: write the session `query` names out as a fork of it, and the
|
||||
/// `atuin threads resume <id> --fork`: write the session `query` names out as a fork of it, and the
|
||||
/// plan that resumes that, with the status line saying so.
|
||||
async fn fork_plan(
|
||||
source: &dyn SessionSource,
|
||||
@@ -714,7 +714,7 @@ async fn fork_plan(
|
||||
here: &str,
|
||||
) -> Result<(ResumePlan, String)> {
|
||||
let Some((row, redirected)) = direct_target(source, query).await? else {
|
||||
bail!("`--fork` forks the session an id names: no single session has the id {query:?}");
|
||||
bail!("`--fork` forks the thread an id names: no single thread has the id {query:?}");
|
||||
};
|
||||
let head = match branch {
|
||||
Some(selector) => Some(pick_head(source, &row, selector, here).await?),
|
||||
@@ -735,7 +735,7 @@ async fn fork_plan(
|
||||
Ok((forked.plan, status))
|
||||
}
|
||||
|
||||
/// What a session named by id does without the picker. Standalone, `atuin ai resume <id>` resumes
|
||||
/// What a session named by id does without the picker. Standalone, `atuin threads resume <id>` resumes
|
||||
/// it. The widget passes whatever is on the command line, so there it follows `enter_accept` like
|
||||
/// the picker's enter: without it, the command lands on the command line to be checked first.
|
||||
fn direct_outcome(plan: ResumePlan, output: Output, enter_accept: bool) -> Outcome {
|
||||
@@ -895,8 +895,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
ambiguous_note("7aaabc31", &[claude, pi], time::UtcOffset::UTC),
|
||||
[
|
||||
"7aaabc31 names 2 sessions; run `atuin ai resume 7aaabc31` on a terminal to pick \
|
||||
one:",
|
||||
"7aaabc31 names 2 threads; run `atuin threads resume 7aaabc31` on a terminal to \
|
||||
pick one:",
|
||||
" Claude Code 2026-09-27 14:03 fix the flaky test (7aaabc31-1631)",
|
||||
" Pi 2026-09-26 09:12 plan the dotfiles sync (7aaabc31-1631)",
|
||||
]
|
||||
@@ -921,7 +921,7 @@ mod tests {
|
||||
let source = FakeSource::from_rows(vec![root.clone(), other, subagent.clone()]);
|
||||
|
||||
let err = ambiguous_matches(&source, "01a11e4", false).await.unwrap_err().to_string();
|
||||
assert!(err.starts_with("01a11e4 names 2 sessions"), "{err}");
|
||||
assert!(err.starts_with("01a11e4 names 2 threads"), "{err}");
|
||||
assert!(!err.contains("01a11e4e"), "{err}");
|
||||
let err = no_single_session_for(&source, "01a11e4", Some("--as-is")).await.unwrap_err();
|
||||
assert!(!err.to_string().contains("01a11e4e"), "{err}");
|
||||
@@ -955,7 +955,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
be_more_specific("--branch", "7aaabc31", &[claude, pi], time::UtcOffset::UTC),
|
||||
[
|
||||
"`--branch` resumes the session an id names: 7aaabc31 names 2 sessions; be more \
|
||||
"`--branch` resumes the thread an id names: 7aaabc31 names 2 threads; be more \
|
||||
specific:",
|
||||
" Claude Code 2026-09-27 14:03 fix the flaky test (7aaabc31-1631)",
|
||||
" Pi 2026-09-26 09:12 plan the dotfiles sync (7aaabc31-1631)",
|
||||
@@ -964,7 +964,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
be_more_specific("--as-is", "flaky", &[], time::UtcOffset::UTC),
|
||||
"`--as-is` resumes the session an id names: no session has the id \"flaky\""
|
||||
"`--as-is` resumes the thread an id names: no thread has the id \"flaky\""
|
||||
);
|
||||
|
||||
let source = FakeSource::new();
|
||||
@@ -976,7 +976,7 @@ mod tests {
|
||||
assert!(err.contains("be more specific"), "{err}");
|
||||
assert!(err.contains("ses_4b8e2f1a9c3d7e6f"), "{err}");
|
||||
let err = no_single_session_for(&source, "flaky", Some(flag)).await.unwrap_err();
|
||||
assert!(err.to_string().contains("no session has the id"), "{err}");
|
||||
assert!(err.to_string().contains("no thread has the id"), "{err}");
|
||||
}
|
||||
// Without them, the picker opens on the matches.
|
||||
assert!(no_single_session_for(&source, ambiguous, None).await.is_ok());
|
||||
@@ -1026,7 +1026,7 @@ mod tests {
|
||||
let err = ambiguous_matches(&source, "ses_4b8e2f1a9c3d7e", false).await.unwrap_err();
|
||||
let err = err.to_string();
|
||||
assert!(
|
||||
err.starts_with(&format!("ses_4b8e2f1a9c3d7e names {} sessions", ids.len())),
|
||||
err.starts_with(&format!("ses_4b8e2f1a9c3d7e names {} threads", ids.len())),
|
||||
"{err}"
|
||||
);
|
||||
assert!(ids.iter().all(|id| err.contains(id)), "{err}");
|
||||
@@ -1141,7 +1141,7 @@ mod tests {
|
||||
assert_eq!(matches!(outcome, Outcome::Resume(_)), runs, "{outcome:?}");
|
||||
}
|
||||
|
||||
/// `atuin ai resume <id> --in <harness>` continues the session the id names, and plans
|
||||
/// `atuin threads resume <id> --in <harness>` continues the session the id names, and plans
|
||||
/// resuming the new session; a query that names no single session is an error.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
@@ -1166,7 +1166,7 @@ mod tests {
|
||||
continue_plan(&source, &resumer, "fix flaky", ContinueIn::Pi, None, fake::THIS_HOST_ID)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("no single session has the id"), "{err}");
|
||||
assert!(err.to_string().contains("no single thread has the id"), "{err}");
|
||||
|
||||
let cli = Cli::try_parse_from(["resume", "7f3c9a12", "--in", "opencode"]).unwrap();
|
||||
assert_eq!(cli.cmd.continue_in, Some(ContinueIn::Opencode));
|
||||
@@ -1237,7 +1237,7 @@ mod tests {
|
||||
assert!(resumer.continues.lock().is_empty());
|
||||
}
|
||||
|
||||
/// `atuin ai resume <id> --fork` forks the session the id names and plans resuming the fork,
|
||||
/// `atuin threads resume <id> --fork` forks the session the id names and plans resuming the fork,
|
||||
/// with `--print` too; it can't go with `--in`.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
@@ -1252,7 +1252,7 @@ mod tests {
|
||||
|
||||
let err =
|
||||
fork_plan(&source, &resumer, "fix flaky", None, fake::THIS_HOST_ID).await.unwrap_err();
|
||||
assert!(err.to_string().contains("no single session has the id"), "{err}");
|
||||
assert!(err.to_string().contains("no single thread has the id"), "{err}");
|
||||
|
||||
let cli = Cli::try_parse_from(["resume", "7f3c9a12", "--fork", "--print"]).unwrap();
|
||||
assert!(cli.cmd.fork);
|
||||
@@ -1268,7 +1268,7 @@ mod tests {
|
||||
(FakeSource::new().with_synced(&row.handle, rows), row)
|
||||
}
|
||||
|
||||
/// `atuin ai resume <id>` catches the session's copy here up with sync first, saying what it
|
||||
/// `atuin threads resume <id>` catches the session's copy here up with sync first, saying what it
|
||||
/// wrote. A choice to make fails, saying why and naming the flags that make it; `--as-is`
|
||||
/// resumes the copy as it is, and `--branch` names no branch the session hasn't got.
|
||||
#[rstest]
|
||||
@@ -1307,8 +1307,8 @@ mod tests {
|
||||
let id = row.handle.session.as_ref();
|
||||
let want = format!(
|
||||
"Claude Code is running this session here: `--as-is` resumes this copy as it is, \
|
||||
`atuin ai resume {id} --fork` forks it from this machine's branch; `--branch` picks \
|
||||
another:\n y this machine · "
|
||||
`atuin threads resume {id} --fork` forks it from this machine's branch; `--branch` \
|
||||
picks another:\n y this machine · "
|
||||
);
|
||||
assert!(err.starts_with(&want), "{err}");
|
||||
assert!(err.contains("\n d @00000002 · "), "{err}");
|
||||
@@ -1411,20 +1411,23 @@ mod tests {
|
||||
};
|
||||
let note = choice_note(&row, &held, here);
|
||||
let id = row.handle.session.as_ref();
|
||||
let want =
|
||||
format!(", `atuin ai resume {id} --switch` switches this copy to @00000002's branch");
|
||||
let want = format!(
|
||||
", `atuin threads resume {id} --switch` switches this copy to @00000002's branch"
|
||||
);
|
||||
assert!(note.contains(&want), "{note}");
|
||||
// Of several, `--switch` takes none: the newest is named.
|
||||
held.branches[0].switch = true;
|
||||
let note = choice_note(&row, &held, here);
|
||||
let want = format!(", `atuin ai resume {id} --switch --branch y` switches this copy to");
|
||||
let want =
|
||||
format!(", `atuin threads resume {id} --switch --branch y` switches this copy to");
|
||||
assert!(note.contains(&want), "{note}");
|
||||
// With a branch named (`--branch`), the switch it offers is to that one, as the fork.
|
||||
held.chosen = 1;
|
||||
let note = choice_note(&row, &held, here);
|
||||
let want = format!(", `atuin ai resume {id} --switch --branch d` switches this copy to");
|
||||
let want =
|
||||
format!(", `atuin threads resume {id} --switch --branch d` switches this copy to");
|
||||
assert!(note.contains(&want), "{note}");
|
||||
assert!(note.contains(&format!("`atuin ai resume {id} --fork --branch d`")), "{note}");
|
||||
assert!(note.contains(&format!("`atuin threads resume {id} --fork --branch d`")), "{note}");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `atuin ai session` -- a client for the daemon's `ai.session.AiSession` service.
|
||||
//! `atuin threads` -- a client for the daemon's `ai.session.AiSession` service, and resume.
|
||||
//!
|
||||
//! Thin wrappers around [`AiClient`] plus rendering. Every subcommand resolves a selector to a
|
||||
//! session, calls the matching RPC, decodes what the daemon streams into the domain types, and
|
||||
//! thread, calls the matching RPC, decodes what the daemon streams into the domain types, and
|
||||
//! renders them either as human-readable text or as JSON/NDJSON for scripting.
|
||||
|
||||
use std::borrow::Cow;
|
||||
@@ -26,58 +26,78 @@ use futures::{StreamExt, TryStreamExt};
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// How a subcommand's output is rendered.
|
||||
#[derive(Args, Debug)]
|
||||
pub struct Cmd {
|
||||
#[command(subcommand)]
|
||||
cmd: SubCmd,
|
||||
|
||||
pub struct Output {
|
||||
/// How output is rendered.
|
||||
#[arg(long, value_enum, default_value_t = Style::Auto, global = true)]
|
||||
#[arg(long, value_enum, default_value_t = Style::Auto)]
|
||||
style: Style,
|
||||
}
|
||||
|
||||
/// `atuin threads`.
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SubCmd {
|
||||
/// List captured sessions, newest first.
|
||||
List,
|
||||
|
||||
/// Show a session and its messages. Accepts an id, a harness id or `latest`.
|
||||
Show {
|
||||
/// 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,
|
||||
pub enum Cmd {
|
||||
/// List captured threads, newest first.
|
||||
List {
|
||||
#[command(flatten)]
|
||||
output: Output,
|
||||
},
|
||||
|
||||
#[command(about = "Full-text search across captured sessions, most relevant first.")]
|
||||
/// Show a thread and its messages. Accepts an id, a harness id or `latest`.
|
||||
Show {
|
||||
/// 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 thread.
|
||||
#[arg(value_name = "ID|latest")]
|
||||
thread: String,
|
||||
|
||||
#[command(flatten)]
|
||||
output: Output,
|
||||
},
|
||||
|
||||
#[command(about = "Full-text search across captured threads, most relevant first.")]
|
||||
Search {
|
||||
#[arg(
|
||||
value_name = "QUERY",
|
||||
allow_hyphen_values = true,
|
||||
help = "Words to look for in session titles, message text, reasoning, and tool calls"
|
||||
help = "Words to look for in thread titles, message text, reasoning, and tool calls"
|
||||
)]
|
||||
query: String,
|
||||
#[arg(long, value_enum, help = "Only search sessions from this harness")]
|
||||
#[arg(long, value_enum, help = "Only search threads from this harness")]
|
||||
harness: Option<HarnessArg>,
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 10,
|
||||
value_name = "N",
|
||||
help = "Maximum sessions to return; 0 for unbounded"
|
||||
help = "Maximum threads to return; 0 for unbounded"
|
||||
)]
|
||||
limit: u32,
|
||||
|
||||
#[command(flatten)]
|
||||
output: Output,
|
||||
},
|
||||
|
||||
/// Follow sessions and messages as they are recorded (until interrupted).
|
||||
Tail,
|
||||
/// Follow threads and messages as they are recorded (until interrupted).
|
||||
Tail {
|
||||
#[command(flatten)]
|
||||
output: Output,
|
||||
},
|
||||
|
||||
/// Import every session the agents have on this machine, including those capture leaves
|
||||
/// Import every thread the agents have on this machine, including those capture leaves
|
||||
/// alone: the ones nobody has gone on with since it was turned on.
|
||||
Import {
|
||||
/// Only import sessions from this harness.
|
||||
/// Only import threads from this harness.
|
||||
#[arg(long, value_parser = parse_harness)]
|
||||
harness: Option<HarnessKind>,
|
||||
|
||||
#[command(flatten)]
|
||||
output: Output,
|
||||
},
|
||||
|
||||
/// Pick a captured thread and resume it.
|
||||
Resume(super::resume::Cmd),
|
||||
|
||||
/// Turn thread capture on (short for `atuin config enable threads`).
|
||||
Enable,
|
||||
}
|
||||
|
||||
// Only harnesses a capture path can actually produce are offered as filters (see AnyHarness);
|
||||
@@ -130,27 +150,36 @@ impl Style {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(cmd: Cmd, settings: &Settings) -> Result<()> {
|
||||
if !settings.ai.capture_sessions {
|
||||
pub async fn run(cmd: Cmd, settings: Settings) -> Result<()> {
|
||||
let style = match cmd {
|
||||
Cmd::Resume(cmd) => return super::resume::run(cmd, settings).await,
|
||||
// The atuin binary runs it as `atuin config enable threads`, which needs the daemon.
|
||||
Cmd::Enable => return Err(eyre!("threads need the daemon, which this build leaves out")),
|
||||
Cmd::List { ref output }
|
||||
| Cmd::Show { ref output, .. }
|
||||
| Cmd::Search { ref output, .. }
|
||||
| Cmd::Tail { ref output }
|
||||
| Cmd::Import { ref output, .. } => output.style.resolve(),
|
||||
};
|
||||
|
||||
if !settings.threads.capture {
|
||||
// stderr so it never pollutes piped stdout (json/ndjson).
|
||||
eprintln!(
|
||||
"note: AI session capture is off. Run `atuin config enable ai-sessions` to record new \
|
||||
sessions."
|
||||
"note: thread capture is off. Run `atuin config enable threads` to record new threads."
|
||||
);
|
||||
}
|
||||
|
||||
let style = cmd.style.resolve();
|
||||
let mut client = AiClient::from_settings(settings).await?;
|
||||
let mut client = AiClient::from_settings(&settings).await?;
|
||||
|
||||
// Right after the daemon starts, and when a store command asks it to, it rebuilds its AI
|
||||
// sessions and refuses reads until done, rather than answer from a partial set. Tail only
|
||||
// streams what happens from now on.
|
||||
if !matches!(cmd.cmd, SubCmd::Tail) {
|
||||
if !matches!(cmd, Cmd::Tail { .. }) {
|
||||
let mut reported = None;
|
||||
client
|
||||
.wait_for_sessions(|progress| {
|
||||
if reported.is_none() {
|
||||
eprintln!("AI sessions are being rebuilt from the record store; waiting...");
|
||||
eprintln!("Threads are being rebuilt from the record store; waiting...");
|
||||
}
|
||||
let (replayed, pending) = progress;
|
||||
if reported != Some(progress) && pending > 0 {
|
||||
@@ -161,19 +190,21 @@ pub async fn run(cmd: Cmd, settings: &Settings) -> Result<()> {
|
||||
.await?;
|
||||
}
|
||||
|
||||
let result = match cmd.cmd {
|
||||
SubCmd::List => list(&mut client, style).await,
|
||||
SubCmd::Show { session } => show(&mut client, &session, style).await,
|
||||
SubCmd::Search {
|
||||
let result = match cmd {
|
||||
Cmd::List { .. } => list(&mut client, style).await,
|
||||
Cmd::Show { thread, .. } => show(&mut client, &thread, style).await,
|
||||
Cmd::Search {
|
||||
query,
|
||||
harness,
|
||||
limit,
|
||||
..
|
||||
} => search(&mut client, &query, harness.map(HarnessKind::from), limit, style).await,
|
||||
SubCmd::Tail => tail(&mut client, style).await,
|
||||
SubCmd::Import { harness } => import(&mut client, harness, style, &mut io::stdout()).await,
|
||||
Cmd::Tail { .. } => tail(&mut client, style).await,
|
||||
Cmd::Import { harness, .. } => import(&mut client, harness, style, &mut io::stdout()).await,
|
||||
Cmd::Resume(_) | Cmd::Enable => unreachable!("returned above"),
|
||||
};
|
||||
|
||||
// A downstream reader that closes the pipe (e.g. `atuin ai session list | head`) makes the next
|
||||
// A downstream reader that closes the pipe (e.g. `atuin threads list | head`) makes the next
|
||||
// write fail with BrokenPipe. That's a clean end of output, not an error, so swallow it rather
|
||||
// than print a scary message and exit non-zero.
|
||||
match result {
|
||||
@@ -219,7 +250,7 @@ async fn list(client: &mut AiClient, style: Style) -> Result<()> {
|
||||
}
|
||||
_ => {
|
||||
if sessions.is_empty() {
|
||||
writeln!(out, "No sessions captured yet.")?;
|
||||
writeln!(out, "No threads captured yet.")?;
|
||||
return Ok(());
|
||||
}
|
||||
writeln!(out, "{:<8} {:<12} {:<16} {:>5} TITLE", "ID", "HARNESS", "UPDATED", "MSGS")?;
|
||||
@@ -316,7 +347,7 @@ async fn search(
|
||||
}
|
||||
_ => {
|
||||
if matches.is_empty() {
|
||||
writeln!(out, "No sessions matched `{query}`.")?;
|
||||
writeln!(out, "No threads matched `{query}`.")?;
|
||||
return Ok(());
|
||||
}
|
||||
writeln!(out, "{:<8} {:<12} {:<16} MATCH", "ID", "HARNESS", "UPDATED")?;
|
||||
@@ -547,7 +578,7 @@ async fn import(
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"done: {} sessions, {} imported, {} skipped, {} failed",
|
||||
"done: {} threads, {} imported, {} skipped, {} failed",
|
||||
s.sessions, s.imported, s.skipped, s.failed,
|
||||
)?;
|
||||
}
|
||||
@@ -582,23 +613,23 @@ async fn resolve(client: &mut AiClient, selector: &str) -> Result<HarnessSession
|
||||
stream.try_collect().await?
|
||||
};
|
||||
select_session(sessions, selector).map_err(|e| match e {
|
||||
SelectError::NotFound(_) => eyre!("{e}. Run `atuin ai session list`."),
|
||||
SelectError::NotFound(_) => eyre!("{e}. Run `atuin threads list`."),
|
||||
e => eyre!(e),
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a selector matched no single session. Callers word the next step themselves: the CLI
|
||||
/// points at `atuin ai session list`, the MCP tool at its own list tool.
|
||||
/// points at `atuin threads list`, the MCP tool at its own list tool.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SelectError {
|
||||
#[error("no sessions captured yet")]
|
||||
#[error("no threads captured yet")]
|
||||
NoSessions,
|
||||
#[error("no session with id `{0}`")]
|
||||
#[error("no thread with id `{0}`")]
|
||||
NotFound(String),
|
||||
#[error("id `{0}` matches more than one session; use a longer or full id")]
|
||||
#[error("id `{0}` matches more than one thread; 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 id:{choices}")]
|
||||
#[error("id `{id}` names a thread in more than one agent; use its id:{choices}")]
|
||||
Shared {
|
||||
id: String,
|
||||
choices: String,
|
||||
@@ -1457,15 +1488,15 @@ mod tests {
|
||||
fn a_search_query_may_start_with_a_hyphen(#[case] args: &[&str]) {
|
||||
#[derive(clap::Parser)]
|
||||
struct Cli {
|
||||
#[command(flatten)]
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
let cli = <Cli as clap::Parser>::try_parse_from(
|
||||
std::iter::once("session").chain(args.iter().copied()),
|
||||
std::iter::once("threads").chain(args.iter().copied()),
|
||||
)
|
||||
.unwrap();
|
||||
let SubCmd::Search { query, .. } = cli.cmd.cmd else {
|
||||
let Cmd::Search { query, .. } = cli.cmd else {
|
||||
panic!("not a search");
|
||||
};
|
||||
assert_eq!(query, "-v");
|
||||
|
||||
+65
-59
@@ -28,9 +28,9 @@ use strum::IntoEnumIterator;
|
||||
|
||||
use crate::tools::output::search::AtuinOutputSearchToolCall;
|
||||
use crate::tools::session::caller::{Caller, OwnSession};
|
||||
use crate::tools::session::list::AtuinAiSessionListToolCall;
|
||||
use crate::tools::session::read::AtuinAiSessionReadToolCall;
|
||||
use crate::tools::session::search::AtuinAiSessionSearchToolCall;
|
||||
use crate::tools::session::list::AtuinThreadsListToolCall;
|
||||
use crate::tools::session::read::AtuinThreadsReadToolCall;
|
||||
use crate::tools::session::search::AtuinThreadsSearchToolCall;
|
||||
use crate::tools::{
|
||||
AtuinHistoryToolCall, AtuinOutputToolCall, DEFAULT_HISTORY_RESULTS, HistorySearchFilterMode,
|
||||
MAX_HISTORY_RESULTS, ToolOutcome,
|
||||
@@ -71,10 +71,10 @@ Do not use `history`, ~/.bash_history, or ~/.zsh_history: they are typically emp
|
||||
atuin_output over re-running an expensive or side-effectful command just to see its output \
|
||||
again.
|
||||
|
||||
Atuin also records AI agent sessions. Use atuin_ai_session_search or atuin_ai_session_list, then \
|
||||
atuin_ai_session_read, whenever a task points at earlier work: 'last time', 'again', \
|
||||
Atuin also records AI agent sessions as threads. Use atuin_threads_search or atuin_threads_list, \
|
||||
then atuin_threads_read, whenever a task points at earlier work: 'last time', 'again', \
|
||||
'still', 'continue', what was decided, or whether something is a known issue. Otherwise do \
|
||||
one quick search only when earlier sessions would clearly help; skip self-contained tasks.";
|
||||
one quick search only when earlier threads would clearly help; skip self-contained tasks.";
|
||||
|
||||
/// The initialize result, separated from the handler so tests can assert on
|
||||
/// it without constructing a database-backed server.
|
||||
@@ -127,18 +127,24 @@ impl ServerHandler for AtuinMcp {
|
||||
Ok(call) => call.execute(&self.db, &self.settings).await,
|
||||
Err(outcome) => outcome,
|
||||
},
|
||||
"atuin_ai_session_search" => match parse::<AtuinAiSessionSearchToolCall>(arguments) {
|
||||
Ok(call) => call.execute(&self.settings, &caller).await,
|
||||
Err(outcome) => outcome,
|
||||
},
|
||||
"atuin_ai_session_list" => match parse::<AtuinAiSessionListToolCall>(arguments) {
|
||||
Ok(call) => call.execute(&self.settings, &caller).await,
|
||||
Err(outcome) => outcome,
|
||||
},
|
||||
"atuin_ai_session_read" => match parse::<AtuinAiSessionReadToolCall>(arguments) {
|
||||
Ok(call) => call.execute(&self.settings, &caller).await,
|
||||
Err(outcome) => outcome,
|
||||
},
|
||||
"atuin_threads_search" | "atuin_ai_session_search" => {
|
||||
match parse::<AtuinThreadsSearchToolCall>(arguments) {
|
||||
Ok(call) => call.execute(&self.settings, &caller).await,
|
||||
Err(outcome) => outcome,
|
||||
}
|
||||
}
|
||||
"atuin_threads_list" | "atuin_ai_session_list" => {
|
||||
match parse::<AtuinThreadsListToolCall>(arguments) {
|
||||
Ok(call) => call.execute(&self.settings, &caller).await,
|
||||
Err(outcome) => outcome,
|
||||
}
|
||||
}
|
||||
"atuin_threads_read" | "atuin_ai_session_read" => {
|
||||
match parse::<AtuinThreadsReadToolCall>(arguments) {
|
||||
Ok(call) => call.execute(&self.settings, &caller).await,
|
||||
Err(outcome) => outcome,
|
||||
}
|
||||
}
|
||||
name => {
|
||||
return Err(ErrorData::invalid_params(format!("unknown tool: {name}"), None));
|
||||
}
|
||||
@@ -313,46 +319,46 @@ fn tool_definitions() -> Vec<Tool> {
|
||||
)
|
||||
.annotate(ToolAnnotations::with_title("Search past command output").read_only(true)),
|
||||
Tool::new(
|
||||
"atuin_ai_session_search",
|
||||
"Full-text search across the transcripts of AI coding-agent sessions Atuin has \
|
||||
captured (Claude Code, Codex, opencode, pi). Reach for it when a task points at \
|
||||
earlier work ('again', 'still', 'continue', a known issue, what was decided): a \
|
||||
previous agent may already have diagnosed or decided it. Finds which session \
|
||||
discussed a topic, hit an error, touched a file, or made a decision — it searches \
|
||||
what the user and agent wrote, tool names, and session titles. Each result gives the \
|
||||
session id, harness, last-active time, title, directory, and the best matching \
|
||||
message's number and snippet; pass the id to atuin_ai_session_read to read around \
|
||||
the match. Requires the Atuin daemon with AI session capture enabled.",
|
||||
schema_for_type::<AtuinAiSessionSearchToolCall>(),
|
||||
"atuin_threads_search",
|
||||
"Full-text search across the transcripts of the threads (AI coding-agent sessions) \
|
||||
Atuin has captured (Claude Code, Codex, opencode, pi). Reach for it when a task \
|
||||
points at earlier work ('again', 'still', 'continue', a known issue, what was \
|
||||
decided): a previous agent may already have diagnosed or decided it. Finds which \
|
||||
thread discussed a topic, hit an error, touched a file, or made a decision — it \
|
||||
searches what the user and agent wrote, tool names, and thread titles. Each result \
|
||||
gives the thread id, harness, last-active time, title, directory, and the best \
|
||||
matching message's number and snippet; pass the id to atuin_threads_read to read \
|
||||
around the match. Requires the Atuin daemon with thread capture enabled.",
|
||||
schema_for_type::<AtuinThreadsSearchToolCall>(),
|
||||
)
|
||||
.annotate(ToolAnnotations::with_title("Search AI agent sessions").read_only(true)),
|
||||
.annotate(ToolAnnotations::with_title("Search agent threads").read_only(true)),
|
||||
Tool::new(
|
||||
"atuin_ai_session_list",
|
||||
"List captured AI coding-agent sessions (Claude Code, Codex, opencode, pi), most \
|
||||
recently active first, optionally only those in a directory (cwd: '.' for this \
|
||||
"atuin_threads_list",
|
||||
"List captured threads, AI coding-agent sessions (Claude Code, Codex, opencode, pi), \
|
||||
most recently active first, optionally only those in a directory (cwd: '.' for this \
|
||||
project) or active since a time (since: 'today'). Use it to pick up where the \
|
||||
previous session on this project left off ('continue', 'last time') or to see what \
|
||||
previous thread on this project left off ('continue', 'last time') or to see what \
|
||||
agents worked on recently, when you have no specific words to search for. Each entry \
|
||||
gives the session id, harness, last-active time, message count, title, and \
|
||||
directory. Requires the Atuin daemon with AI session capture enabled.",
|
||||
schema_for_type::<AtuinAiSessionListToolCall>(),
|
||||
gives the thread id, harness, last-active time, message count, title, and directory. \
|
||||
Requires the Atuin daemon with thread capture enabled.",
|
||||
schema_for_type::<AtuinThreadsListToolCall>(),
|
||||
)
|
||||
.annotate(ToolAnnotations::with_title("List AI agent sessions").read_only(true)),
|
||||
.annotate(ToolAnnotations::with_title("List agent threads").read_only(true)),
|
||||
Tool::new(
|
||||
"atuin_ai_session_read",
|
||||
"Read a captured AI coding-agent session's transcript, a page of messages at a time: \
|
||||
what the user asked, what the agent replied, and which tools it used. Capture keeps \
|
||||
the conversation, not tool arguments or output, so runs of tool calls appear as one \
|
||||
line of tool names. Messages are numbered; page with start (negative counts from the \
|
||||
end, e.g. -10 for how the session ended) and limit. Pages abridge long messages; \
|
||||
read one message (limit: 1) to see it in full; one over 20,000 characters comes in \
|
||||
parts, each giving the offset to read the next from. To find where something was \
|
||||
said in a long session, pass query (only messages with every word) or roles \
|
||||
(['user'] for what the person said) instead of paging through it. Takes a session id \
|
||||
or unique prefix from atuin_ai_session_list or atuin_ai_session_search, or 'latest'.",
|
||||
schema_for_type::<AtuinAiSessionReadToolCall>(),
|
||||
"atuin_threads_read",
|
||||
"Read a captured thread's transcript, a page of messages at a time: what the user \
|
||||
asked, what the agent replied, and which tools it used. Capture keeps the \
|
||||
conversation, not tool arguments or output, so runs of tool calls appear as one line \
|
||||
of tool names. Messages are numbered; page with start (negative counts from the end, \
|
||||
e.g. -10 for how the thread ended) and limit. Pages abridge long messages; read one \
|
||||
message (limit: 1) to see it in full; one over 20,000 characters comes in parts, \
|
||||
each giving the offset to read the next from. To find where something was said in a \
|
||||
long thread, pass query (only messages with every word) or roles (['user'] for what \
|
||||
the person said) instead of paging through it. Takes a thread id or unique prefix \
|
||||
from atuin_threads_list or atuin_threads_search, or 'latest'.",
|
||||
schema_for_type::<AtuinThreadsReadToolCall>(),
|
||||
)
|
||||
.annotate(ToolAnnotations::with_title("Read an AI agent session").read_only(true)),
|
||||
.annotate(ToolAnnotations::with_title("Read an agent thread").read_only(true)),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -374,9 +380,9 @@ mod tests {
|
||||
"atuin_history",
|
||||
"atuin_output",
|
||||
"atuin_output_search",
|
||||
"atuin_ai_session_search",
|
||||
"atuin_ai_session_list",
|
||||
"atuin_ai_session_read",
|
||||
"atuin_threads_search",
|
||||
"atuin_threads_list",
|
||||
"atuin_threads_read",
|
||||
]);
|
||||
|
||||
for tool in &tools {
|
||||
@@ -410,7 +416,7 @@ mod tests {
|
||||
|
||||
#[rstest]
|
||||
fn ai_session_search_schema_matches_the_parser() {
|
||||
// The hand-written schema and the AtuinAiSessionSearchToolCall parser are independent
|
||||
// The hand-written schema and the AtuinThreadsSearchToolCall parser are independent
|
||||
// sources of truth; drive the parser at the schema's advertised bounds so the two cannot
|
||||
// drift apart (e.g. widening the Clamped bounds without updating the model-visible schema).
|
||||
let tools = tool_definitions();
|
||||
@@ -423,7 +429,7 @@ mod tests {
|
||||
let max = limit["maximum"].as_u64().unwrap();
|
||||
let default = limit["default"].as_u64().unwrap();
|
||||
|
||||
let parse = |v: Value| serde_json::from_value::<AtuinAiSessionSearchToolCall>(v);
|
||||
let parse = |v: Value| serde_json::from_value::<AtuinThreadsSearchToolCall>(v);
|
||||
let limit_of = |v: Value| u64::from(parse(v).unwrap().limit.get());
|
||||
assert_eq!(
|
||||
limit_of(json!({"query": "x"})),
|
||||
@@ -467,9 +473,9 @@ mod tests {
|
||||
assert!(instructions.contains("atuin_history"));
|
||||
assert!(instructions.contains("atuin_output"));
|
||||
assert!(instructions.contains("atuin_output_search"));
|
||||
assert!(instructions.contains("atuin_ai_session_search"));
|
||||
assert!(instructions.contains("atuin_ai_session_list"));
|
||||
assert!(instructions.contains("atuin_ai_session_read"));
|
||||
assert!(instructions.contains("atuin_threads_search"));
|
||||
assert!(instructions.contains("atuin_threads_list"));
|
||||
assert!(instructions.contains("atuin_threads_read"));
|
||||
assert!(instructions.len() < MAX_INSTRUCTIONS_LEN, "instructions should stay concise");
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,7 @@ impl State {
|
||||
Choice {
|
||||
harness: session.harness,
|
||||
detail: "fork".to_owned(),
|
||||
unavailable: Some("the session has no messages".to_owned()),
|
||||
unavailable: Some("the thread has no messages".to_owned()),
|
||||
}
|
||||
} else {
|
||||
Choice {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `atuin ai resume`: an interactive picker over captured AI coding-agent sessions.
|
||||
//! `atuin threads resume`: an interactive picker over captured AI coding-agent sessions.
|
||||
//!
|
||||
//! A ratatui view that behaves like the history search (`atuin search -i`): its input box,
|
||||
//! `invert`/`style`/`inline_height`, enter vs tab accept, and emacs/vim keymaps ([`keymap`]). Its
|
||||
@@ -65,10 +65,10 @@ const REFRESH_IDLE: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
const SETTLE: Duration = Duration::from_millis(60);
|
||||
/// The most frames left out in a row while input waits, so a flood of it still shows.
|
||||
const MAX_SKIPPED: u32 = 8;
|
||||
/// How often the picker asks the daemon whether it is rebuilding the session index.
|
||||
/// How often the picker asks the daemon whether it is rebuilding the threads index.
|
||||
const REBUILD_PROBE_EVERY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// When `atuin ai resume` started, for [`startup`]'s timings.
|
||||
/// When `atuin threads resume` started, for [`startup`]'s timings.
|
||||
static STARTED: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||
|
||||
/// Note in the log how long after starting the picker got to `step` (the first call starts the
|
||||
@@ -173,7 +173,7 @@ pub struct Picker<'a> {
|
||||
pub resumer: Arc<dyn Resumer>,
|
||||
pub context: ResumeContext,
|
||||
pub query: String,
|
||||
/// Overrides `[ai.sessions] inline_height` and the top-level `inline_height`.
|
||||
/// Overrides `[threads] inline_height` and the top-level `inline_height`.
|
||||
pub inline_height: Option<u16>,
|
||||
/// A session the query named by id that can't be resumed: open on it, showing why.
|
||||
pub preselect: Option<(SessionRow, NotResumable)>,
|
||||
@@ -441,7 +441,7 @@ fn complete(
|
||||
let row = row.clone();
|
||||
let Some(plan) = state.plans.get(&row.handle).cloned() else {
|
||||
state.pending = Some((row, action));
|
||||
state.status = Some(("locating the session…".to_owned(), Meaning::Annotation));
|
||||
state.status = Some(("locating the thread…".to_owned(), Meaning::Annotation));
|
||||
return None;
|
||||
};
|
||||
let outcome = match (plan, action) {
|
||||
@@ -835,7 +835,7 @@ impl Picker<'_> {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn run(self) -> Result<(Outcome, Option<String>)> {
|
||||
let settings = self.settings;
|
||||
let sessions = &settings.ai.sessions;
|
||||
let sessions = &settings.threads;
|
||||
let inline_height =
|
||||
self.inline_height.or(sessions.inline_height).unwrap_or(settings.inline_height);
|
||||
// Fullscreen when the inline viewport doesn't fit, or stdout is captured (inline needs
|
||||
|
||||
@@ -338,7 +338,7 @@ pub fn tree_lines(
|
||||
let lead = branch.width();
|
||||
let title_w = width.saturating_sub(lead + 2 + tail.width());
|
||||
let mut title = child.title.text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
// A continuation in another harness (`atuin ai resume --in`) says where it went on.
|
||||
// A continuation in another harness (`atuin threads resume --in`) says where it went on.
|
||||
if child.parent.as_ref().is_some_and(|p| p.harness != child.handle.harness) {
|
||||
title = format!("continued in {} · {title}", harness_label(child.handle.harness));
|
||||
}
|
||||
|
||||
@@ -422,6 +422,7 @@ mod tests {
|
||||
t.tool("mcp__atuin__atuin_history");
|
||||
t.tool("atuin_atuin_history");
|
||||
t.tool("atuin_history");
|
||||
t.tool("mcp__atuin__atuin_threads_read");
|
||||
t.tool("mcp__atuin__atuin_ai_session_read");
|
||||
t.tool("mcp__github__create_issue");
|
||||
let transcript = t.finish();
|
||||
@@ -430,7 +431,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
tools_line(tools),
|
||||
"🐢 atuin history ×3 · 🐢 atuin session read · mcp__github__create_issue"
|
||||
"🐢 atuin history ×3 · 🐢 atuin threads read ×2 · mcp__github__create_issue"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Whether the daemon is rebuilding the session index the picker reads.
|
||||
//! Whether the daemon is rebuilding the threads index the picker reads.
|
||||
//!
|
||||
//! During `atuin store rebuild ai-session` (or a purge, or right after the daemon starts) the
|
||||
//! daemon empties the sidecar and replays it from the record store in the background, so the
|
||||
@@ -18,7 +18,7 @@ use tokio::sync::watch;
|
||||
/// How long a probe may take, connecting included, before the daemon counts as unreachable.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(300);
|
||||
|
||||
/// The daemon is rebuilding the session index.
|
||||
/// The daemon is rebuilding the threads index.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Rebuilding {
|
||||
/// Records replayed, and roughly how many there are to replay.
|
||||
@@ -32,11 +32,11 @@ impl Rebuilding {
|
||||
let (replayed, pending) = self.progress;
|
||||
if pending > 0 {
|
||||
format!(
|
||||
"rebuilding the session index: {replayed} of {pending} records; results may be \
|
||||
"rebuilding the threads index: {replayed} of {pending} records; results may be \
|
||||
incomplete"
|
||||
)
|
||||
} else {
|
||||
"rebuilding the session index; results may be incomplete".to_owned()
|
||||
"rebuilding the threads index; results may be incomplete".to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,9 +138,9 @@ mod tests {
|
||||
#[rstest]
|
||||
#[case::progress(
|
||||
at(1200, 5000),
|
||||
"rebuilding the session index: 1200 of 5000 records; results may be incomplete"
|
||||
"rebuilding the threads index: 1200 of 5000 records; results may be incomplete"
|
||||
)]
|
||||
#[case::nothing_to_replay(at(0, 0), "rebuilding the session index; results may be incomplete")]
|
||||
#[case::nothing_to_replay(at(0, 0), "rebuilding the threads index; results may be incomplete")]
|
||||
fn the_status_says_how_far_it_has_got(#[case] rebuilding: Rebuilding, #[case] want: &str) {
|
||||
assert_eq!(rebuilding.status(), want);
|
||||
}
|
||||
|
||||
@@ -1654,7 +1654,7 @@ impl State {
|
||||
} else if row.handle.harness.harness().is_none() {
|
||||
// Nothing atuin can resume or continue it in (a Copilot session).
|
||||
"can't resume here".to_owned()
|
||||
} else if settings.ai.sessions.resume_chooser {
|
||||
} else if settings.threads.resume_chooser {
|
||||
// Enter asks where.
|
||||
"resume…".to_owned()
|
||||
} else {
|
||||
@@ -1710,8 +1710,8 @@ impl State {
|
||||
fn build_stats(&self, theme: &Theme) -> Paragraph<'static> {
|
||||
let text = match self.result_count() {
|
||||
None => String::new(),
|
||||
Some(n) if n == "1" => "1 session".to_owned(),
|
||||
Some(n) => format!("{n} sessions"),
|
||||
Some(n) if n == "1" => "1 thread".to_owned(),
|
||||
Some(n) => format!("{n} threads"),
|
||||
};
|
||||
Paragraph::new(text).style(style(theme, Meaning::Annotation)).alignment(Alignment::Right)
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@ impl State {
|
||||
};
|
||||
let room = st.inner_width.saturating_sub(borders);
|
||||
let width = |spans: &[Span<'_>]| spans.iter().map(|s| s.content.width()).sum::<usize>();
|
||||
let placeholder = "search sessions…";
|
||||
let placeholder = "search threads…";
|
||||
let typed = usize::from(prefix_width) + input.width();
|
||||
let with_placeholder = typed
|
||||
+ if input.is_empty() {
|
||||
@@ -1824,7 +1824,7 @@ impl State {
|
||||
(input_block(Paragraph::new(Line::from(spans)), st), cut)
|
||||
}
|
||||
|
||||
/// What the list says with nothing to show, in its middle: `No sessions match "flaky" in
|
||||
/// What the list says with nothing to show, in its middle: `No threads match "flaky" in
|
||||
/// this repo`, and what to do about it.
|
||||
fn no_results(&self, theme: &Theme) -> Paragraph<'static> {
|
||||
let muted = style(theme, Meaning::Annotation);
|
||||
@@ -1838,10 +1838,10 @@ impl State {
|
||||
FilterMode::Host => " on this machine",
|
||||
};
|
||||
let what = match (query.is_empty(), self.applied_chips) {
|
||||
(true, false) => format!("No sessions{place}"),
|
||||
(true, true) => format!("No sessions match the filters{place}"),
|
||||
(false, false) => format!("No sessions match \"{query}\"{place}"),
|
||||
(false, true) => format!("No sessions match \"{query}\" and the filters{place}"),
|
||||
(true, false) => format!("No threads{place}"),
|
||||
(true, true) => format!("No threads match the filters{place}"),
|
||||
(false, false) => format!("No threads match \"{query}\"{place}"),
|
||||
(false, true) => format!("No threads match \"{query}\" and the filters{place}"),
|
||||
};
|
||||
let mut hints = Vec::new();
|
||||
// ctrl-r goes through the scopes in turn (some narrower), so it changes the scope rather
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Plans come from the harness tools ([`atuin_common::harnesstools::resume`]); this layer adds
|
||||
//! what only the picker knows: whether the session's transcript is on this machine, where to
|
||||
//! resume one that isn't, and the user's `[ai.sessions.resume]` templates. Planning may walk the
|
||||
//! resume one that isn't, and the user's `[threads.resume]` templates. Planning may walk the
|
||||
//! harness's session directories, so the picker asks only for the selected session, never per
|
||||
//! row.
|
||||
//!
|
||||
@@ -369,8 +369,8 @@ pub trait Machine: Send + Sync {
|
||||
/// where it does, when that isn't where it ran.
|
||||
fn restored(restore: &Restore) -> String {
|
||||
match &restore.note {
|
||||
Some(note) => format!("restored the session from sync; {note}"),
|
||||
None => "restored the session from sync".to_owned(),
|
||||
Some(note) => format!("restored the thread from sync; {note}"),
|
||||
None => "restored the thread from sync".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ pub fn switched_dir(settings: &Settings) -> PathBuf {
|
||||
|
||||
impl HarnessResumer {
|
||||
pub fn new(context: ResumeContext, settings: &Settings) -> Self {
|
||||
let templates = settings.ai.sessions.resume.clone();
|
||||
let templates = settings.threads.resume.clone();
|
||||
Self::on(context, templates, ThisMachine, switched_dir(settings))
|
||||
}
|
||||
|
||||
|
||||
@@ -925,7 +925,7 @@ async fn catch_up_in(
|
||||
/// it isn't here, resumed as it is when it is at the head, the rows it lacks appended when it is
|
||||
/// behind; anything else is a choice, with nothing written.
|
||||
#[rstest]
|
||||
#[case::not_here(None, None, false, "/restored/s.jsonl restored the session from sync", &[])]
|
||||
#[case::not_here(None, None, false, "/restored/s.jsonl restored the thread from sync", &[])]
|
||||
#[case::at_the_head(Some((&["a", "b", "c", "d"][..], "d")), None, false, "/t/s.jsonl", &[])]
|
||||
#[case::behind(
|
||||
Some((&["a", "b"][..], "b")),
|
||||
@@ -1113,7 +1113,7 @@ fn synced_with_a_title(diverged: bool) -> Vec<atuin_client::ai_session::Message>
|
||||
}
|
||||
|
||||
/// A session with no copy here is restored along the head named, else, when it diverged, along
|
||||
/// its newest head (whether `atuin ai resume` catches it up or the picker restores it), with the
|
||||
/// its newest head (whether `atuin threads resume` catches it up or the picker restores it), with the
|
||||
/// rows off the tree that go with it; one that went one way is restored from every row synced of
|
||||
/// it, as before.
|
||||
#[rstest]
|
||||
@@ -1276,7 +1276,7 @@ async fn a_copy_gone_since_it_was_located_is_restored(dirs: Dirs) {
|
||||
machine.found.insert("s".to_owned(), PathBuf::from("/t/s.jsonl"));
|
||||
let written = machine.written.clone();
|
||||
let caught = catch_up(&dirs, machine, false).await;
|
||||
assert_eq!(caught, ("/restored/s.jsonl restored the session from sync".to_owned(), vec![]));
|
||||
assert_eq!(caught, ("/restored/s.jsonl restored the thread from sync".to_owned(), vec![]));
|
||||
assert_eq!(written.lock().len(), 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ pub struct SidecarSource {
|
||||
impl SidecarSource {
|
||||
/// Open the sidecar at `path`.
|
||||
pub async fn open(path: &Path, context: &ResumeContext) -> Result<Self> {
|
||||
let db = AiSessionDatabase::open_read_only(path).await.wrap_err_with(|| {
|
||||
format!("could not open the AI session database {}", path.display())
|
||||
})?;
|
||||
let db = AiSessionDatabase::open_read_only(path)
|
||||
.await
|
||||
.wrap_err_with(|| format!("could not open the threads database {}", path.display()))?;
|
||||
let mut wal = OsString::from(path);
|
||||
wal.push("-wal");
|
||||
Ok(Self {
|
||||
@@ -224,7 +224,7 @@ impl SessionSource for SidecarSource {
|
||||
self.db
|
||||
.rehydrate_session(session, cwd.to_owned())
|
||||
.await?
|
||||
.ok_or_else(|| eyre::eyre!("the session isn't in the AI session database"))
|
||||
.ok_or_else(|| eyre::eyre!("the thread isn't in the threads database"))
|
||||
}
|
||||
|
||||
async fn analyse(&self, session: &HarnessSession) -> Result<Option<Analysis>> {
|
||||
@@ -468,7 +468,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn the_stamp_moves_only_when_the_database_is_written() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ai_session_sidecar.db");
|
||||
let path = dir.path().join("threads.db");
|
||||
let writer = AiSessionDatabase::open(&path).await.unwrap();
|
||||
writer.append(&message("root", None, Role::User, "first", 0)).await.unwrap();
|
||||
let source = SidecarSource::open(&path, &context()).await.unwrap();
|
||||
|
||||
@@ -247,9 +247,10 @@ fn tool_label(name: &str) -> &str {
|
||||
"atuin_history" => "🐢 atuin history",
|
||||
"atuin_output" => "🐢 atuin output",
|
||||
"atuin_output_search" => "🐢 atuin output search",
|
||||
"atuin_ai_session_search" => "🐢 atuin session search",
|
||||
"atuin_ai_session_list" => "🐢 atuin session list",
|
||||
"atuin_ai_session_read" => "🐢 atuin session read",
|
||||
// As they were named before threads.
|
||||
"atuin_threads_search" | "atuin_ai_session_search" => "🐢 atuin threads search",
|
||||
"atuin_threads_list" | "atuin_ai_session_list" => "🐢 atuin threads list",
|
||||
"atuin_threads_read" | "atuin_ai_session_read" => "🐢 atuin threads read",
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
@@ -262,7 +263,7 @@ pub trait SessionSource: Send + Sync {
|
||||
/// root's.
|
||||
async fn search(&self, filter: &SessionFilter) -> eyre::Result<Vec<SessionRow>>;
|
||||
|
||||
/// Every session whose id is `id` or starts with it, across harnesses (for `atuin ai resume
|
||||
/// Every session whose id is `id` or starts with it, across harnesses (for `atuin threads resume
|
||||
/// <id>`).
|
||||
async fn find_by_id(&self, id: &str) -> eyre::Result<Vec<SessionRow>>;
|
||||
|
||||
@@ -334,7 +335,7 @@ pub fn harness_badge(harness: HarnessKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The harness as `atuin ai resume --in` names it; `None` for one that can't be continued in.
|
||||
/// The harness as `atuin threads resume --in` names it; `None` for one that can't be continued in.
|
||||
pub fn harness_arg(harness: HarnessKind) -> Option<&'static str> {
|
||||
match harness {
|
||||
HarnessKind::ClaudeCode => Some("claude"),
|
||||
|
||||
@@ -43,7 +43,7 @@ pub const RESTORE: u8 = 3;
|
||||
pub const TRANSCRIPT: u8 = 4;
|
||||
|
||||
/// The status while an empty workspace falls back to every session.
|
||||
const FELL_BACK: &str = "no sessions in this repo yet: showing all of them";
|
||||
const FELL_BACK: &str = "no threads in this repo yet: showing all of them";
|
||||
|
||||
/// How many sessions' transcripts are kept read: the reader shows one at a time, and a long
|
||||
/// session's is large.
|
||||
@@ -339,7 +339,7 @@ pub struct State {
|
||||
|
||||
/// 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
|
||||
/// The daemon is rebuilding the threads index, so results may be incomplete: said in the
|
||||
/// status row while it has nothing else to say.
|
||||
pub rebuilding: Option<Rebuilding>,
|
||||
pub now: Box<dyn Fn() -> OffsetDateTime + Send>,
|
||||
@@ -361,7 +361,7 @@ fn bottom_up(rows: &mut [SessionRow]) {
|
||||
|
||||
impl State {
|
||||
pub fn new(settings: &Settings, context: ResumeContext, query: &str) -> Self {
|
||||
let sessions = &settings.ai.sessions;
|
||||
let sessions = &settings.threads;
|
||||
let mut input = Cursor::from(query.to_owned());
|
||||
input.end();
|
||||
|
||||
@@ -776,7 +776,7 @@ impl State {
|
||||
/// several agents have): they come first, the first selected, until the query changes.
|
||||
pub fn pin_matches(&mut self, rows: Vec<SessionRow>) {
|
||||
self.status = Some((
|
||||
format!("{} names {} sessions: pick one", self.input.as_str().trim(), rows.len()),
|
||||
format!("{} names {} threads: pick one", self.input.as_str().trim(), rows.len()),
|
||||
Meaning::AlertInfo,
|
||||
));
|
||||
self.pin_rows(rows);
|
||||
@@ -1183,7 +1183,7 @@ mod tests {
|
||||
/// detached `HEAD`, or outside one.
|
||||
fn opened(configured: Option<FilterMode>, repo: bool, branch: bool) -> State {
|
||||
let mut settings = settings();
|
||||
settings.ai.sessions.filter_mode = configured;
|
||||
settings.threads.filter_mode = configured;
|
||||
let mut ctx = fake::context();
|
||||
if !repo {
|
||||
ctx.git_root = None;
|
||||
@@ -1238,7 +1238,7 @@ mod tests {
|
||||
#[case::an_agent("a:pi")]
|
||||
fn a_narrowed_workspace_stays_workspace(#[case] query: &str) {
|
||||
let mut settings = settings();
|
||||
settings.ai.sessions.filter_mode = Some(FilterMode::Workspace);
|
||||
settings.threads.filter_mode = Some(FilterMode::Workspace);
|
||||
let mut state = State::new(&settings, fake::context(), query);
|
||||
let (generation, _) = state.next_search().unwrap();
|
||||
state.apply_results(generation, Vec::new());
|
||||
@@ -1398,7 +1398,7 @@ mod tests {
|
||||
assert_eq!(state.results, vec![claude.clone(), pi.clone()]);
|
||||
assert_eq!(state.selected(), Some(&claude));
|
||||
assert!(
|
||||
state.status.as_ref().is_some_and(|(s, _)| s == "7aaabc31 names 2 sessions: pick one")
|
||||
state.status.as_ref().is_some_and(|(s, _)| s == "7aaabc31 names 2 threads: pick one")
|
||||
);
|
||||
// Nothing is planned for them up front: either may be resumable.
|
||||
assert!(state.plans.is_empty());
|
||||
|
||||
@@ -88,7 +88,7 @@ impl Drop for Events {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stdout, or `/dev/tty` when stdout is captured (`cmd=$(atuin ai resume)`).
|
||||
/// Stdout, or `/dev/tty` when stdout is captured (`cmd=$(atuin threads resume)`).
|
||||
enum Writer {
|
||||
Stdout(io::Stdout),
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -21,7 +21,7 @@ fn settings() -> Settings {
|
||||
s.show_preview = true;
|
||||
s.max_preview_height = 4;
|
||||
// The fixtures are laid out around the workspace filter.
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Workspace);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Workspace);
|
||||
s
|
||||
}
|
||||
|
||||
@@ -108,12 +108,12 @@ async fn full_frame_has_history_search_chrome() {
|
||||
lines[0].contains("enter resume… · tab edit command · ctrl-o inspect · esc exit"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(lines[0].trim_end().ends_with("sessions"), "{out}");
|
||||
assert!(lines[0].trim_end().ends_with("threads"), "{out}");
|
||||
// No tab row: the list's box opens right under the header.
|
||||
assert!(!lines[1].contains("Search"), "{out}");
|
||||
assert!(lines[1].trim_start().starts_with('╭'), "list block opens the box: {out}");
|
||||
// A placeholder in the empty query.
|
||||
assert!(out.contains("› search sessions…"), "{out}");
|
||||
assert!(out.contains("› search threads…"), "{out}");
|
||||
// The scopes beside the query, the one listed highlighted.
|
||||
assert!(out.contains("ctrl-r all repo branch dir host"), "{out}");
|
||||
assert!(out.lines().last().unwrap().trim_start().starts_with('╰'), "{out}");
|
||||
@@ -128,14 +128,14 @@ async fn no_results_say_so() {
|
||||
let s = settings();
|
||||
let mut state = loaded(&s, "zzzqqq", 0).await;
|
||||
let out = text(&render(&mut state, &s, 140, 30));
|
||||
assert!(out.contains(r#"No sessions match "zzzqqq" in this repo"#), "{out}");
|
||||
assert!(out.contains(r#"No threads match "zzzqqq" in this repo"#), "{out}");
|
||||
assert!(out.contains("ctrl-r to change the scope · ctrl-u to clear the search"), "{out}");
|
||||
assert!(out.contains("Nothing to read"), "{out}");
|
||||
|
||||
// Every session: only clearing the search finds more.
|
||||
state.mode = atuin_client::settings::AiSessionFilterMode::Global;
|
||||
let out = text(&render(&mut state, &s, 140, 30));
|
||||
assert!(out.contains(r#"No sessions match "zzzqqq""#), "{out}");
|
||||
assert!(out.contains(r#"No threads match "zzzqqq""#), "{out}");
|
||||
assert!(!out.contains("ctrl-r to"), "{out}");
|
||||
// In vim's normal mode, `dd` clears.
|
||||
let mut vim = settings();
|
||||
@@ -164,7 +164,7 @@ async fn an_untitled_session_says_so() {
|
||||
async fn the_list_never_cuts_a_row_or_doubles_a_heading(#[values(false, true)] invert: bool) {
|
||||
let mut s = settings();
|
||||
s.invert = invert;
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
for height in 5..=30u16 {
|
||||
let mut state = loaded(&s, "", 0).await;
|
||||
for step in 0..state.results.len() {
|
||||
@@ -286,7 +286,7 @@ async fn the_preview_says_what_forked_off_a_session() {
|
||||
#[tokio::test]
|
||||
async fn inspect_says_a_remote_session_is_restored() {
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let mut state = loaded(&s, "aarch64", 1).await;
|
||||
let out = text(&render(&mut state, &s, 120, 30));
|
||||
let line = out.lines().find(|l| l.contains("Resume cd -- ")).unwrap();
|
||||
@@ -418,7 +418,7 @@ async fn inspect_tab_shows_metadata_command_and_children() {
|
||||
#[tokio::test]
|
||||
async fn inspect_explains_unresumable_sessions() {
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let mut state = loaded(&s, "theme preview", 1).await;
|
||||
let out = text(&render(&mut state, &s, 100, 30));
|
||||
assert!(out.contains("not resumable: the session's directory is gone"), "{out}");
|
||||
@@ -480,7 +480,7 @@ async fn opens_on_every_session() {
|
||||
ctx.git_root = Some("/home/ellie/src/empty".into());
|
||||
let source = FakeSource::new();
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = None;
|
||||
s.threads.filter_mode = None;
|
||||
let mut state = State::new(&s, ctx, "");
|
||||
state.now = Box::new(fake::now);
|
||||
while let Some((generation, filter)) = state.next_search() {
|
||||
@@ -597,7 +597,7 @@ async fn a_long_session_opens_at_its_match_without_rendering_it_all() {
|
||||
#[tokio::test]
|
||||
async fn overflowing_lists_get_a_scrollbar() {
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let mut state = loaded(&s, "", 0).await;
|
||||
let buf = render(&mut state, &s, 100, 14);
|
||||
let right: String = (0..14).map(|y| buf[(98, y)].symbol().to_owned()).collect();
|
||||
@@ -686,7 +686,7 @@ fn picked(
|
||||
|
||||
fn chooser_settings() -> Settings {
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s
|
||||
}
|
||||
|
||||
@@ -721,7 +721,7 @@ async fn the_chooser_opens_down_from_the_title(#[values(false, true)] invert: bo
|
||||
|
||||
let mut s = settings();
|
||||
s.invert = invert;
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let mut state = loaded(&s, "", 0).await;
|
||||
state.list.selected = 5;
|
||||
let row = state.selected().unwrap().clone();
|
||||
@@ -753,7 +753,7 @@ async fn growing_the_terminal_with_the_chooser_open_on_the_oldest(
|
||||
|
||||
let mut s = settings();
|
||||
s.invert = invert;
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let mut state = loaded(&s, "", 0).await;
|
||||
state.list.selected = state.results.len() - 1;
|
||||
let row = state.selected().unwrap().clone();
|
||||
@@ -771,9 +771,9 @@ async fn growing_the_terminal_with_the_chooser_open_on_the_oldest(
|
||||
#[tokio::test]
|
||||
async fn chips_alone_matching_nothing_say_so() {
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let out = frame(&s, "b:no-such-branch ", 0, 100, 30).await;
|
||||
assert!(out.contains("No sessions match the filters"), "{out}");
|
||||
assert!(out.contains("No threads match the filters"), "{out}");
|
||||
assert!(out.contains("ctrl-u to clear the search"), "{out}");
|
||||
}
|
||||
|
||||
@@ -782,7 +782,7 @@ async fn chips_alone_matching_nothing_say_so() {
|
||||
#[tokio::test]
|
||||
async fn an_empty_scope_has_no_search_to_clear() {
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Branch);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Branch);
|
||||
let mut state = loaded(&s, "", 0).await;
|
||||
state.context.branch = Some("no-such-branch".to_owned());
|
||||
while let Some((generation, filter)) = state.next_search() {
|
||||
@@ -790,7 +790,7 @@ async fn an_empty_scope_has_no_search_to_clear() {
|
||||
state.apply_results(generation, rows);
|
||||
}
|
||||
let out = text(&render(&mut state, &s, 100, 30));
|
||||
assert!(out.contains("No sessions on this branch"), "{out}");
|
||||
assert!(out.contains("No threads on this branch"), "{out}");
|
||||
assert!(!out.contains("to clear the search"), "{out}");
|
||||
}
|
||||
|
||||
@@ -799,7 +799,7 @@ async fn an_empty_scope_has_no_search_to_clear() {
|
||||
#[tokio::test]
|
||||
async fn a_long_query_keeps_the_scope_in_sight() {
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let query = "flaky ".repeat(30);
|
||||
let buf = render(&mut loaded(&s, &query, 0).await, &s, 100, 30);
|
||||
assert_eq!(current_scope(&buf), "all", "{}", text(&buf));
|
||||
@@ -1227,7 +1227,7 @@ async fn a_session_with_nothing_to_fork_never_picks_the_fork(#[values(false, tru
|
||||
let mut row = state.chooser.as_ref().unwrap().row.clone();
|
||||
let out = text(&render(&mut state, &s, 100, 30));
|
||||
if empty {
|
||||
assert!(out.contains(" 2 Claude Code fork: the session has no messages"), "{out}");
|
||||
assert!(out.contains(" 2 Claude Code fork: the thread has no messages"), "{out}");
|
||||
} else {
|
||||
assert!(out.contains(" 2 Claude Code fork: new session, same history"), "{out}");
|
||||
}
|
||||
@@ -1273,7 +1273,7 @@ async fn ctrl_x_opens_the_chooser_on_the_fork() {
|
||||
use super::{ask_fork, worker};
|
||||
|
||||
let mut s = settings();
|
||||
s.ai.sessions.resume_chooser = false;
|
||||
s.threads.resume_chooser = false;
|
||||
let resumer = Arc::new(FakeResumer::default());
|
||||
let (requests, _responses) = worker::spawn(Arc::new(FakeSource::new()), resumer.clone());
|
||||
let mut state = loaded(&s, "", 0).await;
|
||||
@@ -1336,7 +1336,7 @@ async fn the_chooser_opens_when_there_is_a_choice() {
|
||||
use super::{Outcome, accept, worker};
|
||||
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let resumer = Arc::new(FakeResumer::default());
|
||||
let (requests, _responses) = worker::spawn(Arc::new(FakeSource::new()), resumer.clone());
|
||||
|
||||
@@ -1382,7 +1382,7 @@ async fn a_session_from_another_machine_is_restored_first() {
|
||||
use super::{Outcome, accept, apply_response, resume_line, worker};
|
||||
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let resumer = Arc::new(FakeResumer::default());
|
||||
let (requests, _responses) = worker::spawn(Arc::new(FakeSource::new()), resumer.clone());
|
||||
|
||||
@@ -1426,7 +1426,7 @@ async fn a_refresh_does_not_drop_a_waiting_resume(#[case] query: &str, #[case] h
|
||||
use super::{Outcome, accept, respond, worker};
|
||||
|
||||
let mut s = settings();
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
let resumer = Arc::new(FakeResumer::default());
|
||||
let (requests, _responses) = worker::spawn(Arc::new(FakeSource::new()), resumer.clone());
|
||||
let mut state = loaded(&s, query, 0).await;
|
||||
@@ -2206,7 +2206,7 @@ async fn enter_and_tab_in_the_expanded_forks_resume_the_fork() {
|
||||
async fn rows_fit_every_width(#[values(false, true)] global: bool) {
|
||||
let mut s = settings();
|
||||
if global {
|
||||
s.ai.sessions.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
s.threads.filter_mode = Some(atuin_client::settings::AiSessionFilterMode::Global);
|
||||
}
|
||||
let mut state = loaded(&s, "", 0).await;
|
||||
let title = state.selected().unwrap().title.text.clone();
|
||||
@@ -2238,7 +2238,7 @@ async fn a_capped_list_says_so() {
|
||||
state.results =
|
||||
(0..SEARCH_LIMIT).map(|i| fake::row(HarnessKind::Codex, &format!("s{i}"), "t")).collect();
|
||||
let out = text(&render(&mut state, &s, 100, 30));
|
||||
assert!(out.lines().next().unwrap().ends_with("500+ sessions"), "{out}");
|
||||
assert!(out.lines().next().unwrap().ends_with("500+ threads"), "{out}");
|
||||
}
|
||||
|
||||
/// A session used in the last [`LIVE_SECS`](super::state::LIVE_SECS) says so, and what resuming
|
||||
@@ -2624,7 +2624,7 @@ async fn a_rebuild_in_progress_is_said_in_the_status_row() {
|
||||
progress: (1200, 5000),
|
||||
});
|
||||
let out = text(&render(&mut state, &s, 100, 30));
|
||||
let line = "rebuilding the session index: 1200 of 5000 records; results may be incomplete";
|
||||
let line = "rebuilding the threads index: 1200 of 5000 records; results may be incomplete";
|
||||
assert!(out.lines().last().unwrap().contains(line), "{out}");
|
||||
assert!(out.contains("Add an interactive resume picker"), "the list still shows: {out}");
|
||||
let selected = state.list.selected;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `atuin_ai_session_list`: recent AI-agent sessions, newest first.
|
||||
//! `atuin_threads_list`: recent AI-agent sessions, newest first.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::tools::ToolOutcome;
|
||||
|
||||
// Doc comments on the fields are the descriptions the model reads in the tool schema.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct AtuinAiSessionListToolCall {
|
||||
pub struct AtuinThreadsListToolCall {
|
||||
/// Only sessions whose working directory is this path or inside it. Relative paths,
|
||||
/// including '.', resolve against the current project directory, so '.' lists the sessions
|
||||
/// for this project.
|
||||
@@ -39,7 +39,7 @@ pub struct AtuinAiSessionListToolCall {
|
||||
pub include_subagents: bool,
|
||||
}
|
||||
|
||||
impl AtuinAiSessionListToolCall {
|
||||
impl AtuinThreadsListToolCall {
|
||||
pub(crate) async fn execute(&self, settings: &Settings, caller: &Caller<'_>) -> ToolOutcome {
|
||||
let mut client = match connect(settings).await {
|
||||
Ok(client) => client,
|
||||
@@ -79,7 +79,7 @@ impl AtuinAiSessionListToolCall {
|
||||
let fetch_since = since.map(|since| since.min(caller.active_since()));
|
||||
let all = match super::list_sessions(&mut client, harness, fetch_since).await {
|
||||
Ok(all) => all,
|
||||
Err(e) => return ToolOutcome::Error(format!("Listing AI sessions failed: {e}")),
|
||||
Err(e) => return ToolOutcome::Error(format!("Listing threads failed: {e}")),
|
||||
};
|
||||
let own = caller.own_in(&all);
|
||||
let (mut sessions, elsewhere): (Vec<_>, Vec<_>) = all
|
||||
@@ -103,8 +103,8 @@ impl AtuinAiSessionListToolCall {
|
||||
}
|
||||
let note = note.map_or_else(String::new, |n| format!(" {n}"));
|
||||
return ToolOutcome::Success(format!(
|
||||
"No captured AI sessions{scope}.{note} Only sessions recorded while AI session \
|
||||
capture was enabled are available."
|
||||
"No captured threads{scope}.{note} Only threads recorded while thread capture was \
|
||||
enabled are available."
|
||||
));
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ impl AtuinAiSessionListToolCall {
|
||||
if let Some(note) = note {
|
||||
let _ = writeln!(out, "\n{note}");
|
||||
}
|
||||
let _ = write!(out, "\nRead one with atuin_ai_session_read and its session id.");
|
||||
let _ = write!(out, "\nRead one with atuin_threads_read and its thread id.");
|
||||
ToolOutcome::Success(out)
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ mod tests {
|
||||
|
||||
#[rstest]
|
||||
fn defaults() {
|
||||
let call: AtuinAiSessionListToolCall = serde_json::from_value(json!({})).unwrap();
|
||||
let call: AtuinThreadsListToolCall = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(call.limit.get(), 10);
|
||||
assert!(!call.include_subagents);
|
||||
assert!(call.cwd.is_none());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! MCP tools over AI-agent sessions captured by the daemon: `atuin_ai_session_list`,
|
||||
//! `atuin_ai_session_search`, and `atuin_ai_session_read`.
|
||||
//! MCP tools over AI-agent sessions captured by the daemon: `atuin_threads_list`,
|
||||
//! `atuin_threads_search`, and `atuin_threads_read`.
|
||||
|
||||
pub mod caller;
|
||||
pub mod list;
|
||||
@@ -23,8 +23,8 @@ use crate::tools::ToolOutcome;
|
||||
async fn connect(settings: &Settings) -> Result<AiClient, ToolOutcome> {
|
||||
AiClient::from_settings(settings).await.map_err(|e| {
|
||||
ToolOutcome::Error(format!(
|
||||
"AI sessions are unavailable: could not connect to the Atuin daemon ({e}). Shell \
|
||||
history search still works."
|
||||
"Threads are unavailable: could not connect to the Atuin daemon ({e}). Shell history \
|
||||
search still works."
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `atuin_ai_session_read`: page through one captured AI-agent session's transcript.
|
||||
//! `atuin_threads_read`: page through one captured AI-agent session's transcript.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::ops::Range;
|
||||
@@ -48,16 +48,16 @@ const LABELS: [&str; 3] = [SUMMARY_LABEL, MODEL_ERROR_LABEL, TOOL_ERROR_MARK];
|
||||
|
||||
// Doc comments on the fields are the descriptions the model reads in the tool schema.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct AtuinAiSessionReadToolCall {
|
||||
pub struct AtuinThreadsReadToolCall {
|
||||
/// The session to read: a full session id or atuin id, or a unique prefix of one, as returned by
|
||||
/// atuin_ai_session_list or atuin_ai_session_search. 'latest' reads the most recent session
|
||||
/// atuin_threads_list or atuin_threads_search. 'latest' reads the most recent session
|
||||
/// before this one.
|
||||
pub session_id: NonBlankString,
|
||||
/// Message number to start from (0-based). Negative values count back from the end over
|
||||
/// messages with content, so -10 shows the last ten things said. To see a search hit in
|
||||
/// context, start a few messages before its message number. Numbers count every row the
|
||||
/// harness recorded, including ones with nothing to read, so they run past the message count
|
||||
/// atuin_ai_session_list gives; to reach the end, use a negative start.
|
||||
/// atuin_threads_list gives; to reach the end, use a negative start.
|
||||
#[serde(default)]
|
||||
pub start: i64,
|
||||
/// Maximum number of messages to show. Long messages and harness-injected context are
|
||||
@@ -173,10 +173,10 @@ impl Filter {
|
||||
let describe = self.describe();
|
||||
let Range { start, end } = page;
|
||||
let Some(last) = kept.len().checked_sub(1) else {
|
||||
let _ = writeln!(out, "[The session has no messages.]");
|
||||
let _ = writeln!(out, "[The thread has no messages.]");
|
||||
return;
|
||||
};
|
||||
let span = format!("the session runs #0–#{last}");
|
||||
let span = format!("the thread runs #0–#{last}");
|
||||
if n == 0 {
|
||||
let _ = writeln!(out, "[No messages from #{start} on match {describe}; {span}.]");
|
||||
return;
|
||||
@@ -318,7 +318,7 @@ fn without_markup(body: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
impl AtuinAiSessionReadToolCall {
|
||||
impl AtuinThreadsReadToolCall {
|
||||
fn filter(&self) -> Filter {
|
||||
Filter::new(self.query.as_deref(), self.roles.as_deref())
|
||||
}
|
||||
@@ -332,7 +332,7 @@ impl AtuinAiSessionReadToolCall {
|
||||
let selector = self.session_id.trim();
|
||||
let sessions = match super::list_sessions(&mut client, None, None).await {
|
||||
Ok(sessions) => sessions,
|
||||
Err(e) => return ToolOutcome::Error(format!("Listing AI sessions failed: {e}")),
|
||||
Err(e) => return ToolOutcome::Error(format!("Listing threads failed: {e}")),
|
||||
};
|
||||
// `latest` means the last session a person ran before this one: not the caller's own
|
||||
// live session, and not a subagent fragment.
|
||||
@@ -346,7 +346,7 @@ impl AtuinAiSessionReadToolCall {
|
||||
Ok(handle) => handle,
|
||||
Err(e @ SelectError::NotFound(_)) => {
|
||||
return ToolOutcome::Error(format!(
|
||||
"{e}. Find one with atuin_ai_session_list or atuin_ai_session_search."
|
||||
"{e}. Find one with atuin_threads_list or atuin_threads_search."
|
||||
));
|
||||
}
|
||||
Err(e) => return ToolOutcome::Error(e.to_string()),
|
||||
@@ -364,7 +364,7 @@ impl AtuinAiSessionReadToolCall {
|
||||
});
|
||||
let (session, messages, complete) = match read_session(&mut client, handle, page).await {
|
||||
Ok(read) => read,
|
||||
Err(e) => return ToolOutcome::Error(format!("Reading the AI session failed: {e}")),
|
||||
Err(e) => return ToolOutcome::Error(format!("Reading the thread failed: {e}")),
|
||||
};
|
||||
ToolOutcome::Success(self.render(
|
||||
&session,
|
||||
@@ -519,7 +519,7 @@ impl AtuinAiSessionReadToolCall {
|
||||
}
|
||||
}
|
||||
|
||||
impl AtuinAiSessionReadToolCall {
|
||||
impl AtuinThreadsReadToolCall {
|
||||
/// The part of a single message's rendered `body` starting at `offset`, saying where the
|
||||
/// next part starts when there is more.
|
||||
fn window(&self, body: &str) -> String {
|
||||
@@ -876,7 +876,7 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn call(args: Value) -> AtuinAiSessionReadToolCall {
|
||||
fn call(args: Value) -> AtuinThreadsReadToolCall {
|
||||
serde_json::from_value(args).unwrap()
|
||||
}
|
||||
|
||||
@@ -1132,7 +1132,7 @@ mod tests {
|
||||
assert!(!out.contains("#5 ") && !out.contains("#0 "), "{out}");
|
||||
assert!(
|
||||
out.contains(
|
||||
"[1 message matching query \"called records\" from #0; the session runs #0–#6.]"
|
||||
"[1 message matching query \"called records\" from #0; the thread runs #0–#6.]"
|
||||
),
|
||||
"{out}"
|
||||
);
|
||||
@@ -1170,7 +1170,7 @@ mod tests {
|
||||
let none = render(json!({"session_id": "abc", "query": "planetscale"}), &conversation());
|
||||
assert!(
|
||||
none.contains(
|
||||
"[No messages from #0 on match query \"planetscale\"; the session runs #0–#6.]"
|
||||
"[No messages from #0 on match query \"planetscale\"; the thread runs #0–#6.]"
|
||||
),
|
||||
"{none}"
|
||||
);
|
||||
@@ -1283,7 +1283,7 @@ mod tests {
|
||||
#[rstest]
|
||||
fn a_filtered_read_of_an_empty_session_says_so() {
|
||||
let out = render(json!({"session_id": "abc", "roles": ["user"]}), &[]);
|
||||
assert!(out.contains("[The session has no messages.]"), "{out}");
|
||||
assert!(out.contains("[The thread has no messages.]"), "{out}");
|
||||
assert!(!out.contains("#0"), "{out}");
|
||||
}
|
||||
|
||||
@@ -1316,7 +1316,7 @@ mod tests {
|
||||
#[rstest]
|
||||
fn rejects_an_unknown_role() {
|
||||
let args = json!({"session_id": "abc", "roles": ["robot"]});
|
||||
assert!(serde_json::from_value::<AtuinAiSessionReadToolCall>(args).is_err());
|
||||
assert!(serde_json::from_value::<AtuinThreadsReadToolCall>(args).is_err());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `atuin_ai_session_search`: full-text search across captured AI-agent session transcripts.
|
||||
//! `atuin_threads_search`: full-text search across captured AI-agent session transcripts.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
@@ -27,7 +27,7 @@ const NOTE_SCAN: u32 = 100;
|
||||
|
||||
// Doc comments on the fields are the descriptions the model reads in the tool schema.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct AtuinAiSessionSearchToolCall {
|
||||
pub struct AtuinThreadsSearchToolCall {
|
||||
/// Words to look for across captured AI-agent session transcripts (what the user and agent
|
||||
/// wrote, the names of tools used, and session titles), case-insensitive. Sessions with a
|
||||
/// message containing every word rank first; if there are none, sessions matching any of the
|
||||
@@ -57,7 +57,7 @@ pub struct AtuinAiSessionSearchToolCall {
|
||||
pub roles: Option<Vec<RoleArg>>,
|
||||
}
|
||||
|
||||
impl AtuinAiSessionSearchToolCall {
|
||||
impl AtuinThreadsSearchToolCall {
|
||||
pub(crate) async fn execute(&self, settings: &Settings, caller: &Caller<'_>) -> ToolOutcome {
|
||||
self.run(settings, caller).await.map_or_else(|outcome| outcome, ToolOutcome::Success)
|
||||
}
|
||||
@@ -132,19 +132,19 @@ impl AtuinAiSessionSearchToolCall {
|
||||
let query = self.query.trim();
|
||||
return Ok(if only_own {
|
||||
format!(
|
||||
"The only captured AI session{scope} matching {query:?} is this one (the \
|
||||
session calling this tool).{note}"
|
||||
"The only captured thread{scope} matching {query:?} is this one (the thread \
|
||||
calling this tool).{note}"
|
||||
)
|
||||
} else if !any_term {
|
||||
format!(
|
||||
"No captured AI session{scope} has a message containing every word of \
|
||||
"No captured thread{scope} has a message containing every word of \
|
||||
{query:?}.{note}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"No captured AI sessions{scope} matched any word of {query:?}, even as a \
|
||||
prefix.{note} Try other words, or atuin_ai_session_list to browse sessions \
|
||||
by recency and directory."
|
||||
"No captured threads{scope} matched any word of {query:?}, even as a \
|
||||
prefix.{note} Try other words, or atuin_threads_list to browse threads by \
|
||||
recency and directory."
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -154,7 +154,7 @@ impl AtuinAiSessionSearchToolCall {
|
||||
if any_term {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"No single message contained every word, so these sessions match some of them (as \
|
||||
"No single message contained every word, so these threads match some of them (as \
|
||||
prefixes); check the match lines for relevance.\n"
|
||||
);
|
||||
}
|
||||
@@ -166,9 +166,9 @@ impl AtuinAiSessionSearchToolCall {
|
||||
}
|
||||
let _ = write!(
|
||||
out,
|
||||
"\nRead around a match with atuin_ai_session_read, e.g. start a few messages before \
|
||||
the matched message number. Each session shows only its best match; to find every \
|
||||
mention in one, read it with query."
|
||||
"\nRead around a match with atuin_threads_read, e.g. start a few messages before the \
|
||||
matched message number. Each thread shows only its best match; to find every mention \
|
||||
in one, read it with query."
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -204,11 +204,11 @@ async fn search(
|
||||
client
|
||||
.search_sessions(query, terms, &filter, limit)
|
||||
.await
|
||||
.map_err(|e| ToolOutcome::Error(format!("AI session search failed: {e}")))?
|
||||
.map_err(|e| ToolOutcome::Error(format!("Thread search failed: {e}")))?
|
||||
.map(|hit| {
|
||||
hit.map_err(|e| format!("AI session search failed: {e}")).and_then(|hit| {
|
||||
hit.map_err(|e| format!("Thread search failed: {e}")).and_then(|hit| {
|
||||
SessionMatch::try_from(hit)
|
||||
.map_err(|e| format!("AI session search returned a malformed result: {e}"))
|
||||
.map_err(|e| format!("Thread search returned a malformed result: {e}"))
|
||||
})
|
||||
})
|
||||
.try_collect()
|
||||
@@ -256,7 +256,7 @@ mod tests {
|
||||
#[case::clamped_low(json!({"query": "x", "limit": 0}), 1)]
|
||||
#[case::null_limit(json!({"query": "x", "limit": null}), 5)]
|
||||
fn parses_query_and_clamps_limit(#[case] input: serde_json::Value, #[case] limit: u32) {
|
||||
let call: AtuinAiSessionSearchToolCall = serde_json::from_value(input).unwrap();
|
||||
let call: AtuinThreadsSearchToolCall = serde_json::from_value(input).unwrap();
|
||||
assert_eq!(call.limit.get(), limit);
|
||||
assert!(!call.include_subagents);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ mod tests {
|
||||
#[case::unknown_harness(json!({"query": "x", "harness": "emacs"}))]
|
||||
#[case::uncapturable_harness(json!({"query": "x", "harness": "copilot"}))]
|
||||
fn rejects_invalid_input(#[case] input: serde_json::Value) {
|
||||
assert!(serde_json::from_value::<AtuinAiSessionSearchToolCall>(input).is_err());
|
||||
assert!(serde_json::from_value::<AtuinThreadsSearchToolCall>(input).is_err());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
@@ -278,7 +278,7 @@ mod tests {
|
||||
#[case::pi("pi", HarnessKind::Pi)]
|
||||
fn parses_each_harness(#[case] name: &str, #[case] expected: HarnessKind) {
|
||||
let input = json!({"query": "x", "harness": name});
|
||||
let call: AtuinAiSessionSearchToolCall = serde_json::from_value(input).unwrap();
|
||||
let call: AtuinThreadsSearchToolCall = serde_json::from_value(input).unwrap();
|
||||
assert_eq!(HarnessKind::from(call.harness.unwrap()), expected);
|
||||
}
|
||||
|
||||
|
||||
@@ -323,25 +323,38 @@ enter_accept = true
|
||||
## commands that have no recorded shell
|
||||
# shells = "auto"
|
||||
|
||||
[ai.sessions]
|
||||
## Settings for `atuin ai resume`, the picker over captured AI coding-agent sessions.
|
||||
[threads]
|
||||
## Threads are your AI coding agents' sessions (Claude Code, Codex, opencode and Pi),
|
||||
## captured by the daemon so you can search and resume them, here or on another machine.
|
||||
## These were `ai.capture_sessions`, `ai.capture_tools` and `[ai.sessions]`, which are
|
||||
## still read when not set here.
|
||||
|
||||
## Capture threads into the encrypted record store, and sync them like your history.
|
||||
## `atuin config enable threads` turns this on, with the daemon it needs.
|
||||
# capture = false
|
||||
|
||||
## Also capture what each tool call was given and returned (commands and their output,
|
||||
## files read and written), with secrets redacted. Off keeps only each tool's name.
|
||||
# capture_tools = true
|
||||
|
||||
## The rest are settings for `atuin threads resume`, the picker over captured threads.
|
||||
## It follows the history search's style, invert, show_preview, max_preview_height,
|
||||
## enter_accept, keymap_mode and theme settings.
|
||||
|
||||
## The filter the picker opens in. Unset: "global". "workspace" and "branch" open
|
||||
## in "global" outside a git repository, "branch" opens in "workspace" on a
|
||||
## detached HEAD, and "workspace" opens in "global" when the workspace has no
|
||||
## sessions at all. ctrl-r cycles through global, workspace, branch, directory and
|
||||
## threads at all. ctrl-r cycles through global, workspace, branch, directory and
|
||||
## host.
|
||||
# filter_mode = "global"
|
||||
|
||||
## Height of the inline picker. Defaults to the top-level inline_height.
|
||||
# inline_height = 40
|
||||
|
||||
## Ask where to resume a session once it's chosen: in its own agent (the default
|
||||
## Ask where to resume a thread once it's chosen: in its own agent (the default
|
||||
## choice, so enter-enter resumes it), or continued in another agent installed here.
|
||||
## When false, enter and tab resume in its own agent straight away; the chooser still
|
||||
## opens for a session that can't be resumed there.
|
||||
## opens for a thread that can't be resumed there.
|
||||
# resume_chooser = true
|
||||
|
||||
## Take the mouse: the wheel scrolls the preview under it, or moves the selection over the
|
||||
@@ -355,7 +368,7 @@ enter_accept = true
|
||||
## Don't pass them through another shell either (e.g. `sh -c "cd {cwd} && ..."`): that
|
||||
## shell would split and expand them, so a directory with a space or `;` in it breaks the
|
||||
## command or runs part of it as a separate one.
|
||||
# [ai.sessions.resume]
|
||||
# [threads.resume]
|
||||
# claude = "claude --resume {id}"
|
||||
# codex = "codex resume {id}"
|
||||
# opencode = "opencode --session {id}"
|
||||
|
||||
@@ -198,35 +198,35 @@ pub enum Appended {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
#[error("failed to open the ai-session sqlite database: {0}")]
|
||||
#[error("failed to open the threads sqlite database: {0}")]
|
||||
Open(#[from] SqliteOpenOrCreateError),
|
||||
#[error("failed to run ai-session sqlite migrations: {0}")]
|
||||
#[error("failed to run threads sqlite migrations: {0}")]
|
||||
Migrate(#[from] sqlx::migrate::MigrateError),
|
||||
#[error("ai-session sqlite query failed: {0}")]
|
||||
#[error("threads sqlite query failed: {0}")]
|
||||
Query(#[from] sqlx::Error),
|
||||
#[error("ai-session sqlite write failed: {0}")]
|
||||
#[error("threads sqlite write failed: {0}")]
|
||||
Write(#[from] rusqlite::Error),
|
||||
#[error("the ai-session sqlite write was cancelled")]
|
||||
#[error("the threads sqlite write was cancelled")]
|
||||
WriterCancelled,
|
||||
#[error("the ai-session sqlite database is open read-only")]
|
||||
#[error("the threads sqlite database is open read-only")]
|
||||
ReadOnly,
|
||||
#[error("failed to serialize ai-session message: {0}")]
|
||||
#[error("failed to serialize thread message: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("failed to compress ai-session message content: {0}")]
|
||||
#[error("failed to compress thread message content: {0}")]
|
||||
Compress(std::io::Error),
|
||||
#[error("unknown ai-session harness discriminant {0}")]
|
||||
#[error("unknown thread harness discriminant {0}")]
|
||||
UnknownHarness(i64),
|
||||
#[error("failed to decompress ai-session message content: {0}")]
|
||||
#[error("failed to decompress thread message content: {0}")]
|
||||
Decompress(std::io::Error),
|
||||
#[error("stored ai-session content is not valid utf-8")]
|
||||
#[error("stored thread content is not valid utf-8")]
|
||||
InvalidContentEncoding,
|
||||
#[error("stored ai-session record id is not a valid uuid")]
|
||||
#[error("stored thread record id is not a valid uuid")]
|
||||
InvalidRecordId,
|
||||
#[error("stored ai-session atuin id is missing or not a valid uuid")]
|
||||
#[error("stored thread atuin id is missing or not a valid uuid")]
|
||||
InvalidAtuinId,
|
||||
/// No daemon has created the sidecar yet.
|
||||
#[error(
|
||||
"the AI session database has not been created yet: start the atuin daemon (`atuin daemon \
|
||||
"the threads database has not been created yet: start the atuin daemon (`atuin daemon \
|
||||
start`) and try again"
|
||||
)]
|
||||
Uninitialized {
|
||||
@@ -235,7 +235,7 @@ pub enum DbError {
|
||||
/// The sidecar is at an older schema than this build reads: the daemon that owns it is an
|
||||
/// older atuin still running since an upgrade, or is migrating it right now.
|
||||
#[error(
|
||||
"the AI session database is at schema version {found}, older than the version {expected} \
|
||||
"the threads database is at schema version {found}, older than the version {expected} \
|
||||
this atuin reads: the atuin daemon upgrades it when it starts. If atuin was just \
|
||||
updated, restart the daemon (`atuin daemon restart`); if the daemon is starting, try \
|
||||
again in a moment"
|
||||
@@ -249,7 +249,7 @@ pub enum DbError {
|
||||
/// failed part way. The sidecar is a projection of the record store, so deleting it is safe:
|
||||
/// the daemon rebuilds it from the records when it next starts.
|
||||
#[error(
|
||||
"the AI session database at {} cannot be migrated ({source}). It is rebuilt from the \
|
||||
"the threads database at {} cannot be migrated ({source}). It is rebuilt from the \
|
||||
record store, so to start it over, stop the daemon (`atuin daemon stop`), delete that \
|
||||
file with its -wal and -shm files, and start the daemon again",
|
||||
path.display()
|
||||
@@ -260,7 +260,7 @@ pub enum DbError {
|
||||
},
|
||||
/// The sidecar is at a newer schema than this build reads: a newer atuin's daemon owns it.
|
||||
#[error(
|
||||
"the AI session database is at schema version {found}, newer than the version {expected} \
|
||||
"the threads database is at schema version {found}, newer than the version {expected} \
|
||||
this atuin reads: the atuin daemon is newer than this atuin. Update atuin, or check that \
|
||||
the shell and the daemon run the same atuin binary"
|
||||
)]
|
||||
@@ -1451,7 +1451,7 @@ impl AiSessionDatabase {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tool output (with `ai.capture_tools`) is most of a session's bytes, and would grow
|
||||
// Tool output (with `threads.capture_tools`) is most of a session's bytes, and would grow
|
||||
// the index as much, with files and logs rather than what was said or done. Activity
|
||||
// metadata is not conversational text and adds no useful search terms.
|
||||
Content::ToolResult(_) | Content::ReasoningSummary { .. } => {}
|
||||
@@ -3439,7 +3439,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A search's title and snippet carry the spans of what the query matched, which the gRPC
|
||||
/// API and `atuin ai session search --format json` pass on.
|
||||
/// API and `atuin threads search --format json` pass on.
|
||||
#[rstest]
|
||||
#[case::all(
|
||||
SearchTerms::All,
|
||||
|
||||
@@ -9,10 +9,38 @@ pub use store::*;
|
||||
mod database;
|
||||
pub use database::*;
|
||||
|
||||
/// Where the daemon keeps the ai-session sidecar (see [`Settings::ai_session_sidecar_path`]).
|
||||
/// The sidecar's name before threads; [`migrate_sidecar_name`] moves it.
|
||||
const LEGACY_SIDECAR: &str = "ai_session_sidecar.db";
|
||||
|
||||
/// Where the daemon keeps the threads sidecar (see [`Settings::threads_db_path`]). Until a daemon
|
||||
/// has run [`migrate_sidecar_name`], that is still the legacy file.
|
||||
#[must_use]
|
||||
pub fn sidecar_path(settings: &Settings) -> PathBuf {
|
||||
settings.ai_session_sidecar_path()
|
||||
let path = settings.threads_db_path();
|
||||
let legacy = settings.data_dir.join(LEGACY_SIDECAR);
|
||||
if !path.exists() && legacy.exists() {
|
||||
legacy
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename a legacy `ai_session_sidecar.db` to `threads.db`, for the daemon to call before it
|
||||
/// opens the sidecar. The WAL and shared-memory files move first and the database last, so a
|
||||
/// crash midway leaves the legacy database to finish moving on the next start.
|
||||
pub fn migrate_sidecar_name(settings: &Settings) -> std::io::Result<()> {
|
||||
let path = settings.threads_db_path();
|
||||
let legacy = settings.data_dir.join(LEGACY_SIDECAR);
|
||||
if path.exists() || !legacy.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let from = PathBuf::from(format!("{}{suffix}", legacy.display()));
|
||||
if from.exists() {
|
||||
std::fs::rename(&from, format!("{}{suffix}", path.display()))?;
|
||||
}
|
||||
}
|
||||
std::fs::rename(legacy, path)
|
||||
}
|
||||
|
||||
/// Have the daemon reproject the sidecar from the whole record store on its next start, for
|
||||
@@ -20,3 +48,29 @@ pub fn sidecar_path(settings: &Settings) -> PathBuf {
|
||||
pub async fn invalidate_sidecar(settings: &Settings) -> Result<(), DbError> {
|
||||
AiSessionDatabase::invalidate_projection(sidecar_path(settings)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[rstest::rstest]
|
||||
fn legacy_sidecar_moves_to_threads_db() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = Settings::utc();
|
||||
settings.data_dir = dir.path().to_path_buf();
|
||||
for name in [LEGACY_SIDECAR, "ai_session_sidecar.db-wal", "ai_session_sidecar.db-shm"] {
|
||||
std::fs::write(dir.path().join(name), name).unwrap();
|
||||
}
|
||||
assert_eq!(sidecar_path(&settings), dir.path().join(LEGACY_SIDECAR));
|
||||
|
||||
migrate_sidecar_name(&settings).unwrap();
|
||||
|
||||
assert_eq!(sidecar_path(&settings), dir.path().join("threads.db"));
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let legacy = format!("{LEGACY_SIDECAR}{suffix}");
|
||||
assert!(!dir.path().join(&legacy).exists());
|
||||
let moved = dir.path().join(format!("threads.db{suffix}"));
|
||||
assert_eq!(std::fs::read_to_string(moved).unwrap(), legacy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,18 +58,18 @@ pub enum AiSessionRecord {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DecodeError {
|
||||
#[error("empty ai-session record")]
|
||||
#[error("empty thread record")]
|
||||
Empty,
|
||||
#[error("unknown ai-session record kind {0}")]
|
||||
#[error("unknown thread record kind {0}")]
|
||||
UnknownKind(u8),
|
||||
#[error("unknown ai-session record version {}", .0.as_str())]
|
||||
#[error("unknown thread record version {}", .0.as_str())]
|
||||
UnknownVersion(RecordVersion),
|
||||
/// Only written by builds from before records stored the session's id.
|
||||
#[error("ai-session record version {} is no longer read", .0.as_str())]
|
||||
#[error("thread record version {} is no longer read", .0.as_str())]
|
||||
Retired(RecordVersion),
|
||||
#[error("failed to decode ai-session record body: {0}")]
|
||||
#[error("failed to decode thread record body: {0}")]
|
||||
Body(#[from] rmp_serde::decode::Error),
|
||||
#[error("failed to decompress ai-session record body: {0}")]
|
||||
#[error("failed to decompress thread record body: {0}")]
|
||||
Decompress(#[source] std::io::Error),
|
||||
}
|
||||
|
||||
@@ -87,13 +87,13 @@ pub enum BuildError {
|
||||
Db(#[from] DbError),
|
||||
/// Invalidations kept landing in the middle of it: after `REPROJECT_PASSES` passes the
|
||||
/// sidecar may still be missing records. The next reprojection replays what this one left.
|
||||
#[error("the ai-session projection kept being invalidated, and is incomplete")]
|
||||
#[error("the threads projection kept being invalidated, and is incomplete")]
|
||||
Incomplete,
|
||||
/// Only [`AiSessionStore::reproject_beside_capture`]: the host's series was rewritten or
|
||||
/// deleted under its watermark, and forgetting what it projected would delete rows of this
|
||||
/// host, which capture dedups against. Nothing was forgotten: the caller must have it
|
||||
/// forgotten with capture held off, then replay.
|
||||
#[error("the ai-session records of host {0} were rewritten under capture")]
|
||||
#[error("the thread records of host {0} were rewritten under capture")]
|
||||
ForgetHeldOff(HostId),
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ use url::Url;
|
||||
|
||||
static EXAMPLE_CONFIG: &str = include_str!("../config.toml");
|
||||
|
||||
pub mod ai_sessions;
|
||||
pub mod daemon;
|
||||
pub mod disk_usage_limit;
|
||||
mod kv;
|
||||
@@ -39,15 +38,16 @@ pub mod output;
|
||||
mod scripts;
|
||||
pub mod security;
|
||||
pub mod shells;
|
||||
pub mod threads;
|
||||
pub mod watcher;
|
||||
|
||||
pub use ai_sessions::{AiSessionFilterMode, AiSessionResume, AiSessions};
|
||||
pub use daemon::Daemon;
|
||||
pub use disk_usage_limit::{DiskUsageLimit, DiskUsageLimitParseError};
|
||||
use output::OutputCaptureConfig;
|
||||
pub use output::{CaptureLimits, CommandFilter, OutputCapture};
|
||||
pub use security::Security;
|
||||
pub use shells::Shells;
|
||||
pub use threads::{AiSessionFilterMode, AiSessionResume, Threads};
|
||||
|
||||
/// Default sync address for Atuin's hosted service, parsed once.
|
||||
pub static DEFAULT_SYNC_URL: LazyLock<Url> =
|
||||
@@ -712,30 +712,6 @@ pub struct Ai {
|
||||
|
||||
/// Whether the AI TUI surfaces feature tips. `None` = enabled.
|
||||
pub tips: Option<bool>,
|
||||
|
||||
/// Whether the daemon captures live AI harness sessions (Claude Code, Codex, ...) into the
|
||||
/// synced record store. Off by default: capture copies the conversation (what was said, and
|
||||
/// the tools called, see [`Self::capture_tools`]) into the encrypted store used by sync, so
|
||||
/// it is strictly opt-in.
|
||||
#[serde(default)]
|
||||
pub capture_sessions: bool,
|
||||
|
||||
/// With [`Self::capture_sessions`], also capture what each tool call was given and what it
|
||||
/// returned (commands and their output, files read and written), so a resumed session has
|
||||
/// them too. On by default, with secrets redacted and each payload held to 64 KiB; off keeps
|
||||
/// only each tool's name.
|
||||
#[serde(default = "Ai::default_capture_tools")]
|
||||
pub capture_tools: bool,
|
||||
|
||||
/// The `atuin ai resume` session picker (`[ai.sessions]`).
|
||||
#[serde(default)]
|
||||
pub sessions: AiSessions,
|
||||
}
|
||||
|
||||
impl Ai {
|
||||
const fn default_capture_tools() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -1184,6 +1160,9 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub ai: Ai,
|
||||
|
||||
#[serde(default)]
|
||||
pub threads: Threads,
|
||||
|
||||
#[serde(default)]
|
||||
pub security: Security,
|
||||
}
|
||||
@@ -1233,11 +1212,11 @@ impl Settings {
|
||||
self.data_dir.join("output-capture")
|
||||
}
|
||||
|
||||
/// The AI harness session sidecar database, under the [data dir](Self::data_dir). The daemon
|
||||
/// owns and writes it; other readers open it read-only (`AiSessionDatabase::open_read_only`).
|
||||
/// The threads sidecar database, under the [data dir](Self::data_dir). The daemon owns and
|
||||
/// writes it; other readers open it read-only (`AiSessionDatabase::open_read_only`).
|
||||
#[must_use]
|
||||
pub fn ai_session_sidecar_path(&self) -> PathBuf {
|
||||
self.data_dir.join("ai_session_sidecar.db")
|
||||
pub fn threads_db_path(&self) -> PathBuf {
|
||||
self.data_dir.join("threads.db")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -1634,8 +1613,6 @@ impl Settings {
|
||||
.set_default("ai.db_path", ai_sessions_path.to_str())?
|
||||
.set_default("ai.session_continue_minutes", 60)?
|
||||
.set_default("ai.send_cwd", false)?
|
||||
.set_default("ai.capture_sessions", false)?
|
||||
.set_default("ai.capture_tools", true)?
|
||||
.set_default("ai.opening.send_cwd", false)?
|
||||
.set_default("ai.opening.send_last_command", false)?
|
||||
.set_default("ui.syntax_highlight", true)?
|
||||
@@ -1768,7 +1745,47 @@ impl Settings {
|
||||
.unwrap_or_else(|_| panic!("failed to set absolute path override for {key}"))
|
||||
});
|
||||
|
||||
config_builder.build().map_err(Into::into)
|
||||
Ok(Self::with_legacy_threads(config_builder.build()?)?)
|
||||
}
|
||||
|
||||
/// `[threads]` was `ai.capture_sessions`, `ai.capture_tools` and `[ai.sessions]`: read them
|
||||
/// as `threads.capture`, `threads.capture_tools` and `threads.*`, for any key `[threads]`
|
||||
/// doesn't set itself. The defaults are set here, under them, so they never hide a legacy
|
||||
/// value.
|
||||
fn with_legacy_threads(config: Config) -> Result<Config, config::ConfigError> {
|
||||
fn leaves(prefix: String, value: config::Value, out: &mut Vec<(String, config::Value)>) {
|
||||
match value.kind {
|
||||
config::ValueKind::Table(table) => {
|
||||
for (key, value) in table {
|
||||
leaves(format!("{prefix}.{key}"), value, out);
|
||||
}
|
||||
}
|
||||
_ => out.push((prefix, value)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut legacy = Vec::new();
|
||||
for (old, new) in [
|
||||
("ai.capture_sessions", "threads.capture"),
|
||||
("ai.capture_tools", "threads.capture_tools"),
|
||||
("ai.sessions", "threads"),
|
||||
] {
|
||||
if let Ok(value) = config.get::<config::Value>(old) {
|
||||
leaves(new.to_string(), value, &mut legacy);
|
||||
}
|
||||
}
|
||||
legacy.retain(|(key, _)| config.get::<config::Value>(key).is_err());
|
||||
|
||||
legacy
|
||||
.into_iter()
|
||||
.try_fold(
|
||||
Config::builder()
|
||||
.set_default("threads.capture", false)?
|
||||
.set_default("threads.capture_tools", true)?
|
||||
.add_source(config),
|
||||
|builder, (key, value)| builder.set_override(key, value),
|
||||
)?
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`).
|
||||
@@ -1890,9 +1907,11 @@ impl Settings {
|
||||
|
||||
/// Check that a TOML string can be successfully deserialized into a [`Settings`] object.
|
||||
pub fn validate_str(toml: &str) -> Result<(), ValidationError> {
|
||||
let config = Self::builder_with_data_dir(&atuin_common::dirs::unconfigured_data_dir())?
|
||||
.add_source(ConfigFile::from_str(toml, FileFormat::Toml))
|
||||
.build()?;
|
||||
let config = Self::with_legacy_threads(
|
||||
Self::builder_with_data_dir(&atuin_common::dirs::unconfigured_data_dir())?
|
||||
.add_source(ConfigFile::from_str(toml, FileFormat::Toml))
|
||||
.build()?,
|
||||
)?;
|
||||
|
||||
let settings: Self = config.try_deserialize()?;
|
||||
if let Some(dir) = settings.data_dir.to_str() {
|
||||
@@ -2178,15 +2197,60 @@ mod tests {
|
||||
|
||||
/// Deserialize a TOML string into a [`Settings`] object.
|
||||
fn parse_settings(toml: &str) -> Settings {
|
||||
Settings::builder_with_data_dir(&atuin_common::dirs::unconfigured_data_dir())
|
||||
let config = Settings::builder_with_data_dir(&atuin_common::dirs::unconfigured_data_dir())
|
||||
.expect("could not build settings builder")
|
||||
.add_source(ConfigFile::from_str(toml, FileFormat::Toml))
|
||||
.build()
|
||||
.expect("could not build config")
|
||||
.expect("could not build config");
|
||||
Settings::with_legacy_threads(config)
|
||||
.expect("could not read legacy threads settings")
|
||||
.try_deserialize()
|
||||
.expect("could not deserialize config")
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn threads_default_off_with_tools() {
|
||||
let settings = parse_settings("");
|
||||
assert!(!settings.threads.capture);
|
||||
assert!(settings.threads.capture_tools);
|
||||
assert!(settings.threads.resume_chooser);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn legacy_ai_session_settings_are_read_as_threads() {
|
||||
let settings = parse_settings(
|
||||
"[ai]\ncapture_sessions = true\ncapture_tools = \
|
||||
false\n\n[ai.sessions]\nresume_chooser = false\n\n[ai.sessions.resume]\npi = \"pi \
|
||||
--session {id}\"\n",
|
||||
);
|
||||
assert!(settings.threads.capture);
|
||||
assert!(!settings.threads.capture_tools);
|
||||
assert!(!settings.threads.resume_chooser);
|
||||
assert_eq!(settings.threads.resume.pi.as_deref(), Some("pi --session {id}"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn threads_settings_win_over_legacy_ones() {
|
||||
let settings = parse_settings(
|
||||
"[ai]\ncapture_sessions = true\n\n[ai.sessions]\ninline_height = 10\nmouse = \
|
||||
false\n\n[ai.sessions.resume]\nclaude = \"old\"\npi = \"old\"\n\n[threads]\ncapture \
|
||||
= false\ninline_height = 20\n\n[threads.resume]\npi = \"new\"\n",
|
||||
);
|
||||
assert!(!settings.threads.capture);
|
||||
assert_eq!(settings.threads.inline_height, Some(20));
|
||||
assert_eq!(settings.threads.mouse, Some(false));
|
||||
assert_eq!(settings.threads.resume.claude.as_deref(), Some("old"));
|
||||
assert_eq!(settings.threads.resume.pi.as_deref(), Some("new"));
|
||||
}
|
||||
|
||||
/// `atuin config get --resolved` shows the defaults, as it did for `ai.capture_sessions`.
|
||||
#[rstest]
|
||||
fn threads_defaults_resolve() {
|
||||
let config = Settings::with_legacy_threads(config::Config::default()).unwrap();
|
||||
assert!(!config.get_bool("threads.capture").unwrap());
|
||||
assert!(config.get_bool("threads.capture_tools").unwrap());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn zero_log_retention_is_accepted() {
|
||||
// `retention = 0` = keep for zero seconds (prune everything); it must load, not error.
|
||||
|
||||
+41
-20
@@ -1,4 +1,7 @@
|
||||
//! `[ai.sessions]`: the `atuin ai resume` picker.
|
||||
//! `[threads]`: capturing coding agents' threads, and the `atuin threads resume` picker.
|
||||
//!
|
||||
//! Before `[threads]`, these were `ai.capture_sessions`, `ai.capture_tools` and `[ai.sessions]`,
|
||||
//! which are still read for any it doesn't set (see `Settings::with_legacy_threads`).
|
||||
//!
|
||||
//! Only the picker-specific knobs live here. The look and feel (`style`, `invert`, `show_preview`,
|
||||
//! `max_preview_height`, `enter_accept`, `keymap_mode`, `theme`) is read from the top-level
|
||||
@@ -9,24 +12,24 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ai_session::HarnessKind;
|
||||
|
||||
/// Which sessions the picker shows, cycled with ctrl-r in this order (see [`Self::CYCLE`]).
|
||||
/// Which threads the picker shows, cycled with ctrl-r in this order (see [`Self::CYCLE`]).
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize, ValueEnum)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AiSessionFilterMode {
|
||||
/// Every session, from every host.
|
||||
/// Every thread, from every host.
|
||||
Global,
|
||||
/// Sessions recorded on this host.
|
||||
/// Threads recorded on this host.
|
||||
Host,
|
||||
/// Sessions whose working directory is inside the current git repository.
|
||||
/// Threads whose working directory is inside the current git repository.
|
||||
Workspace,
|
||||
/// Sessions started in the current directory.
|
||||
/// Threads started in the current directory.
|
||||
Directory,
|
||||
/// Sessions on the current git branch of the current repository.
|
||||
/// Threads on the current git branch of the current repository.
|
||||
Branch,
|
||||
}
|
||||
|
||||
impl AiSessionFilterMode {
|
||||
/// The order ctrl-r cycles through the modes, wrapping around: from every session (where the
|
||||
/// The order ctrl-r cycles through the modes, wrapping around: from every thread (where the
|
||||
/// picker opens) narrowing to the workspace, its branch and the directory, then this host's.
|
||||
/// The picker lists them in this order.
|
||||
pub const CYCLE: [Self; 5] =
|
||||
@@ -48,12 +51,12 @@ impl AiSessionFilterMode {
|
||||
/// Per-harness resume command templates. Unset harnesses use the built-in command.
|
||||
///
|
||||
/// A template replaces the harness's program and arguments; the harness still decides whether a
|
||||
/// session can be resumed and from which directory (the command runs after `cd -- <cwd> &&`). It
|
||||
/// thread can be resumed and from which directory (the command runs after `cd -- <cwd> &&`). It
|
||||
/// is split into words like a shell command line, then these placeholders are substituted inside
|
||||
/// their word (so never quote them):
|
||||
/// - `{id}`: the harness's native session id;
|
||||
/// - `{path}`: the session's native transcript (or database) on this machine;
|
||||
/// - `{cwd}`: the session's working directory.
|
||||
/// - `{path}`: the thread's native transcript (or database) on this machine;
|
||||
/// - `{cwd}`: the thread's working directory.
|
||||
///
|
||||
/// The substituted value stays one argument of the harness command, but it is not quoted for a
|
||||
/// shell, so never hand a placeholder to another shell (`sh -c "cd {cwd} && …"`): that shell
|
||||
@@ -84,20 +87,32 @@ impl AiSessionResume {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[ai.sessions]`.
|
||||
/// `[threads]`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct AiSessions {
|
||||
pub struct Threads {
|
||||
/// Whether the daemon captures live coding-agent threads (Claude Code, Codex, ...) into the
|
||||
/// synced record store. Off by default: capture copies the conversation (what was said, and
|
||||
/// the tools called, see [`Self::capture_tools`]) into the encrypted store used by sync, so
|
||||
/// it is strictly opt-in.
|
||||
pub capture: bool,
|
||||
|
||||
/// With [`Self::capture`], also capture what each tool call was given and what it returned
|
||||
/// (commands and their output, files read and written), so a resumed thread has them too. On
|
||||
/// by default, with secrets redacted and each payload held to 64 KiB; off keeps only each
|
||||
/// tool's name.
|
||||
pub capture_tools: bool,
|
||||
|
||||
/// The filter the picker opens in. Unset: `global`. A mode that can't apply here opens in
|
||||
/// `workspace` inside a git repository (`branch` on a detached `HEAD`), else in `global`; and
|
||||
/// `workspace` opens in `global` when the workspace has no sessions at all.
|
||||
/// `workspace` opens in `global` when the workspace has no threads at all.
|
||||
pub filter_mode: Option<AiSessionFilterMode>,
|
||||
|
||||
/// Height of the inline picker. Unset: the top-level `inline_height`.
|
||||
pub inline_height: Option<u16>,
|
||||
|
||||
/// Ask where to resume a session once it's chosen (enter or tab): in its own harness, or
|
||||
/// continued in another one installed here. When false, a session resumes in its own harness
|
||||
/// Ask where to resume a thread once it's chosen (enter or tab): in its own harness, or
|
||||
/// continued in another one installed here. When false, a thread resumes in its own harness
|
||||
/// straight away; the chooser still opens for one that can't.
|
||||
pub resume_chooser: bool,
|
||||
|
||||
@@ -111,9 +126,11 @@ pub struct AiSessions {
|
||||
pub resume: AiSessionResume,
|
||||
}
|
||||
|
||||
impl Default for AiSessions {
|
||||
impl Default for Threads {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
capture: false,
|
||||
capture_tools: true,
|
||||
filter_mode: None,
|
||||
inline_height: None,
|
||||
resume_chooser: true,
|
||||
@@ -131,14 +148,16 @@ mod tests {
|
||||
|
||||
#[rstest]
|
||||
fn empty_table_uses_defaults() {
|
||||
let parsed: AiSessions = toml::from_str("").unwrap();
|
||||
assert_eq!(parsed, AiSessions::default());
|
||||
let parsed: Threads = toml::from_str("").unwrap();
|
||||
assert_eq!(parsed, Threads::default());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn parses_every_field() {
|
||||
let parsed: AiSessions = toml::from_str(
|
||||
let parsed: Threads = toml::from_str(
|
||||
r#"
|
||||
capture = true
|
||||
capture_tools = false
|
||||
filter_mode = "global"
|
||||
inline_height = 20
|
||||
resume_chooser = false
|
||||
@@ -150,6 +169,8 @@ mod tests {
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(parsed.capture);
|
||||
assert!(!parsed.capture_tools);
|
||||
assert_eq!(parsed.filter_mode, Some(AiSessionFilterMode::Global));
|
||||
assert_eq!(parsed.inline_height, Some(20));
|
||||
assert!(!parsed.resume_chooser);
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Terminal UI pieces shared by the interactive views (the history search and `atuin ai resume`).
|
||||
//! Terminal UI pieces shared by the interactive views (the history search and `atuin threads resume`).
|
||||
//!
|
||||
//! - [`key`]: parse key names (`ctrl-r`, `alt-h`, `g g`) and convert crossterm events;
|
||||
//! - [`cursor`]: the single-line text input with word motions.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! result as an ordinary tool call and result, losing the block types the API needs to replay
|
||||
//! them, and a bare `tool_use` with no `tool_result` after it is invalid. Both are dropped
|
||||
//! (one captured without its input becomes a note, as below).
|
||||
//! - **Tool calls captured without their input** (`ai.capture_tools` off, or a policy withheld
|
||||
//! - **Tool calls captured without their input** (`threads.capture_tools` off, or a policy withheld
|
||||
//! it): the API rejects a `tool_use` whose `input` is not an object, so each becomes a note in its turn's
|
||||
//! text (`[ran a shell command]`; see [`tool_note`](crate::harnesstools::note::tool_note)) and its `tool_result` is dropped.
|
||||
//! The run of assistant lines the dropped results stood between is merged into its last line,
|
||||
|
||||
@@ -205,7 +205,7 @@ pub fn assert_the_api_takes(lines: &[serde_json::Value]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What capture syncs with `ai.capture_tools` keeps every call's input and output: written back,
|
||||
/// What capture syncs with `threads.capture_tools` keeps every call's input and output: written back,
|
||||
/// each is a call and its result again, which the API takes. (Not session2: its call ids were
|
||||
/// redacted alike, so its results can't be told apart.)
|
||||
#[rstest]
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
//! API by `id`, which a store-less request cannot resolve. The model's earlier reasoning is not
|
||||
//! something it needs to continue, so these items are left out, and re-capture has no row to
|
||||
//! compare them with.
|
||||
//! - **Tool calls captured without their input** (`ai.capture_tools` off, or a policy withheld
|
||||
//! - **Tool calls captured without their input** (`threads.capture_tools` off, or a policy withheld
|
||||
//! it). A
|
||||
//! `function_call`'s `arguments` must be a string of JSON, a `custom_tool_call`'s `input` a
|
||||
//! string, a `local_shell_call`'s `action` an object: Codex cannot read the line back, let alone
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(crate) fn locate(root: &Path, id: &str) -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `session_meta` field in which a fork atuin wrote (`atuin ai resume`) names the session it
|
||||
/// The `session_meta` field in which a fork atuin wrote (`atuin threads resume`) names the session it
|
||||
/// was forked from when that is a segment of a thread (`<thread>_<rollout>`, see
|
||||
/// [`session_id_of`]): `forked_from_id`, which Codex reads as a thread id, can only name the
|
||||
/// thread. Codex's reader leaves fields it doesn't know be (`SessionMeta` denies none).
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
//! The conversation, and the work done in it:
|
||||
//!
|
||||
//! - What the user said, and what the model answered.
|
||||
//! - **Tool calls, as calls.** A call captured with its input and its result (`ai.capture_tools`)
|
||||
//! - **Tool calls, as calls.** A call captured with its input and its result (`threads.capture_tools`)
|
||||
//! is carried over as a call of the target, with its result: as the target's own tool where it
|
||||
//! has one that does the same (a shell command, a file read, written or edited, a search: see
|
||||
//! `tools`), else as the tool it was. A model reads a call to a tool it doesn't have like any
|
||||
//! other; it just can't make it again.
|
||||
//! - **Other calls, as notes**: a short line of text in the assistant's turn saying what was
|
||||
//! called (see [`tool_note`]): `[ran a shell command]`, ``[edited `src/store.rs`]``. That is a
|
||||
//! call captured without its input (`ai.capture_tools` off, or a policy withheld it), one with no
|
||||
//! call captured without its input (`threads.capture_tools` off, or a policy withheld it), one with no
|
||||
//! result (interrupted, or a Codex web search), and one the model's provider ran itself (a
|
||||
//! Claude Code web search), whose result only that provider reads.
|
||||
//! - Summaries (compactions, abandoned branches), as text of the user's turn. A compaction
|
||||
|
||||
@@ -73,7 +73,7 @@ fn session_of(id: &str, messages: Vec<RehydrateMessage>) -> RehydrateSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// A real session recorded by `source`, as capture holds it (with `ai.capture_tools` on).
|
||||
/// A real session recorded by `source`, as capture holds it (with `threads.capture_tools` on).
|
||||
pub async fn recorded(source: AnyHarness) -> RehydrateSession {
|
||||
let mut session = read(source).await;
|
||||
session.messages = testing::synced_as(session.messages, true);
|
||||
@@ -885,7 +885,7 @@ fn layout(s: &RehydrateSession) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Calls captured with their input and output (`ai.capture_tools`) are carried over as calls, in
|
||||
/// Calls captured with their input and output (`threads.capture_tools`) are carried over as calls, in
|
||||
/// the rows the target writes them in, as the target's own tools where it has them.
|
||||
#[rstest]
|
||||
#[case::claude(CLAUDE, &[
|
||||
|
||||
@@ -71,7 +71,7 @@ pub(crate) fn render(parts: &[Part]) -> String {
|
||||
/// | Pi | `bash`, `edit`, `write`, `read`, `grep`, `find`, `ls` | as Claude Code's (`path`) |
|
||||
/// | any | anything else | ``called `<name>` <compact arguments>`` |
|
||||
///
|
||||
/// Without the input (`ai.capture_tools` off, or withheld), each says only what was done: `ran a
|
||||
/// Without the input (`threads.capture_tools` off, or withheld), each says only what was done: `ran a
|
||||
/// shell command`, `edited a file`, `wrote a file`, `read a file`, `applied a patch`, `searched
|
||||
/// the files`, `listed files`, `handed work to a subagent`, `fetched a web page`, `searched the
|
||||
/// web`; and anything else ``called `<name>` ``. Inputs are cut to their first line and
|
||||
@@ -95,7 +95,7 @@ pub fn tool_note(name: &str, input: &Value) -> String {
|
||||
})
|
||||
};
|
||||
let path = || field(&["file_path", "filePath", "path", "notebook_path", "file"]);
|
||||
// What was done, on what when the input says (unless `ai.capture_tools` is off or a policy
|
||||
// What was done, on what when the input says (unless `threads.capture_tools` is off or a policy
|
||||
// withheld it), else only what.
|
||||
let on = |what: Option<String>, detailed: &dyn Fn(&str) -> String, bare: &str| {
|
||||
Some(what.map_or_else(|| bare.to_owned(), |w| detailed(&w)))
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
//!
|
||||
//! # What is not the same
|
||||
//!
|
||||
//! - **Tool calls captured without their input** (`ai.capture_tools` off, or a policy withheld
|
||||
//! - **Tool calls captured without their input** (`threads.capture_tools` off, or a policy withheld
|
||||
//! it) are not written as tool parts: opencode would send the model a call on no input
|
||||
//! (`{"input": null}`, the object its state needs) with an output it never had. Each becomes a
|
||||
//! text part under the call's own part id, reading as a
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
//! (`packages/schema/src/session-event.ts`) is still moving.
|
||||
//! - **What the event log does not hold**: a session's messages from before its log began --
|
||||
//! opencode's log is younger than its sessions, one of its migrations empties it, and its
|
||||
//! `import` (which `atuin ai resume` writes a session with) writes none -- are read from
|
||||
//! `import` (which `atuin threads resume` writes a session with) writes none -- are read from
|
||||
//! opencode's `session`/`message`/`part` projection, ahead of the log: by [`Session::read`], and
|
||||
//! by the live tail when it takes a session up with no checkpoint in it. [`Sessions::existing`]
|
||||
//! lists the sessions only the projection holds (see `Backlog`).
|
||||
@@ -4921,7 +4921,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The live tail delivers what the projection holds of a session ahead of its log, as a
|
||||
/// one-shot read does: a session `opencode import` wrote (`atuin ai resume --in
|
||||
/// one-shot read does: a session `opencode import` wrote (`atuin threads resume --in
|
||||
/// opencode`) has no log of its history, nor of the marker naming its parent. A restart
|
||||
/// from a checkpoint in the backlog delivers the backlog again, for the consumer to keep
|
||||
/// the first of each id; one from a checkpoint in the log resumes in the log.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! content and are skipped, so pi starts on its configured thinking level; the model is named
|
||||
//! by each assistant turn, its provider guessed from the model's name, and pi falls back to its
|
||||
//! default model when it can't use it. A system-role text entry is skipped too.
|
||||
//! - **Tool calls captured without their input** (`ai.capture_tools` off, or a policy withheld
|
||||
//! - **Tool calls captured without their input** (`threads.capture_tools` off, or a policy withheld
|
||||
//! it): pi-ai sends a `toolCall` without `arguments` as an empty input to Anthropic and as
|
||||
//! `"null"` to OpenAI, a call on nothing either way, so each becomes a note in its turn's text
|
||||
//! (`[ran a shell command]`; see [`tool_note`](crate::harnesstools::note::tool_note)) and its
|
||||
|
||||
@@ -70,7 +70,7 @@ pub enum RehydrateError {
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// What a restored tool result says when capture kept no output (`ai.capture_tools` off, a policy
|
||||
/// What a restored tool result says when capture kept no output (`threads.capture_tools` off, a policy
|
||||
/// withheld it, or it was over the size limit): the formats all want some output for a result, and
|
||||
/// an empty one would tell the model the tool printed nothing.
|
||||
pub const UNCAPTURED_OUTPUT: &str = "(output not captured)";
|
||||
@@ -96,7 +96,7 @@ pub enum Flatten<'f> {
|
||||
},
|
||||
}
|
||||
|
||||
/// A tool call capture kept without its input (`ai.capture_tools` off, a policy withheld it, or it
|
||||
/// A tool call capture kept without its input (`threads.capture_tools` off, a policy withheld it, or it
|
||||
/// was over the size limit): only its name is synced.
|
||||
#[must_use]
|
||||
pub fn is_uncaptured(content: &Content) -> bool {
|
||||
@@ -469,14 +469,14 @@ pub(crate) mod testing {
|
||||
use super::*;
|
||||
use crate::harnesstools::session::{ToolResult, ToolUse};
|
||||
|
||||
/// `content` of a `role` row as capture syncs it with `ai.capture_tools` off (the daemon's
|
||||
/// `content` of a `role` row as capture syncs it with `threads.capture_tools` off (the daemon's
|
||||
/// `sanitize`): calls without their input, results without their output, no patches,
|
||||
/// reasoning as a marker, text only of what the user or the model said, nothing kept raw.
|
||||
pub fn sanitize(role: &Role, content: &[Content]) -> Vec<Content> {
|
||||
sanitize_as(role, content, false)
|
||||
}
|
||||
|
||||
/// [`sanitize`], keeping tool inputs, outputs and patches when `tools` (`ai.capture_tools`
|
||||
/// [`sanitize`], keeping tool inputs, outputs and patches when `tools` (`threads.capture_tools`
|
||||
/// on, the default).
|
||||
pub fn sanitize_as(role: &Role, content: &[Content], tools: bool) -> Vec<Content> {
|
||||
let conversation = matches!(role, Role::User | Role::Assistant);
|
||||
|
||||
@@ -54,7 +54,7 @@ pub enum Content {
|
||||
pub const UNREADABLE_SUMMARY: &str =
|
||||
"[the conversation before this was compacted; its summary can't be read]";
|
||||
|
||||
/// Human-readable breadcrumb shared by the transcript and `atuin ai session` rendering.
|
||||
/// Human-readable breadcrumb shared by the transcript and `atuin threads` rendering.
|
||||
#[must_use]
|
||||
pub fn reasoning_label(tokens: Option<u64>) -> String {
|
||||
tokens.map_or_else(|| "Reasoned".to_owned(), |n| format!("Reasoning · {n} tokens"))
|
||||
|
||||
@@ -54,8 +54,8 @@ const REBUILDING_METADATA: &str = "atuin-ai-sessions-rebuilding";
|
||||
|
||||
fn rebuilding_status((replayed, pending): (u64, u64)) -> Status {
|
||||
let mut status = Status::unavailable(format!(
|
||||
"AI sessions are being rebuilt from the record store ({replayed} of {pending} records); \
|
||||
try again shortly"
|
||||
"Threads are being rebuilt from the record store ({replayed} of {pending} records); try \
|
||||
again shortly"
|
||||
));
|
||||
let progress = MetadataValue::try_from(format!("{replayed}/{pending}"))
|
||||
.expect("digits and a slash are valid metadata");
|
||||
@@ -248,7 +248,7 @@ impl GrpcService for Service {
|
||||
// the caller sees it is unavailable.
|
||||
if !self.capture.is_available() {
|
||||
return Err(Status::failed_precondition(
|
||||
"AI session capture is unavailable: the session store failed to open or recover",
|
||||
"Thread capture is unavailable: the threads store failed to open or recover",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -295,17 +295,28 @@ pub async fn boot(
|
||||
|
||||
let host_id = settings.host_id().await?;
|
||||
|
||||
// A rename that failed midway can leave the WAL apart from its database, so open neither.
|
||||
let migrated = atuin_client::ai_session::migrate_sidecar_name(&settings);
|
||||
let ai_session_db_path = atuin_client::ai_session::sidecar_path(&settings);
|
||||
let ai_session_db = match AiSessionDatabase::open(&ai_session_db_path).await {
|
||||
Ok(db) => Some(db),
|
||||
let ai_session_db = match migrated {
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
?err,
|
||||
path = ?ai_session_db_path,
|
||||
"failed to open the ai-session sidecar; ai-session capture is disabled"
|
||||
"failed to rename ai_session_sidecar.db to threads.db; capture is disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
Ok(()) => match AiSessionDatabase::open(&ai_session_db_path).await {
|
||||
Ok(db) => Some(db),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
?err,
|
||||
path = ?ai_session_db_path,
|
||||
"failed to open the ai-session sidecar; ai-session capture is disabled"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
let ai_session_capture = Arc::new(match &ai_session_db {
|
||||
Some(db) => {
|
||||
@@ -323,8 +334,8 @@ pub async fn boot(
|
||||
AiHarnessSessionCapture::open(
|
||||
records,
|
||||
db.clone(),
|
||||
settings.ai.capture_sessions,
|
||||
session_capture::ToolCapture::from_setting(settings.ai.capture_tools),
|
||||
settings.threads.capture,
|
||||
session_capture::ToolCapture::from_setting(settings.threads.capture_tools),
|
||||
session_capture::CapturePolicy::from_settings(&settings),
|
||||
blocking_pool.clone(),
|
||||
)
|
||||
@@ -368,7 +379,7 @@ pub async fn boot(
|
||||
// Use the already-loaded settings from the watcher
|
||||
// (avoids parsing the config file twice)
|
||||
let new_settings = (*settings_rx.borrow()).clone();
|
||||
ai_session_capture.set_capture(new_settings.ai.capture_sessions);
|
||||
ai_session_capture.set_capture(new_settings.threads.capture);
|
||||
watcher_handle.apply_settings(new_settings).await;
|
||||
}
|
||||
tracing::debug!("config file watcher stopped");
|
||||
|
||||
@@ -49,7 +49,7 @@ pub struct SessionCaptureEngine {
|
||||
impl SessionCaptureEngine {
|
||||
/// Capture every harness's sessions from `since` on: one with nothing stored yet only once
|
||||
/// it changes after, then whole. The rest, sessions nobody touches since, are history, which
|
||||
/// is `atuin ai session import`'s to bring in.
|
||||
/// is `atuin threads import`'s to bring in.
|
||||
pub fn spawn(sink: &Arc<Sink>, pool: &BlockingPool, since: SystemTime) -> Self {
|
||||
let mut listeners = Vec::new();
|
||||
|
||||
@@ -256,7 +256,7 @@ async fn capture(
|
||||
}
|
||||
|
||||
/// The read of `session` started over from the beginning of its transcript, which was replaced
|
||||
/// in place (`atuin ai resume` switching it to another branch writes a new file over it) or cut
|
||||
/// in place (`atuin threads resume` switching it to another branch writes a new file over it) or cut
|
||||
/// short: the line reader reads a file whose identity changed, or that shrank below what it had
|
||||
/// read, again from its first byte, under the same stream, and says so on every line it reads
|
||||
/// after ([`Checkpoint::generation`]).
|
||||
@@ -988,7 +988,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A captured transcript replaced in place (written out again along another branch, as
|
||||
/// `atuin ai resume` switching it does: a new file renamed over it, its title line kept) is
|
||||
/// `atuin threads resume` switching it does: a new file renamed over it, its title line kept) is
|
||||
/// read again from its start while it is followed: the rows already stored are not stored
|
||||
/// again (not even its title line, which capture keys on its content and counts), none is
|
||||
/// lost, the new branch's rows are stored, and the checkpoint is the new file's end, not a
|
||||
|
||||
@@ -22,7 +22,7 @@ use time::OffsetDateTime;
|
||||
pub struct MessageEnricher {
|
||||
harness: HarnessKind,
|
||||
sessions: HashMap<String, SessionState>,
|
||||
/// Whether capture keeps the patches of calls (`ai.capture_tools`).
|
||||
/// Whether capture keeps the patches of calls (`threads.capture_tools`).
|
||||
patches: bool,
|
||||
}
|
||||
|
||||
@@ -138,12 +138,12 @@ impl MessageEnricher {
|
||||
state.parent_kind = m.parent_kind();
|
||||
state.native_parent = true;
|
||||
}
|
||||
// A session continued from another (`atuin ai resume --in`), maybe another harness's:
|
||||
// A session continued from another (`atuin threads resume --in`), maybe another harness's:
|
||||
// its first line, which the harness wrote itself, names it. Unless the harness names a
|
||||
// parent itself: a fork of a continuation (Claude Code `--fork-session`, Codex and
|
||||
// opencode forks, Pi `/fork`) copies the marker with the rest, but forks the
|
||||
// continuation, not the session the marker names. An opencode fork's marker
|
||||
// (`atuin ai resume --fork`) stands for the link opencode can't write: it names the parent
|
||||
// (`atuin threads resume --fork`) stands for the link opencode can't write: it names the parent
|
||||
// as a harness would, and a marker copied in after it doesn't replace it.
|
||||
if !state.native_parent
|
||||
&& let Some((harness, parent, kind)) = continuation::linked_from_message(m)
|
||||
@@ -1008,7 +1008,7 @@ mod parser_contract {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Continuations (`atuin ai resume --in`) ----
|
||||
// ---- Continuations (`atuin threads resume --in`) ----
|
||||
|
||||
const ORIGINAL: &str = "019a0d14-f276-77d3-b955-89d5b0151306";
|
||||
|
||||
@@ -1145,7 +1145,7 @@ mod parser_contract {
|
||||
}
|
||||
}
|
||||
|
||||
/// An opencode fork (`atuin ai resume --fork`) names its original in a fork marker, in text
|
||||
/// An opencode fork (`atuin threads resume --fork`) names its original in a fork marker, in text
|
||||
/// opencode wrote itself (here a Claude Code `isMeta` line, read the same way): its rows are a
|
||||
/// fork of the original, and a continuation's marker copied in after it doesn't move them.
|
||||
#[rstest]
|
||||
|
||||
@@ -54,10 +54,10 @@ pub enum AppendError {
|
||||
Push(#[from] PushError),
|
||||
/// The store is unavailable (its recovery or a rebuild failed): its sidecar may be missing
|
||||
/// persisted messages, so the dedup gate cannot be trusted and nothing is written.
|
||||
#[error("the AI session store is unavailable")]
|
||||
#[error("the threads store is unavailable")]
|
||||
Unavailable,
|
||||
/// The append panicked, or the runtime is shutting down.
|
||||
#[error("the AI session capture stopped unexpectedly")]
|
||||
#[error("thread capture stopped unexpectedly")]
|
||||
Aborted,
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ pub enum RebuildError {
|
||||
/// Opening or recovering the store failed at startup: there is nothing to rebuild with until
|
||||
/// the daemon restarts, which rebuilds anyway. (A store that recovered but whose rebuild then
|
||||
/// failed can be rebuilt again.)
|
||||
#[error("the AI session store is unavailable: it failed to open or recover at startup")]
|
||||
#[error("the threads store is unavailable: it failed to open or recover at startup")]
|
||||
Unavailable,
|
||||
#[error(transparent)]
|
||||
Sidecar(#[from] DbError),
|
||||
/// The rebuild panicked, or the runtime is shutting down.
|
||||
#[error("the AI session rebuild stopped unexpectedly")]
|
||||
#[error("the threads rebuild stopped unexpectedly")]
|
||||
Aborted,
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ impl Sink {
|
||||
/// is answered: a session's lines come here in order, live and imported. One made before the
|
||||
/// daemon (re)started is found among the session's stored rows, the first time the session
|
||||
/// comes in, which capture's locks (held) make trustworthy; so is where the session runs. A stored call whose input
|
||||
/// wasn't captured (made while `ai.capture_tools` was off, or dropped), or whose written
|
||||
/// wasn't captured (made while `threads.capture_tools` was off, or dropped), or whose written
|
||||
/// content was withheld, can't be checked, so its output is withheld too.
|
||||
///
|
||||
/// Judging a call is the costly part (its input, whole files at times, is searched for
|
||||
@@ -672,7 +672,7 @@ fn bulky(msg: &Message) -> bool {
|
||||
const BULKY: usize = 256 * 1024;
|
||||
|
||||
/// Retain conversation text, and of tool calls what `tools` keeps: their inputs and outputs,
|
||||
/// unless `ai.capture_tools` is off, `policy` withholds them, or they are over the size limit
|
||||
/// unless `threads.capture_tools` is off, `policy` withholds them, or they are over the size limit
|
||||
/// (then null, which keeps the wire format without storing them). Reasoning is kept only as a
|
||||
/// marker; raw blocks, and text on roles other than user and assistant, are dropped. Text is
|
||||
/// redacted as `policy` does, and text that takes too long to redact is not kept.
|
||||
@@ -844,7 +844,7 @@ pub struct AiHarnessSessionCapture {
|
||||
coordinator: Option<mpsc::UnboundedSender<Msg>>,
|
||||
/// How far the replay running has got.
|
||||
progress: ReprojectProgress,
|
||||
/// Whether capture is enabled (`ai.capture_sessions`), followed once the store is ready.
|
||||
/// Whether capture is enabled (`threads.capture`), followed once the store is ready.
|
||||
capture: watch::Sender<bool>,
|
||||
/// The coordinator, and the capture engine once startup recovery succeeds; aborted on drop.
|
||||
background: Vec<JoinHandle<()>>,
|
||||
@@ -998,7 +998,7 @@ impl AiHarnessSessionCapture {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn capture on or off, as `ai.capture_sessions` is changed in a running daemon. Takes
|
||||
/// Turn capture on or off, as `threads.capture` is changed in a running daemon. Takes
|
||||
/// effect once the store is first ready, as at startup; a nop facade never captures.
|
||||
pub fn set_capture(&self, enabled: bool) {
|
||||
self.capture.send_if_modified(|on| std::mem::replace(on, enabled) != enabled);
|
||||
@@ -1196,7 +1196,7 @@ mod tests {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Turning `ai.capture_sessions` on or off in a running daemon starts or stops capture,
|
||||
/// Turning `threads.capture` on or off in a running daemon starts or stops capture,
|
||||
/// without a restart; setting it to what it already is leaves capture running as it was.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
@@ -1256,7 +1256,7 @@ mod tests {
|
||||
assert_eq!(uuid::Uuid::from(fixed).get_version(), Some(uuid::Version::SortRand));
|
||||
}
|
||||
|
||||
/// A continuation (`atuin ai resume --in`) or a fork (`--fork`) is a new session: it gets an
|
||||
/// A continuation (`atuin threads resume --in`) or a fork (`--fork`) is a new session: it gets an
|
||||
/// atuin id of its own, not the one of the session it came from, whose rows keep theirs.
|
||||
#[rstest]
|
||||
#[case::continuation(atuin_common::harnesstools::session::ParentKind::Continuation)]
|
||||
@@ -1392,7 +1392,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// With `ai.capture_tools`, a call's input and output reach the records, the sidecar and a
|
||||
/// With `threads.capture_tools`, a call's input and output reach the records, the sidecar and a
|
||||
/// rebuild, secrets redacted. Its input is searchable; its output is not indexed.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
@@ -1505,7 +1505,7 @@ mod tests {
|
||||
restarted.append(row(5, Role::User, vec![result("again")])).await.unwrap();
|
||||
assert_last_output_withheld(&sink.sidecar, &rows[0].session).await;
|
||||
|
||||
// As is one made while `ai.capture_tools` was off, whose input wasn't kept to check,
|
||||
// As is one made while `threads.capture_tools` was off, whose input wasn't kept to check,
|
||||
// answered after a restart that turned it on.
|
||||
let names = Sink::with_state(
|
||||
sink.records.clone(),
|
||||
@@ -3895,7 +3895,7 @@ mod pipeline_tests {
|
||||
assert!(!msg.content.is_empty(), "summary text retained");
|
||||
}
|
||||
|
||||
/// A continuation's marker (`atuin ai resume --in`) is harness-injected text, which is not
|
||||
/// A continuation's marker (`atuin threads resume --in`) is harness-injected text, which is not
|
||||
/// synced; the link it makes is, as every row's parent, so it survives sync and reprojection.
|
||||
#[rstest]
|
||||
fn a_continuation_marker_is_dropped_but_its_parent_is_kept() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! What capture keeps of a tool call's input and output (`ai.capture_tools`).
|
||||
//! What capture keeps of a tool call's input and output (`threads.capture_tools`).
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -140,7 +140,7 @@ arg-config-disable-feature = The feature to disable
|
||||
value-config-feature-daemon = The background daemon and daemon-backed search
|
||||
value-config-feature-output-capture = Command output capture, via the daemon and the pty proxy
|
||||
value-config-feature-octavo = Use Octavo, once it is enabled for your account on the hub
|
||||
value-config-feature-ai-sessions = Capture your AI coding agents' sessions, to search and resume them
|
||||
value-config-feature-threads = Capture your AI coding agents' threads, to search and resume them
|
||||
config-enable-octavo-not-logged-in = You are not logged in to the hub, so it cannot say whether Octavo is enabled for your account. Run 'atuin login' first.
|
||||
config-enable-octavo-not-enabled = Octavo is not enabled for your account on { $address }.
|
||||
config-enable-octavo-description = Octavo is Atuin's sync for sharing and searching your history.
|
||||
@@ -150,18 +150,18 @@ config-enable-octavo-backfill = Also upload the { $count } entries already in yo
|
||||
config-enable-octavo-backfilled = Queued { $count } history entries for upload.
|
||||
config-enable-octavo-backfill-old = { $count } entries recorded before Atuin v14 were left out: Octavo only takes the history ids Atuin has used since.
|
||||
|
||||
ai-sessions-offer = Enable AI sessions?
|
||||
ai-sessions-offer-description = Atuin records your coding agents' sessions (Claude Code, Codex, opencode and Pi), so you can search them and resume them here or on another machine. They're encrypted and synced like your history.
|
||||
ai-sessions-enabled = AI sessions are on: sessions your agents work in from now on are captured.
|
||||
ai-sessions-import = Import past sessions?
|
||||
ai-sessions-import-description = Bring in the sessions your agents already have on this machine too.
|
||||
ai-sessions-import-tools = Tool calls are included.
|
||||
ai-sessions-import-later = Run 'atuin ai session import' any time to import past sessions.
|
||||
ai-sessions-no-daemon = The Atuin daemon couldn't be started: { $error }
|
||||
Start it, then run 'atuin ai session import' to import past sessions.
|
||||
ai-sessions-import-failed = Couldn't import past sessions: { $error }
|
||||
Run 'atuin ai session import' to try again.
|
||||
ai-sessions-failed = Couldn't enable AI sessions: { $error }
|
||||
threads-offer = Enable threads?
|
||||
threads-offer-description = Atuin records your coding agents' threads (Claude Code, Codex, opencode and Pi), so you can search them and resume them here or on another machine. They're encrypted and synced like your history.
|
||||
threads-enabled = Threads are on: threads your agents work in from now on are captured.
|
||||
threads-import = Import past threads?
|
||||
threads-import-description = Bring in the threads your agents already have on this machine too.
|
||||
threads-import-tools = Tool calls are included.
|
||||
threads-import-later = Run 'atuin threads import' any time to import past threads.
|
||||
threads-no-daemon = The Atuin daemon couldn't be started: { $error }
|
||||
Start it, then run 'atuin threads import' to import past threads.
|
||||
threads-import-failed = Couldn't import past threads: { $error }
|
||||
Run 'atuin threads import' to try again.
|
||||
threads-failed = Couldn't enable threads: { $error }
|
||||
arg-config-print-key = Print the value of a specific key and all its children
|
||||
arg-daemon-daemonize = Internal flag for daemonization
|
||||
arg-daemon-show-logs = Also write daemon logs to the console (useful for debugging)
|
||||
@@ -234,7 +234,7 @@ cmd-import-xonsh-sqlite = Import history from xonsh sqlite db
|
||||
arg-init-disable-ctrl-r = Disable the binding of CTRL-R to atuin
|
||||
arg-init-disable-up-arrow = Disable the binding of the Up Arrow key to atuin
|
||||
arg-init-disable-ai = Disable the binding of ? to Atuin AI
|
||||
arg-init-bind-ai-resume = Bind CTRL-] to atuin ai resume (zsh, bash and fish; off by default: it replaces the shell's own binding)
|
||||
arg-init-bind-ai-resume = Bind CTRL-] to atuin threads resume (zsh, bash and fish; off by default: it replaces the shell's own binding)
|
||||
value-init-shell-zsh = Zsh setup
|
||||
value-init-shell-bash = Bash setup
|
||||
value-init-shell-fish = Fish setup
|
||||
|
||||
@@ -115,7 +115,12 @@ pub enum Cmd {
|
||||
#[command(subcommand)]
|
||||
Ai(atuin_ai::commands::Command),
|
||||
|
||||
/// Pick a captured AI coding-agent session and resume it (short for `atuin ai resume`)
|
||||
/// Search, read and resume your coding agents' captured threads
|
||||
#[cfg(feature = "ai")]
|
||||
#[command(subcommand)]
|
||||
Threads(atuin_ai::commands::ThreadsCmd),
|
||||
|
||||
/// Pick a captured coding-agent thread and resume it (short for `atuin threads resume`)
|
||||
#[cfg(feature = "ai")]
|
||||
Resume(atuin_ai::commands::ResumeCmd),
|
||||
|
||||
@@ -166,10 +171,21 @@ pub enum Cmd {
|
||||
|
||||
impl Cmd {
|
||||
pub fn run(self) -> Result<()> {
|
||||
// `atuin resume` is `atuin ai resume`, so it runs exactly as that does.
|
||||
// `atuin threads` and `atuin resume` are `atuin ai`'s, so they run exactly as those do, and
|
||||
// `atuin threads enable` is `atuin config enable threads`.
|
||||
#[cfg(feature = "ai")]
|
||||
if let Self::Resume(cmd) = self {
|
||||
return Self::Ai(atuin_ai::commands::Command::Resume(cmd)).run();
|
||||
match self {
|
||||
#[cfg(feature = "daemon")]
|
||||
Self::Threads(atuin_ai::commands::ThreadsCmd::Enable)
|
||||
| Self::Ai(atuin_ai::commands::Command::Threads(
|
||||
atuin_ai::commands::ThreadsCmd::Enable,
|
||||
)) => {
|
||||
let feature = config::Feature::Threads;
|
||||
return Self::Config(config::Cmd::Enable(config::EnableCmd { feature })).run();
|
||||
}
|
||||
Self::Threads(cmd) => return Self::Ai(cmd.into()).run(),
|
||||
Self::Resume(cmd) => return Self::Ai(atuin_ai::commands::Command::Resume(cmd)).run(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Daemonize before creating the async runtime – fork() inside a live
|
||||
@@ -322,15 +338,15 @@ impl Cmd {
|
||||
Self::Update(_) => unreachable!(),
|
||||
|
||||
#[cfg(feature = "ai")]
|
||||
Self::Resume(_) => unreachable!(),
|
||||
Self::Threads(_) | Self::Resume(_) => unreachable!(),
|
||||
|
||||
#[cfg(feature = "ai")]
|
||||
Self::Ai(cli) => {
|
||||
// Resume is what sessions are for, so while capture is off it offers to turn it
|
||||
// Resume is what threads are for, so while capture is off it offers to turn it
|
||||
// on, every time.
|
||||
#[cfg(feature = "daemon")]
|
||||
if let atuin_ai::commands::Command::Resume(cmd) = &cli
|
||||
&& !settings.ai.capture_sessions
|
||||
&& !settings.threads.capture
|
||||
&& cmd.is_interactive()
|
||||
&& let Some(mut term) = ai_sessions::Terminal::tty()
|
||||
&& let Some(enabled) = ai_sessions::offer(&settings, &mut term).await
|
||||
@@ -338,7 +354,7 @@ impl Cmd {
|
||||
settings = enabled;
|
||||
}
|
||||
|
||||
// Resume reads the session database itself, which a daemon of an older build
|
||||
// Resume reads the threads database itself, which a daemon of an older build
|
||||
// keeps at an older schema: replace one, as history does, when Atuin manages it.
|
||||
#[cfg(feature = "daemon")]
|
||||
if matches!(cli, atuin_ai::commands::Command::Resume(_))
|
||||
@@ -349,10 +365,10 @@ impl Cmd {
|
||||
tracing::debug!(?e, "couldn't make the daemon ready before resuming");
|
||||
}
|
||||
|
||||
// Session commands talk to the daemon: start or replace it as history does,
|
||||
// Threads commands talk to the daemon: start or replace it as history does,
|
||||
// instead of failing against a stopped or stale one.
|
||||
#[cfg(feature = "daemon")]
|
||||
if matches!(cli, atuin_ai::commands::Command::Session(_)) {
|
||||
if matches!(cli, atuin_ai::commands::Command::Threads(_)) {
|
||||
daemon::ready_client(&settings).await?;
|
||||
}
|
||||
|
||||
@@ -426,12 +442,27 @@ mod tests {
|
||||
assert!(!format!("{:?}", cli.cmd).contains(SECRET));
|
||||
}
|
||||
|
||||
/// `atuin resume` takes what `atuin ai resume` does.
|
||||
/// `atuin resume`, `atuin threads resume` and (as it was) `atuin ai resume` take the same.
|
||||
#[cfg(feature = "ai")]
|
||||
#[rstest]
|
||||
fn resume_is_short_for_ai_resume() {
|
||||
let cli = Cli::try_parse_from(["atuin", "resume", "0199abcdef", "--in", "codex"])
|
||||
#[case::short(&["resume"])]
|
||||
#[case::threads(&["threads", "resume"])]
|
||||
#[case::ai(&["ai", "resume"])]
|
||||
fn resume_parses_everywhere(#[case] command: &[&str]) {
|
||||
let args = std::iter::once(&"atuin").chain(command).chain(&["0199abcdef", "--in", "codex"]);
|
||||
Cli::try_parse_from(args).expect("the argv parses");
|
||||
}
|
||||
|
||||
/// `atuin ai session` is still `atuin threads`.
|
||||
#[cfg(feature = "ai")]
|
||||
#[rstest]
|
||||
fn ai_session_is_still_threads() {
|
||||
let cli = Cli::try_parse_from(["atuin", "ai", "session", "list", "--style", "json"])
|
||||
.expect("the argv parses");
|
||||
assert!(matches!(cli.cmd, Cmd::Resume(_)), "{:?}", cli.cmd);
|
||||
assert!(
|
||||
matches!(cli.cmd, Cmd::Ai(atuin_ai::commands::Command::Threads(_))),
|
||||
"{:?}",
|
||||
cli.cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Turning AI session capture on: `atuin config enable ai-sessions`, and the offer `atuin resume`
|
||||
//! Turning thread capture on: `atuin config enable threads`, and the offer `atuin resume`
|
||||
//! makes each time it runs while capture is off.
|
||||
|
||||
use std::io::{self, BufRead, BufReader, IsTerminal, Write};
|
||||
@@ -113,12 +113,12 @@ impl Drop for Cooked {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer to turn AI sessions on, as `atuin resume` does while they're off. Returns the settings
|
||||
/// Offer to turn threads on, as `atuin resume` does while they're off. Returns the settings
|
||||
/// with them on if the user said yes and they could be. Failures are reported on the terminal
|
||||
/// rather than returned, so resume still goes on.
|
||||
pub async fn offer(settings: &Settings, term: &mut Terminal) -> Option<Settings> {
|
||||
let enabled = async {
|
||||
if !term.ask(&fl!("ai-sessions-offer"), &fl!("ai-sessions-offer-description"), None)? {
|
||||
if !term.ask(&fl!("threads-offer"), &fl!("threads-offer-description"), None)? {
|
||||
return Ok(None);
|
||||
}
|
||||
enable(settings, term).await.map(Some)
|
||||
@@ -128,20 +128,19 @@ pub async fn offer(settings: &Settings, term: &mut Terminal) -> Option<Settings>
|
||||
match enabled {
|
||||
Ok(settings) => settings,
|
||||
Err(e) => {
|
||||
let _ =
|
||||
writeln!(term.output, "{}", fl!("ai-sessions-failed", error = format!("{e:#}")));
|
||||
let _ = writeln!(term.output, "{}", fl!("threads-failed", error = format!("{e:#}")));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn AI sessions on, with the daemon they need, and offer to import the sessions from before.
|
||||
/// Turn threads on, with the daemon they need, and offer to import the threads from before.
|
||||
/// Returns the settings as they now are.
|
||||
pub async fn enable(settings: &Settings, term: &mut Terminal) -> Result<Settings> {
|
||||
let config_file = Settings::get_config_path()?;
|
||||
let config_str = tokio::fs::read_to_string(&config_file).await?;
|
||||
let updated = EnableCmd {
|
||||
feature: Feature::AiSessions,
|
||||
feature: Feature::Threads,
|
||||
}
|
||||
.get_updated_config(&config_str, settings.daemon.enabled)?;
|
||||
tokio::fs::write(&config_file, &updated).await?;
|
||||
@@ -150,25 +149,21 @@ pub async fn enable(settings: &Settings, term: &mut Terminal) -> Result<Settings
|
||||
|
||||
// A daemon already running picks the change up itself; this starts one that isn't.
|
||||
if let Err(e) = daemon::ready_client(&settings).await {
|
||||
writeln!(term.output, "{}", fl!("ai-sessions-no-daemon", error = format!("{e:#}")))?;
|
||||
writeln!(term.output, "{}", fl!("threads-no-daemon", error = format!("{e:#}")))?;
|
||||
return Ok(settings);
|
||||
}
|
||||
writeln!(term.output, "{}", fl!("ai-sessions-enabled"))?;
|
||||
writeln!(term.output, "{}", fl!("threads-enabled"))?;
|
||||
|
||||
let note = settings.ai.capture_tools.then(|| fl!("ai-sessions-import-tools"));
|
||||
if term.ask(
|
||||
&fl!("ai-sessions-import"),
|
||||
&fl!("ai-sessions-import-description"),
|
||||
note.as_deref(),
|
||||
)? {
|
||||
let note = settings.threads.capture_tools.then(|| fl!("threads-import-tools"));
|
||||
if term.ask(&fl!("threads-import"), &fl!("threads-import-description"), note.as_deref())? {
|
||||
// Capture is on whether or not this works, so it's no reason to say it isn't.
|
||||
if let Err(e) = atuin_ai::commands::import_all(&settings, &mut term.output, term.live).await
|
||||
{
|
||||
let error = format!("{e:#}");
|
||||
writeln!(term.output, "{}", fl!("ai-sessions-import-failed", error = error))?;
|
||||
writeln!(term.output, "{}", fl!("threads-import-failed", error = error))?;
|
||||
}
|
||||
} else {
|
||||
writeln!(term.output, "{}", fl!("ai-sessions-import-later"))?;
|
||||
writeln!(term.output, "{}", fl!("threads-import-later"))?;
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
|
||||
@@ -269,14 +269,14 @@ pub enum Feature {
|
||||
#[value(help = fl!("value-config-feature-octavo"))]
|
||||
Octavo,
|
||||
#[cfg(all(feature = "ai", feature = "daemon"))]
|
||||
#[value(help = fl!("value-config-feature-ai-sessions"))]
|
||||
AiSessions,
|
||||
#[value(help = fl!("value-config-feature-threads"), alias = "ai-sessions")]
|
||||
Threads,
|
||||
}
|
||||
|
||||
impl EnableCmd {
|
||||
pub async fn run(self, settings: &Arc<Settings>) -> Result<()> {
|
||||
#[cfg(all(feature = "ai", feature = "daemon"))]
|
||||
if self.feature == Feature::AiSessions {
|
||||
if self.feature == Feature::Threads {
|
||||
ai_sessions::enable(settings, &mut ai_sessions::Terminal::stdio()).await?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -352,8 +352,8 @@ impl EnableCmd {
|
||||
#[cfg(feature = "octavo")]
|
||||
Feature::Octavo => set_deep_key(&mut doc, "octavo.enabled", Value::from(true))?,
|
||||
#[cfg(all(feature = "ai", feature = "daemon"))]
|
||||
Feature::AiSessions => {
|
||||
set_deep_key(&mut doc, "ai.capture_sessions", Value::from(true))?;
|
||||
Feature::Threads => {
|
||||
set_deep_key(&mut doc, "threads.capture", Value::from(true))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@ impl DisableCmd {
|
||||
Feature::Octavo => restart_autostarted_daemon(settings).await,
|
||||
// The daemon notices the change itself, and the shell doesn't care.
|
||||
#[cfg(feature = "ai")]
|
||||
Feature::AiSessions => return Ok(()),
|
||||
Feature::Threads => return Ok(()),
|
||||
}
|
||||
|
||||
println!("Restart your shell for the change to take effect.");
|
||||
@@ -502,8 +502,8 @@ impl DisableCmd {
|
||||
#[cfg(feature = "octavo")]
|
||||
Feature::Octavo => set_deep_key(&mut doc, "octavo.enabled", Value::from(false))?,
|
||||
#[cfg(all(feature = "ai", feature = "daemon"))]
|
||||
Feature::AiSessions => {
|
||||
set_deep_key(&mut doc, "ai.capture_sessions", Value::from(false))?;
|
||||
Feature::Threads => {
|
||||
set_deep_key(&mut doc, "threads.capture", Value::from(false))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,20 +847,20 @@ mod tests {
|
||||
)]
|
||||
#[cfg_attr(
|
||||
all(feature = "ai", feature = "daemon"),
|
||||
case::ai_sessions(
|
||||
Feature::AiSessions,
|
||||
case::threads(
|
||||
Feature::Threads,
|
||||
"",
|
||||
false,
|
||||
"[daemon]\nenabled = true\nautostart = true\n\n[ai]\ncapture_sessions = true\n"
|
||||
"[daemon]\nenabled = true\nautostart = true\n\n[threads]\ncapture = true\n"
|
||||
)
|
||||
)]
|
||||
#[cfg_attr(
|
||||
all(feature = "ai", feature = "daemon"),
|
||||
case::ai_sessions_with_daemon_already_enabled(
|
||||
Feature::AiSessions,
|
||||
case::threads_with_daemon_already_enabled(
|
||||
Feature::Threads,
|
||||
"[daemon]\nenabled = true\nautostart = false\n",
|
||||
true,
|
||||
"[daemon]\nenabled = true\nautostart = false\n\n[ai]\ncapture_sessions = true\n"
|
||||
"[daemon]\nenabled = true\nautostart = false\n\n[threads]\ncapture = true\n"
|
||||
)
|
||||
)]
|
||||
fn enable_writes(
|
||||
@@ -881,10 +881,10 @@ mod tests {
|
||||
#[case::output_capture(Feature::OutputCapture, "", "[output]\nenabled = false\n")]
|
||||
#[cfg_attr(
|
||||
all(feature = "ai", feature = "daemon"),
|
||||
case::ai_sessions(
|
||||
Feature::AiSessions,
|
||||
"[daemon]\nenabled = true\n\n[ai]\ncapture_sessions = true\n",
|
||||
"[daemon]\nenabled = true\n\n[ai]\ncapture_sessions = false\n"
|
||||
case::threads(
|
||||
Feature::Threads,
|
||||
"[daemon]\nenabled = true\n\n[threads]\ncapture = true\n",
|
||||
"[daemon]\nenabled = true\n\n[threads]\ncapture = false\n"
|
||||
)
|
||||
)]
|
||||
#[case::daemon_search(
|
||||
|
||||
@@ -554,7 +554,7 @@ pub async fn project_synced_ai_sessions(settings: &Settings) -> Result<()> {
|
||||
Ok(Err(err)) if is_legacy_daemon_error(&err) => Ok(()),
|
||||
Ok(projected) => projected,
|
||||
Err(_) => {
|
||||
eprintln!("Indexing AI sessions in the background");
|
||||
eprintln!("Indexing threads in the background");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ struct StaticInitOptions<'a> {
|
||||
pub enable_ctrl_r: bool,
|
||||
#[cfg_attr(not(feature = "ai"), allow(dead_code))]
|
||||
pub enable_ai: bool,
|
||||
/// Bind ctrl-] to `atuin ai resume`. Off unless asked for: it replaces the shell's own
|
||||
/// Bind ctrl-] to `atuin threads resume`. Off unless asked for: it replaces the shell's own
|
||||
/// binding (character search). The widgets are defined either way.
|
||||
#[cfg_attr(not(feature = "ai"), allow(dead_code))]
|
||||
pub enable_ai_resume: bool,
|
||||
|
||||
@@ -29,7 +29,7 @@ fn print_bindings(
|
||||
}
|
||||
}
|
||||
|
||||
/// ctrl-] opens `atuin ai resume`, in the default and insert modes.
|
||||
/// ctrl-] opens `atuin threads resume`, in the default and insert modes.
|
||||
fn print_ai_resume_bindings(indent: &str, options: &StaticInitOptions<'_>, key: &str) {
|
||||
if cfg!(feature = "ai") && options.enable_ai_resume {
|
||||
println!("{indent}bind {key} _atuin_ai_resume");
|
||||
|
||||
@@ -62,7 +62,7 @@ pub enum Cmd {
|
||||
/// has already happened.
|
||||
pub async fn invalidate_ai_sessions(settings: &Settings) {
|
||||
if let Err(err) = atuin_client::ai_session::invalidate_sidecar(settings).await {
|
||||
eprintln!("Failed to schedule a rebuild of the ai session index: {err}");
|
||||
eprintln!("Failed to schedule a rebuild of the threads index: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,18 +84,18 @@ pub async fn reset_ai_sessions(settings: &Settings) -> Result<()> {
|
||||
{
|
||||
if !settings.daemon.enabled {
|
||||
eyre::bail!(
|
||||
"AI sessions need the daemon: enable it (`enabled = true` under `[daemon]` in the \
|
||||
config) to rebuild the AI session index"
|
||||
"Threads need the daemon: enable it (`enabled = true` under `[daemon]` in the \
|
||||
config) to rebuild the threads index"
|
||||
);
|
||||
}
|
||||
daemon::rebuild_ai_sessions(settings)
|
||||
.await
|
||||
.wrap_err("the daemon could not rebuild the AI session index")
|
||||
.wrap_err("the daemon could not rebuild the threads index")
|
||||
}
|
||||
#[cfg(not(feature = "daemon"))]
|
||||
{
|
||||
let _ = settings;
|
||||
eyre::bail!("AI sessions need the daemon, which this build of atuin does not include");
|
||||
eyre::bail!("Threads need the daemon, which this build of atuin does not include");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +107,8 @@ pub async fn reset_ai_sessions_after(settings: &Settings) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
reset_ai_sessions(settings).await.wrap_err(
|
||||
"the records changed, but the AI session index was not rebuilt and still lists what they \
|
||||
held: run `atuin store rebuild ai-session` once the daemon is enabled and running",
|
||||
"the records changed, but the threads index was not rebuilt and still lists what they \
|
||||
held: run `atuin store rebuild threads` once the daemon is enabled and running",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Pull {
|
||||
println!("Clearing local store");
|
||||
store.delete_all().await?;
|
||||
let pulled = self.pull(settings, store, db).await;
|
||||
// Records only this machine had are gone, and the ai session index must forget what
|
||||
// Records only this machine had are gone, and the threads index must forget what
|
||||
// they projected: rebuilt from what the store holds now, however far the pull got.
|
||||
let reset = super::reset_ai_sessions_after(settings).await;
|
||||
match (pulled, reset) {
|
||||
|
||||
@@ -19,7 +19,7 @@ impl Purge {
|
||||
Err(e) => println!("Failed to purge local store: {e:?}"),
|
||||
}
|
||||
// Even a failed purge may have deleted some records, which a replay would not take out
|
||||
// of the ai session index.
|
||||
// of the threads index.
|
||||
super::reset_ai_sessions_after(settings).await
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,10 @@ impl Rebuild {
|
||||
self.rebuild_scripts(settings, store.clone()).await?;
|
||||
}
|
||||
|
||||
// The daemon owns the sidecar, and rebuilds it.
|
||||
"ai-session" => {
|
||||
// The daemon owns the sidecar, and rebuilds it. `ai-session` is the threads' record tag.
|
||||
"threads" | "ai-session" => {
|
||||
super::reset_ai_sessions(settings).await?;
|
||||
println!("The daemon is rebuilding the ai session index");
|
||||
println!("The daemon is rebuilding the threads index");
|
||||
}
|
||||
|
||||
tag => {
|
||||
|
||||
@@ -160,7 +160,7 @@ pub(super) async fn build(
|
||||
&& !downloaded.is_empty()
|
||||
&& let Err(e) = daemon::project_synced_ai_sessions(settings).await
|
||||
{
|
||||
eprintln!("Warning: failed to build ai sessions: {e}");
|
||||
eprintln!("Warning: failed to build threads: {e}");
|
||||
}
|
||||
|
||||
built
|
||||
|
||||
@@ -429,7 +429,7 @@ __atuin_history() {
|
||||
fi
|
||||
}
|
||||
|
||||
# `atuin ai resume`: pick a captured AI coding-agent session. The result uses
|
||||
# `atuin threads resume`: pick a captured AI coding-agent session. The result uses
|
||||
# the same protocol as the history search: `__atuin_accept__:` runs it.
|
||||
__atuin_ai_resume() {
|
||||
# READLINE_LINE and READLINE_POINT are only supported by bash >= 4.0 or
|
||||
@@ -439,7 +439,7 @@ __atuin_ai_resume() {
|
||||
local READLINE_LINE="" READLINE_POINT=0
|
||||
|
||||
local __atuin_output
|
||||
if ! __atuin_output=$(ATUIN_SHELL=bash ATUIN_QUERY=$READLINE_LINE atuin ai resume --shell-widget "$@" 3>&1 1>&2 2>&3 3>&-); then
|
||||
if ! __atuin_output=$(ATUIN_SHELL=bash ATUIN_QUERY=$READLINE_LINE atuin threads resume --shell-widget "$@" 3>&1 1>&2 2>&3 3>&-); then
|
||||
[[ $__atuin_output ]] && printf '%s\n' "$__atuin_output" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -189,7 +189,7 @@ function _atuin_search
|
||||
commandline -f repaint
|
||||
end
|
||||
|
||||
# `atuin ai resume`: pick a captured AI coding-agent session. The result uses
|
||||
# `atuin threads resume`: pick a captured AI coding-agent session. The result uses
|
||||
# the same protocol as the history search: `__atuin_accept__:` runs it.
|
||||
function _atuin_ai_resume
|
||||
set -l keymap_mode
|
||||
@@ -205,7 +205,7 @@ function _atuin_ai_resume
|
||||
set keymap_mode emacs
|
||||
end
|
||||
|
||||
set -l ATUIN_H (ATUIN_SHELL=fish ATUIN_QUERY=(commandline -b) atuin ai resume --shell-widget --keymap-mode=$keymap_mode $argv 3>&1 1>&2 2>&3 3>&- | string collect)
|
||||
set -l ATUIN_H (ATUIN_SHELL=fish ATUIN_QUERY=(commandline -b) atuin threads resume --shell-widget --keymap-mode=$keymap_mode $argv 3>&1 1>&2 2>&3 3>&- | string collect)
|
||||
set -l ATUIN_STATUS $pipestatus[1]
|
||||
|
||||
if test "$ATUIN_STATUS" -ne 0
|
||||
|
||||
@@ -240,14 +240,14 @@ _atuin_up_search_viins() {
|
||||
_atuin_up_search --keymap-mode=vim-insert
|
||||
}
|
||||
|
||||
# `atuin ai resume`: pick a captured AI coding-agent session. The result uses
|
||||
# `atuin threads resume`: pick a captured AI coding-agent session. The result uses
|
||||
# the same protocol as the history search: `__atuin_accept__:` runs it.
|
||||
_atuin_ai_resume() {
|
||||
emulate -L zsh
|
||||
zle -I
|
||||
|
||||
local output __atuin_status
|
||||
output=$(ATUIN_SHELL=zsh ATUIN_QUERY=$BUFFER atuin ai resume --shell-widget "$@" 3>&1 1>&2 2>&3 3>&-)
|
||||
output=$(ATUIN_SHELL=zsh ATUIN_QUERY=$BUFFER atuin threads resume --shell-widget "$@" 3>&1 1>&2 2>&3 3>&-)
|
||||
__atuin_status=$?
|
||||
|
||||
zle reset-prompt
|
||||
|
||||
@@ -16,7 +16,7 @@ macOS, with Homebrew Bash on macOS.
|
||||
- `e2e_ai_sessions`: AI agent sessions captured from each agent's transcripts,
|
||||
synced between two machines through an in-process sync server, and resumed:
|
||||
restored, caught up, forked, switched, or continued in another agent.
|
||||
- `e2e_ai_resume_picker`: `atuin ai resume`'s picker and chooser on a PTY, and
|
||||
- `e2e_ai_resume_picker`: `atuin threads resume`'s picker and chooser on a PTY, and
|
||||
through each shell's widget.
|
||||
|
||||
The AI session tests write transcripts the way Claude Code, Codex, opencode and
|
||||
|
||||
@@ -37,7 +37,7 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Its executable, and the name `atuin ai resume --in` takes.
|
||||
/// Its executable, and the name `atuin threads resume --in` takes.
|
||||
pub fn program(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
|
||||
@@ -125,18 +125,18 @@ pub fn shared_dir() -> TempDir {
|
||||
impl Machine {
|
||||
/// A machine on its own, syncing nowhere.
|
||||
pub fn new() -> Self {
|
||||
Self::with_config("", "capture_sessions = true\n")
|
||||
Self::with_config("", "capture = true\n")
|
||||
}
|
||||
|
||||
/// A machine syncing nowhere with the settings `top` (top-level keys, then any tables) and
|
||||
/// the `[ai]` settings `ai`.
|
||||
pub fn with_config(top: &str, ai: &str) -> Self {
|
||||
Self::configured(None, top, ai)
|
||||
/// the `[threads]` settings `threads`.
|
||||
pub fn with_config(top: &str, threads: &str) -> Self {
|
||||
Self::configured(None, top, threads)
|
||||
}
|
||||
|
||||
/// A machine with the settings `top` and `[ai]` settings `ai`, syncing through the server at
|
||||
/// A machine with the settings `top` and `[threads]` settings `threads`, syncing through the server at
|
||||
/// `sync`, if any: its daemon syncing every so many seconds, or (0) never.
|
||||
fn configured(sync: Option<(&str, u32)>, top: &str, ai: &str) -> Self {
|
||||
fn configured(sync: Option<(&str, u32)>, top: &str, threads: &str) -> Self {
|
||||
let env = FreshEnv::new();
|
||||
let bin = tempfile::Builder::new().prefix("bin-").tempdir_in(target_tmp()).unwrap();
|
||||
install_fakes(bin.path(), &Agent::ALL);
|
||||
@@ -153,7 +153,7 @@ impl Machine {
|
||||
};
|
||||
env.write_config(&format!(
|
||||
"auto_sync = {auto_sync}\n{address}{top}[daemon]\nenabled = true\nautostart = \
|
||||
false\n{frequency}[ai]\n{ai}"
|
||||
false\n{frequency}[threads]\n{threads}"
|
||||
));
|
||||
// Finish migrations before the daemon and the commands race for the databases.
|
||||
env.run(&["store", "status"]);
|
||||
@@ -167,29 +167,29 @@ impl Machine {
|
||||
/// Two machines of one account on `server`, their daemons running and syncing as often as
|
||||
/// they can.
|
||||
pub fn pair(server: &SyncServer) -> (Self, Self) {
|
||||
Self::pair_syncing(server, 1, "capture_sessions = true\n")
|
||||
Self::pair_syncing(server, 1, "capture = true\n")
|
||||
}
|
||||
|
||||
/// Two machines of one account on `server` with the `[ai]` settings `ai`, their daemons
|
||||
/// Two machines of one account on `server` with the `[threads]` settings `threads`, their daemons
|
||||
/// running: the first's syncing as often as it can, the second's every `sync_frequency`
|
||||
/// seconds, or (0) never.
|
||||
pub fn pair_syncing(server: &SyncServer, sync_frequency: u32, ai: &str) -> (Self, Self) {
|
||||
let one = Self::syncing(server, None, 1, ai);
|
||||
pub fn pair_syncing(server: &SyncServer, sync_frequency: u32, threads: &str) -> (Self, Self) {
|
||||
let one = Self::syncing(server, None, 1, threads);
|
||||
let key = one.run(&["key", "--base64"]).trim().to_owned();
|
||||
let two = Self::syncing(server, Some((&one, &key)), sync_frequency, ai);
|
||||
let two = Self::syncing(server, Some((&one, &key)), sync_frequency, threads);
|
||||
(one, two)
|
||||
}
|
||||
|
||||
/// A machine syncing through `server`, its daemon every `sync_frequency` seconds, with the
|
||||
/// `[ai]` settings `ai`: registering a new account, or logging into `login`'s with its key.
|
||||
/// `[threads]` settings `threads`: registering a new account, or logging into `login`'s with its key.
|
||||
/// Logged in before its daemon starts, which keeps the key it starts with.
|
||||
fn syncing(
|
||||
server: &SyncServer,
|
||||
login: Option<(&Self, &str)>,
|
||||
sync_frequency: u32,
|
||||
ai: &str,
|
||||
threads: &str,
|
||||
) -> Self {
|
||||
let mut machine = Self::configured(Some((&server.address, sync_frequency)), "", ai);
|
||||
let mut machine = Self::configured(Some((&server.address, sync_frequency)), "", threads);
|
||||
match login {
|
||||
None => {
|
||||
let user = machine.user();
|
||||
@@ -251,7 +251,7 @@ impl Machine {
|
||||
let socket = self.env.socket();
|
||||
wait("the daemon", || socket.exists());
|
||||
self.daemon = Some(daemon);
|
||||
// `ai session` commands wait for its first replay of the store, so once one answers, the
|
||||
// `threads` commands wait for its first replay of the store, so once one answers, the
|
||||
// daemon is capturing.
|
||||
self.sessions();
|
||||
}
|
||||
@@ -269,20 +269,20 @@ impl Machine {
|
||||
self.daemon.as_ref().map(Process::logs).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every session the daemon has, as `atuin ai session list` gives them.
|
||||
/// Every session the daemon has, as `atuin threads list` gives them.
|
||||
pub fn sessions(&self) -> Vec<Value> {
|
||||
let list = self.run(&["ai", "session", "list", "--style", "json"]);
|
||||
let list = self.run(&["threads", "list", "--style", "json"]);
|
||||
serde_json::from_str::<Value>(&list).unwrap().as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
/// The session `id` (the agent's), as `atuin ai session list` gives it.
|
||||
/// The session `id` (the agent's), as `atuin threads list` gives it.
|
||||
pub fn session(&self, id: &str) -> Option<Value> {
|
||||
self.sessions().into_iter().find(|s| s["session_id"] == id)
|
||||
}
|
||||
|
||||
/// The session `id` and its messages, as `atuin ai session show` gives them.
|
||||
/// The session `id` and its messages, as `atuin threads show` gives them.
|
||||
pub fn show(&self, id: &str) -> Value {
|
||||
let show = self.run(&["ai", "session", "show", id, "--style", "json"]);
|
||||
let show = self.run(&["threads", "show", id, "--style", "json"]);
|
||||
serde_json::from_str(&show).unwrap()
|
||||
}
|
||||
|
||||
@@ -341,10 +341,10 @@ impl Machine {
|
||||
})
|
||||
}
|
||||
|
||||
/// `atuin ai resume args`, run from `cwd` as a shell there would, without a terminal: never
|
||||
/// `atuin threads resume args`, run from `cwd` as a shell there would, without a terminal: never
|
||||
/// the picker, which a test can't answer.
|
||||
pub fn resume(&self, args: &[&str], cwd: &Path) -> Output {
|
||||
let mut command = self.atuin(&[&["ai", "resume"], args].concat());
|
||||
let mut command = self.atuin(&[&["threads", "resume"], args].concat());
|
||||
command.current_dir(cwd).env("PWD", cwd);
|
||||
// SAFETY: `setsid` is async-signal-safe and touches no memory of this process.
|
||||
unsafe {
|
||||
@@ -383,7 +383,7 @@ impl Drop for Machine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of each message of `show` (`atuin ai session show --style json`) that says something
|
||||
/// The text of each message of `show` (`atuin threads show --style json`) that says something
|
||||
/// in conversation: a prompt, or a reply.
|
||||
pub fn said_in(show: &Value) -> Vec<String> {
|
||||
show["messages"]
|
||||
@@ -397,7 +397,7 @@ pub fn said_in(show: &Value) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One tool call of a session, as `atuin ai session show --style json` gives it.
|
||||
/// One tool call of a session, as `atuin threads show --style json` gives it.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Call {
|
||||
pub name: String,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `atuin ai resume`'s picker on a terminal: choosing a session, and where to resume it, runs the
|
||||
//! `atuin threads resume`'s picker on a terminal: choosing a session, and where to resume it, runs the
|
||||
//! agent on it -- straight from the picker, and through each shell's widget, with and without
|
||||
//! `enter_accept`.
|
||||
|
||||
@@ -47,10 +47,10 @@ fn two_sessions(machine: &mut Machine, project: &Path) -> (Transcript, Turn) {
|
||||
/// header.
|
||||
fn pick(pty: &PtyShell, turn: &Turn) {
|
||||
let word = turn.prompt.split(' ').next_back().unwrap();
|
||||
pty.wait_for("2 sessions");
|
||||
pty.wait_for("2 threads");
|
||||
pty.send_str(word);
|
||||
pty.wait_for_screen("only the session searched for", |s| {
|
||||
s.contains(" 1 session") && s.contains(word)
|
||||
s.contains(" 1 thread") && s.contains(word)
|
||||
});
|
||||
pty.send_enter();
|
||||
pty.wait_for("esc back");
|
||||
@@ -77,13 +77,13 @@ fn line_for(pty: &PtyShell, agent: Agent) -> char {
|
||||
#[case::another_agent(Some(Agent::Codex))]
|
||||
fn the_picker_resumes_the_session_chosen(#[case] into: Option<Agent>) {
|
||||
let project = shared_dir();
|
||||
let mut machine = Machine::with_config("enter_accept = true\n", "capture_sessions = true\n");
|
||||
let mut machine = Machine::with_config("enter_accept = true\n", "capture = true\n");
|
||||
let (session, turn) = two_sessions(&mut machine, project.path());
|
||||
|
||||
let vars = BTreeMap::from([("PATH".to_owned(), machine.path_var())]);
|
||||
let pty = PtyShell::spawn(
|
||||
Path::new(env!("CARGO_BIN_EXE_atuin")),
|
||||
&["ai".to_owned(), "resume".to_owned()],
|
||||
&["threads".to_owned(), "resume".to_owned()],
|
||||
&machine.env,
|
||||
&vars,
|
||||
);
|
||||
@@ -113,7 +113,7 @@ fn the_shell_widget_resumes_the_session_chosen(
|
||||
};
|
||||
let project = shared_dir();
|
||||
let top = format!("enter_accept = {enter_accept}\n");
|
||||
let mut machine = Machine::with_config(&top, "capture_sessions = true\n");
|
||||
let mut machine = Machine::with_config(&top, "capture = true\n");
|
||||
let (session, turn) = two_sessions(&mut machine, project.path());
|
||||
|
||||
let vars = BTreeMap::from([("PATH".to_owned(), machine.path_var())]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! AI coding-agent sessions, end to end: captured from each agent's own transcripts by the daemon,
|
||||
//! synced between machines, and resumed with `atuin ai resume` -- restored, caught up, forked,
|
||||
//! synced between machines, and resumed with `atuin threads resume` -- restored, caught up, forked,
|
||||
//! switched or continued in another agent -- by the real binary, against transcripts written the
|
||||
//! way each agent writes them.
|
||||
//!
|
||||
@@ -47,7 +47,7 @@ fn project() -> TempDir {
|
||||
|
||||
/// Whether `query` finds session `id` in `machine`'s search.
|
||||
fn finds(machine: &Machine, query: &str, id: &str) -> bool {
|
||||
let found = machine.run(&["ai", "session", "search", query, "--style", "json"]);
|
||||
let found = machine.run(&["threads", "search", query, "--style", "json"]);
|
||||
let found: serde_json::Value = serde_json::from_str(&found).unwrap();
|
||||
found.as_array().unwrap().iter().any(|m| m["session"]["session_id"] == id)
|
||||
}
|
||||
@@ -68,8 +68,8 @@ fn capture_keeps_the_conversation_and_tool_calls(
|
||||
#[values(Agent::Claude, Agent::Codex, Agent::Opencode, Agent::Pi)] agent: Agent,
|
||||
#[values(true, false)] capture_tools: bool,
|
||||
) {
|
||||
let ai = format!("capture_sessions = true\ncapture_tools = {capture_tools}\n");
|
||||
let mut machine = Machine::with_config("", &ai);
|
||||
let threads = format!("capture = true\ncapture_tools = {capture_tools}\n");
|
||||
let mut machine = Machine::with_config("", &threads);
|
||||
machine.start_daemon();
|
||||
let secret = format!("ghp_{}{}", &word()[1..], "abcd");
|
||||
let mut turns = vec![Turn::new(), Turn::new()];
|
||||
@@ -124,7 +124,7 @@ fn capture_keeps_the_conversation_and_tool_calls(
|
||||
fn capture_applies_the_history_and_output_filters_to_agent_commands(project: TempDir) {
|
||||
let top =
|
||||
"history_filter = [\"^echo dropped-\"]\n[output]\ncommand_filter = [\"^echo quiet-\"]\n";
|
||||
let mut machine = Machine::with_config(top, "capture_sessions = true\n");
|
||||
let mut machine = Machine::with_config(top, "capture = true\n");
|
||||
machine.start_daemon();
|
||||
let mut turns = vec![Turn::new(), Turn::new(), Turn::new()];
|
||||
turns[0].command = format!("echo dropped-{}", word());
|
||||
@@ -189,11 +189,11 @@ fn a_renamed_session_is_listed_under_its_new_title(machine: Machine, project: Te
|
||||
|
||||
/// Turning capture on in the config takes effect in the running daemon, which captures the
|
||||
/// sessions the agents work in from then on. One they wrote while it was off is history, left
|
||||
/// alone until the agent goes on with it, and then captured whole; or until `atuin ai session
|
||||
/// alone until the agent goes on with it, and then captured whole; or until `atuin threads
|
||||
/// import` brings it in.
|
||||
#[rstest]
|
||||
fn capture_starts_when_the_config_turns_it_on(project: TempDir) {
|
||||
let mut machine = Machine::with_config("", "capture_sessions = false\n");
|
||||
let mut machine = Machine::with_config("", "capture = false\n");
|
||||
machine.start_daemon();
|
||||
let old: Vec<Turn> = (0..2).map(|_| Turn::new()).collect();
|
||||
let resumed = Transcript::create(machine.home(), Agent::Claude, project.path(), &old[..1]);
|
||||
@@ -204,9 +204,7 @@ fn capture_starts_when_the_config_turns_it_on(project: TempDir) {
|
||||
assert_eq!(machine.sessions(), Vec::<serde_json::Value>::new(), "captured while off");
|
||||
|
||||
let config = machine.env.config_file();
|
||||
let on = std::fs::read_to_string(&config)
|
||||
.unwrap()
|
||||
.replace("capture_sessions = false", "capture_sessions = true");
|
||||
let on = std::fs::read_to_string(&config).unwrap().replace("capture = false", "capture = true");
|
||||
std::fs::write(&config, on).unwrap();
|
||||
// Longer than the daemon takes to see the config change.
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
@@ -218,11 +216,11 @@ fn capture_starts_when_the_config_turns_it_on(project: TempDir) {
|
||||
machine.wait_until_said(&resumed.id, &said(&old));
|
||||
assert_eq!(machine.session(&untouched.id), None, "history captured unasked");
|
||||
|
||||
machine.run(&["ai", "session", "import"]);
|
||||
machine.run(&["threads", "import"]);
|
||||
machine.wait_until_said(&untouched.id, &said(&idle));
|
||||
}
|
||||
|
||||
/// The command `atuin ai resume --print` prints to resume `transcript` in its own agent, from
|
||||
/// The command `atuin threads resume --print` prints to resume `transcript` in its own agent, from
|
||||
/// `cwd`.
|
||||
fn resume_line(transcript: &Transcript, cwd: &Path) -> String {
|
||||
let id = transcript.id.as_str();
|
||||
@@ -269,10 +267,10 @@ fn the_picker_needs_a_terminal(machine: Machine, #[case] args: &[&str]) {
|
||||
}
|
||||
|
||||
const NEEDS_A_TERMINAL: &str = "Error: the picker needs a terminal; to resume without one, name \
|
||||
the session: `atuin ai resume <id>` (`atuin ai session list` \
|
||||
the thread: `atuin threads resume <id>` (`atuin threads list` \
|
||||
lists them)";
|
||||
|
||||
/// Nor with only stdout on a terminal, and that not its own (`setsid -w atuin ai resume
|
||||
/// Nor with only stdout on a terminal, and that not its own (`setsid -w atuin threads resume
|
||||
/// </dev/null` in a terminal): there's no terminal to read keys from.
|
||||
#[rstest]
|
||||
fn the_picker_needs_a_terminal_to_read_keys_from(machine: Machine) {
|
||||
@@ -286,7 +284,7 @@ fn the_picker_needs_a_terminal_to_read_keys_from(machine: Machine) {
|
||||
unlockpt(&pty).unwrap();
|
||||
let name = ptsname(&pty, Vec::new()).unwrap();
|
||||
let terminal = rustix::fs::open(name.as_c_str(), OFlags::RDWR | OFlags::NOCTTY, Mode::empty());
|
||||
let mut command = machine.atuin(&["ai", "resume"]);
|
||||
let mut command = machine.atuin(&["threads", "resume"]);
|
||||
command.current_dir(machine.home()).stdin(Stdio::null()).stdout(terminal.unwrap());
|
||||
// SAFETY: `setsid` is async-signal-safe and touches no memory of this process.
|
||||
unsafe {
|
||||
@@ -362,7 +360,7 @@ fn restored(agent: Agent) -> Restored {
|
||||
.unwrap_or_else(|| panic!("nothing restored: {stdout} {stderr}"));
|
||||
assert_eq!(stdout.trim(), resume_line(&restored, project.path()), "{stderr}");
|
||||
// Where it ran is here too, and the status says what was written all the same.
|
||||
assert!(stderr.contains("atuin: restored the session from sync\n"), "{stderr}");
|
||||
assert!(stderr.contains("atuin: restored the thread from sync\n"), "{stderr}");
|
||||
assert!(!stderr.lines().any(|line| line.contains("locked")), "{stderr}");
|
||||
Restored {
|
||||
_server: server,
|
||||
@@ -417,7 +415,7 @@ fn a_session_from_another_machine_restores_as_the_same_session(
|
||||
#[rstest]
|
||||
fn a_session_synced_by_hand_is_listed_at_once(project: TempDir) {
|
||||
let server = SyncServer::start();
|
||||
let (there, here) = Machine::pair_syncing(&server, 0, "capture_sessions = true\n");
|
||||
let (there, here) = Machine::pair_syncing(&server, 0, "capture = true\n");
|
||||
let turns = [Turn::new()];
|
||||
let original = Transcript::create(there.home(), Agent::Claude, project.path(), &turns);
|
||||
there.wait_until_said(&original.id, &said(&turns));
|
||||
@@ -475,7 +473,7 @@ fn a_session_gone_on_with_elsewhere_catches_up_where_it_came_from(
|
||||
) {
|
||||
let server = SyncServer::start();
|
||||
let (there, here) =
|
||||
Machine::pair_syncing(&server, 1, "capture_sessions = true\ncapture_tools = false\n");
|
||||
Machine::pair_syncing(&server, 1, "capture = true\ncapture_tools = false\n");
|
||||
let mut turns = vec![Turn::new()];
|
||||
let original = Transcript::create(there.home(), agent, project.path(), &turns);
|
||||
here.wait_until_said(&original.id, &said(&turns));
|
||||
@@ -708,9 +706,9 @@ fn rebuilding_the_index_reproduces_it(machine: Machine, project: TempDir) {
|
||||
ids.iter().map(|id| machine.show(id)).collect()
|
||||
};
|
||||
let before = index(&machine);
|
||||
let out = machine.run(&["store", "rebuild", "ai-session"]);
|
||||
let out = machine.run(&["store", "rebuild", "threads"]);
|
||||
assert!(out.contains("rebuilding"), "{out}");
|
||||
// `ai session` commands wait for the rebuild.
|
||||
// `threads` commands wait for the rebuild.
|
||||
assert_eq!(index(&machine), before);
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ async fn stale_ai_session_index(env: &FreshEnv) -> PathBuf {
|
||||
use atuin_common::harnesstools::session::{Content, Role};
|
||||
|
||||
std::fs::create_dir_all(env.data_dir()).unwrap();
|
||||
let path = env.data_dir().joined("ai_session_sidecar.db");
|
||||
let path = env.data_dir().joined("threads.db");
|
||||
let db = AiSessionDatabase::open(&path).await.unwrap();
|
||||
let msg = Message::builder()
|
||||
.id(atuin_domain::record::RecordId(atuin_common::utils::uuid_v7()))
|
||||
@@ -290,10 +290,10 @@ async fn ai_session_rebuild_without_the_daemon_fails_and_touches_nothing(
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{stderr}");
|
||||
if !enabled {
|
||||
assert!(stderr.contains("AI sessions need the daemon"), "{stderr}");
|
||||
assert!(stderr.contains("Threads need the daemon"), "{stderr}");
|
||||
}
|
||||
if purge {
|
||||
assert!(stderr.contains("atuin store rebuild ai-session"), "{stderr}");
|
||||
assert!(stderr.contains("atuin store rebuild threads"), "{stderr}");
|
||||
}
|
||||
assert!(!daemon.env.socket().exists(), "the daemon should not have been started");
|
||||
assert_eq!(indexed_sessions(&index).await, 1, "the index was touched");
|
||||
|
||||
@@ -258,7 +258,7 @@ fn filter_switching_changes_results(
|
||||
run_echo_marker(pty, &format!("{marker}-resumed"));
|
||||
}
|
||||
|
||||
/// The `atuin ai resume` widget hands its pick back through a pipe, as the history widgets do,
|
||||
/// The `atuin threads resume` widget hands its pick back through a pipe, as the history widgets do,
|
||||
/// and must not hand that pipe down: a process `atuin` leaves running (one an import started)
|
||||
/// would hold it open, and the prompt would hang until it exited. Stands in for `atuin` with a
|
||||
/// function that leaves one behind, with its standard error elsewhere.
|
||||
@@ -284,7 +284,7 @@ fn ai_resume_widget_returns_while_a_process_it_left_runs(
|
||||
// function's saved copies of the pipe (close-on-exec, but never exec'd), which `atuin`
|
||||
// itself, a program, never has.
|
||||
pty.send_line(&format!(
|
||||
"atuin() {{ if [ \"$1\" = ai ]; then (env sleep 60 2>/dev/null &); echo \
|
||||
"atuin() {{ if [ \"$1\" = threads ]; then (env sleep 60 2>/dev/null &); echo \
|
||||
\"__atuin_accept__:echo {marker}-resumed\" >&2; else command atuin \"$@\"; fi; }}"
|
||||
));
|
||||
pty.send_line(bind);
|
||||
|
||||
+18
-12
@@ -60,18 +60,6 @@ Default: `true`
|
||||
|
||||
Display tips in Atuin AI at the bottom of agent turns.
|
||||
|
||||
### `capture_sessions`
|
||||
|
||||
Default: `false`
|
||||
|
||||
Capture the sessions of your AI coding agents (Claude Code, Codex, opencode and Pi) into the encrypted record store, and sync them. Only sessions your agents work in after you turn it on are captured; `atuin ai session import` brings in the rest. `atuin config enable ai-sessions` sets it, along with the daemon it needs. See [Agent sessions](./sessions.md).
|
||||
|
||||
### `capture_tools`
|
||||
|
||||
Default: `true`
|
||||
|
||||
With `capture_sessions`, also capture what each tool call was given and what it returned, and the diff of each edit, so a resumed session has its tool calls and edits back. Set it to `false` to keep only the name of each tool called. See [What's captured](./sessions.md#whats-captured).
|
||||
|
||||
## Capabilities
|
||||
|
||||
Settings that control what capabilities are sent to the LLM, which the LLM uses to understand what features the client has available. These are specified under `[ai.capabilities]`.
|
||||
@@ -136,3 +124,21 @@ Whether to send your previous command as context in the initial request, so the
|
||||
[ai.opening]
|
||||
send_last_command = true
|
||||
```
|
||||
|
||||
## Threads
|
||||
|
||||
Settings for capturing your AI coding agents' sessions as [threads](./threads.md). These are specified under `[threads]`. Before `[threads]`, they were `capture_sessions` and `capture_tools` under `[ai]`, which still work when the `[threads]` setting isn't set.
|
||||
|
||||
### `capture`
|
||||
|
||||
Default: `false`
|
||||
|
||||
Capture the sessions of your AI coding agents (Claude Code, Codex, opencode and Pi) as threads into the encrypted record store, and sync them. Only sessions your agents work in after you turn it on are captured; `atuin threads import` brings in the rest. `atuin config enable threads` sets it, along with the daemon it needs. See [Threads](./threads.md).
|
||||
|
||||
### `capture_tools`
|
||||
|
||||
Default: `true`
|
||||
|
||||
With `capture`, also capture what each tool call was given and what it returned, and the diff of each edit, so a resumed session has its tool calls and edits back. Set it to `false` to keep only the name of each tool called. See [What's captured](./threads.md#whats-captured).
|
||||
|
||||
The picker's own settings also go under `[threads]`: see [Settings](./threads.md#settings).
|
||||
@@ -1,23 +1,24 @@
|
||||
# Agent sessions
|
||||
# Threads
|
||||
|
||||
To turn them on, run:
|
||||
A thread is an AI coding agent's session as Atuin captured it. To turn capture
|
||||
on, run:
|
||||
|
||||
```shell
|
||||
atuin config enable ai-sessions
|
||||
atuin threads enable
|
||||
```
|
||||
|
||||
That sets `capture_sessions = true` under `[ai]`, turns on the daemon if it
|
||||
That's short for `atuin config enable threads`. It sets `capture = true` under `[threads]`, turns on the daemon if it
|
||||
isn't already, and offers to import the sessions your agents already have.
|
||||
`atuin resume` offers the same each time it runs while they're off, and
|
||||
`atuin config disable ai-sessions` turns them off again.
|
||||
`atuin resume` offers the same each time it runs while capture is off, and
|
||||
`atuin config disable threads` turns it off again.
|
||||
|
||||
With `capture_sessions = true` under `[ai]`, the Atuin daemon records the
|
||||
sessions of your AI coding agents (Claude Code, Codex, opencode and Pi) into the
|
||||
encrypted record store, and syncs them like your history. `atuin ai session`
|
||||
lists, shows, searches and follows them.
|
||||
With `capture = true` under `[threads]`, the Atuin daemon records the
|
||||
sessions of your AI coding agents (Claude Code, Codex, opencode and Pi) as
|
||||
threads into the encrypted record store, and syncs them like your history.
|
||||
`atuin threads` lists, shows, searches and follows them.
|
||||
|
||||
The daemon keeps a local search index of these sessions beside the record
|
||||
store, and brings it up to date as sessions are captured, synced in from your
|
||||
The daemon keeps a local search index of these threads beside the record
|
||||
store, and brings it up to date as threads are captured, synced in from your
|
||||
other machines, or imported.
|
||||
|
||||
Capture starts from when you turn it on: a session your agents already had
|
||||
@@ -25,7 +26,7 @@ then is left alone until you go on with it, and then it's captured whole. To
|
||||
bring in the rest too, import them:
|
||||
|
||||
```shell
|
||||
atuin ai session import
|
||||
atuin threads import
|
||||
```
|
||||
|
||||
That imports every session the agents have on this machine (or just one
|
||||
@@ -42,7 +43,7 @@ that the model reasoned.
|
||||
|
||||
When an agent edits a file, capture also keeps the diff the agent recorded for
|
||||
the edit (Claude Code, Codex, opencode and pi each keep one), so a resumed
|
||||
session shows its edits as the agent showed them, and `atuin ai session show`
|
||||
session shows its edits as the agent showed them, and `atuin threads show`
|
||||
prints them. A diff names its file and the lines changed, with a few lines
|
||||
around them, not the whole file. Search covers the names of the files a session
|
||||
changed.
|
||||
@@ -85,28 +86,28 @@ Text that can't be redacted quickly isn't kept. To keep more out, see
|
||||
|
||||
Tool calls can be a lot of your codebase, not just your conversations. To keep
|
||||
only the name of each tool called (and whether the call failed), and no diffs, set
|
||||
`capture_tools = false` under `[ai]`:
|
||||
`capture_tools = false` under `[threads]`:
|
||||
|
||||
```toml
|
||||
[ai]
|
||||
capture_sessions = true
|
||||
[threads]
|
||||
capture = true
|
||||
capture_tools = false
|
||||
```
|
||||
|
||||
These settings, and the filters above, apply to sessions captured or imported
|
||||
after the daemon restarts. Sessions already captured keep what they had.
|
||||
These settings, and the filters above, apply to threads captured or imported
|
||||
after the daemon restarts. Threads already captured keep what they had.
|
||||
|
||||
## Searching
|
||||
|
||||
```shell
|
||||
atuin ai session search "flaky test"
|
||||
atuin threads search "flaky test"
|
||||
```
|
||||
|
||||
- Each word of the query matches a whole word: `atuin ai session search
|
||||
"migration"` finds sessions mentioning migration, but `"migr"` doesn't.
|
||||
- Each word of the query matches a whole word: `atuin threads search
|
||||
"migration"` finds threads mentioning migration, but `"migr"` doesn't.
|
||||
The `atuin resume` picker searches as you type, so there the last word
|
||||
also matches as a prefix once it's at least two characters long.
|
||||
- An empty query (`atuin ai session search ""`) lists the newest sessions
|
||||
- An empty query (`atuin threads search ""`) lists the newest threads
|
||||
first, rather than returning nothing.
|
||||
- `--harness` limits the search to one agent.
|
||||
|
||||
@@ -114,8 +115,8 @@ atuin ai session search "flaky test"
|
||||
|
||||
`atuin resume` (or a key you bind to it, see
|
||||
[Binding `atuin resume`](../reference/init.md#binding-atuin-resume)) opens a picker over
|
||||
your sessions. It works like the history search: type to search, ++ctrl+o++
|
||||
inspects the selected session, and ++esc++ leaves. `atuin ai resume` is the
|
||||
your threads. It works like the history search: type to search, ++ctrl+o++
|
||||
inspects the selected thread, and ++esc++ leaves. `atuin threads resume` is the
|
||||
same command, under its longer name.
|
||||
|
||||
No key opens it by default. To open it with ++ctrl+bracket-right++ (which
|
||||
@@ -149,10 +150,10 @@ to `atuin init`; or bind the widget to a key of your choice after the
|
||||
Through the widget, the command it picks lands in your shell, as with the
|
||||
history search.
|
||||
|
||||
Each session is two lines: its title (`untitled` when it has none), then, dimmed,
|
||||
Each thread is two lines: its title (`untitled` when it has none), then, dimmed,
|
||||
when it was last active, its agent (`CC`, `CX`, `OC` or `PI`), where it ran (its
|
||||
repository, branch and, for a session recorded on another machine, that machine, by
|
||||
the end of its host id), and how many messages it has. Newest first, the sessions are
|
||||
repository, branch and, for a thread recorded on another machine, that machine, by
|
||||
the end of its host id), and how many messages it has. Newest first, the threads are
|
||||
grouped under a heading for each day, the top one staying in sight as you scroll;
|
||||
with search text they're ranked by relevance instead.
|
||||
|
||||
@@ -162,21 +163,21 @@ Today ────────────────────────
|
||||
● now · CC · atuin · ai-resume · 142 msgs
|
||||
```
|
||||
|
||||
The scopes are listed beside the query, the one shown highlighted: `all` sessions
|
||||
The scopes are listed beside the query, the one shown highlighted: `all` threads
|
||||
(where the picker opens), the current `repo`'s, its `branch`'s, the current `dir`'s,
|
||||
and this machine's (`host`). ++ctrl+r++ moves to the next. `repo` and `branch` are
|
||||
left out outside a git repository, and `branch` on a detached `HEAD`.
|
||||
|
||||
The help line says what the keys do to the selected session.
|
||||
The help line says what the keys do to the selected thread.
|
||||
|
||||
Words in the query of the form `filter:value` narrow the search further, and show
|
||||
as chips in the input:
|
||||
|
||||
- `agent:<name>` (or `a:<name>` for short): only that agent's sessions, with
|
||||
- `agent:<name>` (or `a:<name>` for short): only that agent's threads, with
|
||||
`claude-code` (or `claude`, `cc`), `codex` (`cx`), `opencode` (`oc`) or `pi`.
|
||||
++ctrl+t++ cycles through them in turn, and back to all agents.
|
||||
- `m:<model>`: only sessions whose model contains this, as in `m:opus`.
|
||||
- `b:<branch>`: only sessions on this git branch.
|
||||
- `m:<model>`: only threads whose model contains this, as in `m:opus`.
|
||||
- `b:<branch>`: only threads on this git branch.
|
||||
|
||||
Put a `\` before a word to search for it as text instead (`\b:main`).
|
||||
|
||||
@@ -210,7 +211,7 @@ top-level `no_mouse = true` does the same for both the picker and the history
|
||||
search):
|
||||
|
||||
```toml
|
||||
[ai.sessions]
|
||||
[threads]
|
||||
mouse = false
|
||||
```
|
||||
|
||||
@@ -226,8 +227,8 @@ mouse = false
|
||||
nearly all such children are subagents, so the picker treats them as such
|
||||
(they still resume by id).
|
||||
|
||||
Once you choose a session, with ++enter++ (or ++tab++ to edit the command
|
||||
first), Atuin asks where to resume it, under the session in the list, the two
|
||||
Once you choose a thread, with ++enter++ (or ++tab++ to edit the command
|
||||
first), Atuin asks where to resume it, under the thread in the list, the two
|
||||
in a border:
|
||||
|
||||
```
|
||||
@@ -348,7 +349,7 @@ chooser always edits. ++ctrl+y++ copies the command.
|
||||
To resume in the session's own agent straight away, without the chooser:
|
||||
|
||||
```toml
|
||||
[ai.sessions]
|
||||
[threads]
|
||||
resume_chooser = false
|
||||
```
|
||||
|
||||
@@ -370,8 +371,8 @@ switched to; without it, Atuin lists the branches to choose from. `--as-is`,
|
||||
when it names several, Atuin lists them and asks you to be more specific.
|
||||
`--print` prints the command instead of running it.
|
||||
|
||||
The id can be the agent's own, or the session's Atuin id, which Inspect and
|
||||
the MCP session tools show. An Atuin id is a UUIDv7, written as 32 hex digits
|
||||
The id can be the agent's own, or the thread's Atuin id, which Inspect and
|
||||
the MCP threads tools show. An Atuin id is a UUIDv7, written as 32 hex digits
|
||||
like a history id. It's fixed when Atuin first records the session, and is the
|
||||
same for every agent and on every machine. Its first 12 digits are when the
|
||||
session started, so a prefix needs more digits than that to name a single
|
||||
@@ -381,14 +382,14 @@ session.
|
||||
|
||||
The picker follows the history search's `style`, `invert`, `show_preview`,
|
||||
`max_preview_height`, `enter_accept`, `keymap_mode` and theme settings. Its own
|
||||
go under `[ai.sessions]`:
|
||||
go under `[threads]`:
|
||||
|
||||
```toml
|
||||
[ai.sessions]
|
||||
[threads]
|
||||
## The filter the picker opens in: "global" (the default), "workspace", "host",
|
||||
## "directory" or "branch". "workspace" and "branch" open in "global" outside a
|
||||
## git repository, "branch" opens in "workspace" on a detached HEAD, and
|
||||
## "workspace" opens in "global" when the workspace has no sessions at all.
|
||||
## "workspace" opens in "global" when the workspace has no threads at all.
|
||||
## `--filter-mode` overrides it.
|
||||
filter_mode = "global"
|
||||
|
||||
@@ -401,7 +402,7 @@ mouse = true
|
||||
```
|
||||
|
||||
To resume an agent's sessions with a command of your own in place of the
|
||||
built-in one, give a template per agent under `[ai.sessions.resume]` (`claude`,
|
||||
built-in one, give a template per agent under `[threads.resume]` (`claude`,
|
||||
`codex`, `opencode` or `pi`). It runs after `cd -- <cwd> &&`, with these
|
||||
substituted:
|
||||
|
||||
@@ -411,7 +412,7 @@ substituted:
|
||||
- `{cwd}`: the directory the session worked in.
|
||||
|
||||
```toml
|
||||
[ai.sessions.resume]
|
||||
[threads.resume]
|
||||
claude = "claude --resume {id} --model opus"
|
||||
pi = "pi --session {path}"
|
||||
```
|
||||
@@ -422,26 +423,31 @@ expand them. The program is looked up on `PATH` only: shell aliases, functions
|
||||
and `~/` aren't expanded, so give a full path for a program that isn't on
|
||||
`PATH`.
|
||||
|
||||
Before `[threads]`, these settings were `capture_sessions` and `capture_tools`
|
||||
under `[ai]`, and the picker's under `[ai.sessions]`. Those still work for any
|
||||
setting `[threads]` doesn't set, as do the old `atuin ai session` and
|
||||
`atuin ai resume` commands.
|
||||
|
||||
## Rebuilding the index
|
||||
|
||||
The index is derived entirely from the record store. If it ever looks wrong or
|
||||
incomplete, rebuild it:
|
||||
|
||||
```shell
|
||||
atuin store rebuild ai-session
|
||||
atuin store rebuild threads
|
||||
```
|
||||
|
||||
The daemon deletes the index and replays every session record into it in the
|
||||
background (`atuin ai session` waits for it meanwhile), so this needs the daemon
|
||||
The daemon deletes the index and replays every thread record into it in the
|
||||
background (`atuin threads` waits for it meanwhile), so this needs the daemon
|
||||
enabled. `atuin resume` doesn't wait: it stays usable, and its status row says
|
||||
how far the rebuild has got, as its results may be incomplete until it finishes. `atuin store purge` and `atuin store pull --force` do the same after
|
||||
deleting records, and commands that re-encrypt the records (`atuin store rekey`,
|
||||
`atuin login`) have the daemon replay them the next time it starts.
|
||||
|
||||
If `atuin resume` reports that the session database is at an older schema
|
||||
If `atuin resume` reports that the threads database is at an older schema
|
||||
version, the running daemon is an older Atuin than the command: restart it
|
||||
(`atuin daemon restart`) so it upgrades the database.
|
||||
If it reports that the session database was made by another build of Atuin (a
|
||||
If it reports that the threads database was made by another build of Atuin (a
|
||||
development build, say), the daemon deletes it and rebuilds it from the record
|
||||
store when it starts: restart it (`atuin daemon restart`), and sessions reappear
|
||||
store when it starts: restart it (`atuin daemon restart`), and threads reappear
|
||||
as the rebuild replays them.
|
||||
@@ -436,7 +436,7 @@ history_filter = [
|
||||
A filtered command's output isn't captured either. To keep a command in history
|
||||
but not its output, use [`command_filter`](#command_filter) in `[output]`. The
|
||||
filter applies to the commands your AI agents run too: a matching call is
|
||||
[captured](../ai/sessions.md#whats-captured) with its tool's name only.
|
||||
[captured](../ai/threads.md#whats-captured) with its tool's name only.
|
||||
|
||||
### `cwd_filter`
|
||||
|
||||
@@ -454,7 +454,7 @@ This supports regular expressions, so you can hide pretty much whatever you want
|
||||
|
||||
After updating that parameter, you can run [the prune command](../reference/prune.md) to remove old history entries that match the new filters.
|
||||
|
||||
An [AI session](../ai/sessions.md#whats-captured) captured in a matching
|
||||
A [thread](../ai/threads.md#whats-captured) captured in a matching
|
||||
directory keeps only the names of the tools its agent called there.
|
||||
|
||||
### `store_failed`
|
||||
@@ -498,7 +498,7 @@ For the exact expressions, see
|
||||
[`secrets.rs`](https://github.com/atuinsh/atuin/blob/main/crates/atuin-common/src/secrets.rs).
|
||||
|
||||
The same patterns are applied to captured command output, and to
|
||||
[AI sessions](../ai/sessions.md#whats-captured). A command whose own text is
|
||||
[threads](../ai/threads.md#whats-captured). A command whose own text is
|
||||
clean can still print a credential, so recognised values in the captured
|
||||
output are replaced with `****` before storage. Only the value is replaced; the
|
||||
variable name or flag beside it stays. Output is also checked for credentials
|
||||
@@ -1214,7 +1214,7 @@ environment (`printenv`, `env`); and commands that name a
|
||||
|
||||
The filter also applies to the commands your AI agents run, whether or not
|
||||
output capture is enabled: a matching call is
|
||||
[captured](../ai/sessions.md#whats-captured) without its output.
|
||||
[captured](../ai/threads.md#whats-captured) without its output.
|
||||
|
||||
### `sync`
|
||||
|
||||
@@ -1228,7 +1228,7 @@ set to.
|
||||
|
||||
Settings under `[security]` add to Atuin's built-in rules for keeping
|
||||
credentials out of what it stores: captured command output and
|
||||
[AI sessions](../ai/sessions.md#whats-captured). For which setting to reach for,
|
||||
[threads](../ai/threads.md#whats-captured). For which setting to reach for,
|
||||
see [Keeping Secrets Out of Atuin](../guide/excluding-commands.md).
|
||||
|
||||
```toml
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Atuin records your commands, and can also record
|
||||
[what they print](output-capture.md) and your
|
||||
[AI agents' sessions](../ai/sessions.md). This page covers how to keep secrets,
|
||||
AI agents' sessions as [threads](../ai/threads.md). This page covers how to keep secrets,
|
||||
and anything else you don't want stored, out of all three.
|
||||
|
||||
| You want to keep out | Use |
|
||||
@@ -124,7 +124,7 @@ sensitive_files = [
|
||||
## Your own secrets: `redact_patterns`
|
||||
|
||||
Atuin replaces the credentials it recognises with `****` in captured output and
|
||||
AI sessions. If you have secrets in a format it doesn't know, such as internal
|
||||
threads. If you have secrets in a format it doesn't know, such as internal
|
||||
tokens, give it their pattern:
|
||||
|
||||
```toml
|
||||
@@ -140,13 +140,13 @@ command out instead, use [`history_filter`](#commands-history_filter).
|
||||
|
||||
## AI agents' tool calls: `capture_tools`
|
||||
|
||||
When Atuin [captures your AI agents' sessions](../ai/sessions.md), it keeps
|
||||
When Atuin [captures your AI agents' sessions as threads](../ai/threads.md), it keeps
|
||||
their tool calls with what each was given and what it returned, after applying
|
||||
everything on this page. To keep only the name of each tool the agent called,
|
||||
turn that off:
|
||||
|
||||
```toml
|
||||
[ai]
|
||||
[threads]
|
||||
capture_tools = false
|
||||
```
|
||||
|
||||
@@ -178,8 +178,8 @@ Without any of the settings above, Atuin:
|
||||
- **doesn't record commands containing a credential** it recognises, such as
|
||||
AWS, GitHub, OpenAI or Anthropic keys, while
|
||||
[`secrets_filter`](../configuration/config.md#secrets_filter) is on (the
|
||||
default). It also leaves those commands out of AI sessions.
|
||||
- **redacts credentials in captured output and AI sessions**, replacing them
|
||||
default). It also leaves those commands out of threads.
|
||||
- **redacts credentials in captured output and threads**, replacing them
|
||||
with `****`: the same formats, plus private keys, passwords in connection
|
||||
strings, and values assigned to names like `DB_PASSWORD` or `api_key`.
|
||||
- **never stores the output of commands that print a credential**: Atuin's own
|
||||
@@ -211,5 +211,5 @@ This deletes existing entries matching your current `history_filter` and
|
||||
`cwd_filter`. For deleting entries that don't match a filter, see [Deleting
|
||||
History](delete-history.md).
|
||||
|
||||
AI sessions and captured output already stored keep what they had. Changes to
|
||||
these settings apply to AI sessions once the daemon restarts.
|
||||
Threads and captured output already stored keep what they had. Changes to
|
||||
these settings apply to threads once the daemon restarts.
|
||||
@@ -42,7 +42,7 @@ eval "$(atuin init zsh --disable-up-arrow)"
|
||||
|
||||
## Binding `atuin resume`
|
||||
|
||||
`atuin init` defines widgets that open the [`atuin resume`](../ai/sessions.md)
|
||||
`atuin init` defines widgets that open the [`atuin resume`](../ai/threads.md)
|
||||
picker at the prompt and put the command it picks into your shell, but binds no
|
||||
key to them by default. To bind ++ctrl+bracket-right++ in the emacs, vi-insert and
|
||||
vi-command keymaps, pass `--bind-ai-resume`:
|
||||
|
||||
@@ -79,15 +79,15 @@ atuin store rebuild history
|
||||
|
||||
Useful when the record store is intact but the local view of it isn't.
|
||||
|
||||
`atuin store rebuild ai-session` rebuilds the daemon's index of captured [AI
|
||||
agent sessions](../ai/sessions.md) from the session records alone: the daemon
|
||||
`atuin store rebuild threads` rebuilds the daemon's index of captured
|
||||
[threads](../ai/threads.md) from the thread records alone: the daemon
|
||||
(started for it if `daemon.autostart` is on) deletes it and replays every
|
||||
record in the background (`atuin ai session` waits for it meanwhile). `atuin
|
||||
record in the background (`atuin threads` waits for it meanwhile). `atuin
|
||||
store purge` and `atuin store pull --force` do the same after deleting records,
|
||||
so their sessions leave the index. The index belongs to the daemon, so this
|
||||
so their threads leave the index. The index belongs to the daemon, so this
|
||||
needs it enabled and running: without it, the command fails and leaves the
|
||||
index as it was, and a purge or forced pull says the index still lists the
|
||||
deleted sessions until `atuin store rebuild ai-session` runs with the daemon
|
||||
deleted threads until `atuin store rebuild threads` runs with the daemon
|
||||
up.
|
||||
|
||||
### `atuin store push`
|
||||
|
||||
+3
-2
@@ -79,6 +79,7 @@ plugins:
|
||||
- github-actions[bot]
|
||||
- redirects:
|
||||
redirect_maps:
|
||||
"ai/sessions.md": "ai/threads.md"
|
||||
"cli/ai/command-output.md": "ai/command-output.md"
|
||||
"cli/ai/introduction.md": "ai/introduction.md"
|
||||
"cli/ai/mcp.md": "ai/mcp.md"
|
||||
@@ -145,7 +146,7 @@ plugins:
|
||||
- guide/advanced-usage.md: Filter modes (global, host, session, directory, workspace) and search modes (prefix, fulltext, fuzzy, daemon-fuzzy).
|
||||
- guide/shell-integration.md: How shell hooks work, environment variables, and embedded terminals.
|
||||
- guide/agent-hooks.md: Capture commands from AI coding agents (Claude Code, Codex, opencode, pi) and filter by author.
|
||||
- guide/excluding-commands.md: Keep commands, their output, credential files and your own secrets out of history, captured output and AI sessions, by goal - ignorespace, history_filter, cwd_filter, command_filter, sensitive_files, redact_patterns, capture_tools.
|
||||
- guide/excluding-commands.md: Keep commands, their output, credential files and your own secrets out of history, captured output and threads, by goal - ignorespace, history_filter, cwd_filter, command_filter, sensitive_files, redact_patterns, capture_tools.
|
||||
- guide/output-capture.md: Capture what each command printed - setup, reading and searching it, what's kept, privacy, and disk retention.
|
||||
- guide/delete-history.md: Delete single entries, bulk delete by query, remove duplicates, or wipe everything.
|
||||
- guide/theming.md: Customize the TUI with color themes.
|
||||
@@ -185,7 +186,7 @@ plugins:
|
||||
- ai/skills.md: Reusable instruction sets (skills) for Atuin AI - playbooks, conventions, workflows.
|
||||
- ai/tools-permissions.md: Tools the AI can use (shell, filesystem) and permission files to control access.
|
||||
- ai/command-output.md: Let Atuin AI read command outputs - capture via pty-proxy and the daemon.
|
||||
- ai/sessions.md: Search and resume the AI coding-agent sessions the daemon captures, and rebuild their index.
|
||||
- ai/threads.md: Threads - search and resume the AI coding-agent sessions the daemon captures, and rebuild their index.
|
||||
- ai/mcp.md: Built-in MCP server exposing history search and command output to Claude Code, Cursor, and other AI tools.
|
||||
- ai/self-hosting.md: Self-hosting the Atuin AI backend.
|
||||
Misc:
|
||||
|
||||
Reference in new issue
Block a user