Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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="":
Expand Down
73 changes: 48 additions & 25 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
}
Expand Down Expand Up @@ -105,16 +118,38 @@ 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);
}
});
true
}
}

/// 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 };
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Action>,
}

/// 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
Expand Down Expand Up @@ -176,6 +187,7 @@ impl Model {
status: String::new(),
note: NoteEdit::default(),
agent: Agent::default(),
action: None,
};
m.reload();
m
Expand Down Expand Up @@ -287,6 +299,7 @@ impl Model {
status: String::new(),
note: NoteEdit::default(),
agent: Agent::default(),
action: None,
}
}
}
Expand Down
109 changes: 109 additions & 0 deletions src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ pub struct Column {
pub cards: Vec<Task>,
}

/// 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).
///
Expand Down Expand Up @@ -87,6 +99,12 @@ pub fn load() -> Result<Vec<Column>, 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", &[])
Expand Down Expand Up @@ -127,6 +145,52 @@ pub fn load() -> Result<Vec<Column>, 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<u64, String> {
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.
Expand Down Expand Up @@ -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));
Expand Down
67 changes: 66 additions & 1 deletion src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListItem> = 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<ListItem> = 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() {
Expand Down Expand Up @@ -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();
Expand Down
Loading