diff --git a/README.md b/README.md index 639af16..31943eb 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,17 @@ plannotator-tui docs # a folder: file tree on the left, counts per plannotator-tui last # your coding agent's recent replies, pick one, annotate it ``` -Drag with the mouse (or `v` and move) to select, then `a` ๐Ÿ‘ ยท `c` ๐Ÿ’ฌ ยท `d` โœ—. `E` copies the -feedback to the clipboard as numbered annotations (`# Annotations on plan.md`, `## Annotation 1 -(line 12)`, โ€ฆ). Every annotation is saved as JSON the moment you make it; `q` closes. +Drag with the mouse (or `v` and move) to select, then `a` ๐Ÿ‘ ยท `c` ๐Ÿ’ฌ ยท `d` โœ—. In the +notes rail, `i` attaches a local image file (PNG/JPEG/GIF/WebP/BMP/SVG) to the selected +annotation. `E` copies the feedback to the clipboard as numbered annotations (`# Annotations on +plan.md`, `## Annotation 1 (line 12)`, โ€ฆ), with attached images listed as Markdown image links +and local file paths. File annotations are saved as JSON the moment you make them; `q` closes. + +Enter or paste an existing image path, then press Enter to attach it (Esc cancels). Relative +paths resolve from the app's working directory; symlinks are resolved before the reference is +saved. Images are referenced, not copied or uploaded: keep the file available on the machine +where the app runs, and ensure the receiving agent can access it. Pasting image bytes from the +clipboard is not supported; paste a file path instead. Copies go to the clipboard as OSC 52, which is the terminal you are looking at, so on Herdr 0.9.0 they reach your own machine even when the app runs on a remote server; Herdr Annotate's global @@ -59,9 +67,9 @@ they reach your own machine even when the app runs on a remote server; Herdr Ann For file and folder reviews, `E` sends only new or edited annotations. Send A and B, then add C: the next send includes just C. Sent notes stay visible with a marker; editing one -makes it pending again, including after a restart. `R` **Resend all** includes every active -note. With nothing pending, `E` reports โ€œnothing new to sendโ€. A failed send keeps the notes -pending for retry. +or attaching an image makes it pending again, including after a restart. `R` **Resend all** +includes every active note. With nothing pending, `E` reports โ€œnothing new to sendโ€. A failed +send keeps the notes pending for retry. `F` **Finish review** archives sent, unchanged notes and leaves pending ones in place. `U` undoes the last finish during this session. `H` opens the archive, where Enter or a @@ -77,17 +85,24 @@ dimmed. The keys also work without opening the menu. | anywhere | `Tab` cycle tree ยท document ยท notes; `E` send; `t` tree; `r` reload; `q` quit | | document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `v` select with `hjkl` `w` `b` `0` `$` | | toolbar | `a` looks good ยท `c` comment ยท `d` delete ยท `Esc` | -| notes | `j`/`k`; `e` edit; `x` remove; click a bubble | +| notes | `j`/`k`; `e` edit; `i` attach image; `x` remove; click a bubble | | file/folder review | `E` send new ยท `m` review menu (`R` resend all ยท `F` finish review ยท `U` undo ยท `H` archive) | | tree | `j`/`k`; `Enter` open; `E` sends new notes across all reviewed files, including collapsed folders | ## Inside Herdr -Install [Herdr Annotate](https://github.com/plannotator/herdr-annotate); it bundles this binary, -opens it in a pane with `prefix+o` (folder) or `prefix+shift+o` (agent's last reply) or by -Ctrl-clicking a `file://โ€ฆmd` link, and the header button sends the review straight back to -the agent as its next message: `Send 3 new โ–ธ claude in w1:p2 (E)`. Folder reviews show -`Send 3 new across 2 files` and send one combined feedback message. +Install [Herdr Annotate](https://github.com/plannotator/herdr-annotate); it bundles this binary +and opens it in a pane for folder/document review, an agent's last reply, or a Ctrl-clicked +`file://โ€ฆmd` link. In the current Herdr Annotate menu the Herdr prefix is `Ctrl+Space`: +`Prefix + U` reviews a folder/document, `Prefix + Shift + U` reviews the agent's last reply, +`Prefix + A` belongs to the wrapper's selected-terminal-text capture, `Prefix + Shift + A` +copies annotations as context, `Prefix + Ctrl + A` copies and archives, and `Prefix + M` +manages annotations. `plannotator-tui last`/`Prefix + Shift + U` reads the agent transcript; +it does not depend on terminal selection. Selected-text capture depends on the terminal/Herdr +selection state, so terminals configured for copy-on-select can clear the highlight before that +wrapper path sees it. The header button sends the review straight back to the agent as its next +message: `Send 3 new โ–ธ claude in w1:p2 (E)`. Folder reviews show `Send 3 new across 2 files` and +send one combined feedback message. ```toml # ~/.config/plannotator-tui/config.toml @@ -112,7 +127,9 @@ no host is named) and `--session-id ` (Hermes, OpenCode) override detection; reads a document; `--print` writes the newest reply to stdout and always exits 0 (for hooks and scripts). A reply review keeps its annotations in memory only; nothing about it survives the run, but -the feedback you send or copy is archived like any other (see Feedback archive below). +the feedback you send or copy is archived like any other (see Feedback archive below). If the +picker offers several recent replies, `p` reopens it; annotations on every opened reply are kept +in memory and `E` sends them together as one collected review. On Linux, an explicit Codex `--pid` selects the rollout opened by that process. If it cannot be identified uniquely, `last` reports the failure instead of choosing an unrelated @@ -133,8 +150,11 @@ status line. `` is the git repo name, `` the file's basename plus 8 hex of the sha256 of its path: Plannotator's own layout, so both tools see one record per file. The JSON is the -Plannotator Workspaces wire shape; any agent can read it. Nothing is written next to your -files. `PLANNOTATOR_DATA_DIR` relocates the directory. +Plannotator Workspaces wire shape with additive local fields; any agent can read it. The +[annotation schema](crates/plannotator-tui-schema/src/annotation.rs) owns the attachment fields; +[decision 15](docs/decisions.md#15-image-attachments-are-local-references-until-uploaded-2026-09-14) +explains the local-reference boundary. Nothing is written next to your files. +`PLANNOTATOR_DATA_DIR` relocates the directory. ### Feedback archive diff --git a/crates/plannotator-tui-hosts/src/time.rs b/crates/plannotator-tui-hosts/src/time.rs index 054ba0e..7ac22bf 100644 --- a/crates/plannotator-tui-hosts/src/time.rs +++ b/crates/plannotator-tui-hosts/src/time.rs @@ -14,6 +14,7 @@ pub(crate) fn iso_from_unix_ms(ms: u64) -> String { } /// Howard Hinnant's days-to-civil. +#[allow(clippy::similar_names, reason = "standard algorithm variable names")] fn civil_from_days(days: u64) -> (u64, u64, u64) { let z = days + 719_468; let era = z / 146_097; diff --git a/crates/plannotator-tui-schema/src/annotation.rs b/crates/plannotator-tui-schema/src/annotation.rs index 82c8786..6e263a8 100644 --- a/crates/plannotator-tui-schema/src/annotation.rs +++ b/crates/plannotator-tui-schema/src/annotation.rs @@ -1,8 +1,8 @@ //! Annotations, replies, and the request/response bodies, in the Workspaces wire shape. //! -//! One type serves both the local sidecar and the server row: an annotation saved next to -//! a file is exactly what `POST .../annotations` returns. Responses may grow fields; -//! unknown keys are preserved rather than rejected. +//! One type serves both local records and server rows, with additive TUI-owned fields for +//! local references. Responses may grow fields; unknown keys are preserved rather than +//! rejected. use std::collections::BTreeMap; @@ -35,6 +35,59 @@ pub struct Reply { pub other: BTreeMap, } +/// A plannotator-tui-owned attachment. Workspaces still owns the top-level +/// `attachments: string[]` field for uploaded `https://` image URLs; local files live here +/// so a future Workspaces sync can omit or upload them rather than sending invalid URLs. +/// Type-like fields are strings, not enums, so newer attachment kinds still round-trip. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalAttachment { + #[serde(rename = "type")] + pub attachment_type: String, + pub source: String, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + #[serde(flatten)] + pub other: BTreeMap, +} + +impl LocalAttachment { + pub fn image(path: String, alt: Option, media_type: Option) -> Self { + Self { + attachment_type: "image".to_owned(), + source: "local_file".to_owned(), + path, + alt, + media_type, + other: BTreeMap::new(), + } + } + + pub fn is_local_image(&self) -> bool { + self.attachment_type == "image" && self.source == "local_file" && !self.path.is_empty() + } +} + +/// plannotator-tui-owned fields on a root annotation. Empty records serialize exactly like +/// the Workspaces object, and older plannotator-tui builds preserve this object through +/// their flattened `other` map. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnnotationExtras { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attachments: Vec, + #[serde(flatten)] + pub other: BTreeMap, +} + +impl AnnotationExtras { + pub fn is_empty(&self) -> bool { + self.attachments.is_empty() && self.other.is_empty() + } +} + /// A root annotation: the server's `Annotation` object. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Annotation { @@ -50,6 +103,8 @@ pub struct Annotation { pub state: State, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attachments: Vec, + #[serde(default, skip_serializing_if = "AnnotationExtras::is_empty")] + pub plannotator_tui: AnnotationExtras, pub created_at: String, pub updated_at: String, #[serde(default)] @@ -103,3 +158,77 @@ impl std::fmt::Display for ApiError { } impl std::error::Error for ApiError {} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] +mod tests { + use super::*; + use crate::{Anchor, Kind, SourceRange}; + + fn annotation() -> Annotation { + Annotation { + id: "a1".into(), + document_id: "d1".into(), + anchor: Anchor::new( + "hello", + "hello world", + SourceRange { start: 0, end: 5, version: "v".into() }, + Kind::Comment, + Some(0), + ), + body: "see screenshot".into(), + author: None, + author_name: None, + state: State::Open, + attachments: Vec::new(), + plannotator_tui: AnnotationExtras::default(), + created_at: "2026-09-14T00:00:00.000Z".into(), + updated_at: "2026-09-14T00:00:00.000Z".into(), + replies: Vec::new(), + other: BTreeMap::new(), + } + } + + #[test] + fn empty_local_extras_do_not_change_the_workspaces_shape() { + let json = serde_json::to_value(annotation()).expect("serializable"); + assert!(json.get("plannotator_tui").is_none()); + assert!(json.get("attachments").is_none()); + } + + #[test] + fn local_image_attachments_round_trip_under_the_tui_namespace() { + let mut annotation = annotation(); + annotation.plannotator_tui.attachments.push(LocalAttachment::image( + "/tmp/screen.png".into(), + Some("screen.png".into()), + Some("image/png".into()), + )); + let json = serde_json::to_value(&annotation).expect("serializable"); + assert_eq!(json["plannotator_tui"]["attachments"][0]["type"], "image"); + assert_eq!(json["plannotator_tui"]["attachments"][0]["source"], "local_file"); + assert_eq!(json["plannotator_tui"]["attachments"][0]["path"], "/tmp/screen.png"); + assert!(json.get("attachments").is_none(), "local files must not leak into Workspaces URLs"); + let parsed: Annotation = serde_json::from_value(json).expect("parses"); + assert_eq!(parsed, annotation); + assert!(parsed.plannotator_tui.attachments[0].is_local_image()); + } + + #[test] + fn future_local_attachment_kinds_are_preserved() { + let json = serde_json::json!({ + "id": "a1", + "document_id": "d1", + "anchor": {"originalText": "hello"}, + "body": "body", + "author": null, + "state": "open", + "plannotator_tui": {"attachments": [{"type": "video", "source": "local_file", "path": "/tmp/cast.webm", "duration": 4}]}, + "created_at": "2026-09-14T00:00:00.000Z", + "updated_at": "2026-09-14T00:00:00.000Z", + "replies": [] + }); + let parsed: Annotation = serde_json::from_value(json.clone()).expect("parses"); + assert_eq!(serde_json::to_value(parsed).expect("serializable"), json); + } +} diff --git a/crates/plannotator-tui-schema/src/lib.rs b/crates/plannotator-tui-schema/src/lib.rs index 1e8d51c..e284e28 100644 --- a/crates/plannotator-tui-schema/src/lib.rs +++ b/crates/plannotator-tui-schema/src/lib.rs @@ -15,7 +15,9 @@ pub mod source; pub mod version; pub use anchor::{Anchor, CONTEXT_CHARS, Extras, Kind, SourceRange}; -pub use annotation::{Annotation, ApiError, CreateAnnotation, PatchAnnotation, Reply, State}; +pub use annotation::{ + Annotation, AnnotationExtras, ApiError, CreateAnnotation, LocalAttachment, PatchAnnotation, Reply, State, +}; pub use datadir::{annotations_dir, data_dir, history_slug, project_name, sanitize_tag}; pub use resolve::{Resolution, resolve, web_will_match}; pub use source::{DocumentSource, Provenance}; diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index 384bbf2..601557c 100644 --- a/crates/plannotator-tui/README.md +++ b/crates/plannotator-tui/README.md @@ -15,12 +15,13 @@ cargo build --release | anywhere | `Tab` cycle focus (tree ยท document ยท rail) ยท `E` send feedback (clipboard) ยท `t` show/hide tree ยท `r` reload ยท `q` quit | | document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms ยท `j`/`k` or click selects a block ยท `c` comments on the block ยท `x` clears the block's annotations | | selection toolbar | `a` ๐Ÿ‘ looks good ยท `c` ๐Ÿ’ฌ comment (opens a box at the selection) ยท `d` โœ— delete ยท `Esc` clears | -| rail | `j`/`k` move ยท `e` / `Enter` edit body ยท `x` remove ยท click a bubble to focus it | +| rail | `j`/`k` move ยท `e` / `Enter` edit body ยท `i` attach local image ยท `x` remove ยท click a bubble to focus it | | file/folder review | `E` send new ยท `m` review menu (`R` resend all ยท `F` finish review ยท `U` undo ยท `H` archive) | | tree | `j`/`k` move ยท `Enter` open ยท `E` send new feedback across files, including collapsed folders ยท counts show active notes per file | | archive | `j`/`k` or โ†‘/โ†“ select ยท `Enter` or click restore ยท `Esc` close | -Selections and exports are copied to the terminal clipboard (OSC 52). +Selections and exports are copied to the terminal clipboard (OSC 52). See the +[root usage guide](../../README.md#use) for image attachment workflow and limitations. Sent notes remain visible. File reviews send only new or edited notes by default; `R` explicitly includes the sent ones too. `F` archives only sent notes that have not @@ -32,17 +33,10 @@ key. The keys also work directly, without the menu. ## Where things live -Every annotation is saved the moment it is made, as JSON, in the Plannotator data directory: - -``` -~/.plannotator/clients/plannotator-tui/annotations///annotations.json -``` - -`` and `` follow Plannotator's own rules (git repo name; basename + 8 hex of -sha256 of the path), so one file maps to one directory in both tools. The records are in the -Plannotator Workspaces wire shape (`plannotator-tui-schema`); any agent can read them. Nothing is -written next to your files. `PLANNOTATOR_DATA_DIR` relocates the directory. Transient -documents (an agent's last message, stdin) are never persisted. +See the root guide for [annotation storage](../../README.md#where-annotations-live), +[feedback history](../../README.md#feedback-archive), and +[transient reply reviews](../../README.md#agent-replies). The +[annotation schema](../plannotator-tui-schema/src/annotation.rs) owns attachment storage fields. ## Headless tools diff --git a/crates/plannotator-tui/src/app/attachments.rs b/crates/plannotator-tui/src/app/attachments.rs new file mode 100644 index 0000000..2b782af --- /dev/null +++ b/crates/plannotator-tui/src/app/attachments.rs @@ -0,0 +1,149 @@ +//! File-backed image attachments on existing annotations. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::{App, Mode}; + +impl App { + pub(super) fn begin_attach_image(&mut self) { + let placed = self.open.store.placed(); + let Some(target) = placed.get(self.rail_cursor) else { + return; + }; + self.compose = super::compose::Compose::default(); + self.status = Some("image path: paste or type a local PNG/JPEG/GIF/WebP/BMP/SVG file".into()); + self.mode = Mode::AttachImage(target.annotation.id.clone()); + } + + pub(super) fn attach_image_to_annotation(&mut self, id: &str, input: &str) -> Result { + let path = image_attachment_path(input)?; + let media_type = image_media_type(&path).ok_or_else(|| { + anyhow::anyhow!("expected an image file (.png, .jpg, .jpeg, .gif, .webp, .bmp or .svg)") + })?; + anyhow::ensure!(path.is_file(), "not a file: {}", path.display()); + let alt = path.file_name().map(|name| name.to_string_lossy().into_owned()); + let attachment = plannotator_tui_schema::LocalAttachment::image( + path.display().to_string(), + alt, + Some(media_type.to_owned()), + ); + let added = self.open.store.add_image_attachment(id, attachment)?; + if added { + self.mark_unsent(); + self.sync_tree_counts(); + } + Ok(added) + } +} + +fn image_attachment_path(input: &str) -> Result { + let path = input.trim().trim_matches(['\'', '"']); + anyhow::ensure!(!path.is_empty(), "image path is empty"); + let path = file_url_path(path).unwrap_or_else(|| PathBuf::from(path)); + path.canonicalize().with_context(|| format!("resolve image attachment {}", path.display())) +} + +fn file_url_path(url: &str) -> Option { + let rest = url.strip_prefix("file://")?; + let path = match rest.find('/') { + Some(0) => rest, + Some(slash) if matches!(&rest[..slash], "" | "localhost") => &rest[slash..], + _ => return None, + }; + let decoded = percent_decode(path); + #[cfg(windows)] + let decoded = match decoded.as_bytes() { + [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => { + decoded.get(1..).unwrap_or_default().to_owned() + } + _ => decoded, + }; + Some(PathBuf::from(decoded)) +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let decoded = (bytes.get(i) == Some(&b'%')) + .then(|| s.get(i + 1..i + 3)) + .flatten() + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + if let Some(byte) = decoded { + out.push(byte); + i += 3; + } else { + out.extend(bytes.get(i..=i).unwrap_or_default()); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn image_media_type(path: &Path) -> Option<&'static str> { + match path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref() { + Some("png") => Some("image/png"), + Some("jpg" | "jpeg") => Some("image/jpeg"), + Some("gif") => Some("image/gif"), + Some("webp") => Some("image/webp"), + Some("bmp") => Some("image/bmp"), + Some("svg") => Some("image/svg+xml"), + _ => None, + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "tests assert by panicking")] +mod tests { + use super::*; + + #[test] + fn file_urls_and_quoted_paths_become_absolute_paths() { + assert_eq!( + image_attachment_path("'Cargo.toml'").expect("relative path"), + Path::new("Cargo.toml").canonicalize().expect("canonical relative path") + ); + let dir = super::super::tests::scratch_data_dir(); + let path = dir.join("screen shot.png"); + std::fs::write(&path, b"image").expect("image"); + assert_eq!( + image_attachment_path(&format!("'{}'", path.display())).expect("path"), + path.canonicalize().expect("canonical path") + ); + #[cfg(unix)] + assert_eq!( + image_attachment_path(&format!("file://{}/screen%20shot.png", dir.display())).expect("path"), + path.canonicalize().expect("canonical path") + ); + std::fs::remove_dir_all(dir).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn parent_components_follow_symlink_targets() { + let dir = super::super::tests::scratch_data_dir(); + let work = dir.join("work"); + let captures = dir.join("captures"); + std::fs::create_dir_all(&work).expect("work directory"); + std::fs::create_dir_all(captures.join("session")).expect("capture directory"); + std::os::unix::fs::symlink(captures.join("session"), work.join("screens")).expect("screens symlink"); + std::fs::write(work.join("shot.png"), b"wrong image").expect("decoy image"); + std::fs::write(captures.join("shot.png"), b"intended image").expect("intended image"); + + let resolved = image_attachment_path(&work.join("screens/../shot.png").to_string_lossy()) + .expect("attachment path"); + assert_eq!(std::fs::read(&resolved).expect("attached image"), b"intended image"); + assert_eq!(resolved, captures.join("shot.png").canonicalize().expect("canonical path")); + std::fs::remove_dir_all(dir).expect("cleanup"); + } + + #[test] + fn only_known_image_extensions_get_media_types() { + assert_eq!(image_media_type(Path::new("a.PNG")), Some("image/png")); + assert_eq!(image_media_type(Path::new("a.jpeg")), Some("image/jpeg")); + assert_eq!(image_media_type(Path::new("a.txt")), None); + } +} diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index a56778c..54a702f 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -2,7 +2,7 @@ //! floating toolbar and compose box. Pure over `App` except for recording geometry for //! hit-testing. -use plannotator_tui_schema::Kind; +use plannotator_tui_schema::{Annotation, Kind}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; @@ -92,6 +92,7 @@ impl App { match &self.mode { Mode::Compose => self.draw_compose(frame, &self.compose_title("comment")), Mode::Edit(_) => self.draw_compose(frame, &self.compose_title("edit")), + Mode::AttachImage(_) => self.draw_compose(frame, " image path ยท enter attaches ยท esc cancels "), Mode::Browse if self.pending.is_some() => self.draw_toolbar(frame), Mode::Pick => self.draw_pick(frame), Mode::Archive => self.draw_archive(frame), @@ -301,7 +302,9 @@ impl App { } fn edit_origin(&self, height: u16, width: u16) -> Option { - let Mode::Edit(id) = &self.mode else { return None }; + let (Mode::Edit(id) | Mode::AttachImage(id)) = &self.mode else { + return None; + }; let (rect, _) = self.geometry.bubbles.iter().find(|(_, bubble_id)| bubble_id == id)?; let area = self.geometry.doc.union(*rect); let x = rect.right().saturating_sub(width).max(area.x); @@ -328,14 +331,13 @@ impl App { let anchored_y = rail.y + anchor_row.saturating_sub(self.scroll) as u16; let y = anchored_y.max(next_y); let kind = placed.kind(); - let body = if placed.annotation.body.is_empty() { - label(kind).to_owned() - } else { - placed.annotation.body.clone() - }; + let body = rail_body(placed.annotation, kind); let inner_width = usize::from(rail.width.saturating_sub(4)); - let lines: Vec> = - wrap_line(&Line::from(body.as_str()), &[], inner_width).into_iter().map(|r| r.line).collect(); + let lines: Vec> = body + .lines() + .flat_map(|line| wrap_line(&Line::from(line.to_owned()), &[], inner_width)) + .map(|r| r.line) + .collect(); let height = (lines.len() as u16 + 2).min(rail.bottom().saturating_sub(y)); if height < 3 { break; @@ -361,8 +363,12 @@ impl App { let rect = Rect { x: rail.x, y, width: rail.width, height }; let inner = bubble.inner(rect); frame.render_widget(bubble, rect); - let body_style = - if placed.annotation.body.is_empty() { Style::new().dim().italic() } else { Style::new() }; + let body_style = if placed.annotation.body.is_empty() && attachment_count(placed.annotation) == 0 + { + Style::new().dim().italic() + } else { + Style::new() + }; let text_area = Rect { x: inner.x + 1, width: inner.width.saturating_sub(1), ..inner }; frame.render_widget(Paragraph::new(lines).style(body_style), text_area); bubbles.push((rect, placed.annotation.id.clone())); @@ -415,7 +421,7 @@ impl App { let help = match self.focus { _ if self.pending.is_some() => "a looks good ยท c comment ยท d delete ยท esc clear ", Focus::Tree => "j/k ยท enter open ยท E send ยท t hide ยท q quit ", - Focus::Rail => "j/k ยท e edit ยท x remove ยท tab ยท q quit ", + Focus::Rail => "j/k ยท e edit ยท i image ยท x remove ยท tab ยท q quit ", Focus::Document => "drag or v select ยท c comment ยท E send ยท tab ยท q quit ", }; // The status must stay readable at any width, so the key help yields columns to it @@ -437,3 +443,29 @@ fn short_id(id: &str) -> String { let tail: Vec = id.chars().rev().take(5).collect(); tail.into_iter().rev().collect() } + +fn rail_body(annotation: &Annotation, kind: Kind) -> String { + let mut body = if annotation.body.is_empty() { label(kind).to_owned() } else { annotation.body.clone() }; + for attachment in annotation.plannotator_tui.attachments.iter().filter(|a| a.is_local_image()) { + body.push('\n'); + body.push_str("๐Ÿ“Ž image: "); + body.push_str(&attachment_label(&attachment.path)); + } + for url in &annotation.attachments { + body.push('\n'); + body.push_str("๐Ÿ“Ž image: "); + body.push_str(url); + } + body +} + +fn attachment_count(annotation: &Annotation) -> usize { + annotation.plannotator_tui.attachments.iter().filter(|a| a.is_local_image()).count() + + annotation.attachments.len() +} + +fn attachment_label(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .map_or_else(|| path.to_owned(), |name| name.to_string_lossy().into_owned()) +} diff --git a/crates/plannotator-tui/src/app/feedback.rs b/crates/plannotator-tui/src/app/feedback.rs index f89212c..075ed62 100644 --- a/crates/plannotator-tui/src/app/feedback.rs +++ b/crates/plannotator-tui/src/app/feedback.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use plannotator_tui_schema::{Kind, Provenance}; +use plannotator_tui_schema::{Annotation, Kind, Provenance}; use super::App; use crate::archive::AnnotationRecord; @@ -40,6 +40,7 @@ impl ReviewCounts { #[derive(Debug)] pub(super) struct FeedbackPart { pub(super) path: Option, + pub(super) message: Option, pub(super) store: Store, pub(super) ids: Vec, } @@ -48,13 +49,22 @@ pub(super) struct FeedbackPart { pub(super) struct Feedback { pub(super) text: String, pub(super) count: usize, + pub(super) images: usize, pub(super) parts: Vec, pub(super) annotations: Vec, pub(super) counts: HashMap, } impl Feedback { - fn add(&mut self, path: Option, name: &str, doc: &Document, store: Store, scope: SendScope) { + fn add( + &mut self, + path: Option, + message: Option, + name: &str, + doc: &Document, + store: Store, + scope: SendScope, + ) { if let Some(path) = &path { self.counts.insert(path.clone(), ReviewCounts::for_store(&store)); } @@ -77,6 +87,7 @@ impl Feedback { self.text.push_str(&export::feedback(&doc.source, name, &entries)); let ids = entries.iter().map(|e| e.annotation.id.clone()).collect(); self.count += entries.len(); + self.images += entries.iter().map(|entry| image_count(entry.annotation)).sum::(); self.annotations.extend(entries.iter().map(|entry| { let a = entry.annotation; AnnotationRecord { @@ -93,7 +104,7 @@ impl Feedback { original_text: (!a.anchor.original_text.is_empty()).then(|| a.anchor.original_text.clone()), } })); - self.parts.push(FeedbackPart { path, store, ids }); + self.parts.push(FeedbackPart { path, message, store, ids }); } fn exported_text(self) -> String { @@ -101,12 +112,26 @@ impl Feedback { } } +fn image_count(annotation: &Annotation) -> usize { + annotation.attachments.len() + + annotation + .plannotator_tui + .attachments + .iter() + .filter(|attachment| attachment.is_local_image()) + .count() +} + impl App { pub(super) fn is_file_review(&self) -> bool { self.tree.is_some() || (!self.open.source.transient && matches!(self.open.source.provenance, Provenance::File { .. })) } + pub(super) fn is_message_review(&self) -> bool { + !self.candidates.is_empty() && matches!(self.open.source.provenance, Provenance::AgentMessage { .. }) + } + pub(super) fn review_counts(&self) -> ReviewCounts { if self.tree.is_none() { return ReviewCounts::for_store(&self.open.store); @@ -234,11 +259,34 @@ impl App { _ => None, }; let mut feedback = Feedback::default(); - feedback.add(path, &self.open.source.name, &self.open.doc, self.open.store.clone(), scope); + feedback.add(path, None, &self.open.source.name, &self.open.doc, self.open.store.clone(), scope); + feedback + } + + fn message_feedback(&self, scope: SendScope) -> Feedback { + let mut feedback = Feedback::default(); + for index in 0..self.candidates.len() { + let (doc, store) = if index == self.pick_open { + (&self.open.doc, self.open.store.clone()) + } else if let Some(open) = self.pick_cache.get(&index) { + (&open.doc, open.store.clone()) + } else { + continue; + }; + let name = if self.candidates.len() == 1 { + self.open.source.name.clone() + } else { + format!("{} ยท message {} of {}", self.message_host, index + 1, self.candidates.len()) + }; + feedback.add(None, Some(index), &name, doc, store, scope); + } feedback } pub(super) fn prepare_feedback(&self, scope: SendScope) -> Result { + if self.is_message_review() { + return Ok(self.message_feedback(scope)); + } let Some(tree) = &self.tree else { return Ok(self.file_feedback(scope)) }; let mut feedback = Feedback::default(); for path in self.review_files() { @@ -249,10 +297,10 @@ impl App { } let name = path.strip_prefix(tree.root()).unwrap_or(&path).display().to_string(); if self.is_open(&path) { - feedback.add(Some(path), &name, &self.open.doc, self.open.store.clone(), scope); + feedback.add(Some(path), None, &name, &self.open.doc, self.open.store.clone(), scope); } else { let (doc, store) = self.load_review_file(&path)?; - feedback.add(Some(path), &name, &doc, store, scope); + feedback.add(Some(path), None, &name, &doc, store, scope); } } // Folder feedback has always ended each file's block with one extra newline, so diff --git a/crates/plannotator-tui/src/app/feedback/tests.rs b/crates/plannotator-tui/src/app/feedback/tests.rs index d69924f..5eaf1b5 100644 --- a/crates/plannotator-tui/src/app/feedback/tests.rs +++ b/crates/plannotator-tui/src/app/feedback/tests.rs @@ -114,6 +114,46 @@ fn unsuccessful_sends_leave_the_record_pending_and_offer_retry() { } } +#[test] +fn a_rail_image_path_is_stored_rendered_and_counted_in_send_history() { + let (root, mut app, delivery) = file_app("image-attachment"); + let image = root.join("screen shot.png"); + std::fs::write(&image, b"not actually decoded by this layer").expect("image"); + app.add_quote_annotation("one", Kind::Comment, "see screenshot".into()).expect("annotation"); + app.focus = Focus::Rail; + + press(&mut app, 'i'); + assert!(matches!(app.mode, Mode::AttachImage(_))); + app.handle_event(&Event::Paste(image.display().to_string())).expect("paste path"); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Enter))).expect("attach"); + assert_eq!(app.mode, Mode::Browse); + assert_eq!(app.status.as_deref(), Some("image attached")); + let screen = draw(&mut app, 100, 24); + assert!(screen.contains("image: screen shot.png"), "{screen}"); + + let location = Location::for_file(&app.data_dir, &app.project, &root.join("docs/a.md")); + let record: serde_json::Value = + serde_json::from_slice(&std::fs::read(&location.record).expect("record")).expect("JSON"); + assert_eq!( + record["annotations"][0]["plannotator_tui"]["attachments"][0]["path"], + image.display().to_string() + ); + assert_eq!(record["annotations"][0]["plannotator_tui"]["attachments"][0]["mediaType"], "image/png"); + assert!(record["annotations"][0].get("attachments").is_none(), "local images do not use Workspaces URLs"); + + press(&mut app, 'E'); + let sent = delivery.calls.borrow()[0].clone(); + assert!(sent.contains("Attachments:\n- Image 1:"), "{sent}"); + assert!(sent.contains(&format!("`{}`", image.display())), "{sent}"); + assert!(sent.contains("screen%20shot.png"), "{sent}"); + let history = + std::fs::read_to_string(app.data_dir.join("feedback").join(&app.project).join("index.jsonl")) + .expect("history"); + let archived: serde_json::Value = serde_json::from_str(history.trim()).expect("history JSON"); + assert_eq!(archived["counts"]["images"], 1); + std::fs::remove_dir_all(root).expect("cleanup"); +} + #[test] fn folder_counts_and_delivery_cover_collapsed_files_but_exclude_orphans_and_siblings() { let (root, mut app, delivery) = folder_app("folder-scope"); diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 869b228..4354d01 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -25,11 +25,13 @@ impl App { Ok(()) } Mode::ReviewMenu => self.menu_key(*key), - Mode::Compose | Mode::Edit(_) => self.text_key(*key), + Mode::Compose | Mode::Edit(_) | Mode::AttachImage(_) => self.text_key(*key), }, // A paste lands in the comment box verbatim, newlines included; anywhere else // it is ignored rather than replayed as keystrokes. - Event::Paste(text) if matches!(self.mode, Mode::Compose | Mode::Edit(_)) => { + Event::Paste(text) + if matches!(self.mode, Mode::Compose | Mode::Edit(_) | Mode::AttachImage(_)) => + { self.compose.insert_text(text); Ok(()) } @@ -143,6 +145,7 @@ impl App { } KeyCode::Char('k') | KeyCode::Up => self.rail_cursor = self.rail_cursor.saturating_sub(1), KeyCode::Enter | KeyCode::Char('e') => self.edit_selected_annotation(), + KeyCode::Char('i') => self.begin_attach_image(), KeyCode::Char('x') | KeyCode::Delete => self.remove_selected_annotation()?, KeyCode::Esc => self.focus = Focus::Document, _ => {} @@ -292,6 +295,27 @@ impl App { ComposeAction::Cancel => self.mode = Mode::Browse, ComposeAction::Save => { let body = self.compose.value().trim().to_owned(); + if let Mode::AttachImage(id) = self.mode.clone() { + if body.is_empty() { + self.mode = Mode::Browse; + self.status = Some("image attach cancelled: empty".into()); + } else { + match self.attach_image_to_annotation(&id, &body) { + Ok(true) => { + self.mode = Mode::Browse; + self.status = Some("image attached".into()); + } + Ok(false) => { + self.mode = Mode::Browse; + self.status = Some("image already attached".into()); + } + Err(err) => { + self.status = Some(format!("could not attach image: {err:#}")); + } + } + } + return Ok(()); + } match std::mem::replace(&mut self.mode, Mode::Browse) { Mode::Edit(id) => { if body.is_empty() { @@ -306,7 +330,8 @@ impl App { | Mode::ConfirmQuit | Mode::Pick | Mode::Archive - | Mode::ReviewMenu => { + | Mode::ReviewMenu + | Mode::AttachImage(_) => { if !body.is_empty() && let Some(pending) = self.pending.take() { diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index c1c6925..b2a370c 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -2,6 +2,7 @@ //! the data they share and the operations that change it. mod archive_view; +mod attachments; mod compose; mod draw; mod feedback; @@ -44,13 +45,15 @@ const TOOLBAR: [(&str, &str, char, Kind); 3] = [ ("โœ—", "delete", 'd', Kind::Delete), ]; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum Mode { Browse, /// Typing a comment for the pending selection. Compose, /// Editing the body of an existing annotation (by id). Edit(String), + /// Typing a local image path to attach to an existing annotation (by id). + AttachImage(String), /// Quit was asked for while feedback is unsent; the footer asks first. ConfirmQuit, /// Choosing which of the agent's recent messages to review. diff --git a/crates/plannotator-tui/src/app/send.rs b/crates/plannotator-tui/src/app/send.rs index 72cfc4c..985a574 100644 --- a/crates/plannotator-tui/src/app/send.rs +++ b/crates/plannotator-tui/src/app/send.rs @@ -87,18 +87,31 @@ impl App { self.clipboard && Clipboard.deliver(text).is_ok() } - /// Keep the ids from the body we delivered. Attempt every file even when one record - /// cannot be saved, and refresh counts from any intervening changes. + /// Keep the ids from the body we delivered. Attempt every file or message even when + /// one record cannot be saved, and refresh counts from any intervening changes. fn remember_delivery(&mut self, feedback: &mut Feedback, target: &str) -> Vec { let mut errors = Vec::new(); for mut part in feedback.parts.drain(..) { if let Err(err) = self.record_feedback_delivery(&mut part, target) { - let name = part - .path - .as_ref() - .map_or_else(|| self.open.source.name.clone(), |p| self.review_file_name(p)); + let name = part.path.as_ref().map_or_else( + || { + part.message.map_or_else( + || self.open.source.name.clone(), + |index| format!("{} message {}", self.message_host, index + 1), + ) + }, + |p| self.review_file_name(p), + ); errors.push(format!("{name}: {err:#}")); } + if let Some(index) = part.message { + if index == self.pick_open { + self.open.store = part.store; + } else if let Some(open) = self.pick_cache.get_mut(&index) { + open.store = part.store; + } + continue; + } if self.tree.is_some() && let Some(path) = &part.path { @@ -165,12 +178,33 @@ impl App { feedback: &feedback.text, annotations, count: feedback.count, + images: feedback.images, now_ms: None, }); } + fn message_annotation_count(&self) -> usize { + let cached = self.pick_cache.values().map(|open| open.store.placed().len()).sum::(); + self.open.store.placed().len() + cached + } + + fn message_review_delivered(&self) -> bool { + self.message_annotation_count() > 0 + && (self.open.store.placed().is_empty() || self.open.store.all_delivered()) + && self + .pick_cache + .values() + .all(|open| open.store.placed().is_empty() || open.store.all_delivered()) + } + pub(super) fn send_count(&self) -> usize { - if self.is_file_review() { self.review_counts().pending } else { self.open.store.placed().len() } + if self.is_file_review() { + self.review_counts().pending + } else if self.is_message_review() { + self.message_annotation_count() + } else { + self.open.store.placed().len() + } } pub(super) fn send_label(&self) -> String { @@ -218,6 +252,8 @@ impl App { let delivered = if self.is_file_review() { let counts = self.review_counts(); counts.pending == 0 && counts.sent > 0 + } else if self.is_message_review() { + self.message_review_delivered() } else { self.open.store.all_delivered() }; diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 324630d..e00169b 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -13,6 +13,7 @@ use ratatui::crossterm::event::{ Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; +use super::review_test_support::RecordingDelivery; use super::send::SendState; use super::{App, Mode}; use crate::delivery::{Delivery, Discard, HerdrAgent}; @@ -20,7 +21,7 @@ use crate::delivery::{Delivery, Discard, HerdrAgent}; /// A fresh, empty data directory for one test. `App::open` resolves the real one, and a /// successful send archives into it, so every app under test is pointed here instead: /// nothing a test does may reach the developer's own Plannotator data. -fn scratch_data_dir() -> PathBuf { +pub(super) fn scratch_data_dir() -> PathBuf { static NEXT: AtomicUsize = AtomicUsize::new(0); let n = NEXT.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("plannotator-tui-app-{}-{n}", std::process::id())); @@ -252,6 +253,31 @@ fn previewing_away_and_back_keeps_annotations() { assert_eq!(app.open.store.placed().len(), 1, "a reply review only holds annotations in memory"); } +#[test] +fn a_message_review_sends_annotations_from_every_opened_candidate_together() { + let delivery = RecordingDelivery::default(); + let mut app = message_app(None, Box::new(delivery.clone())); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Esc))).expect("esc"); + app.add_block_annotation(0, Kind::Comment, "newest note".to_owned()).expect("newest"); + + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Char('p')))).expect("p"); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Char('j')))).expect("j"); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Enter))).expect("open middle"); + app.add_block_annotation(0, Kind::Comment, "middle note".to_owned()).expect("middle"); + + assert_eq!(app.send_count(), 2); + app.send_feedback().expect("send"); + assert_eq!(app.send_state, SendState::Sent); + let calls = delivery.calls.borrow(); + assert_eq!(calls.len(), 1); + let sent = &calls[0]; + assert!(sent.contains("# Annotations on claude ยท message 1 of 3"), "{sent}"); + assert!(sent.contains("> newest note"), "{sent}"); + assert!(sent.contains("# Annotations on claude ยท message 2 of 3"), "{sent}"); + assert!(sent.contains("> middle note"), "{sent}"); + assert_eq!(app.send_count(), 2, "reply reviews keep the full sent review available"); +} + /// A folder of `count` Markdown files named `f00.md`, `f01.md`, โ€ฆ in a fresh temp dir. fn folder(count: usize) -> PathBuf { let root = std::env::temp_dir().join(format!("plannotator-tui-folder-{}", std::process::id())); diff --git a/crates/plannotator-tui/src/archive.rs b/crates/plannotator-tui/src/archive.rs index 2af74dd..c2805b5 100644 --- a/crates/plannotator-tui/src/archive.rs +++ b/crates/plannotator-tui/src/archive.rs @@ -157,6 +157,7 @@ pub(crate) struct Submission<'a> { /// Per-annotation records; empty in folder mode, where `count` still carries the total. pub(crate) annotations: Vec, pub(crate) count: usize, + pub(crate) images: usize, /// Milliseconds since the epoch; tests pin it, callers pass `None` for now. pub(crate) now_ms: Option, } @@ -188,7 +189,7 @@ fn try_append(submission: &Submission<'_>) -> std::io::Result { target: (!submission.target.is_empty()).then_some(&submission.target), feedback: submission.feedback, annotations: &submission.annotations, - counts: Counts { annotations: submission.count, external: 0, images: 0 }, + counts: Counts { annotations: submission.count, external: 0, images: submission.images }, record_file: None, }; @@ -333,6 +334,7 @@ mod tests { original_text: Some("the selected text".to_owned()), }], count: 1, + images: 0, now_ms: Some(1_788_242_400_123), } } diff --git a/crates/plannotator-tui/src/export.rs b/crates/plannotator-tui/src/export.rs index 60710e3..d7df00d 100644 --- a/crates/plannotator-tui/src/export.rs +++ b/crates/plannotator-tui/src/export.rs @@ -1,8 +1,8 @@ //! Feedback export: annotations as numbered Markdown a coding agent reads without a schema. //! //! `# Annotations on `, then one `## Annotation N (line X)` per annotation in document -//! order: what kind of note it is, the quoted text, and the body as a blockquote. Deleted -//! text is fenced so quoted markdown cannot escape. +//! order: what kind of note it is, the quoted text, the body as a blockquote, and any image +//! attachments as Markdown links. Deleted text is fenced so quoted markdown cannot escape. use std::fmt::Write as _; use std::ops::Range; @@ -47,6 +47,7 @@ pub(crate) fn feedback(source: &str, name: &str, entries: &[Entry<'_>]) -> Strin let _ = writeln!(out, "> {}", quote_lines(body)); } } + write_attachments(&mut out, entry.annotation); for reply in &entry.annotation.replies { let who = reply.author.as_deref().unwrap_or("reply"); let _ = writeln!(out, "- **Reply ({who}):** {}", reply.body.replace('\n', "\n ")); @@ -71,6 +72,71 @@ fn quote_lines(text: &str) -> String { text.replace('\n', "\n> ") } +fn write_attachments(out: &mut String, annotation: &Annotation) { + let total = annotation.attachments.len() + + annotation.plannotator_tui.attachments.iter().filter(|a| a.is_local_image()).count(); + if total == 0 { + return; + } + out.push_str("Attachments:\n"); + let mut index = 1; + for url in &annotation.attachments { + let alt = format!("image {index}"); + let _ = writeln!(out, "- Image {index}: {url}"); + let _ = writeln!(out, " ![{}]({})", markdown_alt(&alt), markdown_target(url)); + index += 1; + } + for attachment in annotation.plannotator_tui.attachments.iter().filter(|a| a.is_local_image()) { + let alt = attachment.alt.as_deref().filter(|alt| !alt.trim().is_empty()).unwrap_or("attached image"); + let target = file_uri(&attachment.path); + let _ = writeln!(out, "- Image {index}: `{}`", attachment.path); + let _ = writeln!(out, " ![{}]({target})", markdown_alt(alt)); + index += 1; + } +} + +fn markdown_alt(text: &str) -> String { + text.replace(['[', ']'], "") +} + +fn markdown_target(text: &str) -> String { + text.replace(' ', "%20").replace('(', "%28").replace(')', "%29") +} + +fn file_uri(path: &str) -> String { + let normalized = if cfg!(windows) { path.replace('\\', "/") } else { path.to_owned() }; + let normalized = if cfg!(windows) { + if let Some(unc) = normalized.strip_prefix("//?/UNC/") { + format!("//{unc}") + } else { + normalized.strip_prefix("//?/").unwrap_or(&normalized).to_owned() + } + } else { + normalized + }; + let path = if cfg!(windows) && normalized.as_bytes().get(1) == Some(&b':') { + format!("/{normalized}") + } else { + normalized + }; + format!("file://{}", percent_encode_path(&path)) +} + +fn percent_encode_path(path: &str) -> String { + let mut out = String::new(); + for byte in path.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => { + out.push(char::from(byte)); + } + _ => { + let _ = write!(out, "%{byte:02X}"); + } + } + } + out +} + /// 1-based line numbers of the first and last byte of `range`. pub(crate) fn line_span(source: &str, range: &Range) -> (usize, usize) { let line_at = |offset: usize| source.get(..offset).map_or(1, |s| s.matches('\n').count() + 1); @@ -83,6 +149,13 @@ mod tests { use super::*; use plannotator_tui_schema::{Anchor, SourceRange, State}; + #[cfg(windows)] + #[test] + fn canonical_attachment_paths_export_as_file_uris() { + assert_eq!(file_uri(r"\\?\C:\captures\shot.png"), "file:///C:/captures/shot.png"); + assert_eq!(file_uri(r"\\?\UNC\server\captures\shot.png"), "file:////server/captures/shot.png"); + } + fn annotation(source: &str, quote: &str, kind: Kind, body: &str) -> (Annotation, Range) { let start = source.find(quote).expect("present"); let range = start..start + quote.len(); @@ -96,6 +169,7 @@ mod tests { author_name: None, state: State::Open, attachments: Vec::new(), + plannotator_tui: plannotator_tui_schema::AnnotationExtras::default(), created_at: String::new(), updated_at: String::new(), replies: Vec::new(), @@ -122,6 +196,71 @@ mod tests { ); } + #[test] + fn images_are_rendered_as_clear_markdown_attachments() { + let source = "see screenshot\n"; + let (mut note, range) = annotation(source, "screenshot", Kind::Comment, "Compare this."); + note.attachments.push("https://cdn.example.com/upload.png".into()); + note.plannotator_tui.attachments.push(plannotator_tui_schema::LocalAttachment::image( + "/tmp/screen shot.png".into(), + Some("screen shot.png".into()), + Some("image/png".into()), + )); + let out = feedback(source, "reply", &[Entry { annotation: ¬e, lines: (1, 1), range }]); + assert!(out.contains("Attachments:\n- Image 1: https://cdn.example.com/upload.png"), "{out}"); + assert!(out.contains("![image 1](https://cdn.example.com/upload.png)"), "{out}"); + assert!(out.contains("- Image 2: `/tmp/screen shot.png`"), "{out}"); + assert!(out.contains("![screen shot.png](file:///tmp/screen%20shot.png)"), "{out}"); + } + + #[test] + fn remote_image_destinations_preserve_markdown_delimiters() { + let source = "screenshot"; + let (mut note, range) = annotation(source, source, Kind::Comment, "Compare this."); + note.attachments = vec![ + "https://cdn.example.com/screenshot).png".into(), + "https://cdn.example.com/screen(shot).png?q=a%20b".into(), + ]; + let out = feedback(source, "reply", &[Entry { annotation: ¬e, lines: (1, 1), range }]); + let destinations: Vec<_> = pulldown_cmark::Parser::new(&out) + .filter_map(|event| match event { + pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image { dest_url, .. }) => { + Some(dest_url.into_string()) + } + _ => None, + }) + .collect(); + assert_eq!( + destinations, + [ + "https://cdn.example.com/screenshot%29.png", + "https://cdn.example.com/screen%28shot%29.png?q=a%20b", + ] + ); + } + + #[cfg(unix)] + #[test] + fn local_image_destinations_preserve_unix_backslashes() { + let source = "screenshot"; + let (mut note, range) = annotation(source, source, Kind::Comment, "Compare this."); + note.plannotator_tui.attachments.push(plannotator_tui_schema::LocalAttachment::image( + "/tmp/screen\\shot.png".into(), + None, + Some("image/png".into()), + )); + let out = feedback(source, "reply", &[Entry { annotation: ¬e, lines: (1, 1), range }]); + let destinations: Vec<_> = pulldown_cmark::Parser::new(&out) + .filter_map(|event| match event { + pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image { dest_url, .. }) => { + Some(dest_url.into_string()) + } + _ => None, + }) + .collect(); + assert_eq!(destinations, ["file:///tmp/screen%5Cshot.png"]); + } + #[test] fn fences_grow_past_embedded_backticks() { assert!(fenced("has ``` inside").starts_with("````\n")); diff --git a/crates/plannotator-tui/src/store.rs b/crates/plannotator-tui/src/store.rs index 236a452..cbdb1b3 100644 --- a/crates/plannotator-tui/src/store.rs +++ b/crates/plannotator-tui/src/store.rs @@ -2,9 +2,9 @@ //! //! The record is `annotations.json` under the Plannotator data directory, keyed the way //! Plannotator keys files (`plannotator_tui_schema::annotations_dir`). It holds -//! `plannotator_tui_schema::Annotation` values โ€” the Workspaces wire shape โ€” so a local record -//! and a server row are interchangeable. Resolution against the current source happens on -//! load; an annotation whose text is gone is kept as an orphan. +//! `plannotator_tui_schema::Annotation` values; that type owns the wire contract and local +//! extensions. Resolution against the current source happens on load; an annotation whose +//! text is gone is kept as an orphan. //! //! A phase-2 sidecar (`.annotations.json` next to the document) is imported once and //! left alone; nothing is written next to the document any more. @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use plannotator_tui_schema::{Anchor, Annotation, Kind, Resolution, State, resolve}; +use plannotator_tui_schema::{Anchor, Annotation, Kind, LocalAttachment, Resolution, State, resolve}; use serde::{Deserialize, Serialize}; use crate::doc::Document; @@ -231,6 +231,7 @@ impl Store { author_name: None, state: State::Open, attachments: Vec::new(), + plannotator_tui: plannotator_tui_schema::AnnotationExtras::default(), created_at: now.clone(), updated_at: now, replies: Vec::new(), @@ -262,6 +263,29 @@ impl Store { Ok(removed) } + pub(crate) fn add_image_attachment(&mut self, id: &str, attachment: LocalAttachment) -> Result { + if !attachment.is_local_image() { + return Ok(false); + } + let Some(annotation) = self.annotations.iter().find(|a| a.id == id) else { return Ok(false) }; + if annotation + .plannotator_tui + .attachments + .iter() + .any(|existing| existing.is_local_image() && existing.path == attachment.path) + { + return Ok(false); + } + let Some(updated_at) = self.next_updated_at(id)? else { return Ok(false) }; + let mut next = self.clone(); + let Some(annotation) = next.annotations.iter_mut().find(|a| a.id == id) else { return Ok(false) }; + annotation.plannotator_tui.attachments.push(attachment); + annotation.updated_at = updated_at; + next.save()?; + *self = next; + Ok(true) + } + fn remove_unsaved(&mut self, id: &str) -> bool { let Some(index) = self.annotations.iter().position(|a| a.id == id) else { return false }; self.annotations.remove(index); @@ -302,6 +326,7 @@ impl Store { } /// Howard Hinnant's days-to-civil, for a dependency-free UTC date. +#[allow(clippy::similar_names, reason = "standard algorithm variable names")] pub(crate) fn civil_from_days(days: u64) -> (u64, u64, u64) { let z = days + 719_468; let era = z / 146_097; diff --git a/crates/plannotator-tui/src/store/review.rs b/crates/plannotator-tui/src/store/review.rs index d5aac3d..9df7d20 100644 --- a/crates/plannotator-tui/src/store/review.rs +++ b/crates/plannotator-tui/src/store/review.rs @@ -76,14 +76,11 @@ impl Store { !self.annotations.is_empty() && self.annotations.iter().all(|a| !self.is_pending(a)) } - /// Replace a body, advancing the existing timestamp beyond its last send even when - /// the clock has not ticked (or has moved backwards) since that send. One millisecond - /// is the smallest step the stored shape can represent. - pub(crate) fn edit_body(&mut self, id: &str, body: String) -> Result { - let Some(annotation) = self.annotations.iter().find(|a| a.id == id) else { return Ok(false) }; - if annotation.body == body { - return Ok(false); - } + /// A mutation timestamp that is after the current annotation and its last delivery, + /// even when the clock has not ticked (or has moved backwards) since then. One + /// millisecond is the smallest step the stored shape can represent. + pub(super) fn next_updated_at(&self, id: &str) -> Result> { + let Some(annotation) = self.annotations.iter().find(|a| a.id == id) else { return Ok(None) }; let previous = [parse_time(&annotation.updated_at), self.last_delivery(id).and_then(|d| parse_time(&d.at))] .into_iter() @@ -94,7 +91,16 @@ impl Store { now = now .max(previous.checked_add(Duration::milliseconds(1)).context("advancing annotation time")?); } - let updated_at = format_millis(now)?; + Ok(Some(format_millis(now)?)) + } + + /// Replace a body, advancing the existing timestamp beyond its last send. + pub(crate) fn edit_body(&mut self, id: &str, body: String) -> Result { + let Some(annotation) = self.annotations.iter().find(|a| a.id == id) else { return Ok(false) }; + if annotation.body == body { + return Ok(false); + } + let Some(updated_at) = self.next_updated_at(id)? else { return Ok(false) }; let mut next = self.clone(); if let Some(annotation) = next.annotations.iter_mut().find(|a| a.id == id) { annotation.body = body; diff --git a/crates/plannotator-tui/src/store/review/tests.rs b/crates/plannotator-tui/src/store/review/tests.rs index 3a8e00e..b6e09a1 100644 --- a/crates/plannotator-tui/src/store/review/tests.rs +++ b/crates/plannotator-tui/src/store/review/tests.rs @@ -64,6 +64,35 @@ fn an_edit_advances_past_a_delivery_even_if_the_clock_went_backwards() { assert!(!store.is_pending(&store.annotations[0])); } +#[test] +fn attaching_an_image_advances_past_delivery_and_survives_reopen() { + let (root, location, doc, mut store) = fixture("attach-image"); + let id = add(&mut store, &doc, "one", "look here"); + store.annotations[0].updated_at = "2099-01-01T00:00:00.000Z".into(); + store.record_delivery("agent", std::slice::from_ref(&id)).expect("send"); + assert!(!store.is_pending(&store.annotations[0])); + + let attached = store + .add_image_attachment( + &id, + plannotator_tui_schema::LocalAttachment::image( + "/tmp/screenshot.png".into(), + Some("screenshot.png".into()), + Some("image/png".into()), + ), + ) + .expect("attach image"); + assert!(attached); + assert_eq!(store.annotations[0].updated_at, "2099-01-01T00:00:00.001Z"); + assert!(store.is_pending(&store.annotations[0])); + + let reloaded = Store::load(&location, &doc).expect("reopen"); + assert_eq!(reloaded.annotations[0].plannotator_tui.attachments.len(), 1); + assert_eq!(reloaded.annotations[0].plannotator_tui.attachments[0].path, "/tmp/screenshot.png"); + assert!(reloaded.is_pending(&reloaded.annotations[0])); + std::fs::remove_dir_all(root).expect("cleanup"); +} + /// `YYYY-MM-DDTHH:MM:SS.mmmZ`: exactly three fractional digits and a trailing `Z`. fn has_millis_shape(value: &str) -> bool { let bytes = value.as_bytes(); diff --git a/docs/decisions.md b/docs/decisions.md index 3aedaea..ad10c0a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -363,3 +363,23 @@ looked up in whichever table holds it. Verified against the `beta` source (`packages/core/src/session/sql.ts`, `packages/schema/src/session-message.ts`, `packages/util/src/global-roots.ts`) and a mixed-schema fixture reproducing the report. + +## 15. Image attachments are local references until uploaded (2026-09-14) + +Workspaces already reserves `Annotation.attachments` for uploaded image URLs: absolute `https://` +strings that the server stores verbatim. A local screenshot path is not valid in that field, so +plannotator-tui uses an additive local namespace instead. The field contract and compatibility +invariants live in the [annotation schema](../crates/plannotator-tui-schema/src/annotation.rs), +with round-trip regression tests alongside it. + +The UI slice is deliberately file-backed. Clipboard image paste waits for a real terminal or +Herdr binary-image path; the existing paste event is text-only. See the +[usage guide](../README.md#use) for the attachment workflow and access limitations. + +Feedback export renders every image (Workspaces URL attachments and local files) inside the same +numbered annotation block, listing the path/URL and a Markdown image link. That keeps Herdr agent +send, OSC-52 copy, headless export, and feedback history sidecars byte-identical. The shared +feedback archive increments `counts.images`; file annotations remain the durable structured +record, while reply annotations stay transient. Aggregation reuses the message stores already +kept in memory for preview navigation; see [Agent replies](../README.md#agent-replies) for send +behavior and [Inside Herdr](../README.md#inside-herdr) for the selected-text capture workaround. diff --git a/docs/spec-last-message.md b/docs/spec-last-message.md index aee281e..2917014 100644 --- a/docs/spec-last-message.md +++ b/docs/spec-last-message.md @@ -12,6 +12,7 @@ Status: contract for phase 4, 2026-08-28. Detection and extraction rules are dec - `plannotator-tui last`: the CLI. Detects the host, finds the transcript, shows a picker of the newest messages, opens the chosen one as a transient document (`Provenance::AgentMessage`), and delivers feedback through the normal seam (clipboard standalone, the agent pane in Herdr). + See [Agent replies](../README.md#agent-replies) for annotation lifetime and multi-reply send behavior. - Herdr: `plannotator-tui herdr last` resolves the agent's pid from `herdr pane process-info` and opens the pane with `PLANNOTATOR_TUI_MESSAGE_PID`; `plannotator-tui herdr pane` is the pane entrypoint that reads the env and opens either a file or a message. diff --git a/docs/spec-local-storage.md b/docs/spec-local-storage.md index 266dd1c..b114f8f 100644 --- a/docs/spec-local-storage.md +++ b/docs/spec-local-storage.md @@ -1,7 +1,7 @@ # Spec: local annotation storage and the folder experience Status: reviewed with plannotator-ops 2026-08-28; conventions below are confirmed against the Plannotator source. Supersedes the sidecar-next-to-the-file storage of -phase 2 for durable records; the sidecar remains as a per-file working copy (see ยง4). +phase 2 for durable records; legacy sidecars are imported and left alone. ## Why this matters @@ -60,19 +60,20 @@ Data dir: `$PLANNOTATOR_DATA_DIR` if set; else an existing `~/.plannotator`; els - Same data-dir resolution and the same `project` / `slug` rules as Plannotator (above), so one file maps to one directory, and a future tool can join the two archives by path. -- `annotations.json` is the wire-shape `Annotation` array (phase 1) with the document - version each anchor was made against. Rewritten atomically on every change โ€” add, edit, - remove, ๐Ÿ‘, โœ—. There is no submit, no export step, no session boundary. -- We write nothing anywhere else in the data dir. `history/`, `plans/`, and the rest are - Plannotator's. No markdown records: the JSON is the record, and an agent reads JSON. +- The persisted envelope is defined by `Record` in + [`store.rs`](../crates/plannotator-tui/src/store.rs); the + [annotation schema](../crates/plannotator-tui-schema/src/annotation.rs) owns the annotation + fields and local extensions. The record is rewritten atomically on every mutation, + independently of sending feedback. +- Successful delivery also writes [feedback history](../README.md#feedback-archive) when enabled; + that history is separate from the durable annotation record. - `clients/plannotator-tui/` survives Plannotator's `uninstall --purge` (it only removes its own known entries); deleting it is the user's call. - No sidecar next to the file. An existing phase-2 sidecar is imported on first open and left alone. Annotating a repo leaves no trace in it. - Transient documents (an agent's last message, stdin) are never saved. -`E` copies the feedback text to the clipboard for pasting into an agent. It is a -convenience, not storage. +See [Use](../README.md#use) for send, resend, and finish-review behavior. ## Folder experience (local, no sharing) @@ -88,7 +89,7 @@ This slice adds: ## Not built -Version snapshots, submission records, a history view, `recent`, `--uninstall-data`, +Version snapshots, a feedback-history browser, `recent`, `--uninstall-data`, sidecar opt-in. Each waits for a reader that needs it. ## Phasing