diff --git a/justfile b/justfile index 341aaa0..457ce37 100644 --- a/justfile +++ b/justfile @@ -19,7 +19,7 @@ install: build exit 1 fi # deck's metadata is fixed; --force so re-installs update in place. - kedi plugin install "{{ wasm }}" --name deck --icon "β–€" --caps dstask,ai --force + kedi plugin install "{{ wasm }}" --name deck --icon "πŸ“‹" --caps dstask,ai --force # remove deck via the kedi plugin manager (registry only; `just uninstall purge=1` deletes the .wasm) uninstall purge="": diff --git a/src/lib.rs b/src/lib.rs index d678f24..2d1bb33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,20 +28,33 @@ struct State { model: Model, } -/// Render the model into the TestBackend buffer, then push it to the host as one ANSI frame. +/// Render the model into the TestBackend buffer, then push it to the host as one ANSI frame. Prepend +/// an OSC-2 set-window-title so kedi's pane bar / tab / exposΓ© label this pane "deck" (xterm.js reads +/// it via onTitleChange and only fires on a change, so re-emitting each frame is free). fn paint(st: &mut State) { let _ = st.term.draw(|f| view::render(f, &st.model)); - let frame = ansi::buffer_to_ansi(st.term.backend().buffer()); + let mut frame = String::from("\x1b]2;deck\x07"); + frame.push_str(&ansi::buffer_to_ansi(st.term.backend().buffer())); host::render(&frame); } -/// Run a dstask mutating op through the governed capability, then reload + report. `args` is the -/// dstask-style argument string (e.g. "1 +now", "2 P1 project:x"); empty for whole-store ops. +/// Fire a dstask mutating op through the governed capability's ASYNC `do` op, then return +/// immediately β€” the git-committing mutation runs off-thread so the pane stays live. `on_tick` polls +/// it and reloads the board once it lands, showing `ok`. `args` is the dstask-style argument string +/// (e.g. "1 +now", "2 P1 project:x"). One mutation at a time: a new op while one's in flight is +/// dropped with a hint (rare β€” the op is sub-second). fn dstask_do(m: &mut Model, op: &str, args: &str, ok: &str) { - match host::invoke("dstask", op, args.as_bytes()) { - Ok(_) => { - m.reload(); - m.status = ok.to_string(); + if m.action.is_some() { + m.status = "β‹― working β€” one moment".into(); + return; + } + match task::dstask_start(op, args) { + Ok(job) => { + m.action = Some(model::Action { + job, + ok: ok.to_string(), + }); + m.status = "β‹― working".into(); } Err(e) => m.status = format!("⚠ {e}"), } @@ -105,9 +118,11 @@ impl Guest for Deck { STATE.with(|s| { let mut guard = s.borrow_mut(); let Some(st) = guard.as_mut() else { return }; - // only work when a job is in flight; keep the tick cheap otherwise. - if st.model.agent.job.is_some() { + // only work when something is in flight; keep the tick cheap otherwise. + let busy = st.model.agent.job.is_some() || st.model.action.is_some(); + if busy { poll_agent(&mut st.model); + poll_action(&mut st.model); paint(st); } }); @@ -115,6 +130,26 @@ impl Guest for Deck { } } +/// Poll an in-flight dstask mutation; on completion reload the board and show its status. Failures +/// surface with `⚠`. Mirrors `poll_agent` for the `do`/`do-poll` cap ops. +fn poll_action(m: &mut Model) { + let Some(action) = m.action.as_ref() else { + return; + }; + match task::dstask_poll(action.job) { + task::DsPoll::Pending => {} + task::DsPoll::Done => { + let ok = m.action.take().map(|a| a.ok).unwrap_or_default(); + m.reload(); + m.status = ok; + } + task::DsPoll::Failed(e) => { + m.action = None; + m.status = format!("⚠ {e}"); + } + } +} + /// Poll the in-flight AI job; on completion, act by kind (show a Query answer, or add Ingest tasks). fn poll_agent(m: &mut Model) { let Some(id) = m.agent.job else { return }; @@ -239,13 +274,7 @@ fn on_agent_answer_key(m: &mut Model, k: Key) { format!("{existing}\n\n{answer}") }; let payload = format!("{}\n{}", m.agent.target_id, blob); - match host::invoke("dstask", "note-set", payload.as_bytes()) { - Ok(_) => { - m.reload(); - m.status = "πŸ“ saved to note".into(); - } - Err(e) => m.status = format!("⚠ {e}"), - } + dstask_do(m, "note-set", &payload, "πŸ“ saved to note"); } else { m.status.clear(); } @@ -341,14 +370,8 @@ fn on_note_key(m: &mut Model, k: Key) { Key::Escape => { let (id, body) = (m.note.id, m.note.text()); m.mode = Mode::Nav; - let payload = format!("{id}\n{body}"); - match host::invoke("dstask", "note-set", payload.as_bytes()) { - Ok(_) => { - m.reload(); - m.status = "πŸ“ note saved".into(); - } - Err(e) => m.status = format!("⚠ {e}"), - } + // async: note-set drops an editor script + git-commits β€” slow enough to freeze the pane. + dstask_do(m, "note-set", &format!("{id}\n{body}"), "πŸ“ note saved"); } Key::Text('\u{3}') => { // Ctrl+C β€” discard, back to nav without saving diff --git a/src/model.rs b/src/model.rs index f38f98c..b139ced 100644 --- a/src/model.rs +++ b/src/model.rs @@ -18,6 +18,17 @@ pub struct Model { pub note: NoteEdit, /// the AI agent (`:` and `I`): the in-flight job + its answer, driven async via on_tick. pub agent: Agent, + /// an in-flight dstask mutation (done/modify/drag/…): the `do` job id + the status to show once + /// it lands. Mutations git-commit (~200-500ms); running them off-thread keeps the pane live. While + /// this is `Some`, further mutating keys are ignored (one op at a time). Driven via on_tick. + pub action: Option, +} + +/// A mutating dstask op fired asynchronously via the `do`/`do-poll` cap ops. `ok` is the status +/// message to show once the op completes and the board reloads. +pub struct Action { + pub job: u64, + pub ok: String, } /// The AI agent's state. A `:` query or `I` ingest fires a job on the `ai` capability and stores its @@ -176,6 +187,7 @@ impl Model { status: String::new(), note: NoteEdit::default(), agent: Agent::default(), + action: None, }; m.reload(); m @@ -287,6 +299,7 @@ impl Model { status: String::new(), note: NoteEdit::default(), agent: Agent::default(), + action: None, } } } diff --git a/src/task.rs b/src/task.rs index e39634e..403f075 100644 --- a/src/task.rs +++ b/src/task.rs @@ -57,6 +57,18 @@ pub struct Column { pub cards: Vec, } +/// Sort key for a dstask priority: P1 first … no-priority last. Keeps the NEXT column from being an +/// arbitrary-order wall β€” P1s float to the top, the backlog sinks. Within a rank we keep dstask's +/// own order (stable sort), which already reflects created/modified time. +fn prio_rank(priority: &str) -> u8 { + match priority { + "P1" => 0, + "P2" => 1, + "P3" => 2, + _ => 3, + } +} + /// Load tasks via the dstask capability and bucket them into columns: /// TODAY (+now) Β· NEXT (actionable pool, P3 hidden) Β· WAITING (+waiting) Β· DONE (resolved today). /// @@ -87,6 +99,12 @@ pub fn load() -> Result, String> { } } + // Sort the open columns by priority so the important cards surface (stable β†’ dstask order within + // a rank). NEXT is the big one β€” this is what turns its "unreadable wall" into a triaged list. + for col in [&mut today, &mut next, &mut waiting] { + col.sort_by_key(|t| prio_rank(&t.priority)); + } + // DONE: resolved tasks stamped today. `today`/`list-resolved` failing is non-fatal β€” better an // empty DONE than no board β€” so we fall back to empty rather than propagate the error. let today_date = host::invoke("dstask", "today", &[]) @@ -127,6 +145,52 @@ pub fn load() -> Result, String> { ]) } +// ── async dstask mutations, via the governed `dstask` cap's `do`/`do-poll` ops ──────────────────── +// +// Mutating dstask ops git-commit (~200-500ms); invoked inline they'd freeze deck's whole pane (the +// guest's host.invoke is blocking). So mutations go through the cap's async `do`/`do-poll`, exactly +// like the `ai` cap: `dstask_start` fires the op and returns a job id, `on_tick` polls it, and the +// board reloads only once it lands. Reads (`list`/`today`/`list-resolved`) stay synchronous β€” they +// don't commit and run in tens of ms. + +/// The state of a polled dstask mutation (from the cap's `P` / `D` / `E\n…` reply). +pub enum DsPoll { + Pending, + Done, + Failed(String), +} + +/// Fire a mutating dstask op off-thread. `op` is one of add/modify/done/start/stop/note/note-set; +/// `args` is that op's own input (whitespace tokens, or the note text). Returns the job id. +pub fn dstask_start(op: &str, args: &str) -> Result { + let payload = format!("{op}\n{args}"); + let bytes = host::invoke("dstask", "do", payload.as_bytes())?; + String::from_utf8_lossy(&bytes) + .trim() + .parse() + .map_err(|_| "dstask: bad job id".to_string()) +} + +/// Poll a dstask mutation by id. Parses the cap's `P` / `D` / `E\n…` reply. +pub fn dstask_poll(id: u64) -> DsPoll { + match host::invoke("dstask", "do-poll", id.to_string().as_bytes()) { + Ok(bytes) => { + let s = String::from_utf8_lossy(&bytes); + let s = s.trim_end(); + if s == "P" { + DsPoll::Pending + } else if s == "D" { + DsPoll::Done + } else if let Some(rest) = s.strip_prefix("E\n") { + DsPoll::Failed(rest.to_string()) + } else { + DsPoll::Failed(format!("dstask: unexpected poll reply {s:?}")) + } + } + Err(e) => DsPoll::Failed(e), + } +} + // ── the AI agent, via the governed `ai` capability ────────────────────────────────────────────── // // The `ai` cap is async (startβ†’poll): `start` returns a job id immediately, `poll` reports the state. @@ -267,6 +331,51 @@ mod tests { assert!(parse_ingest("\n \n").is_empty()); } + #[test] + fn prio_rank_orders_p1_first_none_last() { + assert!(prio_rank("P1") < prio_rank("P2")); + assert!(prio_rank("P2") < prio_rank("P3")); + assert!(prio_rank("P3") < prio_rank("")); + assert_eq!(prio_rank("P1"), 0); + assert_eq!(prio_rank("garbage"), 3); + } + + #[test] + fn priority_sort_is_stable_within_a_rank() { + // a NEXT-like pool in arbitrary dstask order; sorting must float P1s up and keep same-rank + // cards in their original relative order (so it doesn't scramble the backlog). + let mut cards = vec![ + Task { + summary: "a-none".into(), + priority: "".into(), + ..Default::default() + }, + Task { + summary: "b-p2".into(), + priority: "P2".into(), + ..Default::default() + }, + Task { + summary: "c-p1".into(), + priority: "P1".into(), + ..Default::default() + }, + Task { + summary: "d-none".into(), + priority: "".into(), + ..Default::default() + }, + Task { + summary: "e-p1".into(), + priority: "P1".into(), + ..Default::default() + }, + ]; + cards.sort_by_key(|t| prio_rank(&t.priority)); + let order: Vec<&str> = cards.iter().map(|t| t.summary.as_str()).collect(); + assert_eq!(order, vec!["c-p1", "e-p1", "b-p2", "a-none", "d-none"]); + } + #[test] fn parse_poll_prefixes() { assert!(matches!(parse_poll("P"), AiPoll::Pending)); diff --git a/src/view.rs b/src/view.rs index bb5d396..bb1304f 100644 --- a/src/view.rs +++ b/src/view.rs @@ -119,7 +119,32 @@ fn render_column(f: &mut Frame, m: &Model, ci: usize, area: Rect, active: bool) .title(title); let inner_w = area.width.saturating_sub(2) as usize; - let items: Vec = cards.iter().map(|t| card_item(t, inner_w)).collect(); + let inner_h = area.height.saturating_sub(2) as usize; + + // The active column scrolls (the cursor reaches every card via ListState). Inactive columns can't + // be scrolled without focusing them, so instead of letting ratatui silently clip a tall column + // into an unreadable wall, we cap them to what fits (cards are priority-sorted, so the important + // ones show) and append a dim "+N more" line. Priority sort + this count is the NEXT-wall fix. + let mut items: Vec = Vec::with_capacity(cards.len().min(inner_h)); + if active { + items.extend(cards.iter().map(|t| card_item(t, inner_w))); + } else { + let per_card = 2; // each card is a 2-line ListItem + let fits = inner_h / per_card; + if cards.len() <= fits { + items.extend(cards.iter().map(|t| card_item(t, inner_w))); + } else { + // leave one card-slot's worth of room for the "+N more" footer line + let shown = fits.saturating_sub(1).max(1); + items.extend(cards.iter().take(shown).map(|t| card_item(t, inner_w))); + let more = cards.len() - shown; + items.push(ListItem::new(Line::from(Span::styled( + format!(" +{more} more Β· h/l to focus"), + Style::default().fg(DIM).add_modifier(Modifier::ITALIC), + )))); + } + } + let mut list = List::new(items).block(block); let mut state = ListState::default(); if active && !cards.is_empty() { @@ -486,6 +511,46 @@ mod tests { ); } + #[test] + fn inactive_column_caps_with_a_more_footer() { + // NEXT (inactive β€” cursor starts on TODAY) crammed with more cards than fit β†’ the wall is + // capped and a "+N more" line appears instead of a silent clip. + let mut cols = four_cols(); + cols[1].cards = (1..=30) + .map(|i| task(i, &format!("next task {i}"))) + .collect(); + let m = Model::with_cols(cols); + let out = render_text(&m, 120, 24); + assert!( + out.contains("more"), + "a tall inactive column should show a +N more footer:\n{out}" + ); + // the focused column (TODAY) is unaffected; its single card still shows. + assert!( + out.contains("call the plumber"), + "focused column intact:\n{out}" + ); + } + + #[test] + fn active_column_does_not_cap() { + // the focused column scrolls (no cap): with the cursor on NEXT, no "+N more" for it. + let mut cols = four_cols(); + cols[1].cards = (1..=30) + .map(|i| task(i, &format!("next task {i}"))) + .collect(); + let mut m = Model::with_cols(cols); + m.col = 1; // focus NEXT + m.scroll(); + let out = render_text(&m, 120, 24); + // NEXT is active β†’ it scrolls via the cursor rather than showing a "+N more" cap. (TODAY, + // now inactive, has only one card so it never caps either.) + assert!( + !out.contains("more"), + "the active column should scroll, not cap:\n{out}" + ); + } + #[test] fn resolved_card_leads_with_a_check_not_an_id() { let mut cols = four_cols();