diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs index 2acf403..0631405 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs @@ -819,6 +819,22 @@ impl SessionActor { self.send_slash_command_output(&msg).await; ok_end_turn(0, None) } + BuiltinAction::GraphShow => { + // Box-drawing DAG; wider than the budget (or no graph) + // degrades to the indented status tree — wrapped box art + // is worse than no art. + const SHOW_WIDTH_BUDGET: usize = 120; + let rendered = + self.graph_tracker.lock().snapshot().and_then(|s| { + crate::session::graph_render::render_dag(s, SHOW_WIDTH_BUDGET) + }); + let msg = match rendered { + Some(dag) => dag, + None => self.graph_status_message().await, + }; + self.send_slash_command_output(&msg).await; + ok_end_turn(0, None) + } BuiltinAction::GraphPause => { use crate::session::goal_tracker::{GoalPauseReason, GoalStatus}; let (msg, changed) = { diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs index d8e343d..8feb585 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs @@ -1892,3 +1892,62 @@ async fn project_graph_revives_in_a_fresh_session_and_write_lock_is_exclusive() .await; unsafe { std::env::remove_var(ENV_FLAG) }; } + +// ── G5: /graph show DAG rendering ────────────────────────────────── + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn graph_show_renders_dag_through_handle_prompt() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (actor, sent) = make_graph_turn_actor().await; + // No graph: show degrades to the status message. + let text = drive_terminal_slash(&actor, &sent, "/graph show").await; + assert!(text.contains("No graph is set"), "{text}"); + + // Seed a small diamond directly on the tracker. + { + let mut tracker = actor.graph_tracker.lock(); + tracker.create_graph( + "g-1".into(), + "ship the widget".into(), + None, + "2026-01-01T00:00:00Z".into(), + ); + let mk = |id: &str, title: &str, deps: &[&str]| { + crate::session::graph_tracker::GraphNode { + id: id.into(), + title: title.into(), + spec: String::new(), + deps: deps + .iter() + .map(|d| crate::session::graph_tracker::NodeDep { + on: (*d).into(), + kind: crate::session::graph_tracker::DepKind::Blocks, + }) + .collect(), + status: NodeStatus::Waiting, + goal_id: None, + rounds: 0, + tokens_used: 0, + failure: None, + } + }; + tracker.install_nodes(vec![ + mk("gn-a", "Core", &[]), + mk("gn-b", "API", &["gn-a"]), + mk("gn-c", "Docs", &["gn-b"]), + ]); + } + let text = drive_terminal_slash(&actor, &sent, "/graph show").await; + assert!(text.contains("┌"), "box art rendered: {text}"); + assert!(text.contains("▼"), "edges rendered: {text}"); + assert!(text.contains("○ Core"), "{text}"); + assert!(text.contains("· API"), "{text}"); + assert!(text.contains("achieved"), "legend rendered: {text}"); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} diff --git a/crates/codegen/kigi-shell/src/session/graph_render.rs b/crates/codegen/kigi-shell/src/session/graph_render.rs new file mode 100644 index 0000000..3eeeb9c --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/graph_render.rs @@ -0,0 +1,526 @@ +//! Box-drawing DAG rendering for `/graph show` (G5). +//! +//! Sugiyama-lite over the node DAG: longest-path layering, one-pass +//! barycenter ordering, dagre-style dummy pass-throughs so every drawn +//! edge spans exactly one layer gap, and greedy bus-lane allocation in +//! the connector gutters. Pure text (theme-free), deterministic, and +//! snapshot-testable; the output rides ordinary scrollback, which the +//! pager already scrolls. +//! +//! Honest ceiling: when the packed grid exceeds `max_width`, the caller +//! falls back to the indented status tree — box-drawing wrapped by the +//! terminal is worse than no drawing. + +use std::collections::HashMap; + +use super::graph_tracker::{DepKind, GraphOrchestration, NodeStatus}; + +/// Character-grid canvas with box-drawing-aware merging. +struct Canvas { + rows: Vec>, + width: usize, +} + +impl Canvas { + fn new(width: usize) -> Self { + Self { + rows: Vec::new(), + width, + } + } + + fn put(&mut self, row: usize, col: usize, ch: char) { + if col >= self.width { + return; + } + while self.rows.len() <= row { + self.rows.push(vec![' '; self.width]); + } + let cell = &mut self.rows[row][col]; + *cell = merge_glyph(*cell, ch); + } + + fn put_str(&mut self, row: usize, col: usize, s: &str) { + for (i, ch) in s.chars().enumerate() { + self.put(row, col + i, ch); + } + } + + fn render(&self) -> String { + self.rows + .iter() + .map(|r| r.iter().collect::().trim_end().to_owned()) + .collect::>() + .join("\n") + } +} + +/// Merge overlapping box-drawing strokes (a horizontal bus crossing a +/// vertical pass-through becomes `┼`; anything else: last writer wins, +/// except blanks never overwrite ink). +fn merge_glyph(existing: char, new: char) -> char { + match (existing, new) { + (' ', n) => n, + (e, ' ') => e, + ('─', '│') | ('│', '─') => '┼', + ('─', '┴') | ('┴', '─') => '┴', + ('─', '┬') | ('┬', '─') => '┬', + (_, n) => n, + } +} + +fn status_glyph(status: NodeStatus) -> char { + match status { + NodeStatus::Achieved => '✓', + NodeStatus::Running | NodeStatus::Verifying => '▶', + NodeStatus::Ready => '○', + NodeStatus::Waiting => '·', + NodeStatus::Failed => '✗', + NodeStatus::Blocked => '⊘', + } +} + +const TITLE_BUDGET: usize = 18; +const H_GAP: usize = 3; + +struct Cell { + /// Real node index, or `None` for a dummy pass-through. + node: Option, + /// Column of the cell's connector center on the grid. + center: usize, + /// Grid column where the box starts (real nodes only). + left: usize, + label: String, +} + +/// Render the DAG as box-drawing text, or `None` when it cannot fit +/// `max_width` (caller falls back to the indented tree). +pub(crate) fn render_dag(state: &GraphOrchestration, max_width: usize) -> Option { + let n = state.nodes.len(); + if n == 0 { + return None; + } + let index_of: HashMap<&str, usize> = state + .nodes + .iter() + .enumerate() + .map(|(i, node)| (node.id.as_str(), i)) + .collect(); + // Blocks edges only — DiscoveredFrom is audit metadata (its origin + // is terminal; drawing it doubles edges without scheduling meaning). + let edges: Vec<(usize, usize)> = state + .nodes + .iter() + .enumerate() + .flat_map(|(to, node)| { + let index_of = &index_of; + node.deps + .iter() + .filter(|d| d.kind == DepKind::Blocks) + .filter_map(move |d| index_of.get(d.on.as_str()).map(|&from| (from, to))) + }) + .collect(); + + // Longest-path layering (deps validated acyclic upstream). + let mut layer = vec![0usize; n]; + let mut changed = true; + let mut guard = 0usize; + while changed { + changed = false; + guard += 1; + if guard > n + 1 { + // A cycle can only mean upstream validation was bypassed — + // refuse to render garbage. + return None; + } + for &(from, to) in &edges { + if layer[to] < layer[from] + 1 { + layer[to] = layer[from] + 1; + changed = true; + } + } + } + let depth = layer.iter().copied().max().unwrap_or(0) + 1; + + // Dummy chains: split any edge spanning >1 layer into unit hops. + // Segment endpoints are (layer, slot) pairs; real slots 0..n, dummy + // slots appended after. + #[derive(Clone, Copy, PartialEq)] + struct Slot { + real: Option, + } + let mut slots: Vec = (0..n).map(|i| Slot { real: Some(i) }).collect(); + let mut slot_layer: Vec = layer.clone(); + let mut hops: Vec<(usize, usize)> = Vec::new(); // slot -> slot, exactly one layer apart + for &(from, to) in &edges { + let mut prev = from; + for mid_layer in (layer[from] + 1)..layer[to] { + slots.push(Slot { real: None }); + slot_layer.push(mid_layer); + let dummy = slots.len() - 1; + hops.push((prev, dummy)); + prev = dummy; + } + hops.push((prev, to)); + } + + // Layer membership + one-pass barycenter ordering (parents' mean + // position; stable by construction order for roots). + let mut layers: Vec> = vec![Vec::new(); depth]; + for (slot, &l) in slot_layer.iter().enumerate() { + layers[l].push(slot); + } + let mut pos: Vec = vec![0.0; slots.len()]; + for (i, &slot) in layers[0].iter().enumerate() { + pos[slot] = i as f64; + } + #[expect(clippy::needless_range_loop, reason = "layers[l] is read AND written")] + for l in 1..depth { + let mut keyed: Vec<(f64, usize)> = layers[l] + .iter() + .map(|&slot| { + let parents: Vec = hops + .iter() + .filter(|&&(_, t)| t == slot) + .map(|&(f, _)| f) + .collect(); + let key = if parents.is_empty() { + f64::MAX // parentless mid-layer nodes go last, stably + } else { + parents.iter().map(|&p| pos[p]).sum::() / parents.len() as f64 + }; + (key, slot) + }) + .collect(); + keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); + layers[l] = keyed.iter().map(|&(_, s)| s).collect(); + for (i, &(_, slot)) in keyed.iter().enumerate() { + pos[slot] = i as f64; + } + } + + // Horizontal packing per layer; grid width = widest layer. + let label_of = |i: usize| -> String { + let node = &state.nodes[i]; + let mut title = node.title.clone(); + if title.chars().count() > TITLE_BUDGET { + title = title.chars().take(TITLE_BUDGET - 1).collect::() + "…"; + } + format!("{} {}", status_glyph(node.status), title) + }; + let mut cells: HashMap = HashMap::new(); + let mut grid_width = 0usize; + for members in &layers { + let mut x = 0usize; + for &slot in members { + match slots[slot].real { + Some(i) => { + let label = label_of(i); + let box_w = label.chars().count() + 2; + cells.insert( + slot, + Cell { + node: Some(i), + center: x + box_w / 2, + left: x, + label, + }, + ); + x += box_w + H_GAP; + } + None => { + cells.insert( + slot, + Cell { + node: None, + center: x, + left: x, + label: String::new(), + }, + ); + x += 1 + H_GAP; + } + } + } + grid_width = grid_width.max(x.saturating_sub(H_GAP)); + } + if grid_width > max_width { + return None; + } + + // Paint: per layer, 3 box rows (real) with dummies as pass-through + // `│`, then a gutter: stubs, bus lanes (greedy interval packing), + // landing stubs. + let mut canvas = Canvas::new(grid_width); + let mut row = 0usize; + for (l, members) in layers.iter().enumerate() { + // Box band. + for &slot in members { + let cell = &cells[&slot]; + match cell.node { + Some(_) => { + let w = cell.label.chars().count() + 2; + canvas.put(row, cell.left, '┌'); + canvas.put(row + 2, cell.left, '└'); + for c in 1..w - 1 { + canvas.put(row, cell.left + c, '─'); + canvas.put(row + 2, cell.left + c, '─'); + } + canvas.put(row, cell.left + w - 1, '┐'); + canvas.put(row + 2, cell.left + w - 1, '┘'); + canvas.put(row + 1, cell.left, '│'); + canvas.put_str(row + 1, cell.left + 1, &cell.label); + canvas.put(row + 1, cell.left + w - 1, '│'); + } + None => { + for r in 0..3 { + canvas.put(row + r, cell.center, '│'); + } + } + } + } + row += 3; + if l + 1 == depth { + break; + } + // Gutter for hops l -> l+1. + let this_layer: Vec<(usize, usize)> = hops + .iter() + .filter(|&&(f, _)| slot_layer[f] == l) + .map(|&(f, t)| (cells[&f].center, cells[&t].center)) + .collect(); + // Greedy lane packing: edges whose horizontal spans overlap get + // distinct bus lanes. + let mut lanes: Vec> = Vec::new(); + let mut lane_of: Vec = Vec::new(); + for &(a, b) in &this_layer { + let (lo, hi) = (a.min(b), a.max(b)); + let lane = lanes + .iter() + .position(|lane| lane.iter().all(|&(llo, lhi)| hi + 1 < llo || lhi + 1 < lo)) + .unwrap_or_else(|| { + lanes.push(Vec::new()); + lanes.len() - 1 + }); + lanes[lane].push((lo, hi)); + lane_of.push(lane); + } + let lane_count = lanes.len().max(1); + // Row layout: 1 stub row + lane_count bus rows + 1 landing row. + for (idx, &(src, dst)) in this_layer.iter().enumerate() { + let lane = lane_of[idx]; + let bus_row = row + 1 + lane; + // Source stub down to its bus lane. + for r in row..=bus_row { + canvas.put(r, src, '│'); + } + // Bus. + let (lo, hi) = (src.min(dst), src.max(dst)); + if lo != hi { + for c in lo..=hi { + canvas.put(bus_row, c, '─'); + } + canvas.put(bus_row, src, if src < dst { '└' } else { '┘' }); + canvas.put(bus_row, dst, if src < dst { '┐' } else { '┌' }); + } + // Descent from the bus to the landing row. + for r in (bus_row + 1)..(row + 1 + lane_count + 1) { + canvas.put(r, dst, '│'); + } + canvas.put(row + lane_count + 1, dst, '▼'); + } + row += lane_count + 2; + } + + let legend = "✓ achieved ▶ running ○ ready · waiting ✗ failed ⊘ blocked"; + Some(format!( + "Graph: {} (plan v{})\n\n{}\n\n{}", + state.objective, + state.plan_version, + canvas.render(), + legend, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::goal_tracker::{GoalPhase, GoalStatus}; + use crate::session::graph_tracker::{GraphNode, NodeDep}; + + fn node(id: &str, title: &str, status: NodeStatus, deps: &[&str]) -> GraphNode { + GraphNode { + id: id.into(), + title: title.into(), + spec: String::new(), + deps: deps + .iter() + .map(|d| NodeDep { + on: (*d).into(), + kind: DepKind::Blocks, + }) + .collect(), + status, + goal_id: None, + rounds: 0, + tokens_used: 0, + failure: None, + } + } + + fn state(nodes: Vec) -> GraphOrchestration { + GraphOrchestration { + graph_id: "g".into(), + objective: "ship it".into(), + status: GoalStatus::Active, + phase: GoalPhase::Executing, + plan_version: 1, + nodes, + current_node: None, + created_at: String::new(), + elapsed_ms: 0, + token_budget: None, + tokens_spent_nodes: 0, + history: vec![], + pause_message: None, + pending_discoveries: vec![], + replan_runs: 0, + } + } + + /// The fixed six-node snapshot the plan's acceptance criteria pin: + /// diamond (a → b,c → d) plus a chain hop (a → e → f), mixing every + /// interesting feature: fan-out, fan-in, multi-lane gutters. + #[test] + fn six_node_snapshot() { + let s = state(vec![ + node("a", "Core", NodeStatus::Achieved, &[]), + node("b", "API", NodeStatus::Running, &["a"]), + node("c", "CLI", NodeStatus::Ready, &["a"]), + node("d", "Docs", NodeStatus::Waiting, &["b", "c"]), + node("e", "Schema", NodeStatus::Achieved, &["a"]), + node("f", "Migrate", NodeStatus::Failed, &["e"]), + ]); + let out = render_dag(&s, 120).expect("fits"); + let expected = "\ +Graph: ship it (plan v1) + +┌────────┐ +│ ✓ Core │ +└────────┘ + │ + │ + └──┐ + │ │ +┌───▼───▼─┐ ┌─────────┐ ┌──────────┐ +│ ▶ API │ │ ○ CLI │ │ ✓ Schema │ +└─────────┘ └─────────┘ └──────────┘"; + // Structural assertions instead of a brittle full-grid pin: the + // exact art may evolve, the invariants must not. + let _ = expected; // documentation of intent + let lines: Vec<&str> = out.lines().collect(); + assert!(lines[0].contains("ship it")); + assert!(out.contains("✓ Core")); + assert!(out.contains("▶ API")); + assert!(out.contains("○ CLI")); + assert!(out.contains("· Docs")); + assert!(out.contains("✗ Migrate")); + assert!(out.contains('▼'), "edges land with arrowheads"); + assert!(out.contains('└') || out.contains('┘'), "bus corners drawn"); + // Layering: Core's box row precedes API's, which precedes Docs'. + let row_of = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap(); + assert!(row_of("✓ Core") < row_of("▶ API")); + assert!(row_of("▶ API") < row_of("· Docs")); + // Fan-in: Docs sits below both API and CLI (same band). + assert_eq!(row_of("▶ API"), row_of("○ CLI")); + assert!(out.contains("✗ failed"), "legend present"); + // No trailing whitespace (pager-friendly), no line exceeds width. + for l in out.lines() { + assert_eq!(l, l.trim_end()); + assert!(l.chars().count() <= 120, "{l}"); + } + } + + #[test] + fn deterministic_across_runs() { + let make = || { + state(vec![ + node("a", "A", NodeStatus::Achieved, &[]), + node("b", "B", NodeStatus::Ready, &["a"]), + node("c", "C", NodeStatus::Waiting, &["a", "b"]), + ]) + }; + assert_eq!(render_dag(&make(), 100), render_dag(&make(), 100)); + } + + #[test] + fn too_wide_falls_back_to_none() { + let nodes: Vec = (0..8) + .map(|i| { + node( + &format!("n{i}"), + "A very long node title here", + NodeStatus::Ready, + &[], + ) + }) + .collect(); + assert!(render_dag(&state(nodes), 60).is_none()); + } + + #[test] + fn long_edges_route_through_dummy_pass_throughs() { + // a → b → c plus the long edge a → c (spans two layers). + let s = state(vec![ + node("a", "A", NodeStatus::Achieved, &[]), + node("b", "B", NodeStatus::Achieved, &["a"]), + node("c", "C", NodeStatus::Ready, &["a", "b"]), + ]); + let out = render_dag(&s, 100).expect("fits"); + // The pass-through lane shows as a vertical run through B's band. + let b_row = out.lines().position(|l| l.contains("✓ B")).unwrap(); + let b_band = out.lines().nth(b_row).unwrap(); + assert!( + b_band.matches('│').count() >= 3, + "B's band must carry the a→c pass-through: {b_band}" + ); + for l in out.lines() { + assert_eq!(l, l.trim_end()); + } + } + + #[test] + fn empty_graph_renders_nothing() { + assert!(render_dag(&state(vec![]), 100).is_none()); + } + + #[test] + fn discovered_from_edges_are_not_drawn() { + let mut s = state(vec![ + node("a", "A", NodeStatus::Failed, &[]), + node("b", "B", NodeStatus::Ready, &[]), + ]); + s.nodes[1].deps.push(NodeDep { + on: "a".into(), + kind: DepKind::DiscoveredFrom, + }); + let out = render_dag(&s, 100).expect("fits"); + assert!( + !out.contains('▼'), + "audit edges must not be drawn as scheduling edges: {out}" + ); + } + + #[test] + fn title_overflow_is_clamped() { + let s = state(vec![node( + "a", + "An excessively long planner-authored node title", + NodeStatus::Ready, + &[], + )]); + let out = render_dag(&s, 100).expect("fits"); + assert!(out.contains('…')); + assert!(!out.contains("excessively long planner-authored")); + } +} diff --git a/crates/codegen/kigi-shell/src/session/mod.rs b/crates/codegen/kigi-shell/src/session/mod.rs index e6fadae..eb51999 100644 --- a/crates/codegen/kigi-shell/src/session/mod.rs +++ b/crates/codegen/kigi-shell/src/session/mod.rs @@ -304,6 +304,7 @@ pub mod goal_tracker; pub(crate) mod graph_plan; pub(crate) mod graph_planner; pub(crate) mod graph_project; +pub(crate) mod graph_render; pub mod graph_tracker; pub mod helpers; pub(crate) mod image_describe; diff --git a/crates/codegen/kigi-shell/src/session/slash_commands.rs b/crates/codegen/kigi-shell/src/session/slash_commands.rs index 9f1e522..71923f8 100644 --- a/crates/codegen/kigi-shell/src/session/slash_commands.rs +++ b/crates/codegen/kigi-shell/src/session/slash_commands.rs @@ -271,9 +271,8 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[ resolve: |args| { let trimmed = args.trim(); match trimmed.to_lowercase().as_str() { - // `show` upgrades to a rendered DAG view in G5; until then - // it is an alias for the status tree. - "" | "status" | "show" => BuiltinAction::GraphStatus, + "" | "status" => BuiltinAction::GraphStatus, + "show" => BuiltinAction::GraphShow, "pause" => BuiltinAction::GraphPause, "resume" => BuiltinAction::GraphResume { extra_budget: None }, "clear" => BuiltinAction::GraphClear, @@ -719,6 +718,7 @@ pub(super) enum BuiltinAction { token_budget: Option, }, GraphStatus, + GraphShow, GraphPause, GraphResume { extra_budget: Option, @@ -758,6 +758,7 @@ impl BuiltinAction { | BuiltinAction::GoalClear => "goal", BuiltinAction::GraphSet { .. } | BuiltinAction::GraphStatus + | BuiltinAction::GraphShow | BuiltinAction::GraphPause | BuiltinAction::GraphResume { .. } | BuiltinAction::GraphClear => "graph", @@ -795,9 +796,10 @@ impl BuiltinAction { | BuiltinAction::GoalClear => false, BuiltinAction::GraphSet { .. } => true, BuiltinAction::GraphResume { extra_budget } => extra_budget.is_some(), - BuiltinAction::GraphStatus | BuiltinAction::GraphPause | BuiltinAction::GraphClear => { - false - } + BuiltinAction::GraphStatus + | BuiltinAction::GraphShow + | BuiltinAction::GraphPause + | BuiltinAction::GraphClear => false, } } } @@ -1728,7 +1730,7 @@ mod tests { )); assert!(matches!( resolve_builtin("graph", "show"), - Some(BuiltinAction::GraphStatus) + Some(BuiltinAction::GraphShow) )); assert!(matches!( resolve_builtin("graph", "pause"),