diff --git a/README.md b/README.md index 76bc350..97b31b7 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,11 @@ plnk-tui --server http://your-planka-host --username you # prompts for password ``` -Navigate projects → boards → lists → cards with `↑↓→Enter`. Press `L` on any board to promote it to the live target. Edit titles inline with `e` or descriptions in `$EDITOR` with `E`. +Navigate projects → boards → lists → cards with `↑↓→Enter`. Press `L` on any board to promote it to the live target. Edit titles inline with `e` or descriptions in `$EDITOR` with `E`. Press `y` to copy the selected node's ID hierarchy as JSON to the clipboard (or `Y` for a paste-ready `plnk` snapshot command) — built for handing context off to an AI agent in one keystroke. Env pre-fills: `PLANKA_SERVER`, `PLANKA_USERNAME`, `PLANKA_PASSWORD`, `PLNK_TUI_BOARD`. -Docs: [`docs/tui/`](docs/tui/) — [overview](docs/tui/overview.md) · [keybindings](docs/tui/keybindings.md) · [live-target model](docs/tui/live-target.md) · [tree view reference](docs/tui/tree-view.md). +Docs: [`docs/tui/`](docs/tui/) — [overview](docs/tui/overview.md) · [keybindings](docs/tui/keybindings.md) · [live-target model](docs/tui/live-target.md) · [tree view reference](docs/tui/tree-view.md) · [fast copy](docs/tui/fast-copy.md). ## Architecture diff --git a/crates/plnk-tui/src/main.rs b/crates/plnk-tui/src/main.rs index 0ecae57..8c421c9 100644 --- a/crates/plnk-tui/src/main.rs +++ b/crates/plnk-tui/src/main.rs @@ -38,6 +38,53 @@ const SAILS_IO_SDK_VERSION: &str = "1.2.1"; const SUBSCRIBE_ACK_ID: u64 = 1; const MIN_SAVE_FEEDBACK_DURATION: Duration = Duration::from_millis(900); +const BASE64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64_encode(input: &[u8]) -> String { + let mut out = Vec::with_capacity(input.len().div_ceil(3) * 4); + let mut chunks = input.chunks_exact(3); + for chunk in chunks.by_ref() { + let b0 = chunk[0]; + let b1 = chunk[1]; + let b2 = chunk[2]; + out.push(BASE64_TABLE[((b0 >> 2) & 0x3F) as usize]); + out.push(BASE64_TABLE[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize]); + out.push(BASE64_TABLE[(((b1 << 2) | (b2 >> 6)) & 0x3F) as usize]); + out.push(BASE64_TABLE[(b2 & 0x3F) as usize]); + } + let rem = chunks.remainder(); + match rem.len() { + 0 => {} + 1 => { + let b0 = rem[0]; + out.push(BASE64_TABLE[((b0 >> 2) & 0x3F) as usize]); + out.push(BASE64_TABLE[((b0 << 4) & 0x3F) as usize]); + out.push(b'='); + out.push(b'='); + } + 2 => { + let b0 = rem[0]; + let b1 = rem[1]; + out.push(BASE64_TABLE[((b0 >> 2) & 0x3F) as usize]); + out.push(BASE64_TABLE[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize]); + out.push(BASE64_TABLE[((b1 << 2) & 0x3F) as usize]); + out.push(b'='); + } + _ => unreachable!(), + } + String::from_utf8(out).expect("base64 alphabet is ASCII") +} + +fn write_osc52_clipboard(text: &str) -> io::Result<()> { + use std::io::Write; + let encoded = base64_encode(text.as_bytes()); + let mut out = io::stdout().lock(); + out.write_all(b"\x1b]52;c;")?; + out.write_all(encoded.as_bytes())?; + out.write_all(b"\x07")?; + out.flush() +} + #[derive(Debug, Parser)] #[command( name = "plnk-tui", @@ -449,6 +496,116 @@ struct ProjectTree { boards: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct FastCopy { + breadcrumb: String, + json: String, + command: String, +} + +fn fast_copy_for(projects: &[ProjectTree], selected: &TreeKey) -> Option { + let target_card_id = match selected { + TreeKey::Card(id) | TreeKey::GroupedCard { card_id: id, .. } => Some(id.as_str()), + _ => None, + }; + let target_list_id = match selected { + TreeKey::List(id) | TreeKey::LabelGroup { list_id: id, .. } => Some(id.as_str()), + _ => None, + }; + let target_board_id = match selected { + TreeKey::Board(id) => Some(id.as_str()), + _ => None, + }; + let target_project_id = match selected { + TreeKey::Project(id) => Some(id.as_str()), + _ => None, + }; + + for project in projects { + if Some(project.id.as_str()) == target_project_id { + return Some(build_payload(project, None, None, None)); + } + for board in &project.boards { + if Some(board.id.as_str()) == target_board_id { + return Some(build_payload(project, Some(board), None, None)); + } + for list in &board.active_lists { + if Some(list.id.as_str()) == target_list_id { + return Some(build_payload(project, Some(board), Some(list), None)); + } + if let Some(card_id) = target_card_id { + if let Some(card) = list.cards.iter().find(|card| card.id == card_id) { + return Some(build_payload(project, Some(board), Some(list), Some(card))); + } + } + } + } + } + None +} + +fn build_payload( + project: &ProjectTree, + board: Option<&BoardSummary>, + list: Option<&ListSummary>, + card: Option<&CardSummary>, +) -> FastCopy { + let mut breadcrumb_parts = vec![project.name.as_str()]; + let mut json_text = format!( + r#"{{"project":{}"#, + id_name_object(&project.id, &project.name) + ); + + if let Some(board) = board { + breadcrumb_parts.push(&board.name); + json_text.push_str(r#","board":"#); + json_text.push_str(&id_name_object(&board.id, &board.name)); + } + if let Some(list) = list { + breadcrumb_parts.push(&list.name); + json_text.push_str(r#","list":"#); + json_text.push_str(&id_name_object(&list.id, &list.name)); + } + if let Some(card) = card { + breadcrumb_parts.push(&card.name); + json_text.push_str(r#","card":"#); + json_text.push_str(&id_name_object(&card.id, &card.name)); + } + json_text.push('}'); + + let breadcrumb = breadcrumb_parts.join(" > "); + + let snapshot_cmd = match (board, list, card) { + (_, _, Some(card)) => format!("plnk card snapshot {} --output json", card.id), + (_, Some(list), None) => format!("plnk list get {} --output json", list.id), + (Some(board), None, None) => format!("plnk board snapshot {} --output json", board.id), + (None, None, None) => format!("plnk project snapshot {} --output json", project.id), + }; + // Names are user-controlled on the Planka server. A newline in a name would + // break out of the `#` comment line and put attacker-controlled text on its + // own line, which the user's shell would execute on paste. Strip control + // characters before embedding the breadcrumb in shell-pasted output. + let safe_breadcrumb = sanitize_shell_comment(&breadcrumb); + let command = format!("# {safe_breadcrumb}\n{snapshot_cmd}\n"); + + FastCopy { + breadcrumb, + json: json_text, + command, + } +} + +fn id_name_object(id: &str, name: &str) -> String { + serde_json::to_string(&json!({ "id": id, "name": name })) + .expect("id/name object is JSON-serializable") +} + +fn sanitize_shell_comment(text: &str) -> String { + text.chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect() +} + impl BoardSummary { #[allow(clippy::too_many_lines)] fn from_snapshot(snapshot: BoardSnapshot) -> Self { @@ -1595,6 +1752,10 @@ impl AppState { .find(|board| board.id == board_id) } + fn fast_copy(&self) -> Option { + fast_copy_for(&self.projects, self.selected.as_ref()?) + } + fn card_list_id(&self, card_id: &str) -> Option<&str> { self.projects.iter().find_map(|project| { project.boards.iter().find_map(|board| { @@ -4025,6 +4186,36 @@ fn run_app( { app.toggle_debug_log(); } + KeyCode::Char('y') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(payload) = app.fast_copy() { + match write_osc52_clipboard(&payload.json) { + Ok(()) => app.set_notice(format!( + "Copied JSON → clipboard: {}", + payload.breadcrumb + )), + Err(err) => { + app.set_notice(format!("Copy failed: {err}")); + } + } + } else { + app.set_notice("Select a node to copy its ID hierarchy."); + } + } + KeyCode::Char('Y') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(payload) = app.fast_copy() { + match write_osc52_clipboard(&payload.command) { + Ok(()) => app.set_notice(format!( + "Copied snapshot command → clipboard: {}", + payload.breadcrumb + )), + Err(err) => { + app.set_notice(format!("Copy failed: {err}")); + } + } + } else { + app.set_notice("Select a node to copy its ID hierarchy."); + } + } KeyCode::Down | KeyCode::Char('j') => match app.focus { PaneFocus::Explorer if app.has_dirty_card_draft() => { app.set_notice( @@ -4253,7 +4444,7 @@ fn draw(frame: &mut ratatui::Frame<'_>, app: &AppState) { } else if app.filter_editor.is_some() { "FILTER MODE: type text • * and ? globs • Enter keep • Esc clear/close • Ctrl-c force quit" } else { - "↑/↓ nav • / filter • →/Enter expand • r refresh • v toggle view • L live on/off • e edit title • E edit description ($EDITOR) • D debug log • Ctrl-c quit" + "↑/↓ nav • / filter • →/Enter expand • r refresh • v toggle view • L live on/off • e edit title • E edit description ($EDITOR) • y copy JSON • Y copy cmd • D debug log • Ctrl-c quit" }; frame.render_widget( Paragraph::new(key_help).style(Style::default().fg(Color::DarkGray)), @@ -5267,3 +5458,228 @@ fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect { ]) .split(vertical[1])[1] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_rfc4648_vectors() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); + assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn base64_high_bits() { + assert_eq!(base64_encode(&[0xff, 0xff, 0xff]), "////"); + assert_eq!(base64_encode(&[0x00, 0x00, 0x00]), "AAAA"); + } + + fn fixture_card() -> CardSummary { + CardSummary { + id: "card-1".into(), + name: "Fast COPY".into(), + description: None, + position: 0.0, + is_closed: false, + comments_total: 0, + due_date: None, + creator: None, + labels: vec![], + assignees: vec![], + attachments: vec![], + task_lists: vec![], + is_subscribed: false, + } + } + + fn fixture_list(cards: Vec) -> ListSummary { + ListSummary { + id: "list-1".into(), + name: "Backlog".into(), + position: 0.0, + card_count: cards.len(), + active_card_count: cards.len(), + closed_card_count: 0, + cards, + } + } + + fn fixture_board(lists: Vec) -> BoardSummary { + BoardSummary { + id: "board-1".into(), + name: "Work".into(), + project_id: "proj-1".into(), + position: 0.0, + total_cards: 0, + active_card_count: 0, + closed_card_count: 0, + active_lists: lists, + labels: vec![], + members: vec![], + } + } + + fn fixture_project(boards: Vec) -> ProjectTree { + ProjectTree { + id: "proj-1".into(), + name: "planka-cli".into(), + description: None, + boards, + } + } + + fn fixture_tree() -> Vec { + vec![fixture_project(vec![fixture_board(vec![fixture_list( + vec![fixture_card()], + )])])] + } + + #[test] + fn fast_copy_card() { + let projects = fixture_tree(); + let payload = fast_copy_for(&projects, &TreeKey::Card("card-1".into())).unwrap(); + assert_eq!( + payload.breadcrumb, + "planka-cli > Work > Backlog > Fast COPY" + ); + assert_eq!( + payload.json, + r#"{"project":{"id":"proj-1","name":"planka-cli"},"board":{"id":"board-1","name":"Work"},"list":{"id":"list-1","name":"Backlog"},"card":{"id":"card-1","name":"Fast COPY"}}"# + ); + assert_eq!( + payload.command, + "# planka-cli > Work > Backlog > Fast COPY\nplnk card snapshot card-1 --output json\n" + ); + } + + #[test] + fn fast_copy_list() { + let projects = fixture_tree(); + let payload = fast_copy_for(&projects, &TreeKey::List("list-1".into())).unwrap(); + assert_eq!(payload.breadcrumb, "planka-cli > Work > Backlog"); + assert_eq!( + payload.json, + r#"{"project":{"id":"proj-1","name":"planka-cli"},"board":{"id":"board-1","name":"Work"},"list":{"id":"list-1","name":"Backlog"}}"# + ); + assert_eq!( + payload.command, + "# planka-cli > Work > Backlog\nplnk list get list-1 --output json\n" + ); + } + + #[test] + fn fast_copy_board() { + let projects = fixture_tree(); + let payload = fast_copy_for(&projects, &TreeKey::Board("board-1".into())).unwrap(); + assert_eq!(payload.breadcrumb, "planka-cli > Work"); + assert_eq!( + payload.command, + "# planka-cli > Work\nplnk board snapshot board-1 --output json\n" + ); + } + + #[test] + fn fast_copy_project() { + let projects = fixture_tree(); + let payload = fast_copy_for(&projects, &TreeKey::Project("proj-1".into())).unwrap(); + assert_eq!(payload.breadcrumb, "planka-cli"); + assert_eq!( + payload.command, + "# planka-cli\nplnk project snapshot proj-1 --output json\n" + ); + } + + #[test] + fn fast_copy_grouped_card_resolves() { + let projects = fixture_tree(); + let payload = fast_copy_for( + &projects, + &TreeKey::GroupedCard { + group_key: "any".into(), + card_id: "card-1".into(), + }, + ) + .unwrap(); + assert_eq!( + payload.breadcrumb, + "planka-cli > Work > Backlog > Fast COPY" + ); + } + + #[test] + fn fast_copy_label_group_resolves_to_list() { + let projects = fixture_tree(); + let payload = fast_copy_for( + &projects, + &TreeKey::LabelGroup { + board_id: "board-1".into(), + list_id: "list-1".into(), + label_id: None, + }, + ) + .unwrap(); + assert_eq!(payload.breadcrumb, "planka-cli > Work > Backlog"); + } + + #[test] + fn fast_copy_unknown_returns_none() { + let projects = fixture_tree(); + assert!(fast_copy_for(&projects, &TreeKey::Card("missing".into())).is_none()); + } + + #[test] + fn fast_copy_escapes_special_chars_in_json() { + let mut projects = fixture_tree(); + projects[0].boards[0].active_lists[0].cards[0].name = "weird \"name\" \\ \n".into(); + let payload = fast_copy_for(&projects, &TreeKey::Card("card-1".into())).unwrap(); + assert!(payload.json.contains(r#""name":"weird \"name\" \\ \n""#)); + } + + #[test] + fn fast_copy_command_blocks_newline_breakout_in_name() { + let mut projects = fixture_tree(); + projects[0].boards[0].active_lists[0].cards[0].name = "evil\nrm -rf ~".into(); + let payload = fast_copy_for(&projects, &TreeKey::Card("card-1".into())).unwrap(); + + let lines: Vec<&str> = payload.command.lines().collect(); + assert_eq!( + lines.len(), + 2, + "exactly 2 lines (1 comment + 1 plnk command); attacker name produced extra line: {:?}", + payload.command + ); + assert!(lines[0].starts_with("# ")); + assert!(lines[0].contains("evil rm -rf ~")); + assert!(lines[1].starts_with("plnk card snapshot")); + } + + #[test] + fn fast_copy_command_strips_cr_and_escape_sequences() { + let mut projects = fixture_tree(); + projects[0].boards[0].active_lists[0].cards[0].name = "a\rb\x1b[2Jc".into(); + let payload = fast_copy_for(&projects, &TreeKey::Card("card-1".into())).unwrap(); + + let comment_line = payload.command.lines().next().unwrap(); + assert!(!comment_line.contains('\r')); + assert!(!comment_line.contains('\x1b')); + assert!(!comment_line.contains('\x00')); + } + + #[test] + fn fast_copy_command_replaces_controls_with_space() { + let mut projects = fixture_tree(); + projects[0].boards[0].active_lists[0].cards[0].name = "a\nb".into(); + let payload = fast_copy_for(&projects, &TreeKey::Card("card-1".into())).unwrap(); + assert!( + payload.command.contains("> a b\n"), + "expected control char replaced with space, got: {:?}", + payload.command + ); + } +} diff --git a/docs/tui/fast-copy.md b/docs/tui/fast-copy.md new file mode 100644 index 0000000..3fe13da --- /dev/null +++ b/docs/tui/fast-copy.md @@ -0,0 +1,86 @@ +# Fast copy + +`plnk-tui` can put the selected node's full ID hierarchy onto the system clipboard in one keystroke. The copied payload is structured so an AI agent (or you) can immediately operate on the node without re-prompting for IDs. + +Two formats, mapped to two keys: + +| Key | Format | Use when | +|-----|--------|----------| +| `y` | Compact JSON | You want raw structured data with no shell side effects. Hand it to an agent and let it decide what to fetch. | +| `Y` | Snapshot command + breadcrumb | You want a paste-ready command an agent (or human) can run immediately to load full state. | + +The keys work from either pane and on any selected node — project, board, list, card, or label group. Label groups resolve to their underlying list. + +## `y` — compact JSON + +Single-line JSON containing one entry per hierarchy level, each with `id` and `name`. The keys are emitted in hierarchical order: `project`, `board`, `list`, `card`. Lower levels are omitted when not relevant to the selection. + +Card selection: + +```json +{"project":{"id":"175...","name":"planka-cli"},"board":{"id":"175...","name":"Work"},"list":{"id":"175...","name":"Backlog"},"card":{"id":"176...","name":"Fast COPY: copy ID hierarchy for AI context"}} +``` + +List selection (no `card` key): + +```json +{"project":{"id":"175...","name":"planka-cli"},"board":{"id":"175...","name":"Work"},"list":{"id":"175...","name":"Backlog"}} +``` + +Names are JSON-escaped per RFC 8259, so a name containing `"` or `\n` is preserved safely. The clipboard payload contains only inert data — pasting it into a shell does nothing. + +## `Y` — snapshot command + breadcrumb + +Two lines: a `#` comment carrying a human-readable breadcrumb, followed by the most useful `plnk` command for that level. The command writes JSON to stdout, so it's directly pipeable. + +| Selection | Emitted command | +|-----------|-----------------| +| Project | `plnk project snapshot --output json` | +| Board | `plnk board snapshot --output json` | +| List | `plnk list get --output json` | +| Card | `plnk card snapshot --output json` | + +Card example: + +```sh +# planka-cli > Work > Backlog > Fast COPY: copy ID hierarchy for AI context +plnk card snapshot 1761418906062291986 --output json +``` + +`card snapshot` is preferred over `card get` because it returns the card plus all included entities (tasks, comments, attachments, labels, memberships) in a single round trip — exactly what an agent needs for context. + +## How it reaches the clipboard — OSC 52 + +The TUI writes the payload using the [OSC 52](https://www.xfree86.org/current/ctlseqs.html) terminal escape sequence: `ESC ] 52 ; c ; BEL`. The terminal itself sets the system clipboard. There is no native clipboard dependency, so: + +- It works without X11, Wayland, AppKit, or Win32 clipboard APIs. +- It works **over SSH** as long as the local terminal honors OSC 52. +- It works inside tmux when `set -g set-clipboard on` is configured. + +### Terminal compatibility + +| Terminal | Status | +|----------|--------| +| iTerm2 | Works (default) | +| kitty | Works (default) | +| alacritty | Works (default) | +| WezTerm | Works (default) | +| Windows Terminal | Works (default) | +| GNOME Terminal / VTE ≥ 0.50 | Works | +| tmux | Works with `set -g set-clipboard on` in `~/.tmux.conf` | +| Apple Terminal.app | **Not supported** — OSC 52 is silently dropped | +| Older xterms | Variable — check `xterm` allowed window ops | + +If the paste comes up empty, your terminal does not honor OSC 52. There is currently no native fallback; future versions may add `arboard` behind a feature flag. + +## Security + +Project, board, list, and card names are user-editable on the Planka server. A maliciously crafted name like `"Nice card\nrm -rf ~"` could otherwise break out of the leading `#` comment line in the `Y` form and put attacker text on its own line, which would execute on shell paste. + +The TUI strips control characters (C0, DEL, C1) from the breadcrumb before embedding it in the shell command form, replacing each with a single space. The `Y` payload is therefore always exactly two lines: one comment line and one `plnk` command line. + +The `y` (JSON) form is unaffected — JSON's string escaping handles control characters by definition, and JSON is not directly evaluated by a shell. + +## Workflow + +The intended pattern: keep `plnk-tui` open in one window, your AI agent in another. When you want the agent to act on a node, select it in the TUI, press `y`, paste. The agent now has unambiguous IDs and can call `plnk` directly without asking you to disambiguate. diff --git a/docs/tui/keybindings.md b/docs/tui/keybindings.md index f188e44..eff4002 100644 --- a/docs/tui/keybindings.md +++ b/docs/tui/keybindings.md @@ -8,6 +8,8 @@ A flat reference. The footer at the bottom of the TUI shows a short subset of th |-----|--------| | `Ctrl-c` | Quit immediately. Works in every mode, including while saving. | | `Tab` | Cycle focus between the explorer pane and the details pane. | +| `y` | Copy the selected node's hierarchy as compact JSON to the system clipboard via OSC 52. See [fast-copy.md](fast-copy.md). | +| `Y` | Copy the selected node's hierarchy as a paste-ready `plnk` snapshot command (with breadcrumb comment) to the system clipboard. | | `D` | Toggle the websocket debug log overlay. | ## Explorer pane diff --git a/docs/tui/overview.md b/docs/tui/overview.md index 77deb9e..29b18bf 100644 --- a/docs/tui/overview.md +++ b/docs/tui/overview.md @@ -10,6 +10,7 @@ Experimental. Tested against a self-hosted Planka instance. Scope is intentional - Inspect cards (metadata, description, comments) - Edit card title inline and description in `$EDITOR` - Watch a single board live over the websocket +- Copy the selected node's ID hierarchy to the system clipboard for handing off to an AI agent (`y` / `Y`) It is not a replacement for the web UI — drag-and-drop, permission management, attachments upload, and the like all still live in the browser. `plnk-tui` is for the read-heavy / quick-edit case. @@ -58,7 +59,7 @@ Environment variables (clap honors them automatically): The TUI lands on the projects view with no live subscription active. Expand a project with `→` or `Enter`, pick a board, and either explore it read-only or press `L` to make it the live target — from that point on, edits on that board stream in. Press `L` again on the same board to unsubscribe and return to idle. Press `r` on the selected node when you want to refetch that slice of the hierarchy on demand, or `/` to filter the current explorer view client-side by substring or glob pattern. -For the detailed live-sync model, see [live-target.md](live-target.md). For the full key map, see [keybindings.md](keybindings.md). For the tree view's data-model and rendering contract, see [tree-view.md](tree-view.md). +For the detailed live-sync model, see [live-target.md](live-target.md). For the full key map, see [keybindings.md](keybindings.md). For the tree view's data-model and rendering contract, see [tree-view.md](tree-view.md). For the agent-handoff clipboard feature, see [fast-copy.md](fast-copy.md). ## Related