Skip to main content

kitchen_shell/
activity.rs

1//! What happened in the app, as an episodic memory (kitchen-ui's [`Timeline`]): every command run
2//! (from a menu, a key, the search, the title bar or an agent) with who ran it, the time spent in
3//! each workspace, and every job from its start to its end. Attention, not content: a command's
4//! arguments are kept in short, never the data it worked on.
5//!
6//! The registry and the frame record into it by themselves; an app adds what a step produced
7//! ([`produced`]) so the timeline can say where a file came from. [`view`] is a timeline that
8//! follows the log as it grows.
9
10use std::collections::HashMap;
11use std::sync::Mutex;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use gpui_kit::{App, AppContext as _, Entity, SharedString};
15use kitchen_ui::timeline::{Activity, Timeline};
16use serde_json::Value;
17
18/// Who did it.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Actor {
21    /// The person at the app.
22    Person,
23    /// An agent (through MCP or the chat).
24    Agent,
25}
26
27impl Actor {
28    /// Its lane on the timeline.
29    pub fn lane(self) -> &'static str {
30        match self {
31            Actor::Person => "You",
32            Actor::Agent => "Agent",
33        }
34    }
35}
36
37#[derive(Default)]
38struct Log {
39    items: Vec<Activity>,
40    /// Spans still open (a job, the workspace shown): their index; they end at "now".
41    open: HashMap<String, usize>,
42    workspace: String,
43}
44
45static LOG: Mutex<Option<Log>> = Mutex::new(None);
46static GENERATION: AtomicU64 = AtomicU64::new(0);
47
48fn with<T>(f: impl FnOnce(&mut Log) -> T) -> T {
49    let mut g = LOG.lock().unwrap_or_else(|e| e.into_inner());
50    let out = f(g.get_or_insert_with(Log::default));
51    GENERATION.fetch_add(1, Ordering::Relaxed);
52    out
53}
54
55/// Seconds since 1970 (UTC).
56pub fn now() -> f64 {
57    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64())
58}
59
60/// Bumped on every change (redraw when it moves).
61pub fn generation() -> u64 {
62    GENERATION.load(Ordering::Relaxed)
63}
64
65/// Adds an activity as it is.
66pub fn record(a: Activity) {
67    with(|l| l.items.push(a));
68}
69
70/// Opens a span `key` (a job, a workspace shown) that lasts until [`close`]; one open with the
71/// same key is closed first.
72pub fn open(key: impl Into<String>, mut a: Activity) {
73    let key = key.into();
74    let t = now();
75    with(|l| {
76        if let Some(i) = l.open.remove(&key) {
77            l.items[i].end = t;
78        }
79        a.start = t;
80        a.end = t;
81        l.items.push(a);
82        l.open.insert(key, l.items.len() - 1);
83    });
84}
85
86/// Closes the span `key` now.
87pub fn close(key: &str) {
88    let t = now();
89    with(|l| {
90        if let Some(i) = l.open.remove(key) {
91            l.items[i].end = t.max(l.items[i].start + 1.0);
92        }
93    });
94}
95
96/// A short line of JSON: at most `max` characters.
97fn short(v: &Value, max: usize) -> String {
98    let s = v.to_string();
99    if s.chars().count() <= max { s } else { format!("{}…", s.chars().take(max).collect::<String>()) }
100}
101
102/// Records a command run: by whom, how (`via`: menu, key, search, title bar, agent), with what
103/// arguments and what came of it.
104pub fn command(actor: Actor, id: &str, title: &str, group: &str, via: &str, args: &Value, result: &Result<Value, String>) {
105    let mut detail = Vec::new();
106    if !args.is_null() && args != &Value::Object(Default::default()) {
107        detail.push(format!("{id} {}", short(args, 120)));
108    } else {
109        detail.push(id.to_string());
110    }
111    match result {
112        Ok(Value::Null) => {}
113        Ok(v) => detail.push(format!("→ {}", short(v, 120))),
114        Err(e) => detail.push(format!("refused: {e}")),
115    }
116    let t = now();
117    with(|l| {
118        let source = if l.workspace.is_empty() { via.to_string() } else { format!("{} · {via}", l.workspace) };
119        l.items.push(Activity {
120            start: t,
121            end: t,
122            lane: actor.lane().into(),
123            kind: if group.is_empty() { "Commands".into() } else { SharedString::from(group.to_string()) },
124            title: title.to_string().into(),
125            detail: detail.join("  ").into(),
126            source: source.into(),
127            assets: Vec::new(),
128        })
129    });
130}
131
132/// The workspace shown now: a span on the Focus lane, until the next one.
133pub fn focus(workspace: &str) {
134    let name = workspace.to_string();
135    with(|l| l.workspace = name.clone());
136    open("focus", Activity { lane: "Focus".into(), kind: SharedString::from(name.clone()), title: format!("In {name}").into(), ..Default::default() });
137}
138
139/// A job started (its span on the Jobs lane, until [`job_ended`]).
140pub fn job_started(id: u64, kind: &str, name: &str) {
141    open(format!("job-{id}"), Activity { lane: "Jobs".into(), kind: kind.to_string().into(), title: name.to_string().into(), source: "job".into(), ..Default::default() });
142}
143
144/// A job ended.
145pub fn job_ended(id: u64) {
146    close(&format!("job-{id}"));
147}
148
149/// What a step produced (files, ids): added to the last activity titled `title`, or as one of its
150/// own on the person's lane.
151pub fn produced(title: &str, assets: Vec<String>) {
152    let t = now();
153    with(|l| match l.items.iter_mut().rev().find(|a| a.title == title) {
154        Some(a) => a.assets.extend(assets.into_iter().map(SharedString::from)),
155        None => l.items.push(Activity { start: t, end: t, lane: Actor::Person.lane().into(), kind: "Output".into(), title: title.to_string().into(), assets: assets.into_iter().map(SharedString::from).collect(), ..Default::default() }),
156    });
157}
158
159/// Everything so far; spans still open end now.
160pub fn all() -> Vec<Activity> {
161    let t = now();
162    let g = LOG.lock().unwrap_or_else(|e| e.into_inner());
163    let Some(l) = g.as_ref() else { return Vec::new() };
164    let mut out = l.items.clone();
165    for &i in l.open.values() {
166        out[i].end = t.max(out[i].start + 1.0);
167    }
168    out
169}
170
171/// Forgets everything (a new project).
172pub fn clear() {
173    with(|l| {
174        let ws = std::mem::take(&mut l.workspace);
175        *l = Log::default();
176        l.workspace = ws;
177    });
178}
179
180/// A timeline titled `title` that follows the log (read again when it changed, every second).
181pub fn view(title: &str, cx: &mut App) -> Entity<Timeline> {
182    let timeline = cx.new(|_| Timeline::new(title.to_string()));
183    timeline.update(cx, |t, cx| t.set_activities(all(), now(), cx));
184    let weak = timeline.downgrade();
185    cx.spawn(async move |cx| {
186        let mut seen = generation();
187        loop {
188            cx.background_executor().timer(std::time::Duration::from_secs(1)).await;
189            let g = generation();
190            // Open spans grow with time: read again now and then even when nothing was added
191            let stale = g != seen;
192            seen = g;
193            let alive = weak.update(cx, |t, cx| {
194                if stale || !t.activities().is_empty() {
195                    t.set_activities(all(), now(), cx)
196                }
197            });
198            if alive.is_err() {
199                break;
200            }
201        }
202    })
203    .detach();
204    timeline
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    /// Commands land on their actor's lane with their provenance; spans close; jobs end.
212    #[test]
213    fn commands_spans_and_jobs_are_recorded() {
214        clear();
215        focus("Explore");
216        command(Actor::Person, "hist.find_cells", "Find cells", "Analyze", "menu", &Value::Null, &Ok(serde_json::json!({"cells": 12})));
217        command(Actor::Agent, "process.op.blur", "Gaussian blur", "Filter", "agent", &serde_json::json!({"sigma": 2}), &Err("Open an image first".into()));
218        job_started(7, "histology", "Finding cells");
219        produced("Find cells", vec!["cells.parquet".into()]);
220        let a = all();
221        let find = a.iter().find(|x| x.title == "Find cells").unwrap();
222        assert_eq!((find.lane.as_ref(), find.kind.as_ref(), find.source.as_ref()), ("You", "Analyze", "Explore · menu"));
223        assert!(find.detail.contains("cells"));
224        assert_eq!(find.assets, vec![SharedString::from("cells.parquet")]);
225        let blur = a.iter().find(|x| x.title == "Gaussian blur").unwrap();
226        assert_eq!(blur.lane.as_ref(), "Agent");
227        assert!(blur.detail.contains("refused"));
228        assert!(a.iter().any(|x| x.lane == "Jobs" && x.end > x.start), "a running job lasts until now");
229        job_ended(7);
230        focus("Analyze");
231        let a = all();
232        assert_eq!(a.iter().filter(|x| x.lane == "Focus").count(), 2);
233        assert!(a.iter().filter(|x| x.lane == "Focus").all(|x| x.end > x.start));
234    }
235}