Project and Repair workspaces; adoption keeps the person's own pane; truncation fix
This commit is contained in:
@@ -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)]]
|
- 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]]
|
- 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]]
|
- 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 <!-- same-turn-2026-11-10 03:19:00 -->
|
||||||
|
|
||||||
|
- 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]]
|
||||||
|
|||||||
@@ -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)]]
|
- 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]]
|
- 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]]
|
- 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 <!-- same-turn-2026-11-10 03:19:00 -->
|
||||||
|
|
||||||
|
- 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]]
|
||||||
|
|||||||
@@ -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 <!-- same-turn-2026-11-10 03:19:00 -->
|
||||||
|
|
||||||
|
- 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]]
|
||||||
14
CHECKLIST.md
14
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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
17
README.md
17
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
|
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.
|
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
|
## How an interface change is tested
|
||||||
|
|
||||||
Not in a plain terminal, and not in tmux. TUIOS owns part of the keyboard and forwards
|
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 <folder> [--dry-run] [--yes]` | close the pages, remove the worktrees and branches; keep the folder and every Paperclip record |
|
| `bin/project-reset <folder> [--dry-run] [--yes]` | close the pages, remove the worktrees and branches; keep the folder and every Paperclip record |
|
||||||
| `bin/merge <task> [--check]` | rebase, verify, review with `ocr`, merge; a refusal is a decision for the person |
|
| `bin/merge <task> [--check]` | rebase, verify, review with `ocr`, merge; a refusal is a decision for the person |
|
||||||
| `bin/review <task> [--post]` | the same review on its own |
|
| `bin/review <task> [--post]` | the same review on its own |
|
||||||
| `bin/screen tree\|reply\|log\|new [task]` | open one page |
|
| `bin/screen <screen> [task or project]` | open one page by name |
|
||||||
|
|
||||||
## Rules for this project
|
## Rules for this project
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ def pages(cfg, folder):
|
|||||||
("work", "work", "%s -screen work -folder %s" % (shlex.quote(bin_path), shlex.quote(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)),
|
("reply", "reply", "%s -screen reply -issue %s" % (shlex.quote(bin_path), task)),
|
||||||
("new", "new", "%s -screen new -project %s" % (shlex.quote(bin_path), proj)),
|
("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))),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
bin/harness
BIN
bin/harness
Binary file not shown.
Binary file not shown.
@@ -100,19 +100,24 @@ def open_pane(name, workspace_name, cwd, command, session=None, adopt_existing=F
|
|||||||
the pane that keeps the workspace alive.
|
the pane that keeps the workspace alive.
|
||||||
"""
|
"""
|
||||||
ws = claim(workspace_name, session)
|
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):
|
for w in windows(session):
|
||||||
if (w.get("custom_name") or "") == name:
|
if (w.get("custom_name") or "") == name:
|
||||||
return "adopted", w["window_id"], ws
|
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(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
"tuios", "new-window", name,
|
"tuios", "new-window", name,
|
||||||
|
|||||||
@@ -137,9 +137,10 @@ func truncateVisible(s string, w int) string {
|
|||||||
i += len(string(r[0]))
|
i += len(string(r[0]))
|
||||||
visible++
|
visible++
|
||||||
}
|
}
|
||||||
out.WriteString("…")
|
if i < len(s) {
|
||||||
// close any styling that is still open
|
out.WriteString("…")
|
||||||
out.WriteString("\x1b[0m")
|
out.WriteString("\x1b[0m")
|
||||||
|
}
|
||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
tui/main.go
22
tui/main.go
@@ -19,17 +19,17 @@ import (
|
|||||||
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
|
const defaultAgent = "403de1f7-645c-4d35-bc7e-19b01d77bacc"
|
||||||
|
|
||||||
func main() {
|
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)")
|
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")
|
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")
|
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")
|
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")
|
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)")
|
dump := flag.Bool("dump", false, "print the tree as plain text and exit (no full-screen interface)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
choosePalette(*light)
|
choosePalette(*light)
|
||||||
chooseGlyphs(*nerd)
|
chooseGlyphs(*ascii)
|
||||||
|
|
||||||
client, err := paperclip.New()
|
client, err := paperclip.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,6 +54,20 @@ func main() {
|
|||||||
}
|
}
|
||||||
m = newBoardModel(client, proj, dir)
|
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":
|
case "work":
|
||||||
dir := *folder
|
dir := *folder
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
@@ -113,7 +127,7 @@ func main() {
|
|||||||
m = newReplyModel(client, dir, id)
|
m = newReplyModel(client, dir, id)
|
||||||
|
|
||||||
default:
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,3 +366,8 @@ func (c *Client) CostSummary(issueID string) (Cost, error) {
|
|||||||
err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out)
|
err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out)
|
||||||
return out, err
|
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)
|
||||||
|
}
|
||||||
|
|||||||
315
tui/project.go
Normal file
315
tui/project.go
Normal file
@@ -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
|
||||||
323
tui/repair.go
Normal file
323
tui/repair.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
13
tui/theme.go
13
tui/theme.go
@@ -111,12 +111,15 @@ var plainGlyphs = glyphSet{
|
|||||||
// draws cannot be checked from here. The words next to every icon carry the
|
// 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
|
// meaning either way, and -nerd opts into the richer set once it has been seen
|
||||||
// on the terminal that matters.
|
// 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) {
|
func chooseGlyphs(useASCII bool) {
|
||||||
if useNerd {
|
if useASCII {
|
||||||
icons = nerdGlyphs
|
icons = plainGlyphs
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
icons = plainGlyphs
|
icons = nerdGlyphs
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user