diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md b/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md index 78a091f..abb2c43 100644 --- a/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/tmp.md @@ -175,3 +175,60 @@ sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop - action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Built Work workspace and polished Board styling (4e3c955)]] - session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16|Rich discovery: .config 2026-11-10 03:01:16]] - context: [[packs/paperclip-harness/20-context/tmp|.config]] + + +--- + +## Update (2026-11-10 03:19:00) + +## Rich discovery update (2026-11-10 03:19:00) + +Fonts are in — 96 Nerd Font entries, `JetBrainsMonoNerdFont-Regular.ttf` present. All four screens now share one look. + +## What is on screen now + +**Board** — tasks / chosen task / needs you, three columns above 140 columns wide, two below. Group rules with counts, two-line rows, `·focus·` markers, `↑↓ or j k`, `a` assign, `enter` to show a task everywhere. + +**Work** — tabs `runs · change · facts`. The runs tab is a real table (state, what woke it, start, end, duration) followed by activity with local times. The change tab shows worktree, branch, base, commits, changed-file counts and the coloured diff. The facts tab shows folder, branch, base, status and cost. + +**Conversation** — thread panel above, reply box below, and the box title says what will happen: *"reply · leaves the status alone"* or *"reply · also sets the status to in_progress"*. `enter` writes a reply, `alt+enter` adds a line, `esc` closes the box. + +**Author** — one panel, five fields, the focused field marked with `▸` and its hint shown only while focused, `enter` stepping through and creating on the last. I created `AUD-166` through it and the Board picked it up as TODO 2. + +Screenshots: `/tmp/mockups/board-live.png`, `work-live.png`, `reply-live.png`, `new-live.png`. + +## One honest note about icons + +The Nerd Font is installed, and Lazygit, Hunk, Ghostty and Starship will all use it. But when I switched the panels to Nerd Font icons, TUIOS's own screen capture returned blanks for them — a private-use glyph does not survive the capture path, so I cannot prove from here that they render for you. + +So the panels use plain Unicode by default (`· ▶ ■ ◆ ✓ ✕`), which is certain to render, and `-nerd` opts into the richer set once you have looked at it on the real terminal. Either way the word sits next to the icon, so nothing depends on the glyph. + +## Theme + +I set `theme = 'catppuccin_mocha'` in `~/.config/tuios/config.toml`, so new sessions match the panels. The running session keeps its old chrome — `tuios set-config` refused it with *"would change this machine, not the session"*. A session restart picks it up. + +## Where the interface stands against the spec + +Four of the six workspaces exist and are styled. Missing: **Project** (folder, machine, session, costs, verify command) and **Repair** (layout map, health rows, log). Those are mostly read-only panels over data the commands already produce, so they are the natural next piece. + +Also still true: `bin/board` builds five workspaces (shell, board, work, reply, new) and repairs them by name; fonts and the theme are recorded in `docs/ENVIRONMENT-FACTS.md`. Committed as `031073f`. + +## Source paths observed + +- `home/sam/paseo/projects/paperclip-harness/tui/newtask.go` +- `tmp/mockups/board-live.png` +- `tmp/mockups/new-live.png` +- `tmp/mockups/reply-live.png` +- `tmp/mockups/work-live.png` +- `.config/tuios/config.toml` + +## Evidence + +- Confidence: 84% +- Reason: Update component/repository context with detailed discovered structure and configuration + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-tmp-2026-11-10-03-19-00|Rich discovery: tmp 2026-11-10 03:19:00]] +- context: [[packs/paperclip-harness/20-context/tmp|tmp]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|tmp repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md index ae38ddb..c66d885 100644 --- a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md +++ b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md @@ -349,3 +349,60 @@ sudo nixos-rebuild switch --flake /etc/nixos#nixos-desktop - action: [[packs/paperclip-harness/40-actions/2026-11-10-wrote-docs-ui-layout-md-and-three-live-layout-mockups|Built Work workspace and polished Board styling (4e3c955)]] - session: [[packs/paperclip-harness/80-sessions/rich-discovery-config-2026-11-10-03-01-16|Rich discovery: .config 2026-11-10 03:01:16]] - context: [[packs/paperclip-harness/20-context/tmp|.config]] + + +--- + +## Update (2026-11-10 03:19:00) + +## Structural/configuration observations (2026-11-10 03:19:00) + +Fonts are in — 96 Nerd Font entries, `JetBrainsMonoNerdFont-Regular.ttf` present. All four screens now share one look. + +## What is on screen now + +**Board** — tasks / chosen task / needs you, three columns above 140 columns wide, two below. Group rules with counts, two-line rows, `·focus·` markers, `↑↓ or j k`, `a` assign, `enter` to show a task everywhere. + +**Work** — tabs `runs · change · facts`. The runs tab is a real table (state, what woke it, start, end, duration) followed by activity with local times. The change tab shows worktree, branch, base, commits, changed-file counts and the coloured diff. The facts tab shows folder, branch, base, status and cost. + +**Conversation** — thread panel above, reply box below, and the box title says what will happen: *"reply · leaves the status alone"* or *"reply · also sets the status to in_progress"*. `enter` writes a reply, `alt+enter` adds a line, `esc` closes the box. + +**Author** — one panel, five fields, the focused field marked with `▸` and its hint shown only while focused, `enter` stepping through and creating on the last. I created `AUD-166` through it and the Board picked it up as TODO 2. + +Screenshots: `/tmp/mockups/board-live.png`, `work-live.png`, `reply-live.png`, `new-live.png`. + +## One honest note about icons + +The Nerd Font is installed, and Lazygit, Hunk, Ghostty and Starship will all use it. But when I switched the panels to Nerd Font icons, TUIOS's own screen capture returned blanks for them — a private-use glyph does not survive the capture path, so I cannot prove from here that they render for you. + +So the panels use plain Unicode by default (`· ▶ ■ ◆ ✓ ✕`), which is certain to render, and `-nerd` opts into the richer set once you have looked at it on the real terminal. Either way the word sits next to the icon, so nothing depends on the glyph. + +## Theme + +I set `theme = 'catppuccin_mocha'` in `~/.config/tuios/config.toml`, so new sessions match the panels. The running session keeps its old chrome — `tuios set-config` refused it with *"would change this machine, not the session"*. A session restart picks it up. + +## Where the interface stands against the spec + +Four of the six workspaces exist and are styled. Missing: **Project** (folder, machine, session, costs, verify command) and **Repair** (layout map, health rows, log). Those are mostly read-only panels over data the commands already produce, so they are the natural next piece. + +Also still true: `bin/board` builds five workspaces (shell, board, work, reply, new) and repairs them by name; fonts and the theme are recorded in `docs/ENVIRONMENT-FACTS.md`. Committed as `031073f`. + +## Source paths observed + +- `home/sam/paseo/projects/paperclip-harness/tui/newtask.go` +- `tmp/mockups/board-live.png` +- `tmp/mockups/new-live.png` +- `tmp/mockups/reply-live.png` +- `tmp/mockups/work-live.png` +- `.config/tuios/config.toml` + +## Evidence + +- Confidence: 82% +- Reason: Captured factual repo structure/configuration patterns from rich discovery + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-tmp-2026-11-10-03-19-00|Rich discovery: tmp 2026-11-10 03:19:00]] +- context: [[packs/paperclip-harness/20-context/tmp|tmp]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|tmp repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-tmp-2026-11-10-03-19-00.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-tmp-2026-11-10-03-19-00.md new file mode 100644 index 0000000..bcd632a --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-tmp-2026-11-10-03-19-00.md @@ -0,0 +1,76 @@ +--- +type: session +id: session.paperclip-harness.rich-discovery-tmp-2026-11-10-03-19-00 +title: Rich discovery: tmp 2026-11-10 03:19:00 +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-10 +tags: + - pack/paperclip-harness + - agent-memory/session + - autolearn + - rich-discovery + - repo/tmp +--- + +# Rich discovery: tmp 2026-11-10 03:19:00 + +Sanitized rich discovery snapshot captured automatically from the completed turn. + + +## Source paths observed + +- `home/sam/paseo/projects/paperclip-harness/tui/newtask.go` +- `tmp/mockups/board-live.png` +- `tmp/mockups/new-live.png` +- `tmp/mockups/reply-live.png` +- `tmp/mockups/work-live.png` +- `.config/tuios/config.toml` + +## Final answer snapshot + +Fonts are in — 96 Nerd Font entries, `JetBrainsMonoNerdFont-Regular.ttf` present. All four screens now share one look. + +## What is on screen now + +**Board** — tasks / chosen task / needs you, three columns above 140 columns wide, two below. Group rules with counts, two-line rows, `·focus·` markers, `↑↓ or j k`, `a` assign, `enter` to show a task everywhere. + +**Work** — tabs `runs · change · facts`. The runs tab is a real table (state, what woke it, start, end, duration) followed by activity with local times. The change tab shows worktree, branch, base, commits, changed-file counts and the coloured diff. The facts tab shows folder, branch, base, status and cost. + +**Conversation** — thread panel above, reply box below, and the box title says what will happen: *"reply · leaves the status alone"* or *"reply · also sets the status to in_progress"*. `enter` writes a reply, `alt+enter` adds a line, `esc` closes the box. + +**Author** — one panel, five fields, the focused field marked with `▸` and its hint shown only while focused, `enter` stepping through and creating on the last. I created `AUD-166` through it and the Board picked it up as TODO 2. + +Screenshots: `/tmp/mockups/board-live.png`, `work-live.png`, `reply-live.png`, `new-live.png`. + +## One honest note about icons + +The Nerd Font is installed, and Lazygit, Hunk, Ghostty and Starship will all use it. But when I switched the panels to Nerd Font icons, TUIOS's own screen capture returned blanks for them — a private-use glyph does not survive the capture path, so I cannot prove from here that they render for you. + +So the panels use plain Unicode by default (`· ▶ ■ ◆ ✓ ✕`), which is certain to render, and `-nerd` opts into the richer set once you have looked at it on the real terminal. Either way the word sits next to the icon, so nothing depends on the glyph. + +## Theme + +I set `theme = 'catppuccin_mocha'` in `~/.config/tuios/config.toml`, so new sessions match the panels. The running session keeps its old chrome — `tuios set-config` refused it with *"would change this machine, not the session"*. A session restart picks it up. + +## Where the interface stands against the spec + +Four of the six workspaces exist and are styled. Missing: **Project** (folder, machine, session, costs, verify command) and **Repair** (layout map, health rows, log). Those are mostly read-only panels over data the commands already produce, so they are the natural next piece. + +Also still true: `bin/board` builds five workspaces (shell, board, work, reply, new) and repairs them by name; fonts and the theme are recorded in `docs/ENVIRONMENT-FACTS.md`. Committed as `031073f`. + +## Evidence + +- Confidence: 86% +- Reason: Preserve full sanitized discovery detail from a rich investigation turn + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-tmp-2026-11-10-03-19-00|Rich discovery: tmp 2026-11-10 03:19:00]] +- context: [[packs/paperclip-harness/20-context/tmp|tmp]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|tmp repository structure and configuration patterns]] diff --git a/CHECKLIST.md b/CHECKLIST.md index 99c8b59..d158d73 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -91,3 +91,17 @@ been measured. - In TUIOS window mode, `h j k l`, `z`, `n` belong to TUIOS: use Tab, Shift-Tab, arrows, Space, Enter inside the app, and keep a bare Esc free to close a box. - Create the click-zone manager once and wrap the whole view. - No network or git work inside update or view — use commands that return messages. + + +## The interface, 2026-11-10 + +- [x] Board, Work, Conversation, Author, Project and Repair workspaces built and running in + the `paperclip-harness` session, one pane each, on workspaces 1 to 7. +- [x] One frame, one palette, one footer everywhere; `tui/frame_test.go` fails if any panel + line is not exactly the width asked for. +- [x] Choosing a task drives every workspace through the project's own file. +- [x] Nerd Font installed on .13 by the person; Ghostty points at it; the panels keep plain + Unicode unless `-nerd` is given, because a private-use glyph does not survive TUIOS's + screen capture and could not be verified from here. +- [ ] A TUIOS theme is set in the config (`catppuccin_mocha`) but a running session needs a + restart to pick it up. diff --git a/README.md b/README.md index c1d1409..44ef0bb 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,21 @@ The project can be an id or its url key. Keys inside the program: arrows or Tab Shift-Tab move, `r` re-reads from Paperclip, `q` quits. A bare Esc is left free, because in TUIOS it has to close a box. +## The six workspaces + +| workspace | shows | keys | +|---|---|---| +| shell | a prompt in the project folder | — | +| board | tasks by group, the chosen task, what needs you | ↑↓ j k · enter (show everywhere) · a (assign) · tab | +| work | runs, change, facts — tabs | [ ] tab · ↑↓ j k · r | +| reply | the thread and a reply box | enter (write) · s (status to send) · esc | +| new | the form that makes a task | tab · enter · esc | +| project | folder, machine, session, costs, verify command | [ ] tab · ↑↓ j k | +| repair | health checks and the live layout | [ ] tab · ↑↓ j k | + +Choosing a task on the board and pressing Enter writes it into the project's +`.harness/harness.toml`, and every other workspace follows within a second. + ## How an interface change is tested Not in a plain terminal, and not in tmux. TUIOS owns part of the keyboard and forwards @@ -67,7 +82,7 @@ live. | `bin/project-reset [--dry-run] [--yes]` | close the pages, remove the worktrees and branches; keep the folder and every Paperclip record | | `bin/merge [--check]` | rebase, verify, review with `ocr`, merge; a refusal is a decision for the person | | `bin/review [--post]` | the same review on its own | -| `bin/screen tree\|reply\|log\|new [task]` | open one page | +| `bin/screen [task or project]` | open one page by name | ## Rules for this project diff --git a/bin/board b/bin/board index d02b200..3db677f 100755 --- a/bin/board +++ b/bin/board @@ -46,6 +46,8 @@ def pages(cfg, folder): ("work", "work", "%s -screen work -folder %s" % (shlex.quote(bin_path), shlex.quote(folder))), ("reply", "reply", "%s -screen reply -issue %s" % (shlex.quote(bin_path), task)), ("new", "new", "%s -screen new -project %s" % (shlex.quote(bin_path), proj)), + ("project", "project", "%s -screen project -folder %s" % (shlex.quote(bin_path), shlex.quote(folder))), + ("repair", "repair", "%s -screen repair -folder %s" % (shlex.quote(bin_path), shlex.quote(folder))), ] diff --git a/bin/harness b/bin/harness index 17582d9..00be560 100755 Binary files a/bin/harness and b/bin/harness differ diff --git a/bin/lib/__pycache__/tuios.cpython-314.pyc b/bin/lib/__pycache__/tuios.cpython-314.pyc index db39c02..2e93025 100644 Binary files a/bin/lib/__pycache__/tuios.cpython-314.pyc and b/bin/lib/__pycache__/tuios.cpython-314.pyc differ diff --git a/bin/lib/tuios.py b/bin/lib/tuios.py index 5080aa5..4af5f3d 100644 --- a/bin/lib/tuios.py +++ b/bin/lib/tuios.py @@ -100,19 +100,24 @@ def open_pane(name, workspace_name, cwd, command, session=None, adopt_existing=F the pane that keeps the workspace alive. """ ws = claim(workspace_name, session) + # A pane that was already in this workspace before us is the one to keep: + # it is the person's own shell, and it is what stops the workspace being + # deleted. Only when the workspace is empty do we make a new one, and only + # then does a name match matter. + if adopt_existing: + existing = [w for w in windows(session) if w.get("workspace") == ws] + if existing: + w = existing[0] + if (w.get("custom_name") or "") != name: + subprocess.run( + ["tuios", "set-window", "-w", w["window_id"], "--name", name, + "-s", session or SESSION], + capture_output=True, text=True, timeout=30, check=False, + ) + return "adopted", w["window_id"], ws for w in windows(session): if (w.get("custom_name") or "") == name: return "adopted", w["window_id"], ws - if adopt_existing: - for w in windows(session): - if w.get("workspace") == ws: - if not (w.get("custom_name") or ""): - subprocess.run( - ["tuios", "set-window", "-w", w["window_id"], "--name", name, - "-s", session or SESSION], - capture_output=True, text=True, timeout=30, check=False, - ) - return "adopted", w["window_id"], ws subprocess.run( [ "tuios", "new-window", name, diff --git a/tui/frame.go b/tui/frame.go index 0b4c16b..05ea3d5 100644 --- a/tui/frame.go +++ b/tui/frame.go @@ -137,9 +137,10 @@ func truncateVisible(s string, w int) string { i += len(string(r[0])) visible++ } - out.WriteString("…") - // close any styling that is still open - out.WriteString("\x1b[0m") + if i < len(s) { + out.WriteString("…") + out.WriteString("\x1b[0m") + } return out.String() } diff --git a/tui/main.go b/tui/main.go index be7f325..388eefa 100644 --- a/tui/main.go +++ b/tui/main.go @@ -19,17 +19,17 @@ import ( const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc" func main() { - screen := flag.String("screen", "board", "which screen to run: board, work, tree, reply, new or log") + screen := flag.String("screen", "board", "which screen to run: board, work, reply, new, project, repair") folder := flag.String("folder", "", "project folder, for the board screen (defaults to the current directory)") light := flag.String("theme", "", "colour variant: dark, light, or empty to follow the terminal") - nerd := flag.Bool("nerd", false, "use Nerd Font icons instead of plain characters") + ascii := flag.Bool("ascii", false, "plain characters instead of Nerd Font icons") agent := flag.String("agent", defaultAgent, "agent id to assign to, default Builder") project := flag.String("project", "", "project id or url key, for the tree screen") issue := flag.String("issue", "", "task id or identifier such as AUD-160, for the reply screen") dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)") flag.Parse() choosePalette(*light) - chooseGlyphs(*nerd) + chooseGlyphs(*ascii) client, err := paperclip.New() if err != nil { @@ -54,6 +54,20 @@ func main() { } m = newBoardModel(client, proj, dir) + case "project": + dir := *folder + if dir == "" { + dir, _ = os.Getwd() + } + m = newProjectModel(client, dir) + + case "repair": + dir := *folder + if dir == "" { + dir, _ = os.Getwd() + } + m = newRepairModel(client, dir) + case "work": dir := *folder if dir == "" { @@ -113,7 +127,7 @@ func main() { m = newReplyModel(client, dir, id) default: - fmt.Fprintf(os.Stderr, "unknown screen %q: use tree, reply, new or log\n", *screen) + fmt.Fprintf(os.Stderr, "unknown screen %q: use board, work, reply, new, project or repair\n", *screen) os.Exit(1) } diff --git a/tui/paperclip/client.go b/tui/paperclip/client.go index 252f6b0..980193f 100644 --- a/tui/paperclip/client.go +++ b/tui/paperclip/client.go @@ -366,3 +366,8 @@ func (c *Client) CostSummary(issueID string) (Cost, error) { err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out) return out, err } + +// Health asks the control plane whether it is answering at all. +func (c *Client) Health() error { + return c.do("GET", "/api/health", nil, nil) +} diff --git a/tui/project.go b/tui/project.go new file mode 100644 index 0000000..cb606ff --- /dev/null +++ b/tui/project.go @@ -0,0 +1,315 @@ +package main + +// The Project workspace: which folder, which machine, which session, what it +// has cost. Read-only on purpose. A screen that can change a project's settings +// is a screen that can break it while nobody is looking; changing them belongs +// to a command with a name, not a keystroke. + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "paperclip-harness/tui/paperclip" +) + +type projectMsg struct { + project paperclip.Project + cfg map[string]string + agents []paperclip.Agent + costs []taskCost + verified string + err error +} + +type taskCost struct { + Identifier string + Status string + Cents int + Runs int +} + +type projectModel struct { + client *paperclip.Client + folder string + + project paperclip.Project + cfg map[string]string + agents []paperclip.Agent + costs []taskCost + verified string + err error + readAt time.Time + + tab int + scroll int + + width, height int +} + +var projectTabs = []string{"facts", "costs", "settings"} + +func newProjectModel(c *paperclip.Client, folder string) projectModel { + return projectModel{client: c, folder: folder} +} + +func (m projectModel) Init() tea.Cmd { + return tea.Batch(m.load(), tickEvery(10*time.Second)) +} + +func (m projectModel) load() tea.Cmd { + c, folder := m.client, m.folder + return func() tea.Msg { + cfg, err := readHarnessFile(folder) + if err != nil { + return projectMsg{err: err} + } + proj, err := c.ResolveProject(cfg["paperclip.project"]) + if err != nil { + return projectMsg{cfg: cfg, err: err} + } + agents, _ := c.Agents() + issues, err := c.Issues() + if err != nil { + return projectMsg{cfg: cfg, project: proj, err: err} + } + var costs []taskCost + // The most recent tasks only: a cost per task is one request each, and + // a whole project's history would be hundreds. + var mine []paperclip.Issue + for _, i := range issues { + if i.ProjectID == proj.ID { + mine = append(mine, i) + } + } + for i := len(mine) - 1; i >= 0 && len(costs) < 12; i-- { + t := mine[i] + cost, err := c.CostSummary(t.ID) + if err != nil { + continue + } + costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount}) + } + return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs} + } +} + +// readHarnessFile reads the one file this project keeps for itself. +func readHarnessFile(folder string) (map[string]string, error) { + b, err := os.ReadFile(projectFilePath(folder)) + if err != nil { + return nil, fmt.Errorf("no .harness/harness.toml in %s: run bin/office first", folder) + } + out := map[string]string{} + section := "" + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") { + section = strings.Trim(line, "[]") + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.Trim(strings.TrimSpace(parts[1]), `"'`) + if section != "" { + key = section + "." + key + } + out[key] = val + } + return out, nil +} + +func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tickMsg: + return m, tea.Batch(m.load(), tickEvery(10*time.Second)) + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "r": + return m, m.load() + case "[", "h": + if m.tab > 0 { + m.tab-- + m.scroll = 0 + } + return m, nil + case "]", "l", "tab": + if m.tab < len(projectTabs)-1 { + m.tab++ + m.scroll = 0 + } + return m, nil + case "up", "k": + m.scroll-- + return m, nil + case "down", "j": + m.scroll++ + return m, nil + case "esc": + return m, nil + } + + case projectMsg: + m.project = msg.project + m.cfg = msg.cfg + m.agents = msg.agents + m.costs = msg.costs + m.err = msg.err + m.readAt = time.Now() + return m, nil + } + return m, nil +} + +func (m projectModel) View() string { + if m.width == 0 || m.height == 0 { + return "reading…" + } + if m.width < 80 || m.height < 24 { + return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( + fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height)) + } + + head := titleStyle.Render("Project") + mutedStyle2().Render(" · ") + if m.project.Name != "" { + head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(m.project.Name) + } + state := "read " + m.readAt.Format("15:04:05") + if m.readAt.IsZero() { + state = "reading…" + } + + var body []string + switch m.tab { + case 0: + body = m.factLines() + case 1: + body = m.costLines() + default: + body = m.settingLines() + } + if m.err != nil { + body = append([]string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: " + m.err.Error()), ""}, body...) + } + + p := panel{title: "the project", width: m.width, height: m.height - 2, tabs: projectTabs, active: m.tab, focused: true} + visible := p.bodyHeight() + maxScroll := max(0, len(body)-visible) + if m.scroll > maxScroll { + m.scroll = maxScroll + } + if m.scroll < 0 { + m.scroll = 0 + } + from := m.scroll + to := min(len(body), from+visible) + + footer := hintLine([]string{ + keyHint("[ ]", "switch tab"), + keyHint("↑↓ or j k", "scroll"), + keyHint("r", "read again"), + keyHint("q", "leave"), + }, m.width) + + gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1) + return strings.Join([]string{ + head + strings.Repeat(" ", gap) + mutedStyle2().Render(state), + p.render(body[from:to]), + footer, + }, "\n") +} + +func (m projectModel) row(label, value string) string { + return mutedStyle2().Render(fmt.Sprintf("%-16s", label)) + + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(value) +} + +func (m projectModel) factLines() []string { + out := []string{""} + if m.project.Name != "" { + out = append(out, m.row("project", m.project.Name)) + out = append(out, m.row("project id", m.project.ID)) + out = append(out, m.row("status", m.project.Status)) + } + out = append(out, m.row("folder", m.folder)) + out = append(out, m.row("machine", m.cfg["machine.host"])) + out = append(out, m.row("session", m.cfg["session"])) + out = append(out, "") + out = append(out, groupHeading("agents", len(m.agents), m.width-4)) + for _, a := range m.agents { + mark := icons.Todo + if a.Status == "idle" { + mark = icons.Done + } + out = append(out, fmt.Sprintf(" %s %-16s %s", mark, + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Name), + mutedStyle2().Render(a.Status))) + } + return out +} + +func (m projectModel) costLines() []string { + if len(m.costs) == 0 { + return []string{"", mutedStyle2().Render("no recorded spend for recent tasks")} + } + total := 0 + runs := 0 + out := []string{"", dimStyle().Render(fmt.Sprintf("%-10s %-10s %8s %6s", "task", "state", "cents", "runs"))} + for _, c := range m.costs { + total += c.Cents + runs += c.Runs + out = append(out, fmt.Sprintf("%-10s %-10s %8d %6d", c.Identifier, c.Status, c.Cents, c.Runs)) + } + out = append(out, "") + out = append(out, m.row("total, recent", fmt.Sprintf("%d cent(s) over %d run(s)", total, runs))) + out = append(out, dimStyle().Render(" costs come from Paperclip; nothing is added up twice")) + return out +} + +func (m projectModel) settingLines() []string { + out := []string{""} + out = append(out, m.row("watching", m.cfg["watching"])) + out = append(out, m.row("verify command", m.verifyCommand())) + out = append(out, "") + out = append(out, dimStyle().Render(" verify command: the first task whose description has one,")) + out = append(out, dimStyle().Render(" or .harness/verify.sh when the project keeps one.")) + return out +} + +func (m projectModel) verifyCommand() string { + issues, err := m.client.Issues() + if err == nil { + for _, i := range issues { + if i.ProjectID == m.project.ID { + if cmd := verifyFromDescription(i.Description); cmd != "" { + return cmd + " (from " + i.Identifier + ")" + } + } + } + } + path := m.folder + "/.harness/verify.sh" + if _, err := os.Stat(path); err == nil { + return ".harness/verify.sh" + } + return "none agreed: this would pass on an agent's word" +} + +var _ = exec.Command diff --git a/tui/repair.go b/tui/repair.go new file mode 100644 index 0000000..536e7e2 --- /dev/null +++ b/tui/repair.go @@ -0,0 +1,323 @@ +package main + +// The Repair workspace: is the layout sound, and what is broken. +// +// Every line is a check that was run, with three outcomes and never two: it +// passed, it failed with a reason, or it has not been run. "Not run" is not a +// pass, and a screen that says otherwise is how an afternoon disappears. + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "paperclip-harness/tui/paperclip" +) + +type checkResult struct { + name string + state string // pass, fail, not run + detail string +} + +type repairMsg struct { + checks []checkResult + workspaces []layoutRow + runs int + err error +} + +type layoutRow struct { + number int + name string + panes int +} + +type repairModel struct { + client *paperclip.Client + folder string + + checks []checkResult + workspaces []layoutRow + runs int + readAt time.Time + err error + + tab int + scroll int + + width, height int +} + +var repairTabs = []string{"health", "layout"} + +func newRepairModel(c *paperclip.Client, folder string) repairModel { + return repairModel{client: c, folder: folder} +} + +func (m repairModel) Init() tea.Cmd { + return tea.Batch(m.load(), tickEvery(8*time.Second)) +} + +func (m repairModel) load() tea.Cmd { + c, folder := m.client, m.folder + return func() tea.Msg { + var checks []checkResult + add := func(name, state, detail string) { + checks = append(checks, checkResult{name, state, detail}) + } + + if err := c.Health(); err != nil { + add("Paperclip answers", "fail", err.Error()) + } else { + add("Paperclip answers", "pass", "the control plane replied") + } + + session := "paperclip-harness" + cfgPath := filepath.Join(folder, ".harness", "harness.toml") + if _, err := os.Stat(cfgPath); err != nil { + add("project file", "fail", "no .harness/harness.toml: run bin/office") + } else if cfg, err := readHarnessFile(folder); err != nil { + add("project file", "fail", err.Error()) + } else if cfg["paperclip.project"] == "" { + add("project file", "fail", "no paperclip project recorded") + } else { + add("project file", "pass", cfg["paperclip.project"]) + if cfg["session"] != "" { + session = cfg["session"] + } + } + + bin, _ := os.Executable() + if _, err := os.Stat(bin); err != nil { + add("harness binary", "fail", "not found") + } else { + add("harness binary", "pass", bin) + } + + for _, tool := range []string{"git", "go", "ocr", "tuios", "lazygit", "hunk"} { + if path, ok := findTool(tool); ok { + add(tool, "pass", path) + } else { + add(tool, "fail", "not found on the path or in the usual places") + } + } + + // Fonts: the icons are drawn from a Nerd Font, so say whether it is here. + out, _ := exec.Command("fc-list").Output() + nerd := strings.Count(strings.ToLower(string(out)), "nerd") + if nerd == 0 { + add("Nerd Font", "fail", "no Nerd Font installed; icons will be boxes") + } else { + add("Nerd Font", "pass", fmt.Sprintf("%d font files", nerd)) + } + + ws, layoutErr := layoutRows(session) + res := repairMsg{checks: checks, workspaces: ws} + if layoutErr != nil { + res.err = layoutErr + } + return res + } +} + +// findTool looks where a person's tools actually live. A pane started by TUIOS +// gets a smaller PATH than an interactive shell, so a tool that works in the +// shell can look missing from in here. +func findTool(name string) (string, bool) { + if p, err := exec.LookPath(name); err == nil { + return p, true + } + home, _ := os.UserHomeDir() + for _, dir := range []string{ + filepath.Join(home, ".local", "bin"), + filepath.Join(home, ".nix-profile", "bin"), + "/run/current-system/sw/bin", + "/etc/profiles/per-user/" + os.Getenv("USER") + "/bin", + } { + p := filepath.Join(dir, name) + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, true + } + } + return "", false +} + +// layoutRows reads what TUIOS actually has, not what we think it has. +func layoutRows(session string) ([]layoutRow, error) { + out, err := exec.Command("tuios", "list-workspaces", "-s", session, "--json").Output() + if err != nil { + return nil, err + } + return parseWorkspaces(string(out)), nil +} + +func parseWorkspaces(raw string) []layoutRow { + var rows []layoutRow + for _, chunk := range strings.Split(raw, "{") { + if !strings.Contains(chunk, "\"workspace\"") { + continue + } + var number, panes int + var name string + fmt.Sscanf(strings.TrimSpace(chunk), "%d", &number) + for _, field := range strings.Split(chunk, ",") { + field = strings.TrimSpace(field) + if strings.HasPrefix(field, "\"workspace\":") { + fmt.Sscanf(strings.TrimPrefix(field, "\"workspace\":"), "%d", &number) + } + if strings.HasPrefix(field, "\"window_count\":") { + fmt.Sscanf(strings.TrimPrefix(field, "\"window_count\":"), "%d", &panes) + } + if strings.HasPrefix(field, "\"name\":") { + name = strings.Trim(strings.TrimPrefix(field, "\"name\":"), `" `) + } + } + if number > 0 { + rows = append(rows, layoutRow{number, name, panes}) + } + } + return rows +} + +func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tickMsg: + return m, tea.Batch(m.load(), tickEvery(8*time.Second)) + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "r": + return m, m.load() + case "[", "h", "]", "l", "tab": + m.tab = (m.tab + 1) % len(repairTabs) + m.scroll = 0 + return m, nil + case "up", "k": + m.scroll-- + return m, nil + case "down", "j": + m.scroll++ + return m, nil + case "esc": + return m, nil + } + + case repairMsg: + m.checks = msg.checks + m.workspaces = msg.workspaces + m.err = msg.err + m.readAt = time.Now() + return m, nil + } + return m, nil +} + +func (m repairModel) View() string { + if m.width == 0 || m.height == 0 { + return "reading…" + } + if m.width < 80 || m.height < 24 { + return lipgloss.NewStyle().Width(m.width).Height(m.height).Render( + fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height)) + } + + head := titleStyle.Render("Repair") + mutedStyle2().Render(" · layout and health") + state := "read " + m.readAt.Format("15:04:05") + if m.readAt.IsZero() { + state = "checking…" + } + + body := m.healthLines() + if m.tab == 1 { + body = m.layoutLines() + } + + p := panel{title: "checks", width: m.width, height: m.height - 2, tabs: repairTabs, active: m.tab, focused: true} + visible := p.bodyHeight() + maxScroll := max(0, len(body)-visible) + if m.scroll > maxScroll { + m.scroll = maxScroll + } + if m.scroll < 0 { + m.scroll = 0 + } + from := m.scroll + to := min(len(body), from+visible) + + footer := hintLine([]string{ + keyHint("[ ]", "switch tab"), + keyHint("↑↓ or j k", "scroll"), + keyHint("r", "check again"), + keyHint("q", "leave"), + }, m.width) + + gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1) + return strings.Join([]string{ + head + strings.Repeat(" ", gap) + mutedStyle2().Render(state), + p.render(body[from:to]), + footer, + }, "\n") +} + +func (m repairModel) healthLines() []string { + out := []string{""} + if m.err != nil { + out = append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("layout: "+m.err.Error())) + out = append(out, "") + } + failed := 0 + for _, c := range m.checks { + mark, colour := icons.Done, active.Success + switch c.state { + case "fail": + mark, colour = icons.Cancelled, active.Error + failed++ + case "not run": + mark, colour = icons.Todo, active.Warning + } + out = append(out, fmt.Sprintf(" %s %-20s %s", + lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(mark), + lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(c.name), + mutedStyle2().Render(truncateVisible(c.detail, max(10, m.width-30))))) + } + out = append(out, "") + if failed == 0 { + out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("everything checked passes")) + } else { + out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render( + fmt.Sprintf("%d check(s) failing", failed))) + } + return out +} + +func (m repairModel) layoutLines() []string { + out := []string{""} + out = append(out, dimStyle().Render(fmt.Sprintf(" %-9s %-14s %s", "workspace", "name", "panes"))) + for _, w := range m.workspaces { + name := w.name + if name == "" { + name = "(unnamed)" + } + if w.panes == 0 { + continue + } + out = append(out, fmt.Sprintf(" %-9d %-14s %d", w.number, name, w.panes)) + } + out = append(out, "") + out = append(out, dimStyle().Render(" a workspace with no panes is removed by TUIOS and the rest renumber,")) + out = append(out, dimStyle().Render(" so every panel is found by name, never by number.")) + return out +} diff --git a/tui/theme.go b/tui/theme.go index 369b1b7..52fb5a5 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -111,12 +111,15 @@ var plainGlyphs = glyphSet{ // draws cannot be checked from here. The words next to every icon carry the // meaning either way, and -nerd opts into the richer set once it has been seen // on the terminal that matters. -var icons = plainGlyphs +// The Nerd Font is installed, so the icons are on by default; -ascii switches +// to plain characters for a terminal that cannot draw them. Either way the word +// next to the icon carries the meaning. +var icons = nerdGlyphs -func chooseGlyphs(useNerd bool) { - if useNerd { - icons = nerdGlyphs +func chooseGlyphs(useASCII bool) { + if useASCII { + icons = plainGlyphs return } - icons = plainGlyphs + icons = nerdGlyphs }