Skip to main content

teamctl_ui/
onboarding.rs

1//! Onboarding tutorial — multi-step walkthrough of the TUI.
2//!
3//! Triggered automatically on first launch (sentinel file at
4//! `.team/state/ui-tutorial-completed` — separate from PR-UI-1's
5//! `~/.config/teamctl/ui-tutorial-completed`, which marks
6//! per-machine completion; the per-team sentinel lets a brand-new
7//! checkout teach a returning operator about its specific shape
8//! without re-prompting machine-wide).
9//!
10//! Reopenable from any non-modal state via the `t` chord — the
11//! statusline's always-visible `· t tutorial` hint is the
12//! discovery surface. Skippable via `Esc` or first key per SPEC.
13
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Copy)]
17pub struct Step {
18    pub heading: &'static str,
19    pub body: &'static str,
20}
21
22pub const STEPS: &[Step] = &[
23    Step {
24        heading: "Welcome to teamctl-ui",
25        body: "A live view of your team. Roster on the left, focused agent in the middle, mailbox on the right. Press any key to advance, Esc to leave.",
26    },
27    Step {
28        heading: "Roster + state glyphs",
29        body: "Each agent shows a single-cell glyph: ● running · ✉ unread · ! approval pending · ✕ stopped · ? unknown. Tab to focus the roster, j/k to walk it.",
30    },
31    Step {
32        heading: "Detail pane",
33        body: "The selected agent's tmux session streams here. The title line shows which agent you're following; lines tail-clip to fit.",
34    },
35    Step {
36        heading: "Mailbox tabs",
37        body: "Inbox / Channel / Wire — `]` walks forward, `[` walks back, when the mailbox pane is focused. Tab itself always cycles pane focus, never tabs. Inbox is DMs to the focused agent; Wire is project-wide broadcasts.",
38    },
39    Step {
40        heading: "Approvals",
41        body: "When an agent files request_approval, a stripe appears at the top. Press `a` to open the modal, then `y` to approve or Shift-`N` to deny. j/k cycle if multiple are pending.",
42    },
43    Step {
44        heading: "Compose",
45        body: "@ DMs the focused agent; ! broadcasts to a channel (picker first). The editor is vim-style — i to insert, Esc to normal, Ctrl+Enter to send, Esc Esc to cancel.",
46    },
47    Step {
48        heading: "Layouts",
49        body: "Ctrl+W toggles Wall view (4 agents at once + scroll). Ctrl+M toggles Mailbox-first (channel-feed centric). Both fall back to Triptych on toggle.",
50    },
51    Step {
52        heading: "Splits",
53        body: "Ctrl+| / Ctrl+- split the detail pane so you can watch two agents at once. Ctrl+H/J/K/L cycles between splits, Ctrl+W q closes the focused one.",
54    },
55    Step {
56        heading: "Help + quit",
57        body: "? opens the full keymap. q quits (with confirm). t reopens this tour. You're ready.",
58    },
59];
60
61/// Per-team sentinel file path. Returns `None` when no team root
62/// is reachable — onboarding is then a noop and the tutorial
63/// auto-trigger doesn't fire.
64pub fn sentinel_path(team_root: &std::path::Path) -> PathBuf {
65    team_root.join("state/ui-tutorial-completed")
66}
67
68pub fn has_completed(team_root: &std::path::Path) -> bool {
69    sentinel_path(team_root).exists()
70}
71
72/// Mark this team's tutorial as completed by creating the
73/// sentinel file. The design intent is **presence-based**: only
74/// the file's existence matters, never its contents — a partial
75/// write that leaves an empty / truncated file still satisfies
76/// `has_completed`. That's accidentally robust to crash-during-
77/// write (the auto-trigger correctly fires once, then any later
78/// completion makes it stop firing forever) but the property is
79/// load-bearing, not coincidental: `has_completed` deliberately
80/// does NOT validate file content. Future readers tempted to
81/// add atomic-rename or content-validation should know that the
82/// existing crash-safety story already lives entirely in the
83/// presence check; tightening write semantics doesn't strengthen
84/// the contract, it just adds surface area.
85pub fn mark_completed(team_root: &std::path::Path) -> std::io::Result<()> {
86    let path = sentinel_path(team_root);
87    if let Some(parent) = path.parent() {
88        std::fs::create_dir_all(parent)?;
89    }
90    std::fs::write(&path, b"")
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use std::fs;
97
98    #[test]
99    fn step_count_under_ten() {
100        // SPEC budget: <90s skim. 9 short steps fits the budget;
101        // landmark this so future drift isn't silent.
102        assert!(
103            STEPS.len() <= 10,
104            "tutorial bloated to {} steps",
105            STEPS.len()
106        );
107    }
108
109    #[test]
110    fn sentinel_round_trip_in_tempdir() {
111        let tmp = tempfile::tempdir().unwrap();
112        let root = tmp.path();
113        assert!(!has_completed(root));
114        mark_completed(root).unwrap();
115        assert!(has_completed(root));
116        // Marker file is empty — content doesn't matter, only
117        // existence does.
118        let marker = sentinel_path(root);
119        let bytes = fs::read(&marker).unwrap();
120        assert!(bytes.is_empty());
121    }
122}