← All Pull Requests

Add a read-only shots verb: list a channel's shots, and surface when none are tagged #2

Closed opened by Ray 2026-09-02

Closes adom/adom-shotlog#5.

Adds a read-only shots verb. channels says what channels exist; this says what is IN one.

$ adom-shotlog shots -c read-captures --limit 6
   REF  HANDLE              AGE  DESCRIPTION
  #146  —               16h ago  Auto-captured screenshot Claude read for analysis: sheet_…
  #147  —               16h ago  Auto-captured screenshot Claude read for analysis: sheet_…
  #151  —               16h ago  Auto-captured screenshot Claude read for analysis: fin_a.…

151 shots · 0 tagged.
Hint: None of these carry a handle. A human cannot say "heroG is the one" about an
untagged shot, and shots injected by the read-hook never get one. When you iterate
variants, inject deliberately: shotlog inject -c <ch> -d "..." --tag heroG <file>

Flags: -c/--channel, -p/--port, --limit (default 25, newest last), --tagged to show only shots carrying a handle.

Why

--tag shipped in 0.28.2 this morning and works. The problem is the default path: the read-hook auto-injects and never passes one, so a channel silently fills with unreferenceable shots. On this box that is 151 of 151. Nothing surfaces that, and an agent has no way to look.

That is not hypothetical. Ray asked me to iterate hero images; I produced heroG/heroH/heroI, he could only point at them positionally ("top pic", "your last 2"), I misread which he meant, and we rebuilt the wrong one. Every image was already in read-captures, untagged. The hint above is the part that would have changed my behaviour.

Implementation notes

  • Thin wrapper on the existing GET /api/shots; no new endpoint, no new dependency.
  • Mirrors run_channels in structure, table style, fmt_ago, and its connect-error/hint handling.
  • Reuses the escaping convention already at main.rs:871 (channel.replace(char::from(32), "%20")) rather than adding a url crate.
  • Read-only. No behaviour change to inject/paste/serve.

Verified

  • cargo build --release: clean. 7 warnings, identical to the pre-change baseline, so no new ones.
  • Run against the live 0.28.2 server above: listing, --limit, --tagged on a channel with none, and an unknown channel (falls back to the channels hint).

Not merging this myself, adom-shotlog is yours.

One correction I owe you: I filed two issues earlier today from Ray's relay of your Chat message without checking the shipped source first. Both were already implemented, one at your own request this morning. I closed #4 and annotated #3. Sorry for the noise; this one I built and tested.

Diff Skip to comments (1)

--- a/src/main.rs+++ b/src/main.rs@@ -1,1061 +1,1130 @@⋯ 280 unchanged lines ⋯         #[arg(long, default_value_t = 25)]         limit: usize,     },+    /// List the shots in a channel: ref, handle, age, description.+    ///+    /// `channels` answers "what channels exist"; this answers "what is IN one".+    /// Without it an agent can inject but never read back, so it cannot see its+    /// own history, cannot tell the human "compare #41 and #42", and cannot+    /// notice that everything it logged is untagged.+    Shots {+        /// Channel to list+        #[arg(short, long)]+        channel: String,+        /// Port of the running shotlog server+        #[arg(short, long, default_value_t = 8820)]+        port: u16,+        /// Max rows to show, newest last (default 25)+        #[arg(long, default_value_t = 25)]+        limit: usize,+        /// Only shots that carry a --tag handle+        #[arg(long)]+        tagged: bool,+    },     /// Install everything: skill, bash completions     Install,     /// Generate shell completions (bash, zsh, fish)⋯ 244 unchanged lines ⋯         Commands::Channels { port, since_min, limit } => {             run_channels(port, since_min, limit);         }+        Commands::Shots { channel, port, limit, tagged } => {+            run_shots(&channel, port, limit, tagged);+        }         Commands::Install => {             run_install();         }⋯ 407 unchanged lines ⋯     } } +fn run_shots(channel: &str, port: u16, limit: usize, tagged_only: bool) {+    // same escaping the viewer-status endpoint uses (main.rs:871), no new dep+    let endpoint = format!("/api/shots?channel={}", channel.replace(' ', "%20"));+    let val = match http_get(port, &endpoint) {+        Ok(v) => v,+        Err(e) => { err(&format!("Cannot connect to shotlog on port {port}: {e}")); hint("Start it: shotlog serve &"); std::process::exit(1); }+    };+    let rows = val.as_array().cloned().unwrap_or_default();+    if rows.is_empty() {+        println!("No shots in channel {channel}.");+        hint("Channels that DO have shots: shotlog channels");+        return;+    }+    let now = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);+    let fmt_ago = |secs: i64| -> String {+        let m = secs / 60;+        if m < 1 { "just now".into() } else if m < 60 { format!("{m}m ago") }+        else if m < 1440 { format!("{}h ago", m / 60) } else { format!("{}d ago", m / 1440) }+    };+    let total = rows.len();+    let tagged_total = rows.iter().filter(|r| r["tag"].as_str().is_some_and(|t| !t.is_empty())).count();+    let shown: Vec<&serde_json::Value> = rows.iter()+        .filter(|r| !tagged_only || r["tag"].as_str().is_some_and(|t| !t.is_empty()))+        .rev().take(limit).collect();+    println!("{:>6}  {:<12}  {:>9}  {}", "REF", "HANDLE", "AGE", "DESCRIPTION");+    for row in shown.into_iter().rev() {+        let id = row["id"].as_u64().unwrap_or(0);+        let tag = row["tag"].as_str().filter(|t| !t.is_empty()).unwrap_or("—");+        let epoch = row["timestamp"].as_str()+            .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())+            .map(|d| d.timestamp()).unwrap_or(0);+        let ago = if epoch == 0 { "—".to_string() } else { fmt_ago(now - epoch) };+        let desc = row["description"].as_str().unwrap_or("");+        let desc = if desc.chars().count() > 58 {+            format!("{}…", desc.chars().take(57).collect::<String>())+        } else { desc.to_string() };+        println!("{:>6}  {:<12}  {ago:>9}  {desc}", format!("#{id}"), tag);+    }+    println!("\n{total} shots · {tagged_total} tagged.");+    if tagged_total == 0 && total > 2 {+        hint("None of these carry a handle. A human cannot say \"heroG is the one\" about an untagged shot, and shots injected by the read-hook never get one. When you iterate variants, inject deliberately: shotlog inject -c <ch> -d \"...\" --tag heroG <file>");+    }+}+ fn run_install() {     ensure_read_hook();     let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".to_string());⋯ 109 unchanged lines ⋯      serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {e}")) }+

Comments

John Lauer · 1mo ago

Applied in 0.28.3, adapted. shots is in with your table layout, --tagged and --limit, plus --json for agents. Two changes: the footer now says every shot is addressable by its ref ("#42"), because refs are the primary handle in this app, and the --tag hint prints only when a channel of 3+ shots has no handle at all, so it reads as advice rather than an alarm. Your verb also surfaced a real bug: --tag was never persisted to the sidecar, so handles died on restart. Fixed alongside. Closing rather than merging (older base); the code is in and credited. Thanks.

Log in to comment.