Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/atuin-ai/migrations/20260707000000_create_ai_usage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Cache of the server-reported credit usage snapshot, keyed by a hash of
-- the auth token (the client never learns its hub user id). One row per
-- key; snapshot is the JSON `credits` object from the hub.
CREATE TABLE IF NOT EXISTS usage (
user_key TEXT NOT NULL,
snapshot TEXT NOT NULL,
updated_at INTEGER NOT NULL,

PRIMARY KEY (user_key)
);
34 changes: 33 additions & 1 deletion crates/atuin-ai/src/commands/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ async fn run_inline_tui(
.await
.context("failed to open AI session database")?;

// Cached usage renders immediately; a background fetch (spawned below,
// once the event channel exists) replaces it unless it's fresh.
let usage_key = crate::usage::cache_key(&ctx.token);
let (cached_usage, usage_is_fresh) = match service.get_cached_usage(&usage_key).await {
Ok(Some(cached_snapshot)) => {
let age = time::OffsetDateTime::now_utc().unix_timestamp() - cached_snapshot.written_at;
let fresh = age < crate::usage::REFRESH_AFTER.as_secs() as i64;
(Some(cached_snapshot.snapshot), fresh)
}
Ok(None) => (None, false),
Err(e) => {
debug!("failed to read usage cache: {e}");
(None, false)
}
};

let cwd = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned());
Expand Down Expand Up @@ -266,7 +282,8 @@ async fn run_inline_tui(
let skill_registry = crate::skills::SkillRegistry::discover(project_root.as_deref()).await;

// ─── Build initial ViewState from FSM ───────────────────────
let initial_view = build_view_state(&fsm, in_git_project, &skill_registry);
let mut initial_view = build_view_state(&fsm, in_git_project, &skill_registry);
initial_view.usage = cached_usage;

// ─── Build IoContext ────────────────────────────────────────
let io = IoContext {
Expand All @@ -288,6 +305,20 @@ async fn run_inline_tui(
// Wrap sender for components: they send AiTuiEvent, we wrap it
let tui_tx = DriverEventSender(tx.clone());

if !usage_is_fresh {
let endpoint = ctx.endpoint.clone();
let token = ctx.token.clone();
let usage_tx = tx.clone();
tokio::spawn(async move {
match crate::usage::fetch_usage(&endpoint, &token).await {
Ok(snapshot) => {
let _ = usage_tx.send(DriverEvent::Usage(snapshot));
}
Err(e) => debug!("background usage fetch failed: {e}"),
}
});
}

println!();

if let Some(prompt) = initial_prompt {
Expand Down Expand Up @@ -412,6 +443,7 @@ fn build_view_state(
archived_events,
model_picker: fsm.ctx.model_picker.clone(),
model: fsm.ctx.model.clone(),
usage: None,
turns,
has_command,
committed_turn_count: 0,
Expand Down
35 changes: 34 additions & 1 deletion crates/atuin-ai/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ pub(crate) enum DriverEvent {
Tui(AiTuiEvent),
/// Internal FSM event (from spawned stream/tool tasks)
Fsm(Event),
/// Fresh credit-usage snapshot (from the done event or a background
/// fetch). Handled by the driver directly — the FSM never sees it.
Usage(crate::usage::UsageSnapshot),
}

// ============================================================================
Expand Down Expand Up @@ -87,6 +90,9 @@ pub(crate) struct ViewState {
pub model_picker: Option<crate::fsm::ModelPicker>,
/// Model alias currently in effect (`None` = server default).
pub model: Option<String>,
/// Latest known credit usage: cached at startup, refreshed from the
/// done event and background fetches.
pub usage: Option<crate::usage::UsageSnapshot>,

// ─── Pre-computed for rendering ────────────────────────────
pub turns: Vec<turn::UiTurn>,
Expand Down Expand Up @@ -187,6 +193,10 @@ pub(crate) fn run_driver(
tracing::trace!(?tui_event, state = ?fsm.state, "TUI event");
translate_tui_event(tui_event, &handle, &io)
}
DriverEvent::Usage(snapshot) => {
update_usage(&handle, &io, snapshot);
None
}
};

if let Some(event) = fsm_event {
Expand Down Expand Up @@ -929,6 +939,21 @@ fn execute_effect(effect: &Effect, ctx: DriverContext) {
// Persistence
// ============================================================================

/// Apply a fresh usage snapshot: sync it to the view and write it to the
/// local cache so the next TUI open can render it immediately.
fn update_usage(handle: &Handle<ViewState>, io: &IoContext, snapshot: crate::usage::UsageSnapshot) {
handle.update({
let snapshot = snapshot.clone();
move |vs| vs.usage = Some(snapshot)
});

let key = crate::usage::cache_key(&io.app_ctx.token);
let rt = tokio::runtime::Handle::current();
if let Err(e) = rt.block_on(io.session_mgr.set_cached_usage(&key, &snapshot)) {
tracing::warn!("Failed to persist usage cache: {e}");
}
}

fn persist(fsm: &AgentFsm, io: &mut IoContext) {
let start = std::time::Instant::now();
let rt = tokio::runtime::Handle::current();
Expand Down Expand Up @@ -1063,7 +1088,15 @@ async fn run_stream_bridge(
},
Ok(StreamFrame::Control(control)) => match control {
StreamControl::StatusChanged(status) => Some(Event::StreamStatusChanged(status)),
StreamControl::Done { session_id } => Some(Event::StreamDone { session_id }),
StreamControl::Done {
session_id,
credits,
} => {
if let Some(snapshot) = credits {
let _ = tx.send(DriverEvent::Usage(snapshot));
}
Some(Event::StreamDone { session_id })
}
StreamControl::Error(msg) => Some(Event::StreamError(msg)),
},
Ok(StreamFrame::SessionIdentity(session_id)) => {
Expand Down
1 change: 1 addition & 0 deletions crates/atuin-ai/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ pub(crate) mod store;
pub(crate) mod stream;
pub(crate) mod tools;
pub(crate) mod tui;
pub(crate) mod usage;
pub(crate) mod user_context;
32 changes: 32 additions & 0 deletions crates/atuin-ai/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ use eyre::Result;
use crate::event_serde;
use crate::store::{AiSessionStore, StoredEvent, StoredSession};
use crate::tui::ConversationEvent;
use crate::usage::UsageSnapshot;

// ---------------------------------------------------------------------------
// Trait
// ---------------------------------------------------------------------------

pub(crate) struct CachedUsageSnapshot {
pub snapshot: UsageSnapshot,
pub written_at: i64,
}

#[async_trait]
pub(crate) trait SessionService: Send + Sync {
async fn create_session(
Expand Down Expand Up @@ -54,6 +60,11 @@ pub(crate) trait SessionService: Send + Sync {

async fn get_metadata(&self, session_id: &str, key: &str) -> Result<Option<String>>;
async fn set_metadata(&self, session_id: &str, key: &str, value: &str) -> Result<()>;

/// Read the cached usage snapshot (JSON, written-at unix timestamp) for
/// a user key. Not session-scoped: usage is per hub account.
async fn get_cached_usage(&self, user_key: &str) -> Result<Option<CachedUsageSnapshot>>;
async fn set_cached_usage(&self, user_key: &str, snapshot: &UsageSnapshot) -> Result<()>;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -139,6 +150,21 @@ impl SessionService for LocalSessionService {
async fn set_metadata(&self, session_id: &str, key: &str, value: &str) -> Result<()> {
self.store.set_metadata(session_id, key, value).await
}

async fn get_cached_usage(&self, user_key: &str) -> Result<Option<CachedUsageSnapshot>> {
match self.store.get_usage(user_key).await? {
Some((json, written_at)) => Ok(Some(CachedUsageSnapshot {
snapshot: serde_json::from_str(&json)?,
written_at,
})),
None => return Ok(None),
}
}

async fn set_cached_usage(&self, user_key: &str, snapshot: &UsageSnapshot) -> Result<()> {
let snapshot_json = serde_json::to_string(snapshot)?;
self.store.set_usage(user_key, &snapshot_json).await
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -337,6 +363,12 @@ impl SessionManager {
.set_metadata(&self.session_id, key, value)
.await
}

/// Write the usage cache for a user key. Not tied to the current
/// session, so no session row is created.
pub async fn set_cached_usage(&self, user_key: &str, snapshot: &UsageSnapshot) -> Result<()> {
self.service.set_cached_usage(user_key, snapshot).await
}
}

#[cfg(test)]
Expand Down
49 changes: 49 additions & 0 deletions crates/atuin-ai/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,35 @@ impl AiSessionStore {
.await?;
Ok(())
}

// ── Usage cache (server credit totals, one row per user key) ──

/// Read the cached usage snapshot for a user key. Returns the snapshot
/// JSON and the unix timestamp it was written at.
pub async fn get_usage(&self, user_key: &str) -> Result<Option<(String, i64)>> {
Comment thread
BinaryMuse marked this conversation as resolved.
Outdated
let row: Option<(String, i64)> =
sqlx::query_as("SELECT snapshot, updated_at FROM usage WHERE user_key = ?1")
.bind(user_key)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}

/// Write the usage snapshot for a user key (upsert).
pub async fn set_usage(&self, user_key: &str, snapshot_json: &str) -> Result<()> {
Comment thread
BinaryMuse marked this conversation as resolved.
Outdated
let now = OffsetDateTime::now_utc().unix_timestamp();
sqlx::query(
"INSERT INTO usage (user_key, snapshot, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT (user_key) DO UPDATE SET snapshot = ?2, updated_at = ?3",
)
.bind(user_key)
.bind(snapshot_json)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
}

#[cfg(test)]
Expand Down Expand Up @@ -524,6 +553,26 @@ mod tests {
assert_eq!(before.updated_at, after.updated_at);
}

#[tokio::test]
async fn test_usage_cache_roundtrip() {
let store = new_test_store().await;

assert!(store.get_usage("key-a").await.unwrap().is_none());

store.set_usage("key-a", r#"{"used":1}"#).await.unwrap();
let (json, updated_at) = store.get_usage("key-a").await.unwrap().unwrap();
assert_eq!(json, r#"{"used":1}"#);
assert!(updated_at > 0);

// Upsert replaces the snapshot for the same key
store.set_usage("key-a", r#"{"used":2}"#).await.unwrap();
let (json, _) = store.get_usage("key-a").await.unwrap().unwrap();
assert_eq!(json, r#"{"used":2}"#);

// Other keys are independent
assert!(store.get_usage("key-b").await.unwrap().is_none());
}

#[tokio::test]
async fn test_events_ordered_chronologically() {
let store = new_test_store().await;
Expand Down
13 changes: 10 additions & 3 deletions crates/atuin-ai/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ pub(crate) static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSI
/// Frames that alter the stream lifecycle — terminal or state-changing.
#[derive(Debug, Clone)]
pub(crate) enum StreamControl {
Done { session_id: String },
Done {
session_id: String,
/// Period credit totals from the server, when it sends them.
credits: Option<crate::usage::UsageSnapshot>,
},
Error(String),
StatusChanged(String),
}
Expand Down Expand Up @@ -257,9 +261,12 @@ pub(crate) fn create_chat_stream(
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
yield Ok(StreamFrame::Control(StreamControl::Done { session_id }));
let credits = json.get("credits")
.cloned()
.and_then(|v| serde_json::from_value(v).ok());
yield Ok(StreamFrame::Control(StreamControl::Done { session_id, credits }));
} else {
yield Ok(StreamFrame::Control(StreamControl::Done { session_id: String::new() }));
yield Ok(StreamFrame::Control(StreamControl::Done { session_id: String::new(), credits: None }));
}
break;
}
Expand Down
67 changes: 67 additions & 0 deletions crates/atuin-ai/src/tui/view/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,78 @@ fn input_view(state: &ViewState) -> Elements {

})
})

#(status_bar_view(state))
}
})
}
}

/// Usage below this percentage isn't worth a status-bar warning.
const USAGE_BAR_THRESHOLD_PCT: f64 = 50.0;

/// Width of the usage bar in cells.
const USAGE_BAR_WIDTH: usize = 5;

/// One-line status bar under the input box: current model on the left;
/// on the right, once usage crosses the threshold, a small bar chart with
/// the percentage and time until the period resets.
fn status_bar_view(state: &ViewState) -> Elements {
let model_label = format!(" Model: {}", state.model.as_deref().unwrap_or("default"));

let usage = state.usage.as_ref().and_then(|snapshot| {
let pct = snapshot.as_percentage()?;
if pct < USAGE_BAR_THRESHOLD_PCT {
return None;
}
Some((pct, snapshot.resets_in()))
});

element! {
HStack(key: "status-bar") {
View(width: WidthConstraint::Fill) {
Text {
Span(text: model_label, style: Style::default().fg(Color::DarkGray))
}
}
#(if let Some((pct, resets_in)) = usage {
#({
let filled = ((pct / 100.0).clamp(0.0, 1.0) * USAGE_BAR_WIDTH as f64).round() as usize;
let bar_filled = "█".repeat(filled);
let bar_empty = "░".repeat(USAGE_BAR_WIDTH - filled);
let pct_text = format!(" {}%", pct.round() as i64);
let resets_text = resets_in
.map(|d| format!(" · resets in {} ", crate::usage::format_reset_delta(d)))
.unwrap_or_default();

let bar_color = if pct >= 90.0 {
Color::Red
} else if pct >= 70.0 {
Color::Yellow
} else {
Color::Green
};

let width = (USAGE_BAR_WIDTH
+ pct_text.chars().count()
+ resets_text.chars().count()) as u16;

element! {
View(width: WidthConstraint::Fixed(width)) {
Text {
Span(text: bar_filled, style: Style::default().fg(bar_color))
Span(text: bar_empty, style: Style::default().fg(Color::DarkGray))
Span(text: pct_text, style: Style::default().fg(Color::Gray))
Span(text: resets_text, style: Style::default().fg(Color::DarkGray))
}
}
}
})
})
}
}
}

/// Render the /model picker: one row per model, the in-use model marked.
/// `current` is the session's explicit selection; when unset, the server
/// default is what's actually in use, so mark that row instead.
Expand Down
Loading
Loading